Compare commits

...

10 commits

Author SHA1 Message Date
c8f16eda42 Improves task display and spacing
Refines the presentation of scheduled events by adjusting spacing and text displayed when there are no tasks. This enhances visual clarity and provides a more user-friendly experience.
2025-10-31 22:00:19 +01:00
ffe9a10240 Removes redundant admin badge
Removes the admin badge from the scheduled event card.

The badge was deemed unnecessary as the information it conveyed
is already implicitly clear.
2025-10-31 22:00:19 +01:00
7e698d2113 FIX: Show events even if no posts
Moves the "no posts" message to only display when there are no posts and no scheduled events.

Also, ensures "end of feed" message is displayed only when there are posts to show.
2025-10-31 22:00:19 +01:00
937a0e9075 Adds date navigation to scheduled events
Implements date navigation for scheduled events, allowing users to view events for different days.

This change replaces the static "Today's Events" section with a dynamic date selector.
It introduces buttons for navigating to the previous and next days, as well as a "Today" button to return to the current date.
A date display shows the selected date, and a message indicates when there are no scheduled events for a given day.
2025-10-31 22:00:19 +01:00
83a87b2da6 Filters one-time events to avoid duplicates
Ensures that one-time events exclude recurring events, preventing duplicate entries.

This resolves an issue where recurring events were incorrectly included in the list of one-time events, leading to events being displayed multiple times.
2025-10-31 22:00:19 +01:00
0f0eae8800 Enables recurring scheduled event completion
Extends scheduled event completion to support recurring events.

The changes introduce the concept of an "occurrence" for recurring events,
allowing users to mark individual instances of a recurring event as complete.
This involves:
- Adding recurrence information to the ScheduledEvent model.
- Modifying completion logic to handle recurring events with daily/weekly frequencies
- Updating UI to display recurrence information and mark individual occurrences as complete.
2025-10-31 22:00:19 +01:00
2cf737213b Filters and sorts scheduled events
Improves scheduled event retrieval by filtering events
based on user participation and sorting them by start time.

This ensures that users only see events they are participating
in or events that are open to the entire community.
2025-10-31 22:00:19 +01:00
62161dd000 Shows completer name on completed badge
Updates the completed badge to display the name of the user who marked the event as complete.

This provides better context and clarity regarding who triggered the completion status.
2025-10-31 22:00:19 +01:00
93e3e0a1ca Filters scheduled events by participation
Ensures users only see scheduled events they are participating in or events that are open to everyone.

This change filters the list of today's scheduled events based on the current user's participation.
It only displays events where the user is listed as a participant or events that do not have any participants specified.
2025-10-31 22:00:19 +01:00
b023d54264 Replaces custom expand/collapse with Collapsible
Migrates ScheduledEventCard to use the Collapsible component from the UI library.

This simplifies the component's structure and improves accessibility by leveraging the built-in features of the Collapsible component.
Removes custom logic for managing the expanded/collapsed state.
2025-10-31 22:00:19 +01:00
4 changed files with 386 additions and 165 deletions

View file

