diff --git a/package-lock.json b/package-lock.json index aa24fb0..1915af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -141,6 +141,7 @@ "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2646,6 +2647,7 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -5688,6 +5690,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5850,14 +5853,6 @@ "node": ">= 0.4" } }, - "node_modules/async-validator": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", - "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -6058,6 +6053,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -7604,17 +7600,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -8368,6 +8353,7 @@ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=10" } @@ -11718,6 +11704,7 @@ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "license": "MIT", + "peer": true, "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", @@ -12361,6 +12348,7 @@ "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13370,7 +13358,8 @@ "version": "4.0.12", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz", "integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -13505,6 +13494,7 @@ "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -13734,6 +13724,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13984,6 +13975,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14208,6 +14200,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.13", "@vue/compiler-sfc": "3.5.13", @@ -14660,6 +14653,7 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14917,6 +14911,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 32221f5..da71624 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -136,6 +136,7 @@ export const SERVICE_TOKENS = { FEED_SERVICE: Symbol('feedService'), PROFILE_SERVICE: Symbol('profileService'), REACTION_SERVICE: Symbol('reactionService'), + SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'), // Nostr metadata services NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 64790ef..2d0aa4c 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -9,13 +9,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next' +import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next' import { useFeed } from '../composables/useFeed' import { useProfiles } from '../composables/useProfiles' import { useReactions } from '../composables/useReactions' +import { useScheduledEvents } from '../composables/useScheduledEvents' import ThreadedPost from './ThreadedPost.vue' +import ScheduledEventCard from './ScheduledEventCard.vue' import appConfig from '@/app.config' import type { ContentFilter, FeedPost } from '../services/FeedService' +import type { ScheduledEvent } from '../services/ScheduledEventService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { AuthService } from '@/modules/base/auth/auth-service' import type { RelayHub } from '@/modules/base/nostr/relay-hub' @@ -95,6 +98,68 @@ const { getDisplayName, fetchProfiles } = useProfiles() // Use reactions service for likes/hearts const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() +// Use scheduled events service +const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents() + +// Selected date for viewing events (defaults to today) +const selectedDate = ref(new Date().toISOString().split('T')[0]) + +// Get scheduled events for the selected date (reactive) +const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value)) + +// Navigate to previous day +function goToPreviousDay() { + const date = new Date(selectedDate.value) + date.setDate(date.getDate() - 1) + selectedDate.value = date.toISOString().split('T')[0] +} + +// Navigate to next day +function goToNextDay() { + const date = new Date(selectedDate.value) + date.setDate(date.getDate() + 1) + selectedDate.value = date.toISOString().split('T')[0] +} + +// Go back to today +function goToToday() { + selectedDate.value = new Date().toISOString().split('T')[0] +} + +// Check if selected date is today +const isToday = computed(() => { + const today = new Date().toISOString().split('T')[0] + return selectedDate.value === today +}) + +// Format date for display +const dateDisplayText = computed(() => { + const today = new Date().toISOString().split('T')[0] + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayStr = yesterday.toISOString().split('T')[0] + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + const tomorrowStr = tomorrow.toISOString().split('T')[0] + + if (selectedDate.value === today) { + return "Today's Events" + } else if (selectedDate.value === yesterdayStr) { + return "Yesterday's Events" + } else if (selectedDate.value === tomorrowStr) { + return "Tomorrow's Events" + } else { + // Format as "Events for Mon, Jan 15" + const date = new Date(selectedDate.value + 'T00:00:00') + const formatted = date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }) + return `Events for ${formatted}` + } +}) + // Watch for new posts and fetch their profiles and reactions watch(notes, async (newNotes) => { if (newNotes.length > 0) { @@ -109,6 +174,38 @@ watch(notes, async (newNotes) => { } }, { immediate: true }) +// Watch for scheduled events and fetch profiles for event authors and completers +watch(scheduledEventsForDate, async (events) => { + if (events.length > 0) { + const pubkeys = new Set() + + // Add event authors + events.forEach((event: ScheduledEvent) => { + pubkeys.add(event.pubkey) + + // Add completer pubkey if event is completed + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + const completion = getCompletion(eventAddress) + if (completion) { + pubkeys.add(completion.pubkey) + } + }) + + // Fetch all profiles + if (pubkeys.size > 0) { + await fetchProfiles([...pubkeys]) + } + } +}, { immediate: true }) + +// Watch for new completions and fetch profiles for completers +watch(allCompletions, async (completions) => { + if (completions.length > 0) { + const pubkeys = completions.map(c => c.pubkey) + await fetchProfiles(pubkeys) + } +}, { immediate: true }) + // Check if we have admin pubkeys configured const hasAdminPubkeys = computed(() => adminPubkeys.length > 0) @@ -158,6 +255,17 @@ async function onToggleLike(note: FeedPost) { } } +// Handle scheduled event completion toggle +async function onToggleComplete(event: ScheduledEvent, occurrence?: string) { + console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence) + try { + await toggleComplete(event, occurrence) + console.log('✅ NostrFeed: toggleComplete succeeded') + } catch (error) { + console.error('❌ NostrFeed: Failed to toggle event completion:', error) + } +} + // Handle collapse toggle with cascading behavior function onToggleCollapse(postId: string) { const newCollapsed = new Set(collapsedPosts.value) @@ -356,20 +464,70 @@ function cancelDelete() {

