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>
532 lines
17 KiB
TypeScript
532 lines
17 KiB
TypeScript
import { ref, reactive } from 'vue'
|
|
import { BaseService } from '@/core/base/BaseService'
|
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|
import type { Event as NostrEvent } from 'nostr-tools'
|
|
|
|
export interface RecurrencePattern {
|
|
frequency: 'daily' | 'weekly'
|
|
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
|
|
endDate?: string // ISO date string - when to stop recurring (optional)
|
|
}
|
|
|
|
export interface ScheduledEvent {
|
|
id: string
|
|
pubkey: string
|
|
created_at: number
|
|
dTag: string // Unique identifier from 'd' tag
|
|
title: string
|
|
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
|
|
end?: string
|
|
description?: string
|
|
location?: string
|
|
status: string
|
|
eventType?: string // 'task' for completable events, 'announcement' for informational
|
|
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
|
|
content: string
|
|
tags: string[][]
|
|
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 claimed/completed it
|
|
created_at: number
|
|
taskStatus: TaskStatus
|
|
completedAt?: number // Unix timestamp when completed
|
|
notes: string
|
|
}
|
|
|
|
export class ScheduledEventService extends BaseService {
|
|
protected readonly metadata = {
|
|
name: 'ScheduledEventService',
|
|
version: '1.0.0',
|
|
dependencies: []
|
|
}
|
|
|
|
protected relayHub: any = null
|
|
protected authService: any = null
|
|
|
|
// Scheduled events state - indexed by event address
|
|
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
|
|
private _completions = reactive(new Map<string, EventCompletion>())
|
|
private _isLoading = ref(false)
|
|
|
|
protected async onInitialize(): Promise<void> {
|
|
console.log('ScheduledEventService: Starting initialization...')
|
|
|
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
|
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
|
|
|
if (!this.relayHub) {
|
|
throw new Error('RelayHub service not available')
|
|
}
|
|
|
|
console.log('ScheduledEventService: Initialization complete')
|
|
}
|
|
|
|
/**
|
|
* Handle incoming scheduled event (kind 31922)
|
|
* Made public so FeedService can route kind 31922 events to this service
|
|
*/
|
|
public handleScheduledEvent(event: NostrEvent): void {
|
|
try {
|
|
// Extract event data from tags
|
|
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
|
|
if (!dTag) {
|
|
console.warn('Scheduled event missing d tag:', event.id)
|
|
return
|
|
}
|
|
|
|
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
|
|
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
|
|
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
|
|
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
|
|
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
|
|
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
|
|
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
|
|
|
|
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
|
|
const participantTags = event.tags.filter(tag => tag[0] === 'p')
|
|
const participants = participantTags.map(tag => ({
|
|
pubkey: tag[1],
|
|
type: tag[3] // 'required', 'optional', 'organizer'
|
|
}))
|
|
|
|
// Parse recurrence tags
|
|
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
|
|
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
|
|
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
|
|
|
|
let recurrence: RecurrencePattern | undefined
|
|
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
|
|
recurrence = {
|
|
frequency: recurrenceFreq,
|
|
dayOfWeek: recurrenceDayOfWeek,
|
|
endDate: recurrenceEndDate
|
|
}
|
|
}
|
|
|
|
if (!start) {
|
|
console.warn('Scheduled event missing start date:', event.id)
|
|
return
|
|
}
|
|
|
|
// Create event address: "kind:pubkey:d-tag"
|
|
const eventAddress = `31922:${event.pubkey}:${dTag}`
|
|
|
|
const scheduledEvent: ScheduledEvent = {
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
dTag,
|
|
title,
|
|
start,
|
|
end,
|
|
description,
|
|
location,
|
|
status,
|
|
eventType,
|
|
participants: participants.length > 0 ? participants : undefined,
|
|
content: event.content,
|
|
tags: event.tags,
|
|
recurrence
|
|
}
|
|
|
|
// Store or update the event (replaceable by d-tag)
|
|
this._scheduledEvents.set(eventAddress, scheduledEvent)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to handle scheduled event:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle RSVP/completion event (kind 31925)
|
|
* Made public so FeedService can route kind 31925 events to this service
|
|
*/
|
|
public handleCompletionEvent(event: NostrEvent): void {
|
|
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
|
|
|
|
try {
|
|
// Find the event being responded to
|
|
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
|
|
if (!aTag) {
|
|
console.warn('Completion event missing a tag:', event.id)
|
|
return
|
|
}
|
|
|
|
// 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
|
|
|
|
console.log('📋 Completion details:', {
|
|
aTag,
|
|
occurrence,
|
|
taskStatus,
|
|
pubkey: event.pubkey,
|
|
eventId: event.id
|
|
})
|
|
|
|
const completion: EventCompletion = {
|
|
id: event.id,
|
|
eventAddress: aTag,
|
|
occurrence,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
taskStatus,
|
|
completedAt,
|
|
notes: event.content
|
|
}
|
|
|
|
// Store completion (most recent one wins)
|
|
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
|
|
// For non-recurring, just use eventAddress
|
|
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
|
|
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, '- status:', taskStatus)
|
|
} else {
|
|
console.log('⏭️ Skipped older completion for:', completionKey)
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to handle completion event:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all scheduled events
|
|
*/
|
|
getScheduledEvents(): ScheduledEvent[] {
|
|
return Array.from(this._scheduledEvents.values())
|
|
}
|
|
|
|
/**
|
|
* Get events scheduled for a specific date (YYYY-MM-DD)
|
|
*/
|
|
getEventsForDate(date: string): ScheduledEvent[] {
|
|
return this.getScheduledEvents().filter(event => {
|
|
// Simple date matching (start date)
|
|
// For ISO datetime strings, extract just the date part
|
|
const eventDate = event.start.split('T')[0]
|
|
return eventDate === date
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Check if a recurring event occurs on a specific date
|
|
*/
|
|
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
|
|
if (!event.recurrence) return false
|
|
|
|
const target = new Date(targetDate)
|
|
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
|
|
|
|
// Check if target date is before the event start date
|
|
if (target < eventStart) return false
|
|
|
|
// Check if target date is after the event end date (if specified)
|
|
if (event.recurrence.endDate) {
|
|
const endDate = new Date(event.recurrence.endDate)
|
|
if (target > endDate) return false
|
|
}
|
|
|
|
// Check frequency-specific rules
|
|
if (event.recurrence.frequency === 'daily') {
|
|
// Daily events occur every day within the range
|
|
return true
|
|
} else if (event.recurrence.frequency === 'weekly') {
|
|
// Weekly events occur on specific day of week
|
|
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
|
|
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
|
|
return targetDayOfWeek === eventDayOfWeek
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Get events for a specific date, optionally filtered by user participation
|
|
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
|
|
* @param userPubkey - Optional user pubkey to filter by participation
|
|
*/
|
|
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
|
|
const targetDate = date || new Date().toISOString().split('T')[0]
|
|
|
|
// Get one-time events for the date (exclude recurring events to avoid duplicates)
|
|
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
|
|
|
|
// Get all events and check for recurring events that occur on this date
|
|
const allEvents = this.getScheduledEvents()
|
|
const recurringEventsOnDate = allEvents.filter(event =>
|
|
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
|
|
)
|
|
|
|
// Combine one-time and recurring events
|
|
let events = [...oneTimeEvents, ...recurringEventsOnDate]
|
|
|
|
// Filter events based on participation (if user pubkey provided)
|
|
if (userPubkey) {
|
|
events = events.filter(event => {
|
|
// If event has no participants, it's community-wide (show to everyone)
|
|
if (!event.participants || event.participants.length === 0) return true
|
|
|
|
// Otherwise, only show if user is a participant
|
|
return event.participants.some(p => p.pubkey === userPubkey)
|
|
})
|
|
}
|
|
|
|
// Sort by start time (ascending order)
|
|
events.sort((a, b) => {
|
|
// ISO datetime strings can be compared lexicographically
|
|
return a.start.localeCompare(b.start)
|
|
})
|
|
|
|
return events
|
|
}
|
|
|
|
/**
|
|
* Get events for today, optionally filtered by user participation
|
|
*/
|
|
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
|
|
return this.getEventsForSpecificDate(undefined, userPubkey)
|
|
}
|
|
|
|
/**
|
|
* Get completion status for an event (optionally for a specific occurrence)
|
|
*/
|
|
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
|
|
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
|
return this._completions.get(completionKey)
|
|
}
|
|
|
|
/**
|
|
* Check if an event is completed (optionally for a specific occurrence)
|
|
*/
|
|
isCompleted(eventAddress: string, occurrence?: string): boolean {
|
|
const completion = this.getCompletion(eventAddress, occurrence)
|
|
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 update task status')
|
|
}
|
|
|
|
if (!this.relayHub?.isConnected) {
|
|
throw new Error('Not connected to relays')
|
|
}
|
|
|
|
const userPrivkey = this.authService.user.value?.prvkey
|
|
if (!userPrivkey) {
|
|
throw new Error('User private key not available')
|
|
}
|
|
|
|
try {
|
|
this._isLoading.value = true
|
|
|
|
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
|
|
// Create RSVP event with task-status tag
|
|
const tags: string[][] = [
|
|
['a', eventAddress],
|
|
['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])
|
|
}
|
|
|
|
const eventTemplate: EventTemplate = {
|
|
kind: 31925, // Calendar Event RSVP
|
|
content: notes,
|
|
tags,
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
// Sign the event
|
|
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
|
|
|
// Publish the status update
|
|
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
|
|
const result = await this.relayHub.publishEvent(signedEvent)
|
|
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 update task status:', error)
|
|
throw error
|
|
} finally {
|
|
this._isLoading.value = 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 unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
|
|
if (!this.authService?.isAuthenticated?.value) {
|
|
throw new Error('Must be authenticated to unclaim tasks')
|
|
}
|
|
|
|
if (!this.relayHub?.isConnected) {
|
|
throw new Error('Not connected to relays')
|
|
}
|
|
|
|
const userPrivkey = this.authService.user.value?.prvkey
|
|
if (!userPrivkey) {
|
|
throw new Error('User private key not available')
|
|
}
|
|
|
|
try {
|
|
this._isLoading.value = true
|
|
|
|
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
|
const completion = this._completions.get(completionKey)
|
|
|
|
if (!completion) {
|
|
console.log('No completion to unclaim')
|
|
return
|
|
}
|
|
|
|
// 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(deletionEvent, privkeyBytes)
|
|
|
|
// 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 remove from local state
|
|
this._completions.delete(completionKey)
|
|
console.log('🗑️ Removed completion from local state:', completionKey)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to unclaim task:', error)
|
|
throw error
|
|
} finally {
|
|
this._isLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function to convert hex string to Uint8Array
|
|
*/
|
|
private hexToUint8Array(hex: string): Uint8Array {
|
|
const bytes = new Uint8Array(hex.length / 2)
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
/**
|
|
* Get all scheduled events
|
|
*/
|
|
get scheduledEvents(): Map<string, ScheduledEvent> {
|
|
return this._scheduledEvents
|
|
}
|
|
|
|
/**
|
|
* Get all completions
|
|
*/
|
|
get completions(): Map<string, EventCompletion> {
|
|
return this._completions
|
|
}
|
|
|
|
/**
|
|
* Check if currently loading
|
|
*/
|
|
get isLoading(): boolean {
|
|
return this._isLoading.value
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
protected async onDestroy(): Promise<void> {
|
|
this._scheduledEvents.clear()
|
|
this._completions.clear()
|
|
}
|
|
}
|