Implement task status workflow: claimed, in-progress, completed

Added granular task state management to scheduled events/tasks with three states plus unclaimed. Tasks now support a full workflow from claiming to completion with visual feedback at each stage.

**New Task States:**
- **Unclaimed** (no RSVP event) - Task available for anyone to claim
- **Claimed** - User has reserved the task but hasn't started
- **In Progress** - User is actively working on the task
- **Completed** - Task is done
- **Blocked** - Task is stuck (supported but not yet used in UI)
- **Cancelled** - Task won't be completed (supported but not yet used in UI)

**Service Layer (ScheduledEventService.ts):**
- Updated `EventCompletion` interface: replaced `completed: boolean` with `taskStatus: TaskStatus`
- Added `TaskStatus` type: `'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'`
- New methods: `claimTask()`, `startTask()`, `getTaskStatus()`
- Refactored `completeEvent()` and renamed `uncompleteEvent()` to `unclaimTask()`
- Internal `updateTaskStatus()` method handles all state changes
- Uses `task-status` tag instead of `completed` tag in Nostr events
- `unclaimTask()` publishes deletion event (kind 5) to remove RSVP
- Backward compatibility: reads old `completed` tag and converts to new taskStatus

**Composable (useScheduledEvents.ts):**
- Exported new methods: `claimTask`, `startTask`, `unclaimTask`, `getTaskStatus`
- Updated `completeEvent` signature to accept occurrence parameter
- Marked `toggleComplete` as deprecated (still works for compatibility)

**UI (ScheduledEventCard.vue):**
- Context-aware action buttons based on current task status:
  - Unclaimed: "Claim Task" button
  - Claimed: "Start Task" + "Unclaim" buttons
  - In Progress: "Mark Complete" + "Unclaim" buttons
  - Completed: "Unclaim" button only
- Status badges with icons and color coding:
  - 👋 Claimed (blue)
  - 🔄 In Progress (orange)
  - ✓ Completed (green)
- Shows who claimed/is working on/completed each task
- Unified confirmation dialog for all actions
- Quick action buttons in collapsed view
- Full button set in expanded view

**Feed Integration (NostrFeed.vue):**
- Added handlers: `onClaimTask`, `onStartTask`, `onCompleteTask`, `onUnclaimTask`
- Passes `getTaskStatus` prop to ScheduledEventCard
- Wired up all new event emitters

**Nostr Protocol:**
- Uses NIP-52 Calendar Event RSVP (kind 31925)
- Custom `task-status` tag for granular state tracking
- Deletion events (kind 5) for unclaiming tasks
- Fully decentralized - all state stored on Nostr relays

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-11-16 16:45:45 +01:00
parent 2e6f215157
commit d497cfa4d9
4 changed files with 452 additions and 100 deletions

View file

