import type { TaskOverviewPage } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Node } from '@tiptap/core'; import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; import type { NodeViewProps } from '@tiptap/react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import { apiGet, apiPost } from '../../lib/api'; import { attributesFromSpec, nodeSpec } from '../spec-utils'; import { useWikilinks } from '../wikilink-context'; /** * The task overview block (issue #154). Edit mode shows a placeholder card; * read mode fetches the permission-filtered collection of the current page + * subtree (`/read/:pond/:slug/tasks`) and renders the table. Checking a box * posts the toggle (#153) — applied asynchronously through the collab * server — so the UI flips optimistically and refetches shortly after. */ function TaskOverviewView({ editor }: NodeViewProps): React.JSX.Element { const { t, i18n } = useTranslation('tasks'); const { pondSlug } = useWikilinks(); const { pageSlug = '' } = useParams<{ pageSlug: string }>(); const queryClient = useQueryClient(); const [optimistic, setOptimistic] = useState>({}); const editable = editor.isEditable; const overview = useQuery({ queryKey: ['page-tasks', pondSlug, pageSlug], queryFn: () => apiGet(`/read/${pondSlug}/${pageSlug}/tasks`), enabled: !editable && Boolean(pageSlug), }); if (editable) { return ( {t('editorCard')} ); } const formatDate = (iso: string | null): string => iso ? new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format( new Date(`${iso}T00:00:00`), ) : ''; const toggle = async (pageId: string, taskId: string, checked: boolean): Promise => { setOptimistic((prev) => ({ ...prev, [taskId]: checked })); await apiPost(`/pages/${pageId}/tasks/${taskId}`, { checked }); // The collab server applies the edit and persists it debounced (~2 s) — // re-read a couple of times; the optimistic override stays until the // server agrees (cleared below when the data catches up). for (const delay of [2500, 6000]) { setTimeout(() => { void queryClient.invalidateQueries({ queryKey: ['page-tasks', pondSlug, pageSlug] }); }, delay); } }; const pages = overview.data ?? []; // Drop optimistic overrides the server data now agrees with. const agreed = pages .flatMap((page) => page.tasks) .filter((task) => task.id && task.id in optimistic && optimistic[task.id] === task.checked) .map((task) => task.id as string); if (agreed.length > 0) { setOptimistic((prev) => { const next = { ...prev }; for (const id of agreed) delete next[id]; return next; }); } const total = pages.reduce((sum, page) => sum + page.tasks.length, 0); return ( {total === 0 ? (
{t('empty')}
) : ( {pages.flatMap((page) => page.tasks.map((task, index) => { const key = task.id ?? `${page.pageId}:${index}`; const checked = task.id && task.id in optimistic ? optimistic[task.id]! : task.checked; return ( ); }), )}
{t('colDone')} {t('colTask')} {t('colMentions')} {t('colStart')} {t('colDue')} {t('colPage')}
task.id && void toggle(page.pageId, task.id, event.target.checked) } /> {task.text} {task.mentions.map((mention) => ( @{mention.displayName} ))} {formatDate(task.startDate)} {formatDate(task.dueDate)} {page.title}
)}
); } const taskOverviewSpec = nodeSpec('task_overview'); export const TaskOverview = Node.create({ name: 'task_overview', group: taskOverviewSpec.group, atom: taskOverviewSpec.atom, addAttributes() { return attributesFromSpec(taskOverviewSpec); }, parseHTML: () => taskOverviewSpec.parseDOM, renderHTML: ({ node }) => taskOverviewSpec.toDOM!(node), addNodeView() { return ReactNodeViewRenderer(TaskOverviewView); }, });