#154: Aufgabenübersicht als Kern-Block (Seite + Unterseiten)

Neuer Block-Atom task_overview (Markdown-Fence dorfteich-tasks,
HTML-Placeholder). Shared extractTaskRows liest Task-Zeilen mit Text,
Mentions (#150) und Start-/Zieldaten (#152); TasksService sammelt zur
Lesezeit den Teilbaum (rekursiv via collectSubtreeIds, canAccessPage-
Filter je Quellseite) aus Basis-State + page_updates-Log — KEINE
abgeleitete Tabelle nötig (Teilbäume sind klein, kein Drift). Neuer
auth-Endpoint GET /read/:pond/:slug/tasks; die öffentliche Ansicht
expandiert den Placeholder serverseitig zur statischen Tabelle
(Instanz-Sprache). NodeView mit Live-Tabelle und Rückschreib-Checkboxen
(optimistisch, Override bis der debounced Collab-Persist nachzieht);
Einfügen über die Block-Auswahl (eingebauter Eintrag). Unit- + DB-Tests,
neuer CI-Pack tasks.spec (voller Loop inkl. Rückschreiben end-to-end),
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
Claude Fable 5 2026-07-20 01:20:30 +02:00
parent 3f7190ebcc
commit e164370691
24 changed files with 942 additions and 9 deletions

View File

@ -406,6 +406,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
- name: Reset login rate limit before tasks pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run tasks pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/tasks.spec.ts
- name: Reset login rate limit before create-missing-page pack - name: Reset login rate limit before create-missing-page pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -1,9 +1,11 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deMails from '@dorfteich/shared/i18n/de/mails.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enMails from '@dorfteich/shared/i18n/en/mails.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import { createInstance, type i18n as I18n } from 'i18next'; import { createInstance, type i18n as I18n } from 'i18next';
/** /**
@ -15,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({ void apiI18n.init({
resources: { resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal }, en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal }, de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
}, },
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],

View File

@ -7,11 +7,12 @@ import { SearchModule } from '../search/search.module';
import { PagesController } from './pages.controller'; import { PagesController } from './pages.controller';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
import { PluginApiController } from './plugin-api.controller'; import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service';
@Module({ @Module({
imports: [PondsModule, SearchModule, WatchesModule], imports: [PondsModule, SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController], controllers: [PagesController, PluginApiController],
providers: [PagesService], providers: [PagesService, TasksService],
exports: [PagesService], exports: [PagesService, TasksService],
}) })
export class PagesModule {} export class PagesModule {}

View File

@ -0,0 +1,170 @@
import { INestApplication } from '@nestjs/common';
import { markdownToDoc } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { docToState } from './yjs-content';
/**
* The task overview collection (issue #154): tasks of the page and its live
* subtree, permission-filtered per source page; the public rendering expands
* the placeholder into a static table.
*/
describe.skipIf(!hasTestDb)('task overview endpoint (e2e, issue #154)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'uebersicht zeigt alles 1';
let ownerId: string;
let ownerCookie: string;
let pondSlug: string;
let pondId: string;
const api = () => request(app.getHttpServer());
async function makePage(
slug: string,
title: string,
markdown: string,
parentId: string | null = null,
): Promise<string> {
const doc = markdownToDoc(markdown);
const page = await prisma.page.create({
data: {
pondId,
slug,
title,
parentId,
createdBy: ownerId,
sortKey: 'a0',
ydocState: docToState(doc),
contentCache: {
create: { plainText: markdown, markdown, html: '', outline: [] },
},
},
});
return page.id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `overview-owner-${suffix}`;
const owner = await users.createUser({
username,
email: `${username}@example.test`,
displayName: 'Overview Owner',
password,
locale: 'en',
});
ownerId = owner.id;
await users.markEmailVerified(ownerId);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
pondSlug = `overview-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Overview Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'EDITOR',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } });
await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } });
await prisma.page.deleteMany({ where: { pond: { ownerId } } });
await prisma.pond.deleteMany({ where: { ownerId } });
await prisma.session.deleteMany({ where: { userId: ownerId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('collects tasks of the page and its subtree with mentions and dates', async () => {
const rootId = await makePage(
`plan-${suffix}`,
'Plan',
'- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```',
);
await makePage(`kabel-${suffix}`, 'Kabel', '- [x] Kabel prüfen <<2026-07-01', rootId);
// A sibling outside the subtree contributes nothing.
await makePage(`anders-${suffix}`, 'Anders', '- [ ] Fremde Aufgabe');
const res = await api()
.get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`)
.set('Cookie', ownerCookie)
.expect(200);
const pages = res.body as {
title: string;
tasks: { text: string; checked: boolean; dueDate: string | null }[];
}[];
expect(pages.map((p) => p.title)).toEqual(['Plan', 'Kabel']);
expect(pages[0]!.tasks[0]).toMatchObject({
text: 'Bühne buchen',
checked: false,
dueDate: '2026-08-01',
});
expect(pages[1]!.tasks[0]).toMatchObject({ text: 'Kabel prüfen', checked: true });
const allTexts = pages.flatMap((p) => p.tasks.map((t) => t.text));
expect(allTexts).not.toContain('Fremde Aufgabe');
});
it('requires a session and hides unreadable pages', async () => {
await api().get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`).expect(401);
});
it('expands the placeholder into a static table in the public rendering', async () => {
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
// Raw grant rows bypass the permission cache — drop the pond's entry.
app.get(PondPermissionCache).invalidate(pondId);
// The public body comes from the content cache — regenerate it with the
// real renderer so the placeholder div is present.
const { docToHtml } = await import('@dorfteich/shared');
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```');
await prisma.pageContentCache.updateMany({
where: { page: { pondId, slug: `plan-${suffix}` } },
data: { html: docToHtml(doc) },
});
const res = await api().get(`/api/v1/public/${pondSlug}/plan-${suffix}/content`).expect(200);
const html = (res.body as { html: string }).html;
expect(html).toContain('dt-task-overview-table');
expect(html).toContain('Bühne buchen');
expect(html).toContain('Kabel prüfen');
expect(html).not.toContain('data-task-overview');
});
});

View File

@ -0,0 +1,180 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
collectSubtreeIds,
extractTaskRows,
type TaskOverviewPage,
type TaskRow,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { apiI18n } from '../i18n/api-i18n';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { escapeHtml } from '../public/html-shell';
import { docFromStateAndUpdates } from './yjs-content';
/**
* The task overview's collection (issue #154): every task-list line of a page
* and its live subtree, permission-filtered per source page a page the
* viewer may not read contributes nothing (same no-leak rule as the
* transclusion expansion). Rows are extracted at read time from the stored
* Yjs states; subtrees are small (depth 6), so no derived table is needed.
*/
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
async collect(
user: User | null,
pondSlug: string,
pageSlug: string,
): Promise<TaskOverviewPage[]> {
const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } });
if (!pond) throw new NotFoundException();
const root = await this.prisma.page.findFirst({
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
select: { id: true, pondId: true },
});
if (!root || !(await this.permissions.canAccessPage(user, root, 'read'))) {
throw new NotFoundException();
}
return this.collectForPage(user, pond.id, root.id);
}
async collectForPage(
user: User | null,
pondId: string,
rootPageId: string,
): Promise<TaskOverviewPage[]> {
const tree = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
select: { id: true, parentId: true },
});
const subtree = collectSubtreeIds(tree, rootPageId);
const pages = await this.prisma.page.findMany({
where: { id: { in: [...subtree] } },
select: {
id: true,
slug: true,
title: true,
ydocState: true,
labels: { select: { labelId: true } },
},
orderBy: { title: 'asc' },
});
const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
// The live document is the base state plus the update log (merged back
// only at compaction) — apply both, or fresh edits would be invisible.
const updateRows = await this.prisma.pageUpdate.findMany({
where: { pageId: { in: [...subtree] } },
orderBy: { seq: 'asc' },
select: { pageId: true, update: true },
});
const updatesByPage = new Map<string, Uint8Array[]>();
for (const row of updateRows) {
const list = updatesByPage.get(row.pageId) ?? [];
list.push(row.update);
updatesByPage.set(row.pageId, list);
}
const result: TaskOverviewPage[] = [];
// The root page leads; the readable descendants follow alphabetically.
const ordered = [
...pages.filter((page) => page.id === rootPageId),
...pages.filter((page) => page.id !== rootPageId),
];
const mentionIds = new Set<string>();
const raw: { page: (typeof pages)[number]; rows: TaskRow[] }[] = [];
for (const page of ordered) {
if (!readable.has(page.id)) continue;
let rows: TaskRow[] = [];
try {
rows = extractTaskRows(
docFromStateAndUpdates(page.ydocState, updatesByPage.get(page.id) ?? []),
);
} catch {
// An undecodable state contributes nothing rather than failing the view.
rows = [];
}
if (rows.length === 0) continue;
rows.forEach((row) => row.mentions.forEach((m) => m.userId && mentionIds.add(m.userId)));
raw.push({ page, rows });
}
const users = mentionIds.size
? await this.prisma.user.findMany({
where: { id: { in: [...mentionIds] } },
select: { id: true, username: true, displayName: true },
})
: [];
const userById = new Map(users.map((user_) => [user_.id, user_]));
for (const { page, rows } of raw) {
result.push({
pageId: page.id,
slug: page.slug,
title: page.title,
tasks: rows.map((row) => ({
id: row.id,
checked: row.checked,
text: row.text,
mentions: row.mentions
.map((mention) => {
const resolved = mention.userId ? userById.get(mention.userId) : undefined;
return resolved
? {
id: resolved.id,
username: resolved.username,
displayName: resolved.displayName,
}
: { id: '', username: mention.username, displayName: mention.username };
})
.filter((mention) => mention.username),
startDate: row.startDate,
dueDate: row.dueDate,
})),
});
}
return result;
}
/** Static table for the public view / exports (issue #154) — read-only. */
async renderStaticTable(
user: User | null,
pondId: string,
rootPageId: string,
lang: 'de' | 'en',
): Promise<string> {
const pages = await this.collectForPage(user, pondId, rootPageId);
const t = (key: string): string => apiI18n.t(`tasks:${key}`, { lng: lang });
const rows = pages.flatMap((page) =>
page.tasks.map(
(task) =>
`<tr><td><input type="checkbox" disabled${task.checked ? ' checked' : ''}></td>` +
`<td>${escapeHtml(task.text)}</td>` +
`<td>${task.mentions.map((m) => `@${escapeHtml(m.displayName)}`).join(', ')}</td>` +
`<td>${task.startDate ?? ''}</td><td>${task.dueDate ?? ''}</td>` +
`<td><a class="wikilink" href="${escapeHtml(page.slug)}" data-wikilink="${escapeHtml(page.slug)}">${escapeHtml(page.title)}</a></td></tr>`,
),
);
if (rows.length === 0) {
return `<div class="dt-task-overview-empty">${escapeHtml(t('empty'))}</div>`;
}
return (
`<table class="dt-task-overview-table"><thead><tr>` +
`<th></th><th>${escapeHtml(t('colTask'))}</th><th>${escapeHtml(t('colMentions'))}</th>` +
`<th>${escapeHtml(t('colStart'))}</th><th>${escapeHtml(t('colDue'))}</th>` +
`<th>${escapeHtml(t('colPage'))}</th>` +
`</tr></thead><tbody>${rows.join('')}</tbody></table>`
);
}
}

View File

@ -21,7 +21,27 @@ const FRAGMENT_NAME = 'default';
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */ /** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
export class InvalidPageStateError extends Error {} export class InvalidPageStateError extends Error {}
function docFromState(state: Uint8Array): Node { /**
* Decode a page's full current document from its base state plus the
* `page_updates` log (issue #154) the persisted base alone lags behind
* the live document until the next compaction merges the log back.
*/
export function docFromStateAndUpdates(state: Uint8Array, updates: Uint8Array[]): Node {
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, state);
for (const update of updates) Y.applyUpdate(ydoc, update);
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
} catch (error) {
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
} finally {
ydoc.destroy();
}
}
/** Decode a page's stored Yjs state back into its ProseMirror document
* also the entry point for read-time task extraction (issue #154). */
export function docFromState(state: Uint8Array): Node {
const ydoc = new Y.Doc(); const ydoc = new Y.Doc();
try { try {
Y.applyUpdate(ydoc, state); Y.applyUpdate(ydoc, state);

View File

@ -3,6 +3,7 @@ import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client'; import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service'; import { CommentsService } from '../comments/comments.service';
import { TasksService } from '../pages/tasks.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -41,6 +42,7 @@ export class PublicService {
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService, private readonly commentsService: CommentsService,
private readonly tasks: TasksService,
) {} ) {}
private async resolve( private async resolve(
@ -99,7 +101,23 @@ export class PublicService {
): Promise<string> { ): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
return this.expandEmbeds(withFallbacks, user, pondId, depth, visited); const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id);
return this.expandEmbeds(withTasks, user, pondId, depth, visited);
}
/** Replaces each task-overview placeholder (issue #154) with the static,
* permission-filtered table read-only in the public rendering. */
private async expandTaskOverviews(
html: string,
user: User | null,
pondId: string,
pageId: string,
): Promise<string> {
const placeholder = /<div class="dt-task-overview" data-task-overview="1">[^<]*<\/div>/g;
if (!placeholder.test(html)) return html;
const lang = await this.settings.get('instance.defaultLocale');
const table = await this.tasks.renderStaticTable(user, pondId, pageId, lang);
return html.replace(placeholder, () => table);
} }
/** /**

View File

@ -1,6 +1,8 @@
import { Controller, Get, Param, Req } from '@nestjs/common'; import { Controller, Get, Param, Req } from '@nestjs/common';
import type { TaskOverviewPage } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { TasksService } from '../pages/tasks.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { PublicPageContent, PublicService } from './public.service'; import { PublicPageContent, PublicService } from './public.service';
@ -15,7 +17,22 @@ import { PublicPageContent, PublicService } from './public.service';
*/ */
@Controller('read') @Controller('read')
export class ReadContentController { export class ReadContentController {
constructor(private readonly publicPages: PublicService) {} constructor(
private readonly publicPages: PublicService,
private readonly tasks: TasksService,
) {}
// The task collection (issue #154) — registered before the generic
// two-segment route so `tasks` is not read as a page slug.
@Get(':pondSlug/:pageSlug/tasks')
@AuthenticatedOnly()
async tasksOf(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<TaskOverviewPage[]> {
return this.tasks.collect(request.user ?? null, pondSlug, pageSlug);
}
// Session required (explicit access rule, issue #52); per-page read // Session required (explicit access rule, issue #52); per-page read
// permission is enforced in the service (resolve → canAccessPage → 404). // permission is enforced in the service (resolve → canAccessPage → 404).

119
apps/web/e2e/tasks.spec.ts Normal file
View File

@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Tasks pack (issues #150/#152/#153/#154): task lines get stable ids, the
* task overview block collects the page + subtree into a table with mention
* and date columns, and checking a box in the overview writes back to the
* source page through the collab server. Language-independent selectors.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
test('task overview collects subtree tasks and toggles write back', async ({ browser }) => {
test.setTimeout(120_000);
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
const parentRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Tasks Parent ${ts}` },
});
const parent = await parentRes.json();
const childRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Tasks Child ${ts}`, parentId: parent.id },
});
const child = await childRes.json();
const page = await context.newPage();
// Child: one task line with a due date (typed, so it gets an id, #153).
await page.goto(`/p/${pond.slug}/${child.slug}`);
await page.locator('.editor-page__mode-toggle').click();
const body = page.locator('.editor-content .ProseMirror');
await body.click();
await page.locator('.editor-toolbar button', { hasText: '☑' }).click();
await page.keyboard.type('Kabel prüfen >>31.12.2026 ');
await expect(page.locator('.editor-content .dt-date--due')).toBeVisible();
// The collab server persists debounced (~2 s) — wait until the task shows
// up in the collection before moving on.
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`);
return JSON.stringify(await res.json());
},
{ timeout: 20_000 },
)
.toContain('Kabel prüfen');
// Parent: a task line plus the overview block.
await page.goto(`/p/${pond.slug}/${parent.slug}`);
await page.locator('.editor-page__mode-toggle').click();
await page.locator('.editor-content .ProseMirror').click();
await page.locator('.editor-toolbar button', { hasText: '☑' }).click();
await page.keyboard.type('Bühne buchen');
await page.keyboard.press('Enter');
// Leave the task list (an empty item converts back to a paragraph).
await page.keyboard.press('Enter');
await page.locator('.editor-toolbar__block-select').selectOption('builtin/tasks');
await expect(page.locator('.dt-transclusion-card')).toBeVisible();
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${parent.slug}/tasks`);
return ((await res.json()) as { tasks: unknown[] }[]).reduce(
(sum, p) => sum + p.tasks.length,
0,
);
},
{ timeout: 20_000 },
)
.toBe(2);
// Read mode: the overview table lists both tasks with the date column.
await page.reload();
const table = page.locator('.dt-task-overview-table');
await expect(table).toBeVisible();
await expect(table).toContainText('Bühne buchen');
await expect(table).toContainText('Kabel prüfen');
await expect(table.locator('tbody tr')).toHaveCount(2);
// Toggle the child's task from the overview — it writes back through the
// collab server; after the refetch the box stays checked.
const childRow = table.locator('tbody tr', { hasText: 'Kabel prüfen' });
const checkbox = childRow.locator('input[type="checkbox"]');
await expect(checkbox).toBeEnabled();
await checkbox.check();
// Write-back travels api → NOTIFY → collab → debounced persist.
await expect
.poll(
async () => {
const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`);
const pages = (await res.json()) as { tasks: { checked: boolean }[] }[];
return pages[0]?.tasks[0]?.checked ?? false;
},
{ timeout: 20_000 },
)
.toBe(true);
await page.reload();
await expect(
page
.locator('.dt-task-overview-table tbody tr', { hasText: 'Kabel prüfen' })
.locator('input[type="checkbox"]'),
).toBeChecked();
// The source page itself now shows the checked box.
await page.goto(`/p/${pond.slug}/${child.slug}`);
await expect(page.locator('.editor-content li[data-type="task_item"] input')).toBeChecked();
await context.close();
});

