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 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 created_at: number completed: boolean completedAt?: number 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()) private _completions = reactive(new Map()) private _isLoading = ref(false) protected async onInitialize(): Promise { 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", "", "", ""] 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 } const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true' 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, completed, pubkey: event.pubkey, eventId: event.id }) const completion: EventCompletion = { id: event.id, eventAddress: aTag, occurrence, pubkey: event.pubkey, created_at: event.created_at, completed, 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, '- completed:', completed) } 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 today, optionally filtered by user participation */ getTodaysEvents(userPubkey?: string): ScheduledEvent[] { const today = new Date().toISOString().split('T')[0] // Get one-time events for today const oneTimeEvents = this.getEventsForDate(today) // Get all events and check for recurring events that occur today const allEvents = this.getScheduledEvents() const recurringEventsToday = allEvents.filter(event => event.recurrence && this.doesRecurringEventOccurOnDate(event, today) ) // Combine one-time and recurring events let events = [...oneTimeEvents, ...recurringEventsToday] // 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 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?.completed || false } /** * Mark an event as complete (optionally for a specific occurrence) */ async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise { if (!this.authService?.isAuthenticated?.value) { throw new Error('Must be authenticated to complete events') } 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/completion event (NIP-52) const tags: string[][] = [ ['a', eventAddress], ['status', 'accepted'], ['completed', 'true'], ['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 completion console.log('📤 Publishing completion event (kind 31925) for:', eventAddress) const result = await this.relayHub.publishEvent(signedEvent) console.log('✅ Completion event 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) throw error } finally { this._isLoading.value = false } } /** * Uncomplete an event (publish new RSVP with completed=false) */ async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise { if (!this.authService?.isAuthenticated?.value) { throw new Error('Must be authenticated to uncomplete events') } 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 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]) } const eventTemplate: EventTemplate = { kind: 31925, content: '', tags, created_at: Math.floor(Date.now() / 1000) } // Sign the event const privkeyBytes = this.hexToUint8Array(userPrivkey) const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) // Publish the uncomplete await this.relayHub.publishEvent(signedEvent) // Optimistically update local state this.handleCompletionEvent(signedEvent) } catch (error) { console.error('Failed to uncomplete event:', 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 { return this._scheduledEvents } /** * Get all completions */ get completions(): Map { return this._completions } /** * Check if currently loading */ get isLoading(): boolean { return this._isLoading.value } /** * Cleanup */ protected async onDestroy(): Promise { this._scheduledEvents.clear() this._completions.clear() } }