web-app/src/modules/nostr-feed/services/ScheduledEventService.ts
padreug 0f0eae8800 Enables recurring scheduled event completion
Extends scheduled event completion to support recurring events.

The changes introduce the concept of an "occurrence" for recurring events,
allowing users to mark individual instances of a recurring event as complete.
This involves:
- Adding recurrence information to the ScheduledEvent model.
- Modifying completion logic to handle recurring events with daily/weekly frequencies
- Updating UI to display recurrence information and mark individual occurrences as complete.
2025-10-31 22:00:19 +01:00

468 lines
15 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 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<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
}
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<void> {
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<void> {
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<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()
}
}