@ -99,7 +99,16 @@ const { getDisplayName, fetchProfiles } = useProfiles()
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Use scheduled events service
const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents()
const {
getEventsForSpecificDate,
getCompletion,
getTaskStatus,
claimTask,
startTask,
completeEvent,
unclaimTask,
allCompletions
} = useScheduledEvents()
// Selected date for viewing scheduled tasks (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0])
@ -255,14 +264,40 @@ async function onToggleLike(note: FeedPost) {
}
}
// Handle scheduled event completion toggle
async function onToggleComplete(event: ScheduledEvent, occurrence?: string) {
console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence)
// Task action handlers
async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
console.log('👋 NostrFeed: Claiming task:', event.title)
try {
await toggleComplete(event, occurrence)
console.log('✅ NostrFeed: toggleComplete succeeded')
await claimTask(event, '', occurrence)
} catch (error) {
console.error('❌ NostrFeed: Failed to toggle event completion:', error)
console.error('❌ Failed to claim task:', error)
}
}
async function onStartTask(event: ScheduledEvent, occurrence?: string) {
console.log('▶️ NostrFeed: Starting task:', event.title)
try {
await startTask(event, '', occurrence)
} catch (error) {
console.error('❌ Failed to start task:', error)
}
}
async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
console.log('✅ NostrFeed: Completing task:', event.title)
try {
await completeEvent(event, occurrence, '')
} catch (error) {
console.error('❌ Failed to complete task:', error)
}
}
async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
console.log('🔙 NostrFeed: Unclaiming task:', event.title)
try {
await unclaimTask(event, occurrence)
} catch (error) {
console.error('❌ Failed to unclaim task:', error)
}
}
@ -514,8 +549,12 @@ function cancelDelete() {
:event="event"
:get-display-name="getDisplayName"
:get-completion="getCompletion"
:get-task-status="getTaskStatus"
:admin-pubkeys="adminPubkeys"
@toggle-complete="onToggleComplete"
@claim-task="onClaimTask"
@start-task="onStartTask"
@complete-task="onCompleteTask"
@unclaim-task="onUnclaimTask"
/>
</div>
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">

View file

@ -15,18 +15,22 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
interface Props {
event: ScheduledEvent
getDisplayName: (pubkey: string) => string
getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined
getTaskStatus: (eventAddress: string, occurrence?: string) => TaskStatus | null
adminPubkeys?: string[]
}
interface Emits {
(e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void
(e: 'claim-task', event: ScheduledEvent, occurrence?: string): void
(e: 'start-task', event: ScheduledEvent, occurrence?: string): void
(e: 'complete-task', event: ScheduledEvent, occurrence?: string): void
(e: 'unclaim-task', event: ScheduledEvent, occurrence?: string): void
}
const props = withDefaults(defineProps<Props>(), {
@ -53,12 +57,29 @@ const occurrence = computed(() => {
// Check if this is an admin event
const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey))
// Check if event is completed - call function with occurrence for recurring events
const isCompleted = computed(() => props.getCompletion(eventAddress.value, occurrence.value)?.completed || false)
// Get current task status
const taskStatus = computed(() => props.getTaskStatus(eventAddress.value, occurrence.value))
// Check if event is completable (task type)
const isCompletable = computed(() => props.event.eventType === 'task')
// Get completion data
const completion = computed(() => props.getCompletion(eventAddress.value, occurrence.value))
// Status badges configuration
const statusConfig = computed(() => {
switch (taskStatus.value) {
case 'claimed':
return { label: 'Claimed', variant: 'secondary' as const, icon: Hand, color: 'text-blue-600' }
case 'in-progress':
return { label: 'In Progress', variant: 'default' as const, icon: PlayCircle, color: 'text-orange-600' }
case 'completed':
return { label: 'Completed', variant: 'secondary' as const, icon: CheckCircle, color: 'text-green-600' }
default:
return null
}
})
// Format the date/time
const formattedDate = computed(() => {
try {
@ -110,28 +131,102 @@ const formattedTimeRange = computed(() => {
}
})
// Handle mark complete button click - show confirmation dialog
function handleMarkComplete() {
console.log('🔘 Mark Complete button clicked for event:', props.event.title)
// Action type for confirmation dialog
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | null>(null)
// Handle claim task
function handleClaimTask() {
pendingAction.value = 'claim'
showConfirmDialog.value = true
}
// Confirm and execute mark complete
function confirmMarkComplete() {
console.log('✅ Confirmed mark complete for event:', props.event.title, 'occurrence:', occurrence.value)
emit('toggle-complete', props.event, occurrence.value)
showConfirmDialog.value = false
// Handle start task
function handleStartTask() {
pendingAction.value = 'start'
showConfirmDialog.value = true
}
// Cancel mark complete
function cancelMarkComplete() {
showConfirmDialog.value = false
// Handle complete task
function handleCompleteTask() {
pendingAction.value = 'complete'
showConfirmDialog.value = true
}
// Handle unclaim task
function handleUnclaimTask() {
pendingAction.value = 'unclaim'
showConfirmDialog.value = true
}
// Confirm action
function confirmAction() {
if (!pendingAction.value) return
switch (pendingAction.value) {
case 'claim':
emit('claim-task', props.event, occurrence.value)
break
case 'start':
emit('start-task', props.event, occurrence.value)
break
case 'complete':
emit('complete-task', props.event, occurrence.value)
break
case 'unclaim':
emit('unclaim-task', props.event, occurrence.value)
break
}
showConfirmDialog.value = false
pendingAction.value = null
}
// Cancel action
function cancelAction() {
showConfirmDialog.value = false
pendingAction.value = null
}
// Get dialog content based on pending action
const dialogContent = computed(() => {
switch (pendingAction.value) {
case 'claim':
return {
title: 'Claim Task?',
description: `This will mark "${props.event.title}" as claimed by you. You can start working on it later.`,
confirmText: 'Claim Task'
}
case 'start':
return {
title: 'Start Task?',
description: `This will mark "${props.event.title}" as in-progress. Others will see you're actively working on it.`,
confirmText: 'Start Task'
}
case 'complete':
return {
title: 'Complete Task?',
description: `This will mark "${props.event.title}" as completed by you. Other users will be able to see that you completed this task.`,
confirmText: 'Mark Complete'
}
case 'unclaim':
return {
title: 'Unclaim Task?',
description: `This will remove your claim on "${props.event.title}" and make it available for others.`,
confirmText: 'Unclaim Task'
}
default:
return {
title: '',
description: '',
confirmText: ''
}
}
})
</script>
<template>
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
:class="{ 'opacity-60': isCompletable && isCompleted }">
:class="{ 'opacity-60': isCompletable && taskStatus === 'completed' }">
<!-- Collapsed View (Trigger) -->
<CollapsibleTrigger as-child>
<div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors">
@ -143,26 +238,50 @@ function cancelMarkComplete() {
<!-- Title -->
<h3 class="font-semibold text-sm md:text-base flex-1 truncate"
:class="{ 'line-through': isCompletable && isCompleted }">
:class="{ 'line-through': isCompletable && taskStatus === 'completed' }">
{{ event.title }}
</h3>
<!-- Badges and Actions -->
<div class="flex items-center gap-2 shrink-0">
<!-- Mark Complete Button (for uncompleted tasks) -->
<!-- Quick Action Button (context-aware) -->
<Button
v-if="isCompletable && !isCompleted"
@click.stop="handleMarkComplete"
v-if="isCompletable && !taskStatus"
@click.stop="handleClaimTask"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
class="h-7 px-2 text-xs gap-1"
>
<CheckCircle class="h-4 w-4" />
<Hand class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Claim</span>
</Button>
<!-- Completed Badge with completer name -->
<Badge v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" variant="secondary" class="text-xs">
{{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
<Button
v-else-if="isCompletable && taskStatus === 'claimed'"
@click.stop="handleStartTask"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<PlayCircle class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Start</span>
</Button>
<Button
v-else-if="isCompletable && taskStatus === 'in-progress'"
@click.stop="handleCompleteTask"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<CheckCircle class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Complete</span>
</Button>
<!-- Status Badge with claimer/completer name -->
<Badge v-if="isCompletable && statusConfig && completion" :variant="statusConfig.variant" class="text-xs gap-1">
<component :is="statusConfig.icon" class="h-3 w-3" :class="statusConfig.color" />
<span>{{ getDisplayName(completion.pubkey) }}</span>
</Badge>
<!-- Recurring Badge -->
@ -200,10 +319,20 @@ function cancelMarkComplete() {
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
</div>
<!-- Completion info (only for completable events) -->
<div v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" class="text-xs text-muted-foreground mb-3">
Completed by {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
<span v-if="getCompletion(eventAddress, occurrence)!.notes"> - {{ getCompletion(eventAddress, occurrence)!.notes }}</span>
<!-- Task Status Info (only for completable events with status) -->
<div v-if="isCompletable && completion" class="text-xs mb-3">
<div v-if="taskStatus === 'completed'" class="text-muted-foreground">
Completed by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
<div v-else-if="taskStatus === 'in-progress'" class="text-orange-600 dark:text-orange-400 font-medium">
🔄 In Progress by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
<div v-else-if="taskStatus === 'claimed'" class="text-blue-600 dark:text-blue-400 font-medium">
👋 Claimed by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
</div>
<!-- Author (if not admin) -->
@ -211,17 +340,70 @@ function cancelMarkComplete() {
Posted by {{ getDisplayName(event.pubkey) }}
</div>
<!-- Mark Complete Button (only for completable task events) -->
<div v-if="isCompletable && !isCompleted" class="mt-3">
<!-- Action Buttons (only for completable task events) -->
<div v-if="isCompletable" class="mt-3 flex flex-wrap gap-2">
<!-- Unclaimed Task -->
<Button
@click.stop="handleMarkComplete"
v-if="!taskStatus"
@click.stop="handleClaimTask"
variant="outline"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
<Hand class="h-4 w-4" />
Claim Task
</Button>
<!-- Claimed Task -->
<template v-else-if="taskStatus === 'claimed'">
<Button
@click.stop="handleStartTask"
variant="default"
size="sm"
class="gap-2"
>
<PlayCircle class="h-4 w-4" />
Start Task
</Button>
<Button
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
<!-- In Progress Task -->
<template v-else-if="taskStatus === 'in-progress'">
<Button
@click.stop="handleCompleteTask"
variant="default"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
<Button
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
<!-- Completed Task -->
<template v-else-if="taskStatus === 'completed'">
<Button
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
</div>
</div>
</CollapsibleContent>
@ -232,14 +414,14 @@ function cancelMarkComplete() {
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Event as Complete?</DialogTitle>
<DialogTitle>{{ dialogContent.title }}</DialogTitle>
<DialogDescription>
This will mark "{{ event.title }}" as completed by you. Other users will be able to see that you completed this event.
{{ dialogContent.description }}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="cancelMarkComplete">Cancel</Button>
<Button @click="confirmMarkComplete">Mark Complete</Button>
<Button variant="outline" @click="cancelAction">Cancel</Button>
<Button @click="confirmAction">{{ dialogContent.confirmText }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,6 +1,6 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ScheduledEventService, ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import type { AuthService } from '@/modules/base/auth/auth-service'
import { useToast } from '@/core/composables/useToast'
@ -64,8 +64,78 @@ export function useScheduledEvents() {
return scheduledEventService.isCompleted(eventAddress)
}
/**
* Get task status for an event
*/
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
if (!scheduledEventService) return null
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
}
/**
* Claim a task
*/
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.claimTask(event, notes, occurrence)
toast.success('Task claimed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to claim task'
if (message.includes('authenticated')) {
toast.error('Please sign in to claim tasks')
} else {
toast.error(message)
}
console.error('Failed to claim task:', error)
}
}
/**
* Start a task (mark as in-progress)
*/
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.startTask(event, notes, occurrence)
toast.success('Task started!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start task'
toast.error(message)
console.error('Failed to start task:', error)
}
}
/**
* Unclaim a task (remove task status)
*/
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
toast.error(message)
console.error('Failed to unclaim task:', error)
}
}
/**
* Toggle completion status of an event (optionally for a specific occurrence)
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
*/
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
@ -82,19 +152,19 @@ export function useScheduledEvents() {
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
if (currentlyCompleted) {
console.log('⬇️ useScheduledEvents: Marking as incomplete...')
await scheduledEventService.uncompleteEvent(event, occurrence)
toast.success('Event marked as incomplete')
console.log('⬇️ useScheduledEvents: Unclaiming task...')
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} else {
console.log('⬆️ useScheduledEvents: Marking as complete...')
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Event completed!')
toast.success('Task completed!')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
if (message.includes('authenticated')) {
toast.error('Please sign in to complete events')
toast.error('Please sign in to complete tasks')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
@ -108,19 +178,19 @@ export function useScheduledEvents() {
/**
* Complete an event with optional notes
*/
const completeEvent = async (event: ScheduledEvent, notes: string = ''): Promise<void> => {
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.completeEvent(event, notes)
toast.success('Event completed!')
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete event'
const message = error instanceof Error ? error.message : 'Failed to complete task'
toast.error(message)
console.error('Failed to complete event:', error)
console.error('Failed to complete task:', error)
}
}
@ -147,15 +217,21 @@ export function useScheduledEvents() {
})
return {
// Methods
// Methods - Getters
getScheduledEvents,
getEventsForDate,
getEventsForSpecificDate,
getTodaysEvents,
getCompletion,
isCompleted,
toggleComplete,
getTaskStatus,
// Methods - Actions
claimTask,
startTask,
completeEvent,
unclaimTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State
isLoading,

View file

@ -28,14 +28,16 @@ export interface ScheduledEvent {
recurrence?: RecurrencePattern // Optional: for recurring events
}
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
export interface EventCompletion {
id: string
eventAddress: string // "31922:pubkey:d-tag"
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
pubkey: string // Who completed it
pubkey: string // Who claimed/completed it
created_at: number
completed: boolean
completedAt?: number
taskStatus: TaskStatus
completedAt?: number // Unix timestamp when completed
notes: string
}
@ -158,7 +160,19 @@ export class ScheduledEventService extends BaseService {
return
}
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
// Parse task status (new approach)
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
// Backward compatibility: check old 'completed' tag if task-status not present
let taskStatus: TaskStatus
if (taskStatusTag) {
taskStatus = taskStatusTag
} else {
// Legacy support: convert old 'completed' tag to new taskStatus
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
taskStatus = completed ? 'completed' : 'claimed'
}
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
@ -166,7 +180,7 @@ export class ScheduledEventService extends BaseService {
console.log('📋 Completion details:', {
aTag,
occurrence,
completed,
taskStatus,
pubkey: event.pubkey,
eventId: event.id
})
@ -177,7 +191,7 @@ export class ScheduledEventService extends BaseService {
occurrence,
pubkey: event.pubkey,
created_at: event.created_at,
completed,
taskStatus,
completedAt,
notes: event.content
}
@ -189,7 +203,7 @@ export class ScheduledEventService extends BaseService {
const existing = this._completions.get(completionKey)
if (!existing || event.created_at > existing.created_at) {
this._completions.set(completionKey, completion)
console.log('✅ Stored completion for:', completionKey, '- completed:', completed)
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
} else {
console.log('⏭️ Skipped older completion for:', completionKey)
}
@ -310,15 +324,49 @@ export class ScheduledEventService extends BaseService {
*/
isCompleted(eventAddress: string, occurrence?: string): boolean {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.completed || false
return completion?.taskStatus === 'completed'
}
/**
* Get task status for an event
*/
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus || null
}
/**
* Claim a task (mark as claimed)
*/
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
}
/**
* Start a task (mark as in-progress)
*/
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
}
/**
* Mark an event as complete (optionally for a specific occurrence)
*/
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'completed', notes, occurrence)
}
/**
* Internal method to update task status
*/
private async updateTaskStatus(
event: ScheduledEvent,
taskStatus: TaskStatus,
notes: string = '',
occurrence?: string
): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to complete events')
throw new Error('Must be authenticated to update task status')
}
if (!this.relayHub?.isConnected) {
@ -335,14 +383,17 @@ export class ScheduledEventService extends BaseService {
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create RSVP/completion event (NIP-52)
// Create RSVP event with task-status tag
const tags: string[][] = [
['a', eventAddress],
['status', 'accepted'],
['completed', 'true'],
['completed_at', Math.floor(Date.now() / 1000).toString()]
['task-status', taskStatus]
]
// Add completed_at timestamp if task is completed
if (taskStatus === 'completed') {
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
}
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
@ -359,17 +410,17 @@ export class ScheduledEventService extends BaseService {
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the completion
console.log('📤 Publishing completion event (kind 31925) for:', eventAddress)
// Publish the status update
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Completion event published to', result.success, '/', result.total, 'relays')
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
// Optimistically update local state
console.log('🔄 Optimistically updating local state')
this.handleCompletionEvent(signedEvent)
} catch (error) {
console.error('Failed to complete event:', error)
console.error('Failed to update task status:', error)
throw error
} finally {
this._isLoading.value = false
@ -377,11 +428,13 @@ export class ScheduledEventService extends BaseService {
}
/**
* Uncomplete an event (publish new RSVP with completed=false)
* Unclaim/reset a task (removes task status - makes it unclaimed)
* Note: In Nostr, we can't truly "delete" an event, but we can publish
* a deletion request (kind 5) to ask relays to remove our RSVP
*/
async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise<void> {
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to uncomplete events')
throw new Error('Must be authenticated to unclaim tasks')
}
if (!this.relayHub?.isConnected) {
@ -397,38 +450,40 @@ export class ScheduledEventService extends BaseService {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
const completion = this._completions.get(completionKey)
// Create RSVP event with completed=false
const tags: string[][] = [
['a', eventAddress],
['status', 'tentative'],
['completed', 'false']
]
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
if (!completion) {
console.log('No completion to unclaim')
return
}
const eventTemplate: EventTemplate = {
kind: 31925,
content: '',
tags,
// Create deletion event (kind 5) for the RSVP
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task unclaimed',
tags: [
['e', completion.id], // Reference to the RSVP event being deleted
['k', '31925'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the uncomplete
await this.relayHub.publishEvent(signedEvent)
// Publish the deletion request
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
// Optimistically update local state
this.handleCompletionEvent(signedEvent)
// Optimistically remove from local state
this._completions.delete(completionKey)
console.log('🗑️ Removed completion from local state:', completionKey)
} catch (error) {
console.error('Failed to uncomplete event:', error)
console.error('Failed to unclaim task:', error)
throw error
} finally {
this._isLoading.value = false