From e164370691e476679d1e6f0eb2ea887c6d741fe0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:20:30 +0200 Subject: [PATCH] =?UTF-8?q?#154:=20Aufgaben=C3=BCbersicht=20als=20Kern-Blo?= =?UTF-8?q?ck=20(Seite=20+=20Unterseiten)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .gitea/workflows/ci.yml | 10 + apps/api/src/i18n/api-i18n.ts | 6 +- apps/api/src/pages/pages.module.ts | 5 +- .../src/pages/task-overview.e2e.db.test.ts | 170 +++++++++++++++++ apps/api/src/pages/tasks.service.ts | 180 ++++++++++++++++++ apps/api/src/pages/yjs-content.ts | 22 ++- apps/api/src/public/public.service.ts | 20 +- .../api/src/public/read-content.controller.ts | 19 +- apps/web/e2e/tasks.spec.ts | 119 ++++++++++++ apps/web/src/editor/PluginBlockMenu.tsx | 9 +- apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/task-overview.tsx | 155 +++++++++++++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/styles/base.css | 29 +++ docs/de/manual/user-guide.md | 12 ++ docs/manual/user-guide.md | 11 ++ packages/shared/i18n/de/tasks.json | 11 ++ packages/shared/i18n/en/tasks.json | 11 ++ packages/shared/src/editor-schema/html.ts | 4 + packages/shared/src/editor-schema/index.ts | 1 + packages/shared/src/editor-schema/markdown.ts | 13 ++ packages/shared/src/editor-schema/schema.ts | 13 ++ .../src/editor-schema/task-overview.test.ts | 45 +++++ packages/shared/src/editor-schema/tasks.ts | 80 ++++++++ 24 files changed, 942 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/pages/task-overview.e2e.db.test.ts create mode 100644 apps/api/src/pages/tasks.service.ts create mode 100644 apps/web/e2e/tasks.spec.ts create mode 100644 apps/web/src/editor/nodes/task-overview.tsx create mode 100644 packages/shared/i18n/de/tasks.json create mode 100644 packages/shared/i18n/en/tasks.json create mode 100644 packages/shared/src/editor-schema/task-overview.test.ts create mode 100644 packages/shared/src/editor-schema/tasks.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5815f24..4d04484 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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%';" | \ diff --git a/apps/api/src/i18n/api-i18n.ts b/apps/api/src/i18n/api-i18n.ts index e5a3819..c22fa4f 100644 --- a/apps/api/src/i18n/api-i18n.ts +++ b/apps/api/src/i18n/api-i18n.ts @@ -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'], diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index b6d5a6e..ab9eace 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -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 {} diff --git a/apps/api/src/pages/task-overview.e2e.db.test.ts b/apps/api/src/pages/task-overview.e2e.db.test.ts new file mode 100644 index 0000000..362ccde --- /dev/null +++ b/apps/api/src/pages/task-overview.e2e.db.test.ts @@ -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 { + 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'); + }); +}); diff --git a/apps/api/src/pages/tasks.service.ts b/apps/api/src/pages/tasks.service.ts new file mode 100644 index 0000000..3298de6 --- /dev/null +++ b/apps/api/src/pages/tasks.service.ts @@ -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 { + 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 { + 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(); + 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(); + 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 { + 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) => + `` + + `${escapeHtml(task.text)}` + + `${task.mentions.map((m) => `@${escapeHtml(m.displayName)}`).join(', ')}` + + `${task.startDate ?? ''}${task.dueDate ?? ''}` + + `${escapeHtml(page.title)}`, + ), + ); + if (rows.length === 0) { + return `
${escapeHtml(t('empty'))}
`; + } + return ( + `` + + `` + + `` + + `` + + `${rows.join('')}
${escapeHtml(t('colTask'))}${escapeHtml(t('colMentions'))}${escapeHtml(t('colStart'))}${escapeHtml(t('colDue'))}${escapeHtml(t('colPage'))}
` + ); + } +} diff --git a/apps/api/src/pages/yjs-content.ts b/apps/api/src/pages/yjs-content.ts index bc8d2b2..c0dc742 100644 --- a/apps/api/src/pages/yjs-content.ts +++ b/apps/api/src/pages/yjs-content.ts @@ -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); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 5e052f7..1c596b3 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -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 { 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 { + const placeholder = /
[^<]*<\/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); } /** diff --git a/apps/api/src/public/read-content.controller.ts b/apps/api/src/public/read-content.controller.ts index 902417d..227645a 100644 --- a/apps/api/src/public/read-content.controller.ts +++ b/apps/api/src/public/read-content.controller.ts @@ -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 { + 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). diff --git a/apps/web/e2e/tasks.spec.ts b/apps/web/e2e/tasks.spec.ts new file mode 100644 index 0000000..cb3ea66 --- /dev/null +++ b/apps/web/e2e/tasks.spec.ts @@ -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>; + +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(); +}); diff --git a/apps/web/src/editor/PluginBlockMenu.tsx b/apps/web/src/editor/PluginBlockMenu.tsx index cc258a5..74a6e2c 100644 --- a/apps/web/src/editor/PluginBlockMenu.tsx +++ b/apps/web/src/editor/PluginBlockMenu.tsx @@ -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({ + {options.map((option) => { const key = `${option.pluginId}/${option.blockType}`; return ( diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 52ab57e..eb67fd8 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -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, diff --git a/apps/web/src/editor/nodes/task-overview.tsx b/apps/web/src/editor/nodes/task-overview.tsx new file mode 100644 index 0000000..1ff1367 --- /dev/null +++ b/apps/web/src/editor/nodes/task-overview.tsx @@ -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>({}); + 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('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); + }, +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index befaf1d..2b946e0 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -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, }, diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 77df6f4..3cedb5a 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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); +} diff --git a/docs/de/manual/user-guide.md b/docs/de/manual/user-guide.md index 33be5cd..c783cfd 100644 --- a/docs/de/manual/user-guide.md +++ b/docs/de/manual/user-guide.md @@ -177,6 +177,18 @@ Hat ein Teich-Admin eine Seite öffentlich geschaltet, ist sie ohne Konto unter `/public//` 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 diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md index 2483a07..4d17c5d 100644 --- a/docs/manual/user-guide.md +++ b/docs/manual/user-guide.md @@ -158,6 +158,17 @@ If a pond admin has published a page for the public, it is readable without an account at `/public//` — 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 diff --git a/packages/shared/i18n/de/tasks.json b/packages/shared/i18n/de/tasks.json new file mode 100644 index 0000000..2405bef --- /dev/null +++ b/packages/shared/i18n/de/tasks.json @@ -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." +} diff --git a/packages/shared/i18n/en/tasks.json b/packages/shared/i18n/en/tasks.json new file mode 100644 index 0000000..40f9797 --- /dev/null +++ b/packages/shared/i18n/en/tasks.json @@ -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." +} diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 44f63bf..90d0847 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -148,6 +148,10 @@ function renderBlock(node: Node): string { ` data-plugin-data="${data}">[${pluginId}/${blockType}]
` ); } + case 'task_overview': + // The task overview (issue #154): a placeholder the permission-aware + // renderers (public view, exports) replace with the static table. + return '
[tasks]
'; 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 diff --git a/packages/shared/src/editor-schema/index.ts b/packages/shared/src/editor-schema/index.ts index a871a31..873cfac 100644 --- a/packages/shared/src/editor-schema/index.ts +++ b/packages/shared/src/editor-schema/index.ts @@ -6,3 +6,4 @@ export * from './plain-text'; export * from './outline'; export * from './wikilinks'; export * from './mentions'; +export * from './tasks'; diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index 322ae79..f779785 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -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); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index 8bde30f..25c8622 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -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 diff --git a/packages/shared/src/editor-schema/task-overview.test.ts b/packages/shared/src/editor-schema/task-overview.test.ts new file mode 100644 index 0000000..c5793bf --- /dev/null +++ b/packages/shared/src/editor-schema/task-overview.test.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/editor-schema/tasks.ts b/packages/shared/src/editor-schema/tasks.ts new file mode 100644 index 0000000..e7c4590 --- /dev/null +++ b/packages/shared/src/editor-schema/tasks.ts @@ -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[]; +}