From b6d8a78cd806fe4b5673b6781c14673e73f2e82d Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 6 Nov 2025 11:30:36 +0100 Subject: [PATCH 01/18] Adds peer dependencies to package-lock.json Ensures that necessary peer dependencies are correctly installed when the project is set up, preventing potential runtime errors or unexpected behavior due to missing dependencies. --- package-lock.json | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) 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" } From 9b05bcc2386077e9950ccf2cba7faf5962bf94d8 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 21 Oct 2025 21:58:58 +0200 Subject: [PATCH 02/18] Adds scheduled events to the feed Implements NIP-52 scheduled events, allowing users to view and interact with calendar events. A new `ScheduledEventService` is introduced to manage fetching, storing, and completing scheduled events. A new `ScheduledEventCard` component is introduced for displaying the scheduled events. --- src/core/di-container.ts | 1 + .../nostr-feed/components/NostrFeed.vue | 42 ++- .../components/ScheduledEventCard.vue | 151 ++++++++ .../composables/useScheduledEvents.ts | 143 ++++++++ .../nostr-feed/config/content-filters.ts | 13 + src/modules/nostr-feed/index.ts | 7 + .../nostr-feed/services/FeedService.ts | 25 ++ .../services/ScheduledEventService.ts | 335 ++++++++++++++++++ 8 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 src/modules/nostr-feed/components/ScheduledEventCard.vue create mode 100644 src/modules/nostr-feed/composables/useScheduledEvents.ts create mode 100644 src/modules/nostr-feed/services/ScheduledEventService.ts 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..6031a0e 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -13,9 +13,12 @@ import { Megaphone, RefreshCw, AlertCircle } 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,12 @@ const { getDisplayName, fetchProfiles } = useProfiles() // Use reactions service for likes/hearts const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() +// Use scheduled events service +const { getTodaysEvents, getCompletion, toggleComplete } = useScheduledEvents() + +// Get today's scheduled events (reactive) +const todaysScheduledEvents = computed(() => getTodaysEvents()) + // Watch for new posts and fetch their profiles and reactions watch(notes, async (newNotes) => { if (newNotes.length > 0) { @@ -158,6 +167,15 @@ async function onToggleLike(note: FeedPost) { } } +// Handle scheduled event completion toggle +async function onToggleComplete(event: ScheduledEvent) { + try { + await toggleComplete(event) + } catch (error) { + console.error('Failed to toggle event completion:', error) + } +} + // Handle collapse toggle with cascading behavior function onToggleCollapse(postId: string) { const newCollapsed = new Set(collapsedPosts.value) @@ -369,7 +387,29 @@ function cancelDelete() {
-
+ +
+

+ 📅 Today's Events +

+
+ +
+
+ + +
+

+ 💬 Posts +

