dorfteich/apps/web/src/editor/nodes/task-overview.tsx
Claude Fable 5 8719b0ee1e #168: Formulare — Fehler-Verdrahtung und Namenslücken
Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00

158 lines
5.8 KiB
TypeScript

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<Record<string, boolean>>({});
const editable = editor.isEditable;
const overview = useQuery({
queryKey: ['page-tasks', pondSlug, pageSlug],
queryFn: () => apiGet<TaskOverviewPage[]>(`/read/${pondSlug}/${pageSlug}/tasks`),
enabled: !editable && Boolean(pageSlug),
});
if (editable) {
return (
<NodeViewWrapper className="dt-transclusion-card" contentEditable={false}>
<span className="dt-transclusion-card__icon" aria-hidden>
</span>
<span className="dt-transclusion-card__label">{t('editorCard')}</span>
</NodeViewWrapper>
);
}
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<void> => {
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 (
<NodeViewWrapper className="dt-task-overview-view" contentEditable={false}>
{total === 0 ? (
<div className="dt-task-overview-empty">{t('empty')}</div>
) : (
<table className="dt-task-overview-table">
<thead>
<tr>
<th>
<span className="visually-hidden">{t('colDone')}</span>
</th>
<th>{t('colTask')}</th>
<th>{t('colMentions')}</th>
<th>{t('colStart')}</th>
<th>{t('colDue')}</th>
<th>{t('colPage')}</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={key}>
<td>
<input
type="checkbox"
checked={checked}
disabled={!task.id}
title={task.id ? undefined : t('unsavedHint')}
onChange={(event) =>
task.id && void toggle(page.pageId, task.id, event.target.checked)
}
/>
</td>
<td>{task.text}</td>
<td>
{task.mentions.map((mention) => (
<span key={mention.id || mention.username} className="dt-mention">
@{mention.displayName}
</span>
))}
</td>
<td>{formatDate(task.startDate)}</td>
<td>{formatDate(task.dueDate)}</td>
<td>
<Link className="wikilink" to={`/p/${pondSlug}/${page.slug}`}>
{page.title}
</Link>
</td>
</tr>
);
}),
)}
</tbody>
</table>
)}
</NodeViewWrapper>
);
}
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);
},
});