View File

@ -24,10 +24,14 @@ export function PluginBlockMenu({
options: PluginBlockOption[]; options: PluginBlockOption[];
}): React.JSX.Element | null { }): React.JSX.Element | null {
const { t, i18n } = useTranslation('editor'); const { t, i18n } = useTranslation('editor');
const { t: tTasks } = useTranslation('tasks');
if (options.length === 0) return null;
function insert(key: string): void { function insert(key: string): void {
if (key === 'builtin/tasks') {
// The built-in task overview block (issue #154).
editor.chain().focus().insertContent({ type: 'task_overview' }).run();
return;
}
const [pluginId, blockType] = key.split('/'); const [pluginId, blockType] = key.split('/');
if (!pluginId || !blockType) return; if (!pluginId || !blockType) return;
editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run(); editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run();
@ -47,6 +51,7 @@ export function PluginBlockMenu({
<option value="" disabled> <option value="" disabled>
{t('toolbar.pluginBlock.placeholder')} {t('toolbar.pluginBlock.placeholder')}
</option> </option>
<option value="builtin/tasks">{tTasks('title')}</option>
{options.map((option) => { {options.map((option) => {
const key = `${option.pluginId}/${option.blockType}`; const key = `${option.pluginId}/${option.blockType}`;
return ( return (

View File

@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item'; import { TaskItem } from './nodes/task-item';
import { DateMarker } from './nodes/date-marker'; import { DateMarker } from './nodes/date-marker';
import { TaskOverview } from './nodes/task-overview';
import { TaskItemIds } from './task-item-ids'; import { TaskItemIds } from './task-item-ids';
import { Mention } from './nodes/mention'; import { Mention } from './nodes/mention';
import { Transclusion } from './nodes/transclusion'; import { Transclusion } from './nodes/transclusion';
@ -49,6 +50,7 @@ export const documentExtensions: AnyExtension[] = [
Wikilink, Wikilink,
Mention, Mention,
DateMarker, DateMarker,
TaskOverview,
TaskItemIds, TaskItemIds,
Transclusion, Transclusion,
Table, Table,

View File

@ -0,0 +1,155 @@
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></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);
},
});

View File

@ -21,6 +21,7 @@ import deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSetup from '@dorfteich/shared/i18n/de/setup.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json';
import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json'; import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json';
import deSystem from '@dorfteich/shared/i18n/de/system.json'; import deSystem from '@dorfteich/shared/i18n/de/system.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import deUsers from '@dorfteich/shared/i18n/de/users.json'; import deUsers from '@dorfteich/shared/i18n/de/users.json';
import deWatches from '@dorfteich/shared/i18n/de/watches.json'; import deWatches from '@dorfteich/shared/i18n/de/watches.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
@ -47,6 +48,7 @@ import enSearch from '@dorfteich/shared/i18n/en/search.json';
import enSetup from '@dorfteich/shared/i18n/en/setup.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json';
import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json'; import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json';
import enSystem from '@dorfteich/shared/i18n/en/system.json'; import enSystem from '@dorfteich/shared/i18n/en/system.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import enUsers from '@dorfteich/shared/i18n/en/users.json'; import enUsers from '@dorfteich/shared/i18n/en/users.json';
import enWatches from '@dorfteich/shared/i18n/en/watches.json'; import enWatches from '@dorfteich/shared/i18n/en/watches.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json';
@ -90,6 +92,7 @@ void i18n
setup: enSetup, setup: enSetup,
apiTokens: enApiTokens, apiTokens: enApiTokens,
system: enSystem, system: enSystem,
tasks: enTasks,
users: enUsers, users: enUsers,
watches: enWatches, watches: enWatches,
}, },
@ -118,6 +121,7 @@ void i18n
setup: deSetup, setup: deSetup,
apiTokens: deApiTokens, apiTokens: deApiTokens,
system: deSystem, system: deSystem,
tasks: deTasks,
users: deUsers, users: deUsers,
watches: deWatches, watches: deWatches,
}, },

View File

@ -3843,3 +3843,32 @@ ul[data-type='task_list'] li p:last-of-type {
.dt-date--overdue { .dt-date--overdue {
color: #b91c1c; color: #b91c1c;
} }
/* Task overview block (issue #154). */
.dt-task-overview-table {
width: 100%;
border-collapse: collapse;
margin: var(--space-3) 0;
font-size: 0.9375rem;
}
.dt-task-overview-table th,
.dt-task-overview-table td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
.dt-task-overview-table th {
color: var(--color-text-muted);
font-weight: var(--font-weight-heading);
}
.dt-task-overview-empty {
margin: var(--space-3) 0;
padding: var(--space-2) var(--space-3);
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text-muted);
}

View File

@ -177,6 +177,18 @@ Hat ein Teich-Admin eine Seite öffentlich geschaltet, ist sie ohne Konto
unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs
und einem Link auf die Rechtsseiten der Instanz. und einem Link auf die Rechtsseiten der Instanz.
## Aufgaben über Seiten hinweg
Aufgabenzeilen können strukturierte Extras tragen: `@nutzername`
erwähnt einen Nutzer (instanzweites Autocomplete; neu Erwähnte mit
Leserecht bekommen eine Benachrichtigung), `>>2026-12-31` setzt ein
Zieldatum, `<<2026-07-01` ein Startdatum (auch `>>31.12.2026` geht —
gespeichert wird kanonisch, angezeigt in deiner Sprache). Der Block
**Aufgabenübersicht** (über die Block-Auswahl einfügen) sammelt alle
Aufgabenzeilen der aktuellen Seite und ihrer Unterseiten in einer
Tabelle — Aufgabe, Wer, Daten, Quellseite — und ein Haken dort ändert
die Quellseite für alle, live.
## Feeds (Atom) ## Feeds (Atom)
Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten

View File

@ -158,6 +158,17 @@ If a pond admin has published a page for the public, it is readable
without an account at `/public/<pond>/<page>` — with the pond's without an account at `/public/<pond>/<page>` — with the pond's
typography and a link to the instance's legal pages. typography and a link to the instance's legal pages.
## Tasks across pages
Task lines can carry structured extras: `@username` mentions a user
(instance-wide autocomplete; newly mentioned people with read access get
a notification), `>>2026-12-31` sets a due date and `<<2026-07-01` a
start date (type `>>31.12.2026` if you prefer — stored canonically,
shown in your language). The **task overview** block (insert via the
block picker) collects every task line of the current page and its
subpages into a table — task, people, dates, source page — and checking
a box there updates the source page for everyone, live.
## Feeds (Atom) ## Feeds (Atom)
Every pond has an Atom feed of its recently created and updated pages at Every pond has an Atom feed of its recently created and updated pages at

View File

@ -0,0 +1,11 @@
{
"title": "Aufgabenübersicht",
"editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten",
"colTask": "Aufgabe",
"colMentions": "Wer",
"colStart": "Start",
"colDue": "Ziel",
"colPage": "Seite",
"empty": "Keine Aufgaben auf dieser Seite oder ihren Unterseiten.",
"unsavedHint": "Öffne die Seite einmal im Editor, damit ihre Checkboxen hier abhakbar werden."
}

View File

@ -0,0 +1,11 @@
{
"title": "Task overview",
"editorCard": "Task overview — this page and its subpages",
"colTask": "Task",
"colMentions": "Who",
"colStart": "Start",
"colDue": "Due",
"colPage": "Page",
"empty": "No tasks on this page or its subpages.",
"unsavedHint": "Open this page once in the editor to make its checkboxes toggleable here."
}

View File

@ -148,6 +148,10 @@ function renderBlock(node: Node): string {
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>` ` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
); );
} }
case 'task_overview':
// The task overview (issue #154): a placeholder the permission-aware
// renderers (public view, exports) replace with the static table.
return '<div class="dt-task-overview" data-task-overview="1">[tasks]</div>';
case 'transclusion': { case 'transclusion': {
// A page embed (#135). The static HTML is a placeholder carrying the // A page embed (#135). The static HTML is a placeholder carrying the
// target slug; the read view / public renderer expands it server-side to // target slug; the read view / public renderer expands it server-side to

View File

@ -6,3 +6,4 @@ export * from './plain-text';
export * from './outline'; export * from './outline';
export * from './wikilinks'; export * from './wikilinks';
export * from './mentions'; export * from './mentions';
export * from './tasks';

View File

@ -128,6 +128,13 @@ function transformTokens(tokens: Token[]): Token[] {
} }
} }
if (tok.type === 'fence' && tok.info.trim() === 'dorfteich-tasks') {
// The task overview block (issue #154) — an empty fence marker.
out.push(retype(tok, 'task_overview'));
i += 1;
continue;
}
if (tok.type === 'fence') { if (tok.type === 'fence') {
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim()); const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
if (info) { if (info) {
@ -415,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
data: pluginBlockData(tok.content.trim()), data: pluginBlockData(tok.content.trim()),
}), }),
}, },
task_overview: { node: 'task_overview' },
paragraph: { block: 'paragraph' }, paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' }, list_item: { block: 'list_item' },
task_item: { task_item: {
@ -557,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer(
state.write(fence); state.write(fence);
state.closeBlock(node); state.closeBlock(node);
}, },
task_overview(state, node) {
// An empty marker fence (issue #154) — the overview has no payload.
state.write('```dorfteich-tasks\n```');
state.closeBlock(node);
},
heading(state, node) { heading(state, node) {
state.write(`${state.repeat('#', node.attrs.level as number)} `); state.write(`${state.repeat('#', node.attrs.level as number)} `);
state.renderInline(node, false); state.renderInline(node, false);

View File

@ -274,6 +274,19 @@ export const editorSchema = new Schema({
}, },
}, },
// Task overview block (issue #154): collects the task-list lines of the
// current page and its subtree into a table (text, mentioned users,
// start/due dates, source page) with write-back checkboxes. A block atom
// without payload — the collection is computed where permissions are
// known (server-side for the public view, the /read tasks endpoint for
// the editor's read mode).
task_overview: {
group: 'block',
atom: true,
parseDOM: [{ tag: 'div[data-task-overview]' }],
toDOM: () => ['div', { 'data-task-overview': '1', class: 'dt-task-overview' }, 'Tasks'],
},
// `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline // `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as // atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
// a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in // a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { extractTaskRows } from './tasks';
import { docToHtml } from './html';
import { docToMarkdown, markdownToDoc } from './markdown';
describe('task overview block + task extraction (issue #154)', () => {
it('round-trips the marker fence', () => {
const doc = markdownToDoc('Intro\n\n```dorfteich-tasks\n```\n\nOutro');
let found = 0;
doc.descendants((node) => {
if (node.type.name === 'task_overview') found += 1;
});
expect(found).toBe(1);
expect(docToMarkdown(doc)).toContain('```dorfteich-tasks');
});
it('renders the placeholder div for the permission-aware expansion', () => {
const html = docToHtml(markdownToDoc('```dorfteich-tasks\n```'));
expect(html).toContain('class="dt-task-overview" data-task-overview="1"');
});
it('extracts rows with text, mentions, and dates from task lists', () => {
const doc = markdownToDoc(
'- [ ] Bühne buchen @nadia >>2026-08-01\n- [x] Kabel prüfen <<2026-07-01\n\nKein Task.',
);
const rows = extractTaskRows(doc);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
checked: false,
text: 'Bühne buchen',
dueDate: '2026-08-01',
startDate: null,
});
expect(rows[0]!.mentions).toEqual([{ userId: '', username: 'nadia' }]);
expect(rows[1]).toMatchObject({
checked: true,
text: 'Kabel prüfen',
startDate: '2026-07-01',
dueDate: null,
});
// Markdown-born rows have no stable id yet (assigned in the editor, #153).
expect(rows[0]!.id).toBeNull();
});
});

