web-app/src/modules/nostr-feed/services/ScheduledEventService.ts
padreug 661b700092 Adds support for completable task events
Enables marking scheduled events as complete based on a new "event-type" tag.

This change introduces the concept of "completable" events, specifically for events of type "task". It modifies the ScheduledEventCard component to:

- Display completion information only for completable events
- Show the "Mark Complete" button only for completable events that are not yet completed
- Adjust the opacity and strikethrough styling based on the event's completable and completed status.

The ScheduledEventService is updated to extract the event type from the "event-type" tag.
2025-11-06 11:30:42 +01:00

353 lines
10 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 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
content: string
tags: string[][]
}
export interface EventCompletion {
id: string
eventAddress: string // "31922:pubkey:d-tag"
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]
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,
content: event.content,
tags: event.tags
}
// 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
console.log('📋 Completion details:', {
aTag,
completed,
pubkey: event.pubkey,
eventId: event.id
})
const completion: EventCompletion = {
id: event.id,
eventAddress: aTag,
pubkey: event.pubkey,
created_at: event.created_at,
completed,
completedAt,
notes: event.content
}
// Store completion (most recent one wins)
const existing = this._completions.get(aTag)
if (!existing || event.created_at > existing.created_at) {
this._completions.set(aTag, completion)
console.log('✅ Stored completion for:', aTag, '- completed:', completed)
} else {
console.log('⏭️ Skipped older completion for:', aTag)
}
} 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
})
}
/**
* Get events for today
*/
getTodaysEvents(): ScheduledEvent[] {
const today = new Date().toISOString().split('T')[0]
return this.getEventsForDate(today)
}
/**
* Get completion status for an event
*/
getCompletion(eventAddress: string): EventCompletion | undefined {
return this._completions.get(eventAddress)
}
/**
* Check if an event is completed
*/
isCompleted(eventAddress: string): boolean {
const completion = this.getCompletion(eventAddress)
return completion?.completed || false
}
/**
* Mark an event as complete
*/
async completeEvent(event: ScheduledEvent, notes: 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 eventTemplate: EventTemplate = {
kind: 31925, // Calendar Event RSVP
content: notes,
tags: [
['a', eventAddress],
['status', 'accepted'],
['completed', 'true'],
['completed_at', Math.floor(Date.now() / 1000).toString()]
],
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): 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 eventTemplate: EventTemplate = {
kind: 31925,
content: '',
tags: [
['a', eventAddress],
['status', 'tentative'],
['completed', 'false']
],
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()
}
}