@ -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<string>()
// 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() {
</p>
</div>
<!-- No Posts -->
<div v-else-if="threadedPosts.length === 0" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No posts yet</span>
</div>
<p class="text-sm text-muted-foreground">
Check back later for community updates.
</p>
</div>
<!-- Posts List - Natural flow without internal scrolling -->
<div v-else>
<!-- Today's Scheduled Events Section -->
<div v-if="todaysScheduledEvents.length > 0" class="mb-6 md:mb-8">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3">
📅 Today's Events
</h3>
<div class="md:space-y-3">
<!-- Scheduled Events Section with Date Navigation -->
<div class="my-2 md:my-4">
<div class="flex items-center justify-between px-4 md:px-0 mb-3">
<!-- Left Arrow -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="goToPreviousDay"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<!-- Date Header with Today Button -->
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
📅 {{ dateDisplayText }}
</h3>
<Button
v-if="!isToday"
variant="outline"
size="sm"
class="h-6 text-xs"
@click="goToToday"
>
Today
</Button>
</div>
<!-- Right Arrow -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="goToNextDay"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
<!-- Events List or Empty State -->
<div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3">
<ScheduledEventCard
v-for="event in todaysScheduledEvents"
v-for="event in scheduledEventsForDate"
:key="`${event.pubkey}:${event.dTag}`"
:event="event"
:get-display-name="getDisplayName"
@ -437,11 +518,14 @@ function cancelDelete() {
@toggle-complete="onToggleComplete"
/>
</div>
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
{{ isToday ? 'no tasks today' : 'no tasks for this day' }}
</div>
</div>
<!-- Posts Section -->
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
<h3 v-if="todaysScheduledEvents.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
<h3 v-if="scheduledEventsForDate.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
💬 Posts
</h3>
<ThreadedPost
@ -464,8 +548,19 @@ function cancelDelete() {
/>
</div>
<!-- No Posts Message (show whenever there are no posts, regardless of events) -->
<div v-else class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No posts yet</span>
</div>
<p class="text-sm text-muted-foreground">
Check back later for community updates.
</p>
</div>
<!-- End of feed message -->
<div class="text-center py-6 text-md text-muted-foreground">
<div v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
<p>🐢</p>
</div>
</div>

View file

@ -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<Props>(), {
@ -33,17 +38,23 @@ const emit = defineEmits<Emits>()
// 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
}
</script>
<template>
<div class="border-b md:border md:rounded-lg bg-card transition-all"
:class="{ 'opacity-60': isCompletable && isCompleted }">
<!-- Collapsed View (Default) -->
<div v-if="!isExpanded"
class="flex items-center gap-3 p-3 md:p-4">
<!-- Time -->
<div @click="toggleExpanded" class="flex items-center gap-1.5 text-sm text-muted-foreground shrink-0 cursor-pointer">
<Clock class="h-3.5 w-3.5" />
<span class="font-medium">{{ formattedTimeRange || formattedDate }}</span>
</div>
<!-- Title -->
<h3 @click="toggleExpanded"
class="font-semibold text-sm md:text-base flex-1 truncate cursor-pointer hover:text-foreground/80 transition-colors"
:class="{ 'line-through': isCompletable && isCompleted }">
{{ event.title }}
</h3>
<!-- Badges and Actions -->
<div class="flex items-center gap-2 shrink-0">
<!-- Mark Complete Button (for uncompleted tasks) -->
<Button
v-if="isCompletable && !isCompleted"
@click.stop="handleMarkComplete"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
>
<CheckCircle class="h-4 w-4" />
</Button>
<!-- Completed Badge -->
<Badge v-if="isCompletable && isCompleted" variant="secondary" class="text-xs">
</Badge>
<!-- Admin Badge -->
<Badge v-if="isAdminEvent" variant="secondary" class="text-xs">
Admin
</Badge>
</div>
</div>
<!-- Expanded View -->
<div v-else class="p-4 md:p-6">
<!-- Event Details -->
<div class="flex-1 min-w-0">
<!-- Title and badges with close button -->
<div class="flex items-start gap-2 mb-2 flex-wrap">
<h3 class="font-semibold text-base md:text-lg flex-1"
:class="{ 'line-through': isCompletable && isCompleted }">
{{ event.title }}
</h3>
<Badge v-if="isAdminEvent" variant="secondary" class="shrink-0">
Admin
</Badge>
<Button
@click="toggleExpanded"
variant="ghost"
size="sm"
class="h-6 w-6 p-0 shrink-0"
>
</Button>
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
:class="{ 'opacity-60': isCompletable && isCompleted }">
<!-- Collapsed View (Trigger) -->
<CollapsibleTrigger as-child>
<div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors">
<!-- Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground shrink-0">
<Clock class="h-3.5 w-3.5" />
<span class="font-medium">{{ formattedTimeRange || formattedDate }}</span>
</div>
<!-- Title -->
<h3 class="font-semibold text-sm md:text-base flex-1 truncate"
:class="{ 'line-through': isCompletable && isCompleted }">
{{ event.title }}
</h3>
<!-- Badges and Actions -->
<div class="flex items-center gap-2 shrink-0">
<!-- Mark Complete Button (for uncompleted tasks) -->
<Button
v-if="isCompletable && !isCompleted"
@click.stop="handleMarkComplete"
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
>
<CheckCircle class="h-4 w-4" />
</Button>
<!-- Completed Badge with completer name -->
<Badge v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" variant="secondary" class="text-xs">
{{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
</Badge>
<!-- Recurring Badge -->
<Badge v-if="isRecurring" variant="outline" class="text-xs">
🔄
</Badge>
</div>
</div>
</CollapsibleTrigger>
<!-- Expanded View (Content) -->
<CollapsibleContent class="p-4 md:p-6 pt-0">
<!-- Event Details -->
<div class="flex-1 min-w-0">
<!-- Date/Time -->
<div class="flex items-center gap-4 text-sm text-muted-foreground mb-2 flex-wrap">
<div class="flex items-center gap-1.5">
@ -215,9 +201,9 @@ function toggleExpanded() {
</div>
<!-- Completion info (only for completable events) -->
<div v-if="isCompletable && isCompleted && getCompletion(eventAddress)" class="text-xs text-muted-foreground mb-3">
Completed by {{ getDisplayName(getCompletion(eventAddress)!.pubkey) }}
<span v-if="getCompletion(eventAddress)!.notes"> - {{ getCompletion(eventAddress)!.notes }}</span>
<div v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" class="text-xs text-muted-foreground mb-3">
Completed by {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
<span v-if="getCompletion(eventAddress, occurrence)!.notes"> - {{ getCompletion(eventAddress, occurrence)!.notes }}</span>
</div>
<!-- Author (if not admin) -->
@ -238,22 +224,23 @@ function toggleExpanded() {
</Button>
</div>
</div>
</div>
</CollapsibleContent>
<!-- Confirmation Dialog -->
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Event as Complete?</DialogTitle>
<DialogDescription>
This will mark "{{ event.title }}" as completed by you. Other users will be able to see that you completed this event.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="cancelMarkComplete">Cancel</Button>
<Button @click="confirmMarkComplete">Mark Complete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Collapsible>
<!-- Confirmation Dialog -->
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Event as Complete?</DialogTitle>
<DialogDescription>
This will mark "{{ event.title }}" as completed by you. Other users will be able to see that you completed this event.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="cancelMarkComplete">Cancel</Button>
<Button @click="confirmMarkComplete">Mark Complete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -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<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
const authService = injectService<AuthService>(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<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title)
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
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,

View file

@ -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", "<pubkey>", "<relay-hint>", "<participation-type>"]
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<void> {
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
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<void> {
async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise<void> {
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)
}