webapp/src/modules/nostr-feed/components/ScheduledEventCard.vue
padreug 706ceea84b 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-11-06 11:30:42 +01:00

251 lines
8.2 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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, occurrence?: string) => EventCompletion | undefined
adminPubkeys?: string[]
}
interface Emits {
(e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void
}
const props = withDefaults(defineProps<Props>(), {
adminPubkeys: () => []
})
const emit = defineEmits<Emits>()
// Confirmation dialog state
const showConfirmDialog = 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 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')
// 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 mark complete button click - show confirmation dialog
function handleMarkComplete() {
console.log('🔘 Mark Complete button clicked for event:', props.event.title)
showConfirmDialog.value = true
}
// Confirm and execute mark complete
function confirmMarkComplete() {
console.log('✅ Confirmed mark complete for event:', props.event.title, 'occurrence:', occurrence.value)
emit('toggle-complete', props.event, occurrence.value)
showConfirmDialog.value = false
}
// Cancel mark complete
function cancelMarkComplete() {
showConfirmDialog.value = false
}
</script>
<template>
<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>
<!-- Admin Badge -->
<Badge v-if="isAdminEvent" variant="secondary" class="text-xs">
Admin
</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">
<Calendar class="h-4 w-4" />
<span>{{ formattedDate }}</span>
</div>
<div v-if="formattedTimeRange" class="flex items-center gap-1.5">
<Clock class="h-4 w-4" />
<span>{{ formattedTimeRange }}</span>
</div>
</div>
<!-- Location -->
<div v-if="event.location" class="flex items-center gap-1.5 text-sm text-muted-foreground mb-3">
<MapPin class="h-4 w-4" />
<span>{{ event.location }}</span>
</div>
<!-- Description/Content -->
<div v-if="event.description || event.content" class="text-sm mb-3">
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
</div>
<!-- Completion info (only for completable events) -->
<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) -->
<div v-if="!isAdminEvent" class="text-xs text-muted-foreground mb-3">
Posted by {{ getDisplayName(event.pubkey) }}
</div>
<!-- Mark Complete Button (only for completable task events) -->
<div v-if="isCompletable && !isCompleted" class="mt-3">
<Button
@click.stop="handleMarkComplete"
variant="outline"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
</div>
</div>
</CollapsibleContent>
</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>