+import { computed } from 'vue' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Calendar, MapPin, Clock } from 'lucide-vue-next' +import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService' + +interface Props { + event: ScheduledEvent + completion?: EventCompletion + getDisplayName: (pubkey: string) => string + adminPubkeys?: string[] +} + +interface Emits { + (e: 'toggle-complete', event: ScheduledEvent): void +} + +const props = withDefaults(defineProps(), { + adminPubkeys: () => [] +}) + +const emit = defineEmits() + +// Check if this is an admin event +const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey)) + +// Check if event is completed +const isCompleted = computed(() => props.completion?.completed || false) + +// Format the date/time +const formattedDate = computed(() => { + try { + const date = new Date(props.event.start) + + // Check if it's a datetime or just date + if (props.event.start.includes('T')) { + // Full datetime - show date and time + return date.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) + } else { + // Just date + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }) + } + } catch (error) { + return props.event.start + } +}) + +// Format the time range if end time exists +const formattedTimeRange = computed(() => { + if (!props.event.end || !props.event.start.includes('T')) return null + + try { + const start = new Date(props.event.start) + const end = new Date(props.event.end) + + const startTime = start.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }) + const endTime = end.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }) + + return `${startTime} - ${endTime}` + } catch (error) { + return null + } +}) + +// Handle checkbox toggle +function handleToggleComplete() { + emit('toggle-complete', props.event) +} + + + diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts new file mode 100644 index 0000000..54a9db8 --- /dev/null +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -0,0 +1,143 @@ +import { computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ScheduledEventService, ScheduledEvent, EventCompletion } from '../services/ScheduledEventService' +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 toast = useToast() + + /** + * 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 today's scheduled events + */ + const getTodaysEvents = (): ScheduledEvent[] => { + if (!scheduledEventService) return [] + return scheduledEventService.getTodaysEvents() + } + + /** + * 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 + */ + const toggleComplete = async (event: ScheduledEvent, notes: string = ''): Promise => { + if (!scheduledEventService) { + toast.error('Scheduled event service not available') + return + } + + try { + const eventAddress = `31922:${event.pubkey}:${event.dTag}` + const currentlyCompleted = scheduledEventService.isCompleted(eventAddress) + + if (currentlyCompleted) { + await scheduledEventService.uncompleteEvent(event) + toast.success('Event marked as incomplete') + } else { + await scheduledEventService.completeEvent(event, notes) + 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('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) + */ + const allCompletions = computed(() => { + return scheduledEventService?.completions ?? new Map() + }) + + return { + // Methods + getScheduledEvents, + getEventsForDate, + 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..667bc82 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,22 @@ 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) { + if (this.scheduledEventService) { + this.scheduledEventService.handleCompletionEvent(event) + } + 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..136e29a --- /dev/null +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -0,0 +1,335 @@ +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 + 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()) + 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' + + 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, + 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 { + 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 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) + } + + } 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 { + 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 + await this.relayHub.publishEvent(signedEvent) + + // Optimistically update 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 { + 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 { + 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() + } +} From 4050b33d0efb00ac1e5177207bc5f9aff3ce67e1 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 21 Oct 2025 22:31:35 +0200 Subject: [PATCH 03/18] Enables marking scheduled events as complete Implements a feature to mark scheduled events as complete, replacing the checkbox with a button for improved UX. This commit enhances the Scheduled Events functionality by allowing users to mark events as complete. It also includes: - Replaces the checkbox with a "Mark Complete" button for better usability. - Adds logging for debugging purposes during event completion toggling. - Routes completion events (kind 31925) to the ScheduledEventService. - Optimistically updates the local state after publishing completion events. --- .../nostr-feed/components/NostrFeed.vue | 6 +- .../components/ScheduledEventCard.vue | 117 +++++++++--------- .../composables/useScheduledEvents.ts | 8 +- .../nostr-feed/services/FeedService.ts | 3 + .../services/ScheduledEventService.ts | 17 ++- 5 files changed, 91 insertions(+), 60 deletions(-) diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 6031a0e..614eb90 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -169,10 +169,12 @@ async function onToggleLike(note: FeedPost) { // Handle scheduled event completion toggle async function onToggleComplete(event: ScheduledEvent) { + console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title) try { await toggleComplete(event) + console.log('✅ NostrFeed: toggleComplete succeeded') } catch (error) { - console.error('Failed to toggle event completion:', error) + console.error('❌ NostrFeed: Failed to toggle event completion:', error) } } @@ -397,8 +399,8 @@ function cancelDelete() { v-for="event in todaysScheduledEvents" :key="`${event.pubkey}:${event.dTag}`" :event="event" - :completion="getCompletion(`31922:${event.pubkey}:${event.dTag}`)" :get-display-name="getDisplayName" + :get-completion="getCompletion" :admin-pubkeys="adminPubkeys" @toggle-complete="onToggleComplete" /> diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue index 4480974..7ea3d65 100644 --- a/src/modules/nostr-feed/components/ScheduledEventCard.vue +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -1,14 +1,14 @@ @@ -88,63 +92,64 @@ function handleToggleComplete() { From 46418ef6fdec103fca5981e5551af6551e60a519 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 21 Oct 2025 22:47:34 +0200 Subject: [PATCH 05/18] Fetches profiles for event authors/completers Ensures profiles are fetched for authors and completers of scheduled events, improving user experience by displaying relevant user information. This is achieved by watching for scheduled events and completions, then fetching profiles for any new pubkeys encountered. --- .../nostr-feed/components/NostrFeed.vue | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 614eb90..e9592e5 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -99,7 +99,7 @@ const { getDisplayName, fetchProfiles } = useProfiles() const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() // Use scheduled events service -const { getTodaysEvents, getCompletion, toggleComplete } = useScheduledEvents() +const { getTodaysEvents, getCompletion, toggleComplete, allCompletions } = useScheduledEvents() // Get today's scheduled events (reactive) const todaysScheduledEvents = computed(() => getTodaysEvents()) @@ -118,6 +118,40 @@ watch(notes, async (newNotes) => { } }, { immediate: true }) +// Watch for scheduled events and fetch profiles for event authors and completers +watch(todaysScheduledEvents, async (events) => { + if (events.length > 0) { + const pubkeys = new Set() + + // Add event authors + events.forEach(event => { + 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.size > 0) { + const pubkeys = [...completions.values()].map(c => c.pubkey) + if (pubkeys.length > 0) { + await fetchProfiles(pubkeys) + } + } +}, { immediate: true }) + // Check if we have admin pubkeys configured const hasAdminPubkeys = computed(() => adminPubkeys.length > 0) From 661b700092b87127f22640f316df1471616b3c2c Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 21 Oct 2025 22:53:08 +0200 Subject: [PATCH 06/18] 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. --- .../nostr-feed/components/ScheduledEventCard.vue | 15 +++++++++------ .../nostr-feed/services/ScheduledEventService.ts | 3 +++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue index 49ae6c4..058425f 100644 --- a/src/modules/nostr-feed/components/ScheduledEventCard.vue +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -42,6 +42,9 @@ const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubk // Check if event is completed - call function directly const isCompleted = computed(() => props.getCompletion(eventAddress.value)?.completed || false) +// Check if event is completable (task type) +const isCompletable = computed(() => props.event.eventType === 'task') + // Format the date/time const formattedDate = computed(() => { try { @@ -114,13 +117,13 @@ function cancelMarkComplete() {