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:
parent
2e6f215157
commit
d497cfa4d9
4 changed files with 452 additions and 100 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue