Compare commits
10 commits
5171b24ba3
...
c8f16eda42
| Author | SHA1 | Date | |
|---|---|---|---|
| c8f16eda42 | |||
| ffe9a10240 | |||
| 7e698d2113 | |||
| 937a0e9075 | |||
| 83a87b2da6 | |||
| 0f0eae8800 | |||
| 2cf737213b | |||
| 62161dd000 | |||
| 93e3e0a1ca | |||
| b023d54264 |
4 changed files with 386 additions and 165 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue