From fee519b8a95a80c061f476280cfdcb24f5501e61 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 20:00:00 +0200 Subject: [PATCH] docs(timeline): document /zeitstrahl, lib/timeline, monthBuckets move; RTM #779 Route tables (CLAUDE.md + frontend/CLAUDE.md), the document/timeline.ts -> $lib/shared/utils/monthBuckets move (document + shared READMEs), GLOSSARY Lebensweg entry, the c4 l3-frontend people-stories diagram, and the RTM rows REQ-001..027 for feature zeitstrahl-global-view (#779), all marked Done. Refs #779 Co-Authored-By: Claude Opus 4.8 --- .specify/rtm.md | 27 ++++++++++++++++ CLAUDE.md | 1 + docs/GLOSSARY.md | 2 ++ .../c4/l3-frontend-3c-people-stories.puml | 3 ++ frontend/CLAUDE.md | 6 ++-- frontend/src/lib/document/README.md | 1 + frontend/src/lib/shared/README.md | 32 ++++++++++--------- 7 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.specify/rtm.md b/.specify/rtm.md index a8a79d6c..827546fa 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -79,3 +79,30 @@ | REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done | | REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done | | REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done | +| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done | +| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done | +| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single
    ` | Done | +| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done | +| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done | +| REQ-006 | single `
      ` chronological; each band a `
      ` with sticky `

      ` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single
        `, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done | +| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done | +| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done | +| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 1914–1918 with a Zeitraum aria-label` | Done | +| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done | +| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done | +| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done | +| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done | +| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done | +| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done | +| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done | +| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done | +| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done | +| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done | +| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done | +| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done | +| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done | +| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done | +| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels German-only across locales) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `eventCardConfig.spec.ts` (German labels), Paraglide compile | Done | +| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done | +| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done | +| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done | diff --git a/CLAUDE.md b/CLAUDE.md index 8fbfe119..19998f96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,7 @@ frontend/src/routes/ ├── aktivitaeten/ Unified activity feed (Chronik) ├── geschichten/ Stories — list, [id], [id]/edit, new ├── stammbaum/ Family tree (Stammbaum) +├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode) ├── themen/ Topics directory — browsable tag index ├── enrich/ Enrichment workflow — [id], done ├── admin/ User, group, tag, OCR, system management diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 29cc18e1..bc6dabac 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -170,6 +170,8 @@ _Not to be confused with a document item's optional note_ — a document item's **Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain. +**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view). + **derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043). _Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database. diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index cf424d63..6ba983ff 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") + Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.") @@ -27,6 +28,8 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications" Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") +Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser") +Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a6fa8df7..3d367b4d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -34,6 +34,7 @@ src/ │ ├── api/ # Internal API proxies (server-side only) │ ├── geschichten/ # Stories (list, [id], [id]/edit, new) │ ├── stammbaum/ # Family tree +│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline │ ├── enrich/ # Enrichment workflow ([id], done) │ ├── hilfe/transkription/ # Transcription help page │ ├── profile/ # User profile settings @@ -49,6 +50,7 @@ src/ │ │ ├── relationship/ # Relationship form + chip components │ │ └── genealogy/ # Stammbaum (family tree) components │ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker +│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/) │ ├── geschichte/ # Geschichte (story) domain: editor + card │ ├── notification/ # Notification bell + dropdown + store │ ├── activity/ # Activity feed (Chronik) components @@ -59,8 +61,8 @@ src/ │ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.) │ │ ├── server/ # Server-only utilities (locale, session) │ │ ├── services/ # Client-side service helpers -│ │ ├── utils/ # Pure utility functions (date, search, etc.) -│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.) +│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip) +│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.) │ │ ├── dashboard/ # Dashboard stat components │ │ ├── discussion/ # CommentThread + shared discussion UI │ │ ├── help/ # Help/FAQ page components diff --git a/frontend/src/lib/document/README.md b/frontend/src/lib/document/README.md index 16885de7..b0485d76 100644 --- a/frontend/src/lib/document/README.md +++ b/frontend/src/lib/document/README.md @@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`. - `tag/TagInput.svelte` — tag chip input - `ocr/OcrProgress.svelte` — job status indicator in the document header - `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI +- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`) ## Backend counterpart diff --git a/frontend/src/lib/shared/README.md b/frontend/src/lib/shared/README.md index 5968ed4a..07c77df6 100644 --- a/frontend/src/lib/shared/README.md +++ b/frontend/src/lib/shared/README.md @@ -14,21 +14,23 @@ If any condition fails, the file belongs in the domain folder of its primary con ## What this folder owns -| Sub-folder / file | Purpose | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions | -| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping | -| `types.ts` | Cross-domain TypeScript interfaces | -| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) | -| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) | -| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells | -| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` | -| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route | -| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` | -| `services/` | Generic client-side service helpers | -| `actions/` | Shared SvelteKit form action utilities | -| `server/` | Server-only shared utilities (load function helpers) | -| `help/` | Coach marks and empty-state components used across multiple domains | +| Sub-folder / file | Purpose | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions | +| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping | +| `types.ts` | Cross-domain TypeScript interfaces | +| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) | +| `utils/monthBuckets.ts` | Pure month-bucket math (boundaries, sequences, gap-fill, year aggregation, axis ticks) shared by the `document/` density chart and the `timeline/` density strip — moved up from `document/timeline.ts` so `timeline/` need not import `document/` | +| `primitives/Sparkline.svelte` | Fixed-series bar sparkline (one bar per value) — used by the timeline density strip | +| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) | +| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells | +| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` | +| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route | +| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` | +| `services/` | Generic client-side service helpers | +| `actions/` | Shared SvelteKit form action utilities | +| `server/` | Server-only shared utilities (load function helpers) | +| `help/` | Coach marks and empty-state components used across multiple domains | ## What does NOT belong here