M22: Aufgaben-Paket — Mentions (#150/#151), Datums-Marker (#152), Task-IDs+Toggle (#153), Aufgabenübersicht (#154) #158

Merged
fable-5 merged 7 commits from m22-aufgaben into main 2026-07-20 02:25:40 +02:00
24 changed files with 942 additions and 9 deletions
Showing only changes of commit e164370691 - Show all commits

View File

@ -406,6 +406,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
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
run: |
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 deLegal from '@dorfteich/shared/i18n/de/legal.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 enLegal from '@dorfteich/shared/i18n/en/legal.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';
/**
@ -15,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({
resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal },
de: { errors: deErrors, mails: deMails, legal: deLegal },
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
},
fallbackLng: 'en',
supportedLngs: ['de', 'en'],

View File

@ -7,11 +7,12 @@ import { SearchModule } from '../search/search.module';
import { PagesController } from './pages.controller';
import { PagesService } from './pages.service';
import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [PondsModule, SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController],
providers: [PagesService],
exports: [PagesService],
providers: [PagesService, TasksService],
exports: [PagesService, TasksService],
})
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. */
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();
try {
Y.applyUpdate(ydoc, state);

View File

@ -3,6 +3,7 @@ import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service';
import { TasksService } from '../pages/tasks.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service';
@ -41,6 +42,7 @@ export class PublicService {
private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
private readonly tasks: TasksService,
) {}
private async resolve(
@ -99,7 +101,23 @@ export class PublicService {
): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
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 type { TaskOverviewPage } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { TasksService } from '../pages/tasks.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { PublicPageContent, PublicService } from './public.service';
@ -15,7 +17,22 @@ import { PublicPageContent, PublicService } from './public.service';
*/
@Controller('read')
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
// 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[];
}): React.JSX.Element | null {
const { t, i18n } = useTranslation('editor');
if (options.length === 0) return null;
const { t: tTasks } = useTranslation('tasks');
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('/');
if (!pluginId || !blockType) return;
editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run();
@ -47,6 +51,7 @@ export function PluginBlockMenu({
<option value="" disabled>
{t('toolbar.pluginBlock.placeholder')}
</option>
<option value="builtin/tasks">{tTasks('title')}</option>
{options.map((option) => {
const key = `${option.pluginId}/${option.blockType}`;
return (

View File

@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item';
import { DateMarker } from './nodes/date-marker';
import { TaskOverview } from './nodes/task-overview';
import { TaskItemIds } from './task-item-ids';
import { Mention } from './nodes/mention';
import { Transclusion } from './nodes/transclusion';
@ -49,6 +50,7 @@ export const documentExtensions: AnyExtension[] = [
Wikilink,
Mention,
DateMarker,
TaskOverview,
TaskItemIds,
Transclusion,
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 deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.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 deWatches from '@dorfteich/shared/i18n/de/watches.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 enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.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 enWatches from '@dorfteich/shared/i18n/en/watches.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
@ -90,6 +92,7 @@ void i18n
setup: enSetup,
apiTokens: enApiTokens,
system: enSystem,
tasks: enTasks,
users: enUsers,
watches: enWatches,
},
@ -118,6 +121,7 @@ void i18n
setup: deSetup,
apiTokens: deApiTokens,
system: deSystem,
tasks: deTasks,
users: deUsers,
watches: deWatches,
},

View File

@ -3843,3 +3843,32 @@ ul[data-type='task_list'] li p:last-of-type {
.dt-date--overdue {
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
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)
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
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)
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>`
);
}
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': {
// A page embed (#135). The static HTML is a placeholder carrying the
// 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 './wikilinks';
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') {
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
if (info) {
@ -415,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
data: pluginBlockData(tok.content.trim()),
}),
},
task_overview: { node: 'task_overview' },
paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' },
task_item: {
@ -557,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer(
state.write(fence);
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) {
state.write(`${state.repeat('#', node.attrs.level as number)} `);
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
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
// 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[];
}