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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ formattedTimeRange || formattedDate }}
+
+
+
+
+ {{ event.title }}
+
+
+
+
+
+
+
+
+
+ ✓ {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
+
+
+
+
+ 🔄
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formattedDate }}
+
+
+
+ {{ formattedTimeRange }}
+
+
+
+
+
+
+ {{ event.location }}
+
+
+
+
+
{{ event.description || event.content }}
+
+
+
+
+ ✓ Completed by {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
+ - {{ getCompletion(eventAddress, occurrence)!.notes }}
+
+
+
+
+ Posted by {{ getDisplayName(event.pubkey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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()
+ }
+}