import { create } from 'zustand'; import type { CalendarTask } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { debug } from '@/lib/debug'; import { taskHooks } from '@/lib/plugin-hooks'; import { taskToView } from '@/lib/plugin-projection'; export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; /** * The `error` a failed task load leaves. The tasks loaded before stay; the * task list shows a notice for it. */ export const TASKS_LOAD_ERROR = 'Failed to fetch tasks'; interface TaskStore { tasks: CalendarTask[]; selectedTaskId: string | null; filter: TaskViewFilter; showCompleted: boolean; isLoading: boolean; error: string | null; setTasks: (tasks: CalendarTask[]) => void; setSelectedTaskId: (id: string | null) => void; setFilter: (filter: TaskViewFilter) => void; setShowCompleted: (show: boolean) => void; fetchTasks: (client: IJMAPClient, calendarIds?: string[]) => Promise; /** Resolves to null when a plugin stopped the create. */ createTask: (client: IJMAPClient, task: Partial) => Promise; /** false when a plugin stopped it (the plugin tells the user why). */ updateTask: (client: IJMAPClient, id: string, updates: Partial) => Promise; deleteTask: (client: IJMAPClient, id: string) => Promise; toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise; clearTasks: () => void; } // Each fetch gets a number: only the latest one's answer is kept, so an // answer that comes late never puts an older list back. let latestFetch = 0; export const useTaskStore = create((set, get) => ({ tasks: [], selectedTaskId: null, filter: 'all', showCompleted: false, isLoading: false, error: null, setTasks: (tasks) => set({ tasks }), setSelectedTaskId: (id) => set({ selectedTaskId: id }), setFilter: (filter) => { if (get().filter === filter) return; set({ filter }); void taskHooks.onTaskFilterChange.emit({ filter, showCompleted: get().showCompleted }); }, setShowCompleted: (show) => { if (get().showCompleted === show) return; set({ showCompleted: show }); void taskHooks.onTaskFilterChange.emit({ filter: get().filter, showCompleted: show }); }, fetchTasks: async (client, calendarIds) => { debug.log('tasks', 'TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' }); const fetch = ++latestFetch; const stillCurrent = () => fetch === latestFetch; set({ isLoading: true, error: null }); try { const tasks = await client.getCalendarTasks(calendarIds); // A fetch started later decides the list. if (!stillCurrent()) return; debug.log('tasks', 'TaskStore/fetchTasks received', tasks.length, 'tasks'); tasks.forEach((t, i) => { debug.log('tasks', `TaskStore/fetchTasks [${i}]`, { id: t.id, uid: t.uid, '@type': t['@type'], title: t.title, due: t.due, progress: t.progress, showWithoutTime: t.showWithoutTime, calendarIds: t.calendarIds, }); }); set({ tasks, isLoading: false }); if (taskHooks.onTasksLoaded.size > 0) void taskHooks.onTasksLoaded.emit(tasks.map(taskToView)); } catch (error) { debug.error('TaskStore/fetchTasks failed', error); if (stillCurrent()) set({ isLoading: false, error: TASKS_LOAD_ERROR }); } }, createTask: async (client, task) => { debug.log('tasks', 'TaskStore/createTask', task); if (!(await taskHooks.onBeforeTaskCreate.intercept(taskToView({ id: '', ...task } as CalendarTask)))) return null; const created = await client.createCalendarTask(task); debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); set({ tasks: [...get().tasks, created] }); void taskHooks.onTaskCreate.emit(taskToView(created)); return created; }, updateTask: async (client, id, updates) => { if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return false; const before = get().tasks.find(t => t.id === id); const newId = await client.updateCalendarTask(id, updates); if (newId && newId !== id) { // Moved into a calendar of another account: the server recreated it // under a new id, so reload rather than patch the old entry. (#1204) set({ selectedTaskId: get().selectedTaskId === id ? newId : get().selectedTaskId }); await get().fetchTasks(client); const moved = get().tasks.find(t => t.id === newId) ?? ({ ...before, ...updates, id: newId } as CalendarTask); void taskHooks.onTaskUpdate.emit(taskToView(moved)); return true; } const updated = { ...(before ?? { id }), ...updates, updated: new Date().toISOString() } as CalendarTask; set({ tasks: get().tasks.map(t => t.id === id ? updated : t), }); void taskHooks.onTaskUpdate.emit(taskToView(updated)); return true; }, deleteTask: async (client, id) => { const title = get().tasks.find(t => t.id === id)?.title || ''; if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return false; await client.deleteCalendarTask(id); set({ tasks: get().tasks.filter(t => t.id !== id), selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId, }); void taskHooks.onTaskDelete.emit({ taskId: id, title }); return true; }, toggleTaskComplete: async (client, task) => { const newProgress = task.progress === 'completed' ? 'needs-action' : 'completed'; const updates: Partial = { progress: newProgress, }; await client.updateCalendarTask(task.id, updates); set({ tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t), }); // A toggle is reported as onTaskToggleComplete only, not also as // onTaskUpdate, so a plugin that listens to both sees each change once. void taskHooks.onTaskToggleComplete.emit({ taskId: task.id, complete: newProgress === 'completed' }); }, clearTasks: () => { latestFetch += 1; set({ tasks: [], selectedTaskId: null, error: null, isLoading: false }); }, }));