diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 1061ea5..7f22c2b 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -200,10 +200,10 @@ async function onToggleLike(note: FeedPost) { } // Handle scheduled event completion toggle -async function onToggleComplete(event: ScheduledEvent) { - console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title) +async function onToggleComplete(event: ScheduledEvent, occurrence?: string) { + console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence) try { - await toggleComplete(event) + await toggleComplete(event, occurrence) console.log('✅ NostrFeed: toggleComplete succeeded') } catch (error) { console.error('❌ NostrFeed: Failed to toggle event completion:', error) diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue index f7954bd..fbe50b9 100644 --- a/src/modules/nostr-feed/components/ScheduledEventCard.vue +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -21,12 +21,12 @@ import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEvent interface Props { event: ScheduledEvent getDisplayName: (pubkey: string) => string - getCompletion: (eventAddress: string) => EventCompletion | undefined + getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined adminPubkeys?: string[] } interface Emits { - (e: 'toggle-complete', event: ScheduledEvent): void + (e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void } const props = withDefaults(defineProps(), { @@ -41,11 +41,20 @@ const showConfirmDialog = ref(false) // Event address for tracking completion const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`) +// Check if this is a recurring event +const isRecurring = computed(() => !!props.event.recurrence) + +// For recurring events, occurrence is today's date. For non-recurring, it's undefined. +const occurrence = computed(() => { + if (!isRecurring.value) return undefined + return new Date().toISOString().split('T')[0] // YYYY-MM-DD +}) + // Check if this is an admin event const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey)) -// Check if event is completed - call function directly -const isCompleted = computed(() => props.getCompletion(eventAddress.value)?.completed || false) +// Check if event is completed - call function with occurrence for recurring events +const isCompleted = computed(() => props.getCompletion(eventAddress.value, occurrence.value)?.completed || false) // Check if event is completable (task type) const isCompletable = computed(() => props.event.eventType === 'task') @@ -109,8 +118,8 @@ function handleMarkComplete() { // Confirm and execute mark complete function confirmMarkComplete() { - console.log('✅ Confirmed mark complete for event:', props.event.title) - emit('toggle-complete', props.event) + console.log('✅ Confirmed mark complete for event:', props.event.title, 'occurrence:', occurrence.value) + emit('toggle-complete', props.event, occurrence.value) showConfirmDialog.value = false } @@ -152,8 +161,13 @@ function cancelMarkComplete() { - - ✓ {{ getDisplayName(getCompletion(eventAddress)!.pubkey) }} + + ✓ {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }} + + + + + 🔄 @@ -192,9 +206,9 @@ function cancelMarkComplete() { -
- ✓ Completed by {{ getDisplayName(getCompletion(eventAddress)!.pubkey) }} - - {{ getCompletion(eventAddress)!.notes }} +
+ ✓ Completed by {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }} + - {{ getCompletion(eventAddress, occurrence)!.notes }}
diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts index 42552c7..2f24189 100644 --- a/src/modules/nostr-feed/composables/useScheduledEvents.ts +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -56,10 +56,10 @@ export function useScheduledEvents() { } /** - * Toggle completion status of an event + * Toggle completion status of an event (optionally for a specific occurrence) */ - const toggleComplete = async (event: ScheduledEvent, notes: string = ''): Promise => { - console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title) + const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise => { + console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence) if (!scheduledEventService) { console.error('❌ useScheduledEvents: Scheduled event service not available') @@ -69,16 +69,16 @@ export function useScheduledEvents() { try { const eventAddress = `31922:${event.pubkey}:${event.dTag}` - const currentlyCompleted = scheduledEventService.isCompleted(eventAddress) + const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence) console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted) if (currentlyCompleted) { console.log('⬇️ useScheduledEvents: Marking as incomplete...') - await scheduledEventService.uncompleteEvent(event) + await scheduledEventService.uncompleteEvent(event, occurrence) toast.success('Event marked as incomplete') } else { console.log('⬆️ useScheduledEvents: Marking as complete...') - await scheduledEventService.completeEvent(event, notes) + await scheduledEventService.completeEvent(event, notes, occurrence) toast.success('Event completed!') } } catch (error) { diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts index 6215e4b..17d9f43 100644 --- a/src/modules/nostr-feed/services/ScheduledEventService.ts +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -4,6 +4,12 @@ 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 @@ -19,11 +25,13 @@ export interface ScheduledEvent { 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 @@ -87,6 +95,20 @@ export class ScheduledEventService extends BaseService { 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 @@ -109,7 +131,8 @@ export class ScheduledEventService extends BaseService { eventType, participants: participants.length > 0 ? participants : undefined, content: event.content, - tags: event.tags + tags: event.tags, + recurrence } // Store or update the event (replaceable by d-tag) @@ -138,9 +161,11 @@ export class ScheduledEventService extends BaseService { 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 @@ -149,6 +174,7 @@ export class ScheduledEventService extends BaseService { const completion: EventCompletion = { id: event.id, eventAddress: aTag, + occurrence, pubkey: event.pubkey, created_at: event.created_at, completed, @@ -157,12 +183,15 @@ export class ScheduledEventService extends BaseService { } // Store completion (most recent one wins) - const existing = this._completions.get(aTag) + // 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(aTag, completion) - console.log('✅ Stored completion for:', aTag, '- completed:', completed) + this._completions.set(completionKey, completion) + console.log('✅ Stored completion for:', completionKey, '- completed:', completed) } else { - console.log('⏭️ Skipped older completion for:', aTag) + console.log('⏭️ Skipped older completion for:', completionKey) } } catch (error) { @@ -189,12 +218,55 @@ export class ScheduledEventService extends BaseService { }) } + /** + * 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] - let events = this.getEventsForDate(today) + + // 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) { @@ -217,24 +289,25 @@ export class ScheduledEventService extends BaseService { } /** - * Get completion status for an event + * Get completion status for an event (optionally for a specific occurrence) */ - getCompletion(eventAddress: string): EventCompletion | undefined { - return this._completions.get(eventAddress) + getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined { + const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress + return this._completions.get(completionKey) } /** - * Check if an event is completed + * Check if an event is completed (optionally for a specific occurrence) */ - isCompleted(eventAddress: string): boolean { - const completion = this.getCompletion(eventAddress) + isCompleted(eventAddress: string, occurrence?: string): boolean { + const completion = this.getCompletion(eventAddress, occurrence) return completion?.completed || false } /** - * Mark an event as complete + * Mark an event as complete (optionally for a specific occurrence) */ - async completeEvent(event: ScheduledEvent, notes: string = ''): Promise { + async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise { if (!this.authService?.isAuthenticated?.value) { throw new Error('Must be authenticated to complete events') } @@ -254,15 +327,22 @@ export class ScheduledEventService extends BaseService { 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: [ - ['a', eventAddress], - ['status', 'accepted'], - ['completed', 'true'], - ['completed_at', Math.floor(Date.now() / 1000).toString()] - ], + tags, created_at: Math.floor(Date.now() / 1000) } @@ -290,7 +370,7 @@ export class ScheduledEventService extends BaseService { /** * Uncomplete an event (publish new RSVP with completed=false) */ - async uncompleteEvent(event: ScheduledEvent): Promise { + async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise { if (!this.authService?.isAuthenticated?.value) { throw new Error('Must be authenticated to uncomplete events') } @@ -310,14 +390,21 @@ export class ScheduledEventService extends BaseService { 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: [ - ['a', eventAddress], - ['status', 'tentative'], - ['completed', 'false'] - ], + tags, created_at: Math.floor(Date.now() / 1000) }