Implement task status workflow: claimed, in-progress, completed
Added granular task state management to scheduled events/tasks with three states plus unclaimed. Tasks now support a full workflow from claiming to completion with visual feedback at each stage. **New Task States:** - **Unclaimed** (no RSVP event) - Task available for anyone to claim - **Claimed** - User has reserved the task but hasn't started - **In Progress** - User is actively working on the task - **Completed** - Task is done - **Blocked** - Task is stuck (supported but not yet used in UI) - **Cancelled** - Task won't be completed (supported but not yet used in UI) **Service Layer (ScheduledEventService.ts):** - Updated `EventCompletion` interface: replaced `completed: boolean` with `taskStatus: TaskStatus` - Added `TaskStatus` type: `'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'` - New methods: `claimTask()`, `startTask()`, `getTaskStatus()` - Refactored `completeEvent()` and renamed `uncompleteEvent()` to `unclaimTask()` - Internal `updateTaskStatus()` method handles all state changes - Uses `task-status` tag instead of `completed` tag in Nostr events - `unclaimTask()` publishes deletion event (kind 5) to remove RSVP - Backward compatibility: reads old `completed` tag and converts to new taskStatus **Composable (useScheduledEvents.ts):** - Exported new methods: `claimTask`, `startTask`, `unclaimTask`, `getTaskStatus` - Updated `completeEvent` signature to accept occurrence parameter - Marked `toggleComplete` as deprecated (still works for compatibility) **UI (ScheduledEventCard.vue):** - Context-aware action buttons based on current task status: - Unclaimed: "Claim Task" button - Claimed: "Start Task" + "Unclaim" buttons - In Progress: "Mark Complete" + "Unclaim" buttons - Completed: "Unclaim" button only - Status badges with icons and color coding: - 👋 Claimed (blue) - 🔄 In Progress (orange) - ✓ Completed (green) - Shows who claimed/is working on/completed each task - Unified confirmation dialog for all actions - Quick action buttons in collapsed view - Full button set in expanded view **Feed Integration (NostrFeed.vue):** - Added handlers: `onClaimTask`, `onStartTask`, `onCompleteTask`, `onUnclaimTask` - Passes `getTaskStatus` prop to ScheduledEventCard - Wired up all new event emitters **Nostr Protocol:** - Uses NIP-52 Calendar Event RSVP (kind 31925) - Custom `task-status` tag for granular state tracking - Deletion events (kind 5) for unclaiming tasks - Fully decentralized - all state stored on Nostr relays 🐢 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2e6f215157
commit
d497cfa4d9
4 changed files with 452 additions and 100 deletions
|
|
@ -99,7 +99,16 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
|||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
||||
|
||||
// Use scheduled events service
|
||||
const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents()
|
||||
const {
|
||||
getEventsForSpecificDate,
|
||||
getCompletion,
|
||||
getTaskStatus,
|
||||
claimTask,
|
||||
startTask,
|
||||
completeEvent,
|
||||
unclaimTask,
|
||||
allCompletions
|
||||
} = useScheduledEvents()
|
||||
|
||||
// Selected date for viewing scheduled tasks (defaults to today)
|
||||
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||
|
|
@ -255,14 +264,40 @@ async function onToggleLike(note: FeedPost) {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle scheduled event completion toggle
|
||||
async function onToggleComplete(event: ScheduledEvent, occurrence?: string) {
|
||||
console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence)
|
||||
// Task action handlers
|
||||
async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
|
||||
console.log('👋 NostrFeed: Claiming task:', event.title)
|
||||
try {
|
||||
await toggleComplete(event, occurrence)
|
||||
console.log('✅ NostrFeed: toggleComplete succeeded')
|
||||
await claimTask(event, '', occurrence)
|
||||
} catch (error) {
|
||||
console.error('❌ NostrFeed: Failed to toggle event completion:', error)
|
||||
console.error('❌ Failed to claim task:', 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -514,8 +549,12 @@ function cancelDelete() {
|
|||
:event="event"
|
||||
:get-display-name="getDisplayName"
|
||||
:get-completion="getCompletion"
|
||||
:get-task-status="getTaskStatus"
|
||||
:admin-pubkeys="adminPubkeys"
|
||||
@toggle-complete="onToggleComplete"
|
||||
@claim-task="onClaimTask"
|
||||
@start-task="onStartTask"
|
||||
@complete-task="onCompleteTask"
|
||||
@unclaim-task="onUnclaimTask"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
|
||||
|
|
|
|||
|
|
@ -15,18 +15,22 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Calendar, MapPin, Clock, CheckCircle } from 'lucide-vue-next'
|
||||
import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
|
||||
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand } from 'lucide-vue-next'
|
||||
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
|
||||
|
||||
interface Props {
|
||||
event: ScheduledEvent
|
||||
getDisplayName: (pubkey: string) => string
|
||||
getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined
|
||||
getTaskStatus: (eventAddress: string, occurrence?: string) => TaskStatus | null
|
||||
adminPubkeys?: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void
|
||||
(e: 'claim-task', 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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -53,12 +57,29 @@ const occurrence = computed(() => {
|
|||
// 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)
|
||||
// Get current task status
|
||||
const taskStatus = computed(() => props.getTaskStatus(eventAddress.value, occurrence.value))
|
||||
|
||||
// Check if event is completable (task type)
|
||||
const isCompletable = computed(() => props.event.eventType === 'task')
|
||||
|
||||
// Get completion data
|
||||
const completion = computed(() => props.getCompletion(eventAddress.value, occurrence.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
|
||||
const formattedDate = computed(() => {
|
||||
try {
|
||||
|
|
@ -110,28 +131,102 @@ const formattedTimeRange = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
// Handle mark complete button click - show confirmation dialog
|
||||
function handleMarkComplete() {
|
||||
console.log('🔘 Mark Complete button clicked for event:', props.event.title)
|
||||
// Action type for confirmation dialog
|
||||
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | null>(null)
|
||||
|
||||
// Handle claim task
|
||||
function handleClaimTask() {
|
||||
pendingAction.value = 'claim'
|
||||
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
|
||||
// Handle start task
|
||||
function handleStartTask() {
|
||||
pendingAction.value = 'start'
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
// Cancel mark complete
|
||||
function cancelMarkComplete() {
|
||||
showConfirmDialog.value = false
|
||||
// Handle complete task
|
||||
function handleCompleteTask() {
|
||||
pendingAction.value = 'complete'
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
// Handle unclaim task
|
||||
function handleUnclaimTask() {
|
||||
pendingAction.value = 'unclaim'
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
// Confirm action
|
||||
function confirmAction() {
|
||||
if (!pendingAction.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
|
||||
}
|
||||
|
||||
showConfirmDialog.value = false
|
||||
pendingAction.value = null
|
||||
}
|
||||
|
||||
// Cancel action
|
||||
function cancelAction() {
|
||||
showConfirmDialog.value = false
|
||||
pendingAction.value = null
|
||||
}
|
||||
|
||||
// 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.`,
|
||||
confirmText: 'Unclaim Task'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
confirmText: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
|
||||
:class="{ 'opacity-60': isCompletable && isCompleted }">
|
||||
:class="{ 'opacity-60': isCompletable && taskStatus === 'completed' }">
|
||||
<!-- 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">
|
||||
|
|
@ -143,26 +238,50 @@ function cancelMarkComplete() {
|
|||
|
||||
<!-- Title -->
|
||||
<h3 class="font-semibold text-sm md:text-base flex-1 truncate"
|
||||
:class="{ 'line-through': isCompletable && isCompleted }">
|
||||
:class="{ 'line-through': isCompletable && taskStatus === 'completed' }">
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
|
||||
<!-- Badges and Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Mark Complete Button (for uncompleted tasks) -->
|
||||
<!-- Quick Action Button (context-aware) -->
|
||||
<Button
|
||||
v-if="isCompletable && !isCompleted"
|
||||
@click.stop="handleMarkComplete"
|
||||
v-if="isCompletable && !taskStatus"
|
||||
@click.stop="handleClaimTask"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
class="h-7 px-2 text-xs gap-1"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4" />
|
||||
<Hand class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">Claim</span>
|
||||
</Button>
|
||||
|
||||
<!-- Completed Badge with completer name -->
|
||||
<Badge v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" variant="secondary" class="text-xs">
|
||||
✓ {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
|
||||
<Button
|
||||
v-else-if="isCompletable && taskStatus === 'claimed'"
|
||||
@click.stop="handleStartTask"
|
||||
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>
|
||||
|
||||
<!-- Recurring Badge -->
|
||||
|
|
@ -200,10 +319,20 @@ function cancelMarkComplete() {
|
|||
<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>
|
||||
<!-- Task Status Info (only for completable events with status) -->
|
||||
<div v-if="isCompletable && completion" class="text-xs mb-3">
|
||||
<div v-if="taskStatus === 'completed'" class="text-muted-foreground">
|
||||
✓ Completed by {{ getDisplayName(completion.pubkey) }}
|
||||
<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>
|
||||
|
||||
<!-- Author (if not admin) -->
|
||||
|
|
@ -211,17 +340,70 @@ function cancelMarkComplete() {
|
|||
Posted by {{ getDisplayName(event.pubkey) }}
|
||||
</div>
|
||||
|
||||
<!-- Mark Complete Button (only for completable task events) -->
|
||||
<div v-if="isCompletable && !isCompleted" class="mt-3">
|
||||
<!-- Action Buttons (only for completable task events) -->
|
||||
<div v-if="isCompletable" class="mt-3 flex flex-wrap gap-2">
|
||||
<!-- Unclaimed Task -->
|
||||
<Button
|
||||
@click.stop="handleMarkComplete"
|
||||
v-if="!taskStatus"
|
||||
@click.stop="handleClaimTask"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4" />
|
||||
Mark Complete
|
||||
<Hand class="h-4 w-4" />
|
||||
Claim Task
|
||||
</Button>
|
||||
|
||||
<!-- Claimed Task -->
|
||||
<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="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
|
||||
@click.stop="handleUnclaimTask"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Unclaim
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Completed Task -->
|
||||
<template v-else-if="taskStatus === 'completed'">
|
||||
<Button
|
||||
@click.stop="handleUnclaimTask"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Unclaim
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -232,14 +414,14 @@ function cancelMarkComplete() {
|
|||
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mark Event as Complete?</DialogTitle>
|
||||
<DialogTitle>{{ dialogContent.title }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will mark "{{ event.title }}" as completed by you. Other users will be able to see that you completed this event.
|
||||
{{ dialogContent.description }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="cancelMarkComplete">Cancel</Button>
|
||||
<Button @click="confirmMarkComplete">Mark Complete</Button>
|
||||
<Button variant="outline" @click="cancelAction">Cancel</Button>
|
||||
<Button @click="confirmAction">{{ dialogContent.confirmText }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { computed } from 'vue'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ScheduledEventService, ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
|
||||
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
|
||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
|
|
@ -64,8 +64,78 @@ export function useScheduledEvents() {
|
|||
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)
|
||||
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
|
||||
*/
|
||||
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
||||
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
|
||||
|
|
@ -82,19 +152,19 @@ export function useScheduledEvents() {
|
|||
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
|
||||
|
||||
if (currentlyCompleted) {
|
||||
console.log('⬇️ useScheduledEvents: Marking as incomplete...')
|
||||
await scheduledEventService.uncompleteEvent(event, occurrence)
|
||||
toast.success('Event marked as incomplete')
|
||||
console.log('⬇️ useScheduledEvents: Unclaiming task...')
|
||||
await scheduledEventService.unclaimTask(event, occurrence)
|
||||
toast.success('Task unclaimed')
|
||||
} else {
|
||||
console.log('⬆️ useScheduledEvents: Marking as complete...')
|
||||
await scheduledEventService.completeEvent(event, notes, occurrence)
|
||||
toast.success('Event completed!')
|
||||
toast.success('Task completed!')
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
|
||||
|
||||
if (message.includes('authenticated')) {
|
||||
toast.error('Please sign in to complete events')
|
||||
toast.error('Please sign in to complete tasks')
|
||||
} else if (message.includes('Not connected')) {
|
||||
toast.error('Not connected to relays')
|
||||
} else {
|
||||
|
|
@ -108,19 +178,19 @@ export function useScheduledEvents() {
|
|||
/**
|
||||
* Complete an event with optional notes
|
||||
*/
|
||||
const completeEvent = async (event: ScheduledEvent, notes: string = ''): Promise<void> => {
|
||||
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
||||
if (!scheduledEventService) {
|
||||
toast.error('Scheduled event service not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await scheduledEventService.completeEvent(event, notes)
|
||||
toast.success('Event completed!')
|
||||
await scheduledEventService.completeEvent(event, notes, occurrence)
|
||||
toast.success('Task completed!')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to complete event'
|
||||
const message = error instanceof Error ? error.message : 'Failed to complete task'
|
||||
toast.error(message)
|
||||
console.error('Failed to complete event:', error)
|
||||
console.error('Failed to complete task:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,15 +217,21 @@ export function useScheduledEvents() {
|
|||
})
|
||||
|
||||
return {
|
||||
// Methods
|
||||
// Methods - Getters
|
||||
getScheduledEvents,
|
||||
getEventsForDate,
|
||||
getEventsForSpecificDate,
|
||||
getTodaysEvents,
|
||||
getCompletion,
|
||||
isCompleted,
|
||||
toggleComplete,
|
||||
getTaskStatus,
|
||||
|
||||
// Methods - Actions
|
||||
claimTask,
|
||||
startTask,
|
||||
completeEvent,
|
||||
unclaimTask,
|
||||
toggleComplete, // DEPRECATED: Use specific actions instead
|
||||
|
||||
// State
|
||||
isLoading,
|
||||
|
|
|
|||
|
|
@ -28,14 +28,16 @@ export interface ScheduledEvent {
|
|||
recurrence?: RecurrencePattern // Optional: for recurring events
|
||||
}
|
||||
|
||||
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
|
||||
|
||||
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
|
||||
pubkey: string // Who claimed/completed it
|
||||
created_at: number
|
||||
completed: boolean
|
||||
completedAt?: number
|
||||
taskStatus: TaskStatus
|
||||
completedAt?: number // Unix timestamp when completed
|
||||
notes: string
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +160,19 @@ export class ScheduledEventService extends BaseService {
|
|||
return
|
||||
}
|
||||
|
||||
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
|
||||
// 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'
|
||||
taskStatus = completed ? 'completed' : 'claimed'
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -166,7 +180,7 @@ export class ScheduledEventService extends BaseService {
|
|||
console.log('📋 Completion details:', {
|
||||
aTag,
|
||||
occurrence,
|
||||
completed,
|
||||
taskStatus,
|
||||
pubkey: event.pubkey,
|
||||
eventId: event.id
|
||||
})
|
||||
|
|
@ -177,7 +191,7 @@ export class ScheduledEventService extends BaseService {
|
|||
occurrence,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
completed,
|
||||
taskStatus,
|
||||
completedAt,
|
||||
notes: event.content
|
||||
}
|
||||
|
|
@ -189,7 +203,7 @@ export class ScheduledEventService extends BaseService {
|
|||
const existing = this._completions.get(completionKey)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
this._completions.set(completionKey, completion)
|
||||
console.log('✅ Stored completion for:', completionKey, '- completed:', completed)
|
||||
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
|
||||
} else {
|
||||
console.log('⏭️ Skipped older completion for:', completionKey)
|
||||
}
|
||||
|
|
@ -310,15 +324,49 @@ export class ScheduledEventService extends BaseService {
|
|||
*/
|
||||
isCompleted(eventAddress: string, occurrence?: string): boolean {
|
||||
const completion = this.getCompletion(eventAddress, occurrence)
|
||||
return completion?.completed || false
|
||||
return completion?.taskStatus === 'completed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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) {
|
||||
throw new Error('Must be authenticated to complete events')
|
||||
throw new Error('Must be authenticated to update task status')
|
||||
}
|
||||
|
||||
if (!this.relayHub?.isConnected) {
|
||||
|
|
@ -335,14 +383,17 @@ export class ScheduledEventService extends BaseService {
|
|||
|
||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
||||
|
||||
// Create RSVP/completion event (NIP-52)
|
||||
// Create RSVP event with task-status tag
|
||||
const tags: string[][] = [
|
||||
['a', eventAddress],
|
||||
['status', 'accepted'],
|
||||
['completed', 'true'],
|
||||
['completed_at', Math.floor(Date.now() / 1000).toString()]
|
||||
['task-status', taskStatus]
|
||||
]
|
||||
|
||||
// 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)
|
||||
if (occurrence) {
|
||||
tags.push(['occurrence', occurrence])
|
||||
|
|
@ -359,17 +410,17 @@ export class ScheduledEventService extends BaseService {
|
|||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||
|
||||
// Publish the completion
|
||||
console.log('📤 Publishing completion event (kind 31925) for:', eventAddress)
|
||||
// Publish the status update
|
||||
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
|
||||
const result = await this.relayHub.publishEvent(signedEvent)
|
||||
console.log('✅ Completion event published to', result.success, '/', result.total, 'relays')
|
||||
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
|
||||
|
||||
// Optimistically update local state
|
||||
console.log('🔄 Optimistically updating local state')
|
||||
this.handleCompletionEvent(signedEvent)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to complete event:', error)
|
||||
console.error('Failed to update task status:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this._isLoading.value = false
|
||||
|
|
@ -377,11 +428,13 @@ export class ScheduledEventService extends BaseService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Uncomplete an event (publish new RSVP with completed=false)
|
||||
* Unclaim/reset a task (removes task status - makes it unclaimed)
|
||||
* 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 uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise<void> {
|
||||
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
|
||||
if (!this.authService?.isAuthenticated?.value) {
|
||||
throw new Error('Must be authenticated to uncomplete events')
|
||||
throw new Error('Must be authenticated to unclaim tasks')
|
||||
}
|
||||
|
||||
if (!this.relayHub?.isConnected) {
|
||||
|
|
@ -397,38 +450,40 @@ export class ScheduledEventService extends BaseService {
|
|||
this._isLoading.value = true
|
||||
|
||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
||||
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
||||
const completion = this._completions.get(completionKey)
|
||||
|
||||
// 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])
|
||||
if (!completion) {
|
||||
console.log('No completion to unclaim')
|
||||
return
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 31925,
|
||||
content: '',
|
||||
tags,
|
||||
// Create deletion event (kind 5) for the RSVP
|
||||
const deletionEvent: EventTemplate = {
|
||||
kind: 5,
|
||||
content: 'Task unclaimed',
|
||||
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)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
||||
|
||||
// Publish the uncomplete
|
||||
await this.relayHub.publishEvent(signedEvent)
|
||||
// Publish the deletion request
|
||||
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
|
||||
const result = await this.relayHub.publishEvent(signedEvent)
|
||||
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
|
||||
|
||||
// Optimistically update local state
|
||||
this.handleCompletionEvent(signedEvent)
|
||||
// Optimistically remove from local state
|
||||
this._completions.delete(completionKey)
|
||||
console.log('🗑️ Removed completion from local state:', completionKey)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to uncomplete event:', error)
|
||||
console.error('Failed to unclaim task:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this._isLoading.value = false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue