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

@ -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