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.
This commit is contained in:
padreug 2025-10-22 00:16:47 +02:00
parent 2cf737213b
commit 0f0eae8800
4 changed files with 147 additions and 46 deletions

View file

@ -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<void> {
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
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<void> {
async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise<void> {
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)
}