Compare commits

..

No commits in common. "6515ce1f5aa821136c64714e29b082230c768b60" and "1f7e50cfe1ed4c741a538c5db7fa3bb01acc0555" have entirely different histories.

7 changed files with 112 additions and 779 deletions

View file

@ -64,7 +64,7 @@ export const appConfig: AppConfig = {
}, },
events: { events: {
name: 'events', name: 'events',
enabled: false, enabled: true,
lazy: false, lazy: false,
config: { config: {
apiConfig: { apiConfig: {

View file

@ -540,12 +540,8 @@ export class RelayHub extends BaseService {
const successful = results.filter(result => result.status === 'fulfilled').length const successful = results.filter(result => result.status === 'fulfilled').length
const total = results.length const total = results.length
this.emit('eventPublished', { eventId: event.id, success: successful, total })
// Throw error if no relays accepted the event this.emit('eventPublished', { eventId: event.id, success: successful, total })
if (successful === 0) {
throw new Error(`Failed to publish event - none of the ${total} relay(s) accepted it`)
}
return { success: successful, total } return { success: successful, total }
} }

View file

@ -99,22 +99,12 @@ const { getDisplayName, fetchProfiles } = useProfiles()
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Use scheduled events service // Use scheduled events service
const { const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents()
getEventsForSpecificDate,
getCompletion,
getTaskStatus,
claimTask,
startTask,
completeEvent,
unclaimTask,
deleteTask,
allCompletions
} = useScheduledEvents()
// Selected date for viewing scheduled tasks (defaults to today) // Selected date for viewing events (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0]) const selectedDate = ref(new Date().toISOString().split('T')[0])
// Get scheduled tasks for the selected date (reactive) // Get scheduled events for the selected date (reactive)
const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value)) const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value))
// Navigate to previous day // Navigate to previous day
@ -153,20 +143,20 @@ const dateDisplayText = computed(() => {
const tomorrowStr = tomorrow.toISOString().split('T')[0] const tomorrowStr = tomorrow.toISOString().split('T')[0]
if (selectedDate.value === today) { if (selectedDate.value === today) {
return "Today's Tasks" return "Today's Events"
} else if (selectedDate.value === yesterdayStr) { } else if (selectedDate.value === yesterdayStr) {
return "Yesterday's Tasks" return "Yesterday's Events"
} else if (selectedDate.value === tomorrowStr) { } else if (selectedDate.value === tomorrowStr) {
return "Tomorrow's Tasks" return "Tomorrow's Events"
} else { } else {
// Format as "Tasks for Mon, Jan 15" // Format as "Events for Mon, Jan 15"
const date = new Date(selectedDate.value + 'T00:00:00') const date = new Date(selectedDate.value + 'T00:00:00')
const formatted = date.toLocaleDateString('en-US', { const formatted = date.toLocaleDateString('en-US', {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}) })
return `Tasks for ${formatted}` return `Events for ${formatted}`
} }
}) })
@ -265,49 +255,14 @@ async function onToggleLike(note: FeedPost) {
} }
} }
// Task action handlers // Handle scheduled event completion toggle
async function onClaimTask(event: ScheduledEvent, occurrence?: string) { async function onToggleComplete(event: ScheduledEvent, occurrence?: string) {
console.log('👋 NostrFeed: Claiming task:', event.title) console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence)
try { try {
await claimTask(event, '', occurrence) await toggleComplete(event, occurrence)
console.log('✅ NostrFeed: toggleComplete succeeded')
} catch (error) { } catch (error) {
console.error('❌ Failed to claim task:', error) console.error('❌ NostrFeed: Failed to toggle event completion:', error)
}
}
async function onStartTask(event: ScheduledEvent, occurrence?: string) {
console.log('▶️ NostrFeed: Starting task:', event.title)
try {
await startTask(event, '', occurrence)
} catch (error) {
console.error('❌ Failed to start task:', error)
}
}
async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
console.log('✅ NostrFeed: Completing task:', event.title)
try {
await completeEvent(event, occurrence, '')
} catch (error) {
console.error('❌ Failed to complete task:', error)
}
}
async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
console.log('🔙 NostrFeed: Unclaiming task:', event.title)
try {
await unclaimTask(event, occurrence)
} catch (error) {
console.error('❌ Failed to unclaim task:', error)
}
}
async function onDeleteTask(event: ScheduledEvent) {
console.log('🗑️ NostrFeed: Deleting task:', event.title)
try {
await deleteTask(event)
} catch (error) {
console.error('❌ Failed to delete task:', error)
} }
} }
@ -511,7 +466,7 @@ function cancelDelete() {
<!-- Posts List - Natural flow without internal scrolling --> <!-- Posts List - Natural flow without internal scrolling -->
<div v-else> <div v-else>
<!-- Scheduled Tasks Section with Date Navigation --> <!-- Scheduled Events Section with Date Navigation -->
<div class="my-2 md:my-4"> <div class="my-2 md:my-4">
<div class="flex items-center justify-between px-4 md:px-0 mb-3"> <div class="flex items-center justify-between px-4 md:px-0 mb-3">
<!-- Left Arrow --> <!-- Left Arrow -->
@ -551,7 +506,7 @@ function cancelDelete() {
</Button> </Button>
</div> </div>
<!-- Scheduled Tasks List or Empty State --> <!-- Events List or Empty State -->
<div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3"> <div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3">
<ScheduledEventCard <ScheduledEventCard
v-for="event in scheduledEventsForDate" v-for="event in scheduledEventsForDate"
@ -559,13 +514,8 @@ function cancelDelete() {
:event="event" :event="event"
:get-display-name="getDisplayName" :get-display-name="getDisplayName"
:get-completion="getCompletion" :get-completion="getCompletion"
:get-task-status="getTaskStatus"
:admin-pubkeys="adminPubkeys" :admin-pubkeys="adminPubkeys"
@claim-task="onClaimTask" @toggle-complete="onToggleComplete"
@start-task="onStartTask"
@complete-task="onCompleteTask"
@unclaim-task="onUnclaimTask"
@delete-task="onDeleteTask"
/> />
</div> </div>
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4"> <div v-else class="text-center py-3 text-muted-foreground text-sm px-4">

View file

@ -2,7 +2,6 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -16,25 +15,18 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next' import { Calendar, MapPin, Clock, CheckCircle } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService' import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
interface Props { interface Props {
event: ScheduledEvent event: ScheduledEvent
getDisplayName: (pubkey: string) => string getDisplayName: (pubkey: string) => string
getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined
getTaskStatus: (eventAddress: string, occurrence?: string) => TaskStatus | null
adminPubkeys?: string[] adminPubkeys?: string[]
} }
interface Emits { interface Emits {
(e: 'claim-task', event: ScheduledEvent, occurrence?: string): void (e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void
(e: 'start-task', event: ScheduledEvent, occurrence?: string): void
(e: 'complete-task', event: ScheduledEvent, occurrence?: string): void
(e: 'unclaim-task', event: ScheduledEvent, occurrence?: string): void
(e: 'delete-task', event: ScheduledEvent): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -43,12 +35,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// Get auth service to check current user
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
// Confirmation dialog state // Confirmation dialog state
const showConfirmDialog = ref(false) const showConfirmDialog = ref(false)
const hasConfirmedCommunication = ref(false)
// Event address for tracking completion // Event address for tracking completion
const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`) const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`)
@ -65,46 +53,12 @@ const occurrence = computed(() => {
// Check if this is an admin event // Check if this is an admin event
const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey)) const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey))
// Get current task status // Check if event is completed - call function with occurrence for recurring events
const taskStatus = computed(() => props.getTaskStatus(eventAddress.value, occurrence.value)) const isCompleted = computed(() => props.getCompletion(eventAddress.value, occurrence.value)?.completed || false)
// Check if event is completable (task type) // Check if event is completable (task type)
const isCompletable = computed(() => props.event.eventType === 'task') const isCompletable = computed(() => props.event.eventType === 'task')
// Get completion data
const completion = computed(() => props.getCompletion(eventAddress.value, occurrence.value))
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
// Check if current user can unclaim
// Only show unclaim for "claimed" state, and only if current user is the one who claimed it
const canUnclaim = computed(() => {
if (!completion.value || !currentUserPubkey.value) return false
if (taskStatus.value !== 'claimed') return false
return completion.value.pubkey === currentUserPubkey.value
})
// Check if current user is the author of the task
const isAuthor = computed(() => {
if (!currentUserPubkey.value) return false
return props.event.pubkey === currentUserPubkey.value
})
// Status badges configuration
const statusConfig = computed(() => {
switch (taskStatus.value) {
case 'claimed':
return { label: 'Claimed', variant: 'secondary' as const, icon: Hand, color: 'text-blue-600' }
case 'in-progress':
return { label: 'In Progress', variant: 'default' as const, icon: PlayCircle, color: 'text-orange-600' }
case 'completed':
return { label: 'Completed', variant: 'secondary' as const, icon: CheckCircle, color: 'text-green-600' }
default:
return null
}
})
// Format the date/time // Format the date/time
const formattedDate = computed(() => { const formattedDate = computed(() => {
try { try {
@ -156,124 +110,28 @@ const formattedTimeRange = computed(() => {
} }
}) })
// Action type for confirmation dialog // Handle mark complete button click - show confirmation dialog
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | 'delete' | null>(null) function handleMarkComplete() {
console.log('🔘 Mark Complete button clicked for event:', props.event.title)
// Handle claim task
function handleClaimTask() {
pendingAction.value = 'claim'
showConfirmDialog.value = true showConfirmDialog.value = true
} }
// Handle start task // Confirm and execute mark complete
function handleStartTask() { function confirmMarkComplete() {
pendingAction.value = 'start' console.log('✅ Confirmed mark complete for event:', props.event.title, 'occurrence:', occurrence.value)
showConfirmDialog.value = true emit('toggle-complete', props.event, occurrence.value)
}
// Handle complete task
function handleCompleteTask() {
pendingAction.value = 'complete'
showConfirmDialog.value = true
}
// Handle unclaim task
function handleUnclaimTask() {
pendingAction.value = 'unclaim'
showConfirmDialog.value = true
}
// Handle delete task
function handleDeleteTask() {
pendingAction.value = 'delete'
showConfirmDialog.value = true
}
// Confirm action
function confirmAction() {
if (!pendingAction.value) return
// For unclaim action, require checkbox confirmation
if (pendingAction.value === 'unclaim' && !hasConfirmedCommunication.value) {
return
}
switch (pendingAction.value) {
case 'claim':
emit('claim-task', props.event, occurrence.value)
break
case 'start':
emit('start-task', props.event, occurrence.value)
break
case 'complete':
emit('complete-task', props.event, occurrence.value)
break
case 'unclaim':
emit('unclaim-task', props.event, occurrence.value)
break
case 'delete':
emit('delete-task', props.event)
break
}
showConfirmDialog.value = false showConfirmDialog.value = false
pendingAction.value = null
hasConfirmedCommunication.value = false
} }
// Cancel action // Cancel mark complete
function cancelAction() { function cancelMarkComplete() {
showConfirmDialog.value = false showConfirmDialog.value = false
pendingAction.value = null
hasConfirmedCommunication.value = false
} }
// Get dialog content based on pending action
const dialogContent = computed(() => {
switch (pendingAction.value) {
case 'claim':
return {
title: 'Claim Task?',
description: `This will mark "${props.event.title}" as claimed by you. You can start working on it later.`,
confirmText: 'Claim Task'
}
case 'start':
return {
title: 'Start Task?',
description: `This will mark "${props.event.title}" as in-progress. Others will see you're actively working on it.`,
confirmText: 'Start Task'
}
case 'complete':
return {
title: 'Complete Task?',
description: `This will mark "${props.event.title}" as completed by you. Other users will be able to see that you completed this task.`,
confirmText: 'Mark Complete'
}
case 'unclaim':
return {
title: 'Unclaim Task?',
description: `This will remove your claim on "${props.event.title}" and make it available for others.\n\nHave you communicated to others that you are unclaiming this task?`,
confirmText: 'Unclaim Task'
}
case 'delete':
return {
title: 'Delete Task?',
description: `This will permanently delete "${props.event.title}". This action cannot be undone.`,
confirmText: 'Delete Task'
}
default:
return {
title: '',
description: '',
confirmText: ''
}
}
})
</script> </script>
<template> <template>
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all" <Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
:class="{ 'opacity-60': isCompletable && taskStatus === 'completed' }"> :class="{ 'opacity-60': isCompletable && isCompleted }">
<!-- Collapsed View (Trigger) --> <!-- Collapsed View (Trigger) -->
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>
<div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors"> <div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors">
@ -285,50 +143,26 @@ const dialogContent = computed(() => {
<!-- Title --> <!-- Title -->
<h3 class="font-semibold text-sm md:text-base flex-1 truncate" <h3 class="font-semibold text-sm md:text-base flex-1 truncate"
:class="{ 'line-through': isCompletable && taskStatus === 'completed' }"> :class="{ 'line-through': isCompletable && isCompleted }">
{{ event.title }} {{ event.title }}
</h3> </h3>
<!-- Badges and Actions --> <!-- Badges and Actions -->
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<!-- Quick Action Button (context-aware) --> <!-- Mark Complete Button (for uncompleted tasks) -->
<Button <Button
v-if="isCompletable && !taskStatus" v-if="isCompletable && !isCompleted"
@click.stop="handleClaimTask" @click.stop="handleMarkComplete"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-7 px-2 text-xs gap-1" class="h-7 w-7 p-0"
> >
<Hand class="h-3.5 w-3.5" /> <CheckCircle class="h-4 w-4" />
<span class="hidden sm:inline">Claim</span>
</Button> </Button>
<Button <!-- Completed Badge with completer name -->
v-else-if="isCompletable && taskStatus === 'claimed'" <Badge v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" variant="secondary" class="text-xs">
@click.stop="handleStartTask" {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<PlayCircle class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Start</span>
</Button>
<Button
v-else-if="isCompletable && taskStatus === 'in-progress'"
@click.stop="handleCompleteTask"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<CheckCircle class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Complete</span>
</Button>
<!-- Status Badge with claimer/completer name -->
<Badge v-if="isCompletable && statusConfig && completion" :variant="statusConfig.variant" class="text-xs gap-1">
<component :is="statusConfig.icon" class="h-3 w-3" :class="statusConfig.color" />
<span>{{ getDisplayName(completion.pubkey) }}</span>
</Badge> </Badge>
<!-- Recurring Badge --> <!-- Recurring Badge -->
@ -366,20 +200,10 @@ const dialogContent = computed(() => {
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p> <p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
</div> </div>
<!-- Task Status Info (only for completable events with status) --> <!-- Completion info (only for completable events) -->
<div v-if="isCompletable && completion" class="text-xs mb-3"> <div v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" class="text-xs text-muted-foreground mb-3">
<div v-if="taskStatus === 'completed'" class="text-muted-foreground"> Completed by {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
Completed by {{ getDisplayName(completion.pubkey) }} <span v-if="getCompletion(eventAddress, occurrence)!.notes"> - {{ getCompletion(eventAddress, occurrence)!.notes }}</span>
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
<div v-else-if="taskStatus === 'in-progress'" class="text-orange-600 dark:text-orange-400 font-medium">
🔄 In Progress by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
<div v-else-if="taskStatus === 'claimed'" class="text-blue-600 dark:text-blue-400 font-medium">
👋 Claimed by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
</div> </div>
<!-- Author (if not admin) --> <!-- Author (if not admin) -->
@ -387,30 +211,10 @@ const dialogContent = computed(() => {
Posted by {{ getDisplayName(event.pubkey) }} Posted by {{ getDisplayName(event.pubkey) }}
</div> </div>
<!-- Action Buttons (only for completable task events) --> <!-- Mark Complete Button (only for completable task events) -->
<div v-if="isCompletable" class="mt-3 flex flex-wrap gap-2"> <div v-if="isCompletable && !isCompleted" class="mt-3">
<!-- Unclaimed Task - Show all options including jump ahead -->
<template v-if="!taskStatus">
<Button <Button
@click.stop="handleClaimTask" @click.stop="handleMarkComplete"
variant="default"
size="sm"
class="gap-2"
>
<Hand class="h-4 w-4" />
Claim Task
</Button>
<Button
@click.stop="handleStartTask"
variant="outline"
size="sm"
class="gap-2"
>
<PlayCircle class="h-4 w-4" />
Mark In Progress
</Button>
<Button
@click.stop="handleCompleteTask"
variant="outline" variant="outline"
size="sm" size="sm"
class="gap-2" class="gap-2"
@ -418,83 +222,6 @@ const dialogContent = computed(() => {
<CheckCircle class="h-4 w-4" /> <CheckCircle class="h-4 w-4" />
Mark Complete Mark Complete
</Button> </Button>
</template>
<!-- Claimed Task - Show start and option to skip directly to complete -->
<template v-else-if="taskStatus === 'claimed'">
<Button
@click.stop="handleStartTask"
variant="default"
size="sm"
class="gap-2"
>
<PlayCircle class="h-4 w-4" />
Start Task
</Button>
<Button
@click.stop="handleCompleteTask"
variant="outline"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
<Button
v-if="canUnclaim"
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
<!-- In Progress Task -->
<template v-else-if="taskStatus === 'in-progress'">
<Button
@click.stop="handleCompleteTask"
variant="default"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
<Button
v-if="canUnclaim"
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
<!-- Completed Task -->
<template v-else-if="taskStatus === 'completed'">
<Button
v-if="canUnclaim"
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
</div>
<!-- Delete Task Button (only for task author) -->
<div v-if="isAuthor" class="mt-4 pt-4 border-t border-border">
<Button
@click.stop="handleDeleteTask"
variant="destructive"
size="sm"
class="gap-2"
>
<Trash2 class="h-4 w-4" />
Delete Task
</Button>
</div> </div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
@ -505,35 +232,14 @@ const dialogContent = computed(() => {
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val"> <Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{{ dialogContent.title }}</DialogTitle> <DialogTitle>Mark Event as Complete?</DialogTitle>
<DialogDescription> <DialogDescription>
{{ dialogContent.description }} This will mark "{{ event.title }}" as completed by you. Other users will be able to see that you completed this event.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<!-- Communication confirmation checkbox (only for unclaim) -->
<div v-if="pendingAction === 'unclaim'" class="flex items-start space-x-3 py-4">
<Checkbox
:model-value="hasConfirmedCommunication"
@update:model-value="(val) => hasConfirmedCommunication = !!val"
id="confirm-communication"
/>
<label
for="confirm-communication"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
I have communicated this to the team.
</label>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" @click="cancelAction">Cancel</Button> <Button variant="outline" @click="cancelMarkComplete">Cancel</Button>
<Button <Button @click="confirmMarkComplete">Mark Complete</Button>
@click="confirmAction"
:disabled="pendingAction === 'unclaim' && !hasConfirmedCommunication"
>
{{ dialogContent.confirmText }}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -1,6 +1,6 @@
import { computed } from 'vue' import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService' import type { ScheduledEventService, ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
@ -64,78 +64,8 @@ export function useScheduledEvents() {
return scheduledEventService.isCompleted(eventAddress) return scheduledEventService.isCompleted(eventAddress)
} }
/**
* Get task status for an event
*/
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
if (!scheduledEventService) return null
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
}
/**
* Claim a task
*/
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.claimTask(event, notes, occurrence)
toast.success('Task claimed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to claim task'
if (message.includes('authenticated')) {
toast.error('Please sign in to claim tasks')
} else {
toast.error(message)
}
console.error('Failed to claim task:', error)
}
}
/**
* Start a task (mark as in-progress)
*/
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.startTask(event, notes, occurrence)
toast.success('Task started!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start task'
toast.error(message)
console.error('Failed to start task:', error)
}
}
/**
* Unclaim a task (remove task status)
*/
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
toast.error(message)
console.error('Failed to unclaim task:', error)
}
}
/** /**
* Toggle completion status of an event (optionally for a specific occurrence) * Toggle completion status of an event (optionally for a specific occurrence)
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
*/ */
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => { const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence) console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
@ -152,19 +82,19 @@ export function useScheduledEvents() {
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted) console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
if (currentlyCompleted) { if (currentlyCompleted) {
console.log('⬇️ useScheduledEvents: Unclaiming task...') console.log('⬇️ useScheduledEvents: Marking as incomplete...')
await scheduledEventService.unclaimTask(event, occurrence) await scheduledEventService.uncompleteEvent(event, occurrence)
toast.success('Task unclaimed') toast.success('Event marked as incomplete')
} else { } else {
console.log('⬆️ useScheduledEvents: Marking as complete...') console.log('⬆️ useScheduledEvents: Marking as complete...')
await scheduledEventService.completeEvent(event, notes, occurrence) await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!') toast.success('Event completed!')
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle completion' const message = error instanceof Error ? error.message : 'Failed to toggle completion'
if (message.includes('authenticated')) { if (message.includes('authenticated')) {
toast.error('Please sign in to complete tasks') toast.error('Please sign in to complete events')
} else if (message.includes('Not connected')) { } else if (message.includes('Not connected')) {
toast.error('Not connected to relays') toast.error('Not connected to relays')
} else { } else {
@ -178,19 +108,19 @@ export function useScheduledEvents() {
/** /**
* Complete an event with optional notes * Complete an event with optional notes
*/ */
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => { const completeEvent = async (event: ScheduledEvent, notes: string = ''): Promise<void> => {
if (!scheduledEventService) { if (!scheduledEventService) {
toast.error('Scheduled event service not available') toast.error('Scheduled event service not available')
return return
} }
try { try {
await scheduledEventService.completeEvent(event, notes, occurrence) await scheduledEventService.completeEvent(event, notes)
toast.success('Task completed!') toast.success('Event completed!')
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete task' const message = error instanceof Error ? error.message : 'Failed to complete event'
toast.error(message) toast.error(message)
console.error('Failed to complete task:', error) console.error('Failed to complete event:', error)
} }
} }
@ -208,25 +138,6 @@ export function useScheduledEvents() {
return scheduledEventService?.scheduledEvents ?? new Map() return scheduledEventService?.scheduledEvents ?? new Map()
}) })
/**
* Delete a task (only author can delete)
*/
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.deleteTask(event)
toast.success('Task deleted!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete task'
toast.error(message)
console.error('Failed to delete task:', error)
}
}
/** /**
* Get all completions (reactive) - returns array for better reactivity * Get all completions (reactive) - returns array for better reactivity
*/ */
@ -236,22 +147,15 @@ export function useScheduledEvents() {
}) })
return { return {
// Methods - Getters // Methods
getScheduledEvents, getScheduledEvents,
getEventsForDate, getEventsForDate,
getEventsForSpecificDate, getEventsForSpecificDate,
getTodaysEvents, getTodaysEvents,
getCompletion, getCompletion,
isCompleted, isCompleted,
getTaskStatus, toggleComplete,
// Methods - Actions
claimTask,
startTask,
completeEvent, completeEvent,
unclaimTask,
deleteTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State // State
isLoading, isLoading,

View file

@ -383,28 +383,6 @@ export class FeedService extends BaseService {
return return
} }
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
if (deletedKind === '31925') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleDeletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Handle post deletions (kind 1) in FeedService // Handle post deletions (kind 1) in FeedService
if (deletedKind === '1' || !deletedKind) { if (deletedKind === '1' || !deletedKind) {
// Extract event IDs to delete from 'e' tags // Extract event IDs to delete from 'e' tags

View file

@ -28,16 +28,14 @@ export interface ScheduledEvent {
recurrence?: RecurrencePattern // Optional: for recurring events recurrence?: RecurrencePattern // Optional: for recurring events
} }
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
export interface EventCompletion { export interface EventCompletion {
id: string id: string
eventAddress: string // "31922:pubkey:d-tag" eventAddress: string // "31922:pubkey:d-tag"
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD) occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
pubkey: string // Who claimed/completed it pubkey: string // Who completed it
created_at: number created_at: number
taskStatus: TaskStatus completed: boolean
completedAt?: number // Unix timestamp when completed completedAt?: number
notes: string notes: string
} }
@ -160,19 +158,7 @@ export class ScheduledEventService extends BaseService {
return return
} }
// Parse task status (new approach)
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
// Backward compatibility: check old 'completed' tag if task-status not present
let taskStatus: TaskStatus
if (taskStatusTag) {
taskStatus = taskStatusTag
} else {
// Legacy support: convert old 'completed' tag to new taskStatus
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true' const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
taskStatus = completed ? 'completed' : 'claimed'
}
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1] const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
@ -180,7 +166,7 @@ export class ScheduledEventService extends BaseService {
console.log('📋 Completion details:', { console.log('📋 Completion details:', {
aTag, aTag,
occurrence, occurrence,
taskStatus, completed,
pubkey: event.pubkey, pubkey: event.pubkey,
eventId: event.id eventId: event.id
}) })
@ -191,7 +177,7 @@ export class ScheduledEventService extends BaseService {
occurrence, occurrence,
pubkey: event.pubkey, pubkey: event.pubkey,
created_at: event.created_at, created_at: event.created_at,
taskStatus, completed,
completedAt, completedAt,
notes: event.content notes: event.content
} }
@ -203,7 +189,7 @@ export class ScheduledEventService extends BaseService {
const existing = this._completions.get(completionKey) const existing = this._completions.get(completionKey)
if (!existing || event.created_at > existing.created_at) { if (!existing || event.created_at > existing.created_at) {
this._completions.set(completionKey, completion) this._completions.set(completionKey, completion)
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus) console.log('✅ Stored completion for:', completionKey, '- completed:', completed)
} else { } else {
console.log('⏭️ Skipped older completion for:', completionKey) console.log('⏭️ Skipped older completion for:', completionKey)
} }
@ -213,90 +199,6 @@ export class ScheduledEventService extends BaseService {
} }
} }
/**
* Handle deletion event (kind 5) for completion events
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
try {
// Extract event IDs to delete from 'e' tags
const eventIdsToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'e')
.map((tag: string[]) => tag[1]) || []
if (eventIdsToDelete.length === 0) {
console.warn('Deletion event missing e tags:', event.id)
return
}
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
// Find and remove completions that match the deleted event IDs
let deletedCount = 0
for (const [completionKey, completion] of this._completions.entries()) {
// Only delete if:
// 1. The completion event ID matches one being deleted
// 2. The deletion request comes from the same author (NIP-09 validation)
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
this._completions.delete(completionKey)
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
deletedCount++
}
}
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Handle deletion event (kind 5) for scheduled events (kind 31922)
* Made public so FeedService can route deletion events to this service
*/
public handleTaskDeletion(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
try {
// Extract event addresses to delete from 'a' tags
const eventAddressesToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'a')
.map((tag: string[]) => tag[1]) || []
if (eventAddressesToDelete.length === 0) {
console.warn('Task deletion event missing a tags:', event.id)
return
}
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
// Find and remove tasks that match the deleted event addresses
let deletedCount = 0
for (const eventAddress of eventAddressesToDelete) {
const task = this._scheduledEvents.get(eventAddress)
// Only delete if:
// 1. The task exists
// 2. The deletion request comes from the task author (NIP-09 validation)
if (task && task.pubkey === event.pubkey) {
this._scheduledEvents.delete(eventAddress)
console.log('✅ Deleted task:', eventAddress)
deletedCount++
} else if (task) {
console.warn('⚠️ Deletion request not from task author:', eventAddress)
}
}
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
} catch (error) {
console.error('Failed to handle task deletion event:', error)
}
}
/** /**
* Get all scheduled events * Get all scheduled events
*/ */
@ -408,49 +310,15 @@ export class ScheduledEventService extends BaseService {
*/ */
isCompleted(eventAddress: string, occurrence?: string): boolean { isCompleted(eventAddress: string, occurrence?: string): boolean {
const completion = this.getCompletion(eventAddress, occurrence) const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus === 'completed' return completion?.completed || false
}
/**
* Get task status for an event
*/
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus || null
}
/**
* Claim a task (mark as claimed)
*/
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
}
/**
* Start a task (mark as in-progress)
*/
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
} }
/** /**
* Mark an event as complete (optionally for a specific occurrence) * Mark an event as complete (optionally for a specific occurrence)
*/ */
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> { async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'completed', notes, occurrence)
}
/**
* Internal method to update task status
*/
private async updateTaskStatus(
event: ScheduledEvent,
taskStatus: TaskStatus,
notes: string = '',
occurrence?: string
): Promise<void> {
if (!this.authService?.isAuthenticated?.value) { if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to update task status') throw new Error('Must be authenticated to complete events')
} }
if (!this.relayHub?.isConnected) { if (!this.relayHub?.isConnected) {
@ -467,17 +335,14 @@ export class ScheduledEventService extends BaseService {
const eventAddress = `31922:${event.pubkey}:${event.dTag}` const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create RSVP event with task-status tag // Create RSVP/completion event (NIP-52)
const tags: string[][] = [ const tags: string[][] = [
['a', eventAddress], ['a', eventAddress],
['task-status', taskStatus] ['status', 'accepted'],
['completed', 'true'],
['completed_at', Math.floor(Date.now() / 1000).toString()]
] ]
// Add completed_at timestamp if task is completed
if (taskStatus === 'completed') {
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
}
// Add occurrence tag if provided (for recurring events) // Add occurrence tag if provided (for recurring events)
if (occurrence) { if (occurrence) {
tags.push(['occurrence', occurrence]) tags.push(['occurrence', occurrence])
@ -494,17 +359,17 @@ export class ScheduledEventService extends BaseService {
const privkeyBytes = this.hexToUint8Array(userPrivkey) const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the status update // Publish the completion
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress) console.log('📤 Publishing completion event (kind 31925) for:', eventAddress)
const result = await this.relayHub.publishEvent(signedEvent) const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task status published to', result.success, '/', result.total, 'relays') console.log('✅ Completion event published to', result.success, '/', result.total, 'relays')
// Update local state (publishEvent throws if no relays accepted) // Optimistically update local state
console.log('🔄 Updating local state (event published successfully)') console.log('🔄 Optimistically updating local state')
this.handleCompletionEvent(signedEvent) this.handleCompletionEvent(signedEvent)
} catch (error) { } catch (error) {
console.error('Failed to update task status:', error) console.error('Failed to complete event:', error)
throw error throw error
} finally { } finally {
this._isLoading.value = false this._isLoading.value = false
@ -512,13 +377,11 @@ export class ScheduledEventService extends BaseService {
} }
/** /**
* Unclaim/reset a task (removes task status - makes it unclaimed) * Uncomplete an event (publish new RSVP with completed=false)
* Note: In Nostr, we can't truly "delete" an event, but we can publish
* a deletion request (kind 5) to ask relays to remove our RSVP
*/ */
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> { async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) { if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to unclaim tasks') throw new Error('Must be authenticated to uncomplete events')
} }
if (!this.relayHub?.isConnected) { if (!this.relayHub?.isConnected) {
@ -534,102 +397,38 @@ export class ScheduledEventService extends BaseService {
this._isLoading.value = true this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}` const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
const completion = this._completions.get(completionKey)
if (!completion) { // Create RSVP event with completed=false
console.log('No completion to unclaim') const tags: string[][] = [
return ['a', eventAddress],
['status', 'tentative'],
['completed', 'false']
]
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
} }
// Create deletion event (kind 5) for the RSVP const eventTemplate: EventTemplate = {
const deletionEvent: EventTemplate = { kind: 31925,
kind: 5, content: '',
content: 'Task unclaimed', tags,
tags: [
['e', completion.id], // Reference to the RSVP event being deleted
['k', '31925'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000) created_at: Math.floor(Date.now() / 1000)
} }
// Sign the event // Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey) const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes) const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion request // Publish the uncomplete
console.log('📤 Publishing deletion request for task RSVP:', completion.id) await this.relayHub.publishEvent(signedEvent)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted) // Optimistically update local state
this._completions.delete(completionKey) this.handleCompletionEvent(signedEvent)
console.log('🗑️ Removed completion from local state:', completionKey)
} catch (error) { } catch (error) {
console.error('Failed to unclaim task:', error) console.error('Failed to uncomplete event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Delete a scheduled event (kind 31922)
* Only the author can delete their own event
*/
async deleteTask(event: ScheduledEvent): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to delete tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
const userPubkey = this.authService.user.value?.pubkey
if (!userPrivkey || !userPubkey) {
throw new Error('User credentials not available')
}
// Only author can delete
if (userPubkey !== event.pubkey) {
throw new Error('Only the task author can delete this task')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create deletion event (kind 5) for the scheduled event
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task deleted',
tags: [
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
['k', '31922'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task:', eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._scheduledEvents.delete(eventAddress)
console.log('🗑️ Removed task from local state:', eventAddress)
} catch (error) {
console.error('Failed to delete task:', error)
throw error throw error
} finally { } finally {
this._isLoading.value = false this._isLoading.value = false