diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 1061ea5..2d0aa4c 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -9,7 +9,7 @@ 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' @@ -99,10 +99,66 @@ const { getDisplayName, fetchProfiles } = useProfiles() const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() // Use scheduled events service -const { getTodaysEvents, getCompletion, toggleComplete, allCompletions } = useScheduledEvents() +const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents() -// Get today's scheduled events (reactive) -const todaysScheduledEvents = computed(() => getTodaysEvents()) +// 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) => { @@ -119,12 +175,12 @@ watch(notes, async (newNotes) => { }, { immediate: true }) // Watch for scheduled events and fetch profiles for event authors and completers -watch(todaysScheduledEvents, async (events) => { +watch(scheduledEventsForDate, async (events) => { if (events.length > 0) { const pubkeys = new Set() // Add event authors - events.forEach(event => { + events.forEach((event: ScheduledEvent) => { pubkeys.add(event.pubkey) // Add completer pubkey if event is completed @@ -200,10 +256,10 @@ async function onToggleLike(note: FeedPost) { } // Handle scheduled event completion toggle -async function onToggleComplete(event: ScheduledEvent) { - console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title) +async function onToggleComplete(event: ScheduledEvent, occurrence?: string) { + console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence) try { - await toggleComplete(event) + await toggleComplete(event, occurrence) console.log('✅ NostrFeed: toggleComplete succeeded') } catch (error) { console.error('❌ NostrFeed: Failed to toggle event completion:', error) @@ -408,27 +464,52 @@ function cancelDelete() {

- -
-
- - No posts yet -
-

- Check back later for community updates. -

-
-
- -
-

- 📅 Today's Events -

-
+ +
+
+ + + + +
+