- -
-
- - No posts yet -
-

- Check back later for community updates. -

-
-
-
+ +
+
+ + + + +
+

+ 📅 {{ dateDisplayText }} +

+ +
+ + + +
+ + +
+ +
+
+ {{ isToday ? 'no tasks today' : 'no tasks for this day' }} +
+
+ + +
+

+ 💬 Posts +

+ +
+
+ + No posts yet +
+

+ Check back later for community updates. +

+
+ -
+

🐢

diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue new file mode 100644 index 0000000..dfc48af --- /dev/null +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -0,0 +1,246 @@ + + + diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts new file mode 100644 index 0000000..e56c002 --- /dev/null +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -0,0 +1,165 @@ +import { computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ScheduledEventService, ScheduledEvent, EventCompletion } from '../services/ScheduledEventService' +import type { AuthService } from '@/modules/base/auth/auth-service' +import { useToast } from '@/core/composables/useToast' + +/** + * Composable for managing scheduled events in the feed + */ +export function useScheduledEvents() { + const scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE) + const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) + const toast = useToast() + + // Get current user's pubkey + const currentUserPubkey = computed(() => authService?.user.value?.pubkey) + + /** + * Get all scheduled events + */ + const getScheduledEvents = (): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getScheduledEvents() + } + + /** + * Get events for a specific date (YYYY-MM-DD) + */ + const getEventsForDate = (date: string): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getEventsForDate(date) + } + + /** + * Get events for a specific date (filtered by current user participation) + * @param date - ISO date string (YYYY-MM-DD). Defaults to today. + */ + const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value) + } + + /** + * Get today's scheduled events (filtered by current user participation) + */ + const getTodaysEvents = (): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getTodaysEvents(currentUserPubkey.value) + } + + /** + * Get completion status for an event + */ + const getCompletion = (eventAddress: string): EventCompletion | undefined => { + if (!scheduledEventService) return undefined + return scheduledEventService.getCompletion(eventAddress) + } + + /** + * Check if an event is completed + */ + const isCompleted = (eventAddress: string): boolean => { + if (!scheduledEventService) return false + return scheduledEventService.isCompleted(eventAddress) + } + + /** + * Toggle completion status of an event (optionally for a specific occurrence) + */ + 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') + toast.error('Scheduled event service not available') + return + } + + try { + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + 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, occurrence) + toast.success('Event marked as incomplete') + } else { + console.log('⬆️ useScheduledEvents: Marking as complete...') + await scheduledEventService.completeEvent(event, notes, occurrence) + toast.success('Event completed!') + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to toggle completion' + + if (message.includes('authenticated')) { + toast.error('Please sign in to complete events') + } else if (message.includes('Not connected')) { + toast.error('Not connected to relays') + } else { + toast.error(message) + } + + console.error('❌ useScheduledEvents: Failed to toggle completion:', error) + } + } + + /** + * Complete an event with optional notes + */ + const completeEvent = async (event: ScheduledEvent, notes: string = ''): Promise => { + if (!scheduledEventService) { + toast.error('Scheduled event service not available') + return + } + + try { + await scheduledEventService.completeEvent(event, notes) + toast.success('Event completed!') + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to complete event' + toast.error(message) + console.error('Failed to complete event:', error) + } + } + + /** + * Get loading state + */ + const isLoading = computed(() => { + return scheduledEventService?.isLoading ?? false + }) + + /** + * Get all scheduled events (reactive) + */ + const allScheduledEvents = computed(() => { + return scheduledEventService?.scheduledEvents ?? new Map() + }) + + /** + * Get all completions (reactive) - returns array for better reactivity + */ + const allCompletions = computed(() => { + if (!scheduledEventService?.completions) return [] + return Array.from(scheduledEventService.completions.values()) + }) + + return { + // Methods + getScheduledEvents, + getEventsForDate, + getEventsForSpecificDate, + getTodaysEvents, + getCompletion, + isCompleted, + toggleComplete, + completeEvent, + + // State + isLoading, + allScheduledEvents, + allCompletions + } +} diff --git a/src/modules/nostr-feed/config/content-filters.ts b/src/modules/nostr-feed/config/content-filters.ts index 46808ee..903131d 100644 --- a/src/modules/nostr-feed/config/content-filters.ts +++ b/src/modules/nostr-feed/config/content-filters.ts @@ -88,6 +88,14 @@ export const CONTENT_FILTERS: Record = { description: 'Rideshare requests, offers, and coordination', tags: ['rideshare', 'carpool'], // NIP-12 tags keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶'] + }, + + // Scheduled events (NIP-52) + scheduledEvents: { + id: 'scheduled-events', + label: 'Scheduled Events', + kinds: [31922], // NIP-52: Calendar Events + description: 'Calendar-based tasks and scheduled activities' } } @@ -110,6 +118,11 @@ export const FILTER_PRESETS: Record = { // Rideshare only rideshare: [ CONTENT_FILTERS.rideshare + ], + + // Scheduled events only + scheduledEvents: [ + CONTENT_FILTERS.scheduledEvents ] } diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts index 9967476..e3a7191 100644 --- a/src/modules/nostr-feed/index.ts +++ b/src/modules/nostr-feed/index.ts @@ -6,6 +6,7 @@ import { useFeed } from './composables/useFeed' import { FeedService } from './services/FeedService' import { ProfileService } from './services/ProfileService' import { ReactionService } from './services/ReactionService' +import { ScheduledEventService } from './services/ScheduledEventService' /** * Nostr Feed Module Plugin @@ -23,10 +24,12 @@ export const nostrFeedModule: ModulePlugin = { const feedService = new FeedService() const profileService = new ProfileService() const reactionService = new ReactionService() + const scheduledEventService = new ScheduledEventService() container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService) container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService) container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService) + container.provide(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE, scheduledEventService) console.log('nostr-feed module: Services registered in DI container') // Initialize services @@ -43,6 +46,10 @@ export const nostrFeedModule: ModulePlugin = { reactionService.initialize({ waitForDependencies: true, maxRetries: 3 + }), + scheduledEventService.initialize({ + waitForDependencies: true, + maxRetries: 3 }) ]) console.log('nostr-feed module: Services initialized') diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts index 377186b..500f963 100644 --- a/src/modules/nostr-feed/services/FeedService.ts +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -47,6 +47,7 @@ export class FeedService extends BaseService { protected relayHub: any = null protected visibilityService: any = null protected reactionService: any = null + protected scheduledEventService: any = null // Event ID tracking for deduplication private seenEventIds = new Set() @@ -72,10 +73,12 @@ export class FeedService extends BaseService { this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) + this.scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE) console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: VisibilityService injected:', !!this.visibilityService) console.log('FeedService: ReactionService injected:', !!this.reactionService) + console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService) if (!this.relayHub) { throw new Error('RelayHub service not available') @@ -199,6 +202,12 @@ export class FeedService extends BaseService { kinds: [5] // All deletion events (for both posts and reactions) }) + // Add scheduled events (kind 31922) and RSVPs (kind 31925) + filters.push({ + kinds: [31922, 31925], // Calendar events and RSVPs + limit: 200 + }) + console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters) // Subscribe to all events (posts, reactions, deletions) with deduplication @@ -257,6 +266,25 @@ export class FeedService extends BaseService { return } + // Route scheduled events (kind 31922) to ScheduledEventService + if (event.kind === 31922) { + if (this.scheduledEventService) { + this.scheduledEventService.handleScheduledEvent(event) + } + return + } + + // Route RSVP/completion events (kind 31925) to ScheduledEventService + if (event.kind === 31925) { + console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService') + if (this.scheduledEventService) { + this.scheduledEventService.handleCompletionEvent(event) + } else { + console.warn('⚠️ FeedService: ScheduledEventService not available') + } + return + } + // Skip if event already seen (for posts only, kind 1) if (this.seenEventIds.has(event.id)) { return diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts new file mode 100644 index 0000000..21af01f --- /dev/null +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -0,0 +1,477 @@ +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 a specific date, optionally filtered by user participation + * @param date - ISO date string (YYYY-MM-DD). Defaults to today. + * @param userPubkey - Optional user pubkey to filter by participation + */ + getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] { + const targetDate = date || new Date().toISOString().split('T')[0] + + // Get one-time events for the date (exclude recurring events to avoid duplicates) + const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence) + + // Get all events and check for recurring events that occur on this date + const allEvents = this.getScheduledEvents() + const recurringEventsOnDate = allEvents.filter(event => + event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate) + ) + + // Combine one-time and recurring events + let events = [...oneTimeEvents, ...recurringEventsOnDate] + + // 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 events for today, optionally filtered by user participation + */ + getTodaysEvents(userPubkey?: string): ScheduledEvent[] { + return this.getEventsForSpecificDate(undefined, userPubkey) + } + + /** + * 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() + } +}