View File

@ -0,0 +1,80 @@
import { Node } from 'prosemirror-model';
/**
* Task extraction for the task overview block (issue #154): every
* `task_item` of a document as a flat row its stable id (#153, null for
* lines not yet opened in an editor), checked state, the line's text
* (mentions and date markers excluded they become their own columns),
* the mentioned users (#150), and the start/due dates (#152).
*/
export interface TaskRow {
id: string | null;
checked: boolean;
text: string;
mentions: { userId: string; username: string }[];
startDate: string | null;
dueDate: string | null;
}
export function extractTaskRows(doc: Node): TaskRow[] {
const rows: TaskRow[] = [];
doc.descendants((node) => {
if (node.type.name !== 'task_item') return;
const row: TaskRow = {
id: (node.attrs.id as string | null) ?? null,
checked: node.attrs.checked === true,
text: '',
mentions: [],
startDate: null,
dueDate: null,
};
// The line's own content is its first paragraph; nested task lists
// produce their own rows via the outer descendants walk.
const paragraph = node.firstChild;
if (paragraph && paragraph.type.name === 'paragraph') {
paragraph.forEach((child) => {
if (child.isText) {
row.text += child.text ?? '';
} else if (child.type.name === 'mention') {
row.mentions.push({
userId: child.attrs.userId as string,
username: child.attrs.username as string,
});
} else if (child.type.name === 'date_marker') {
if (child.attrs.kind === 'due') row.dueDate = child.attrs.date as string;
else row.startDate = child.attrs.date as string;
} else if (child.type.name === 'wikilink') {
row.text +=
(child.attrs.displayText as string | null) ?? (child.attrs.targetSlug as string);
}
});
}
row.text = row.text.replace(/\s+/g, ' ').trim();
rows.push(row);
});
return rows;
}
/** Wire shapes of `GET /read/:pond/:slug/tasks` (issue #154). */
export interface TaskOverviewMention {
id: string;
username: string;
displayName: string;
}
export interface TaskOverviewRow {
/** Null for lines that never got an id — shown read-only. */
id: string | null;
checked: boolean;
text: string;
mentions: TaskOverviewMention[];
startDate: string | null;
dueDate: string | null;
}
export interface TaskOverviewPage {
pageId: string;
slug: string;
title: string;
tasks: TaskOverviewRow[];
}