+ 📅 {{ 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 index 5acfa51..dfc48af 100644 --- a/src/modules/nostr-feed/components/ScheduledEventCard.vue +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -10,18 +10,23 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' import { Calendar, MapPin, Clock, CheckCircle } from 'lucide-vue-next' import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService' interface Props { event: ScheduledEvent getDisplayName: (pubkey: string) => string - getCompletion: (eventAddress: string) => EventCompletion | undefined + getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined adminPubkeys?: string[] } interface Emits { - (e: 'toggle-complete', event: ScheduledEvent): void + (e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void } const props = withDefaults(defineProps(), { @@ -33,17 +38,23 @@ const emit = defineEmits() // Confirmation dialog state const showConfirmDialog = ref(false) -// Collapsed state (collapsed by default) -const isExpanded = ref(false) - // Event address for tracking completion const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`) +// Check if this is a recurring event +const isRecurring = computed(() => !!props.event.recurrence) + +// For recurring events, occurrence is today's date. For non-recurring, it's undefined. +const occurrence = computed(() => { + if (!isRecurring.value) return undefined + return new Date().toISOString().split('T')[0] // YYYY-MM-DD +}) + // Check if this is an admin event const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey)) -// Check if event is completed - call function directly -const isCompleted = computed(() => props.getCompletion(eventAddress.value)?.completed || false) +// Check if event is completed - call function with occurrence for recurring events +const isCompleted = computed(() => props.getCompletion(eventAddress.value, occurrence.value)?.completed || false) // Check if event is completable (task type) const isCompletable = computed(() => props.event.eventType === 'task') @@ -107,8 +118,8 @@ function handleMarkComplete() { // Confirm and execute mark complete function confirmMarkComplete() { - console.log('✅ Confirmed mark complete for event:', props.event.title) - emit('toggle-complete', props.event) + console.log('✅ Confirmed mark complete for event:', props.event.title, 'occurrence:', occurrence.value) + emit('toggle-complete', props.event, occurrence.value) showConfirmDialog.value = false } @@ -116,81 +127,56 @@ function confirmMarkComplete() { function cancelMarkComplete() { showConfirmDialog.value = false } - -// Toggle expanded/collapsed state -function toggleExpanded() { - isExpanded.value = !isExpanded.value -} diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts index ae79702..e56c002 100644 --- a/src/modules/nostr-feed/composables/useScheduledEvents.ts +++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts @@ -1,6 +1,7 @@ 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' /** @@ -8,8 +9,12 @@ import { useToast } from '@/core/composables/useToast' */ 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 */ @@ -27,11 +32,20 @@ export function useScheduledEvents() { } /** - * Get today's scheduled events + * 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() + return scheduledEventService.getTodaysEvents(currentUserPubkey.value) } /** @@ -51,10 +65,10 @@ export function useScheduledEvents() { } /** - * Toggle completion status of an event + * Toggle completion status of an event (optionally for a specific occurrence) */ - const toggleComplete = async (event: ScheduledEvent, notes: string = ''): Promise => { - console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title) + 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') @@ -64,16 +78,16 @@ export function useScheduledEvents() { try { const eventAddress = `31922:${event.pubkey}:${event.dTag}` - const currentlyCompleted = scheduledEventService.isCompleted(eventAddress) + 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) + await scheduledEventService.uncompleteEvent(event, occurrence) toast.success('Event marked as incomplete') } else { console.log('⬆️ useScheduledEvents: Marking as complete...') - await scheduledEventService.completeEvent(event, notes) + await scheduledEventService.completeEvent(event, notes, occurrence) toast.success('Event completed!') } } catch (error) { @@ -136,6 +150,7 @@ export function useScheduledEvents() { // Methods getScheduledEvents, getEventsForDate, + getEventsForSpecificDate, getTodaysEvents, getCompletion, isCompleted, diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts index 19bda58..21af01f 100644 --- a/src/modules/nostr-feed/services/ScheduledEventService.ts +++ b/src/modules/nostr-feed/services/ScheduledEventService.ts @@ -4,6 +4,12 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { finalizeEvent, type EventTemplate } from 'nostr-tools' import type { Event as NostrEvent } from 'nostr-tools' +export interface RecurrencePattern { + frequency: 'daily' | 'weekly' + dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc. + endDate?: string // ISO date string - when to stop recurring (optional) +} + export interface ScheduledEvent { id: string pubkey: string @@ -16,13 +22,16 @@ export interface ScheduledEvent { 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 @@ -79,6 +88,27 @@ export class ScheduledEventService extends BaseService { 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 @@ -99,8 +129,10 @@ export class ScheduledEventService extends BaseService { location, status, eventType, + participants: participants.length > 0 ? participants : undefined, content: event.content, - tags: event.tags + tags: event.tags, + recurrence } // Store or update the event (replaceable by d-tag) @@ -129,9 +161,11 @@ export class ScheduledEventService extends BaseService { const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true' const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1] const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined + const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string console.log('📋 Completion details:', { aTag, + occurrence, completed, pubkey: event.pubkey, eventId: event.id @@ -140,6 +174,7 @@ export class ScheduledEventService extends BaseService { const completion: EventCompletion = { id: event.id, eventAddress: aTag, + occurrence, pubkey: event.pubkey, created_at: event.created_at, completed, @@ -148,12 +183,15 @@ export class ScheduledEventService extends BaseService { } // Store completion (most recent one wins) - const existing = this._completions.get(aTag) + // For recurring events, include occurrence in the key: "eventAddress:occurrence" + // For non-recurring, just use eventAddress + const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag + const existing = this._completions.get(completionKey) if (!existing || event.created_at > existing.created_at) { - this._completions.set(aTag, completion) - console.log('✅ Stored completion for:', aTag, '- completed:', completed) + this._completions.set(completionKey, completion) + console.log('✅ Stored completion for:', completionKey, '- completed:', completed) } else { - console.log('⏭️ Skipped older completion for:', aTag) + console.log('⏭️ Skipped older completion for:', completionKey) } } catch (error) { @@ -181,32 +219,104 @@ export class ScheduledEventService extends BaseService { } /** - * Get events for today + * Check if a recurring event occurs on a specific date */ - getTodaysEvents(): ScheduledEvent[] { - const today = new Date().toISOString().split('T')[0] - return this.getEventsForDate(today) + 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 completion status for an event + * 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 */ - getCompletion(eventAddress: string): EventCompletion | undefined { - return this._completions.get(eventAddress) + 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 } /** - * Check if an event is completed + * Get events for today, optionally filtered by user participation */ - isCompleted(eventAddress: string): boolean { - const completion = this.getCompletion(eventAddress) + 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 + * Mark an event as complete (optionally for a specific occurrence) */ - async completeEvent(event: ScheduledEvent, notes: string = ''): Promise { + async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise { if (!this.authService?.isAuthenticated?.value) { throw new Error('Must be authenticated to complete events') } @@ -226,15 +336,22 @@ export class ScheduledEventService extends BaseService { const eventAddress = `31922:${event.pubkey}:${event.dTag}` // Create RSVP/completion event (NIP-52) + const tags: string[][] = [ + ['a', eventAddress], + ['status', 'accepted'], + ['completed', 'true'], + ['completed_at', Math.floor(Date.now() / 1000).toString()] + ] + + // Add occurrence tag if provided (for recurring events) + if (occurrence) { + tags.push(['occurrence', occurrence]) + } + const eventTemplate: EventTemplate = { kind: 31925, // Calendar Event RSVP content: notes, - tags: [ - ['a', eventAddress], - ['status', 'accepted'], - ['completed', 'true'], - ['completed_at', Math.floor(Date.now() / 1000).toString()] - ], + tags, created_at: Math.floor(Date.now() / 1000) } @@ -262,7 +379,7 @@ export class ScheduledEventService extends BaseService { /** * Uncomplete an event (publish new RSVP with completed=false) */ - async uncompleteEvent(event: ScheduledEvent): Promise { + async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise { if (!this.authService?.isAuthenticated?.value) { throw new Error('Must be authenticated to uncomplete events') } @@ -282,14 +399,21 @@ export class ScheduledEventService extends BaseService { const eventAddress = `31922:${event.pubkey}:${event.dTag}` // Create RSVP event with completed=false + const tags: string[][] = [ + ['a', eventAddress], + ['status', 'tentative'], + ['completed', 'false'] + ] + + // Add occurrence tag if provided (for recurring events) + if (occurrence) { + tags.push(['occurrence', occurrence]) + } + const eventTemplate: EventTemplate = { kind: 31925, content: '', - tags: [ - ['a', eventAddress], - ['status', 'tentative'], - ['completed', 'false'] - ], + tags, created_at: Math.floor(Date.now() / 1000) }