feat(timeline): global /zeitstrahl timeline (Concept A) — #779 #831

Merged
marcel merged 22 commits from feat/issue-779-zeitstrahl into main 2026-06-13 21:56:46 +02:00
47 changed files with 2131 additions and 443 deletions

View File

@@ -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-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-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-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 <ol>` | 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 `<ol>` chronological; each band a `<section>` with sticky `<h2>` 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 <ol>`, `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 19141918 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 localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, 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 |

View File

@@ -207,6 +207,7 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik) ├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new ├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum) ├── 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 ├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done ├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management ├── admin/ User, group, tag, OCR, system management

View File

@@ -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. **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). **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. _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.

View File

@@ -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(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(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(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(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(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}.") 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(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(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(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(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

@@ -34,6 +34,7 @@ src/
│ ├── api/ # Internal API proxies (server-side only) │ ├── api/ # Internal API proxies (server-side only)
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new) │ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
│ ├── stammbaum/ # Family tree │ ├── stammbaum/ # Family tree
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline
│ ├── enrich/ # Enrichment workflow ([id], done) │ ├── enrich/ # Enrichment workflow ([id], done)
│ ├── hilfe/transkription/ # Transcription help page │ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings │ ├── profile/ # User profile settings
@@ -49,6 +50,7 @@ src/
│ │ ├── relationship/ # Relationship form + chip components │ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components │ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker │ ├── 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 │ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store │ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components │ ├── activity/ # Activity feed (Chronik) components
@@ -59,8 +61,8 @@ src/
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.) │ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
│ │ ├── server/ # Server-only utilities (locale, session) │ │ ├── server/ # Server-only utilities (locale, session)
│ │ ├── services/ # Client-side service helpers │ │ ├── services/ # Client-side service helpers
│ │ ├── utils/ # Pure utility functions (date, search, etc.) │ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.) │ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
│ │ ├── dashboard/ # Dashboard stat components │ │ ├── dashboard/ # Dashboard stat components
│ │ ├── discussion/ # CommentThread + shared discussion UI │ │ ├── discussion/ # CommentThread + shared discussion UI
│ │ ├── help/ # Help/FAQ page components │ │ ├── help/ # Help/FAQ page components

View File

@@ -0,0 +1,84 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
/**
* Global /zeitstrahl timeline (#779). Runs against the real stack with the
* seeded admin session (auth.setup). Covers the primary journey (nav → page,
* timeline inside <main>) and the 320px no-overflow guarantee on a populated
* timeline seeded with 25+char correspondent names (REQ-005).
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
const res = await request.post('/api/persons', {
data: { personType: 'PERSON', firstName, lastName }
});
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
return (await res.json()).id as string;
}
/** Seeds one dated letter with long sender/receiver names so it lands on the timeline. */
async function seedDatedLetter(request: APIRequestContext) {
const senderId = await createPerson(
request,
'Friedrich-Wilhelm',
`Maximilian von Habsburg ${stamp()}`
);
const receiverId = await createPerson(
request,
'Maria-Magdalena',
`Hohenzollern-Sigmaringen ${stamp()}`
);
const createRes = await request.post('/api/documents', {
multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` }
});
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
const docId = (await createRes.json()).id as string;
const put = await request.put(`/api/documents/${docId}`, {
multipart: {
title: `E2E Zeitstrahl Brief ${stamp()}`,
documentDate: '1915-06-15',
metaDatePrecision: 'DAY',
senderId,
receiverIds: receiverId
}
});
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
}
test.describe('Zeitstrahl — global timeline (#779)', () => {
test('nav link opens /zeitstrahl and the timeline lives in <main>', async ({ page }) => {
await page.goto('/');
await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click();
await expect(page).toHaveURL(/\/zeitstrahl$/);
await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible();
// The main landmark contains either the populated <ol> or the empty state.
const main = page.getByRole('main');
const ol = main.locator('ol');
const empty = main.getByText('Noch keine Ereignisse.');
await expect(async () => {
const populated = (await ol.count()) > 0;
const isEmpty = await empty.isVisible().catch(() => false);
expect(populated || isEmpty).toBe(true);
}).toPass();
});
test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({
page,
request
}) => {
await seedDatedLetter(request);
await page.setViewportSize({ width: 320, height: 900 });
await page.goto('/zeitstrahl');
// Populated: the seeded letter puts the timeline <ol> in the DOM.
await expect(page.getByRole('main').locator('ol')).toHaveCount(1);
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
expect(scrollWidth).toBe(320);
});
});

View File

@@ -215,6 +215,7 @@ export default defineConfig(
'ocr', 'ocr',
'activity', 'activity',
'conversation', 'conversation',
'timeline',
'shared' 'shared'
] ]
} }

View File

@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "{count} werden bearbeitet", "bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum", "nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten", "nav_geschichten": "Geschichten",
"nav_zeitstrahl": "Zeitstrahl",
"timeline_heading": "Zeitstrahl",
"timeline_empty_state": "Noch keine Ereignisse.",
"timeline_undated_section": "Ohne Datum",
"timeline_unknown_person": "Unbekannt",
"timeline_gap_empty": "keine Einträge",
"timeline_letters_count": "{count} Briefe",
"timeline_strip_expand": "Briefe anzeigen",
"timeline_range_aria": "Zeitraum: {from} bis {to}",
"timeline_layer_world": "Weltgeschehen",
"timeline_layer_family": "Familie",
"timeline_derived_birth": "Geburt",
"timeline_derived_death": "Tod",
"timeline_derived_marriage": "Heirat",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.", "error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.", "error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",

View File

@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "{count} will be edited", "bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree", "nav_stammbaum": "Family tree",
"nav_geschichten": "Stories", "nav_geschichten": "Stories",
"nav_zeitstrahl": "Timeline",
"timeline_heading": "Timeline",
"timeline_empty_state": "No events yet.",
"timeline_undated_section": "Without Date",
"timeline_unknown_person": "Unknown",
"timeline_gap_empty": "no entries",
"timeline_letters_count": "{count} letters",
"timeline_strip_expand": "Show letters",
"timeline_range_aria": "Period: {from} to {to}",
"timeline_layer_world": "World events",
"timeline_layer_family": "Family",
"timeline_derived_birth": "Birth",
"timeline_derived_death": "Death",
"timeline_derived_marriage": "Marriage",
"error_geschichte_not_found": "The story was not found.", "error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.", "error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.", "error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",

View File

@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "Se editarán {count}", "bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico", "nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias", "nav_geschichten": "Historias",
"nav_zeitstrahl": "Línea de tiempo",
"timeline_heading": "Línea de tiempo",
"timeline_empty_state": "Aún no hay eventos.",
"timeline_undated_section": "Sin Fecha",
"timeline_unknown_person": "Desconocido",
"timeline_gap_empty": "sin entradas",
"timeline_letters_count": "{count} cartas",
"timeline_strip_expand": "Mostrar cartas",
"timeline_range_aria": "Período: {from} a {to}",
"timeline_layer_world": "Acontecimientos mundiales",
"timeline_layer_family": "Familia",
"timeline_derived_birth": "Nacimiento",
"timeline_derived_death": "Fallecimiento",
"timeline_derived_marriage": "Matrimonio",
"error_geschichte_not_found": "No se encontró la historia.", "error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.", "error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.", "error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",

View File

@@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`.
- `tag/TagInput.svelte` — tag chip input - `tag/TagInput.svelte` — tag chip input
- `ocr/OcrProgress.svelte` — job status indicator in the document header - `ocr/OcrProgress.svelte` — job status indicator in the document header
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI - `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 ## Backend counterpart

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { formatTickLabel } from '$lib/document/timeline'; import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';

View File

@@ -7,7 +7,7 @@ import {
selectionBoundaryFrom, selectionBoundaryFrom,
selectionBoundaryTo, selectionBoundaryTo,
formatTickLabel formatTickLabel
} from '$lib/document/timeline'; } from '$lib/shared/utils/monthBuckets';
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte'; import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import TimelineBars from '$lib/document/TimelineBars.svelte'; import TimelineBars from '$lib/document/TimelineBars.svelte';

View File

@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { tick } from 'svelte'; import { tick } from 'svelte';
import TimelineDensityFilter from './TimelineDensityFilter.svelte'; import TimelineDensityFilter from './TimelineDensityFilter.svelte';
import { formatTickLabel } from './timeline'; import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline'; import { formatTickLabel, tickIndicesFor } from '$lib/shared/utils/monthBuckets';
import { getLocale } from '$lib/paraglide/runtime'; import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';

View File

@@ -1,191 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { import { fetchDensity, buildDensityUrl } from './timeline';
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
fetchDensity,
buildDensityUrl,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo,
clipBucketsToRange,
tickIndicesFor,
formatTickLabel
} from './timeline';
describe('monthBoundaryFrom', () => {
it('returns the first day of the given month', () => {
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
});
it('handles January', () => {
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
});
});
describe('monthBoundaryTo', () => {
it('returns the last day of a 31-day month', () => {
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('returns the last day of a 30-day month', () => {
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
});
it('returns 28 for February in a non-leap year', () => {
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
});
it('returns 29 for February in a leap year', () => {
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
});
});
describe('buildMonthSequence', () => {
it('returns a single month when min and max are in the same month', () => {
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
});
it('returns months from minDate through maxDate inclusive', () => {
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
'1915-08',
'1915-09',
'1915-10',
'1915-11'
]);
});
it('crosses year boundaries correctly', () => {
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
'1915-11',
'1915-12',
'1916-01',
'1916-02'
]);
});
it('returns empty array when minDate or maxDate is null', () => {
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
expect(buildMonthSequence(null, null)).toEqual([]);
});
});
describe('fillDensityGaps', () => {
it('returns empty array when minDate or maxDate is null', () => {
expect(fillDensityGaps([], null, null)).toEqual([]);
});
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-11', count: 2 }
];
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
expect(result).toEqual([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 },
{ month: '1915-11', count: 2 }
]);
});
it('returns all-zero sequence when buckets array is empty', () => {
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
expect(result).toEqual([
{ month: '1915-08', count: 0 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 }
]);
});
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
const buckets = [
{ month: '1915-10', count: 3 },
{ month: '1915-08', count: 1 }
];
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
describe('buildDensityUrl', () => { describe('buildDensityUrl', () => {
it('returns the bare endpoint when no filters provided', () => { it('returns the bare endpoint when no filters provided', () => {
@@ -309,84 +123,3 @@ describe('fetchDensity', () => {
warn.mockRestore(); warn.mockRestore();
}); });
}); });
describe('tickIndicesFor', () => {
it('returns no indices for an empty bucket list', () => {
expect(tickIndicesFor([])).toEqual([]);
});
it('picks years divisible by 25 when the year span exceeds 120', () => {
const buckets = Array.from({ length: 150 }, (_, i) => ({
month: String(1875 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
});
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
const buckets = Array.from({ length: 50 }, (_, i) => ({
month: String(1900 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
});
it('picks January boundaries for long month ranges', () => {
const buckets = [
{ month: '1914-08', count: 1 },
{ month: '1914-09', count: 1 },
{ month: '1914-10', count: 1 },
{ month: '1914-11', count: 1 },
{ month: '1914-12', count: 1 },
{ month: '1915-01', count: 1 },
{ month: '1915-02', count: 1 },
{ month: '1915-03', count: 1 },
{ month: '1915-04', count: 1 },
{ month: '1915-05', count: 1 },
{ month: '1915-06', count: 1 },
{ month: '1915-07', count: 1 },
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 1 },
{ month: '1915-10', count: 1 },
{ month: '1915-11', count: 1 },
{ month: '1915-12', count: 1 },
{ month: '1916-01', count: 1 },
{ month: '1916-02', count: 1 }
];
const ticks = tickIndicesFor(buckets);
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
});
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
const buckets = Array.from({ length: 12 }, (_, i) => ({
month: `1905-${String(i + 1).padStart(2, '0')}`,
count: 1
}));
const ticks = tickIndicesFor(buckets);
expect(ticks.length).toBeGreaterThanOrEqual(5);
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks[0]).toBe(0);
});
});
describe('formatTickLabel', () => {
it('returns the year string unchanged for year labels', () => {
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
});
it('formats month labels with the year by default', () => {
const result = formatTickLabel('1905-06', 'en-US');
expect(result).toMatch(/Jun/);
expect(result).toMatch(/1905/);
});
it('omits the year when omitYear is true', () => {
const result = formatTickLabel('1905-06', 'en-US', true);
expect(result).toMatch(/Jun/);
expect(result).not.toMatch(/1905/);
});
});

View File

@@ -12,160 +12,6 @@ export type DensityState = {
const SKIP: DensityState = { density: null, minDate: null, maxDate: null }; const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null }; const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
export function monthBoundaryFrom(yearMonth: string): string {
return `${yearMonth}-01`;
}
export function monthBoundaryTo(yearMonth: string): string {
const [year, month] = yearMonth.split('-').map(Number);
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
}
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
if (!minDate || !maxDate) return [];
const [minY, minM] = minDate.split('-').map(Number);
const [maxY, maxM] = maxDate.split('-').map(Number);
const sequence: string[] = [];
let year = minY;
let month = minM;
while (year < maxY || (year === maxY && month <= maxM)) {
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return sequence;
}
export function fillDensityGaps(
buckets: MonthBucket[],
minDate: string | null,
maxDate: string | null
): MonthBucket[] {
const sequence = buildMonthSequence(minDate, maxDate);
if (sequence.length === 0) return [];
const counts = new Map(buckets.map((b) => [b.month, b.count]));
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
* to whether bars are years or months and how many are visible:
* - Year bars: pick years divisible by a step that scales with range length
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
* one year zoomed in to months), fall back to evenly spaced ticks so we
* show ~6 labels even when no January boundary exists.
*/
export function tickIndicesFor(filled: MonthBucket[]): number[] {
if (filled.length === 0) return [];
const isYearMode = filled[0].month.length === 4;
const indices: number[] = [];
if (isYearMode) {
const years = filled.length;
const step =
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
for (let i = 0; i < filled.length; i++) {
const year = parseInt(filled[i].month, 10);
if (year % step === 0) indices.push(i);
}
return indices;
}
if (filled.length <= 18) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
return indices;
}
// Long month range — pick January boundaries (year breaks).
for (let i = 0; i < filled.length; i++) {
if (filled[i].month.endsWith('-01')) indices.push(i);
}
// Fallback if there's no January in the visible range (rare): even spacing.
if (indices.length === 0) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
}
return indices;
}
/**
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
* "Jan", "Feb", … without repetition.
*/
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
if (label.length === 4) return label;
const [yearStr, monthStr] = label.split('-');
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
const opts: Intl.DateTimeFormatOptions = omitYear
? { month: 'short' }
: { month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(locale, opts).format(date);
}
/** /**
* The subset of /documents URL params that should narrow the density chart. * The subset of /documents URL params that should narrow the density chart.
* Date bounds (`from`/`to`) are intentionally excluded — see * Date bounds (`from`/`to`) are intentionally excluded — see

View File

@@ -40,4 +40,32 @@ describe('message key parity', () => {
expect(es).toHaveProperty('layout_menu_open'); expect(es).toHaveProperty('layout_menu_open');
expect(es).toHaveProperty('layout_menu_close'); expect(es).toHaveProperty('layout_menu_close');
}); });
// REQ-024: the timeline layer/life-event labels feed sr-only / aria text, so
// they are localized per locale (the original German-only MVP decision was
// reversed for accessibility). Pin the values so en/es can never silently
// drift back to the German source strings.
it('timeline layer/derived labels are localized per locale (REQ-024)', () => {
expect(de).toMatchObject({
timeline_layer_world: 'Weltgeschehen',
timeline_layer_family: 'Familie',
timeline_derived_birth: 'Geburt',
timeline_derived_death: 'Tod',
timeline_derived_marriage: 'Heirat'
});
expect(en).toMatchObject({
timeline_layer_world: 'World events',
timeline_layer_family: 'Family',
timeline_derived_birth: 'Birth',
timeline_derived_death: 'Death',
timeline_derived_marriage: 'Marriage'
});
expect(es).toMatchObject({
timeline_layer_world: 'Acontecimientos mundiales',
timeline_layer_family: 'Familia',
timeline_derived_birth: 'Nacimiento',
timeline_derived_death: 'Fallecimiento',
timeline_derived_marriage: 'Matrimonio'
});
});
}); });

View File

@@ -14,21 +14,23 @@ If any condition fails, the file belongs in the domain folder of its primary con
## What this folder owns ## What this folder owns
| Sub-folder / file | Purpose | | 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 | | `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 | | `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping |
| `types.ts` | Cross-domain TypeScript interfaces | | `types.ts` | Cross-domain TypeScript interfaces |
| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) | | `utils.ts` | Pure utility functions (date formatting, sorting, debounce) |
| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) | | `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/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells | | `primitives/Sparkline.svelte` | Fixed-series bar sparkline (one bar per value) — used by the timeline density strip |
| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` | | `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) |
| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route | | `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells |
| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` | | `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` |
| `services/` | Generic client-side service helpers | | `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route |
| `actions/` | Shared SvelteKit form action utilities | | `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` |
| `server/` | Server-only shared utilities (load function helpers) | | `services/` | Generic client-side service helpers |
| `help/` | Coach marks and empty-state components used across multiple domains | | `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 ## What does NOT belong here

View File

@@ -0,0 +1,38 @@
<script lang="ts">
/**
* A minimal fixed-series bar sparkline: one bar per value, heights scaled to the
* largest value. Presentational only — callers supply the already-bucketed
* counts. Used by the timeline density strip; reusable by the document chart.
*/
let {
values,
label,
class: className = ''
}: { values: number[]; label?: string; class?: string } = $props();
const max = $derived(Math.max(1, ...values));
// Empty buckets keep a faint floor so the series reads as a continuous axis
// rather than disappearing to nothing.
const MIN_HEIGHT_PCT = 4;
function heightPct(value: number): number {
if (value <= 0) return MIN_HEIGHT_PCT;
return Math.max(MIN_HEIGHT_PCT, (value / max) * 100);
}
</script>
<div
class="flex h-8 items-end gap-[1.5px] {className}"
role="img"
aria-label={label}
aria-hidden={label ? undefined : 'true'}
>
{#each values as value, i (i)}
<div
data-testid="sparkline-bar"
class="flex-1 rounded-[1px] bg-brand-mint"
style="height: {heightPct(value)}%"
></div>
{/each}
</div>

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import Sparkline from './Sparkline.svelte';
afterEach(() => cleanup());
describe('Sparkline', () => {
it('renders one bar per value', () => {
render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] });
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('scales bar heights relative to the largest value', () => {
render(Sparkline, { values: [5, 10, 0] });
const bars = document.querySelectorAll<HTMLElement>('[data-testid="sparkline-bar"]');
const h = (i: number) => parseFloat(bars[i].style.height);
// 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest.
expect(h(1)).toBeGreaterThan(h(0));
expect(h(0)).toBeGreaterThan(h(2));
});
it('exposes an accessible label when provided', () => {
render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' });
const img = document.querySelector('[role="img"]');
expect(img?.getAttribute('aria-label')).toBe('Monatsdichte');
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect } from 'vitest';
import {
monthBoundaryFrom,
monthBoundaryTo,
buildMonthSequence,
fillDensityGaps,
aggregateToYears,
selectionBoundaryFrom,
selectionBoundaryTo,
clipBucketsToRange,
tickIndicesFor,
formatTickLabel
} from './monthBuckets';
describe('monthBoundaryFrom', () => {
it('returns the first day of the given month', () => {
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
});
it('handles January', () => {
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
});
});
describe('monthBoundaryTo', () => {
it('returns the last day of a 31-day month', () => {
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('returns the last day of a 30-day month', () => {
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
});
it('returns 28 for February in a non-leap year', () => {
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
});
it('returns 29 for February in a leap year', () => {
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
});
});
describe('buildMonthSequence', () => {
it('returns a single month when min and max are in the same month', () => {
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
});
it('returns months from minDate through maxDate inclusive', () => {
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
'1915-08',
'1915-09',
'1915-10',
'1915-11'
]);
});
it('crosses year boundaries correctly', () => {
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
'1915-11',
'1915-12',
'1916-01',
'1916-02'
]);
});
it('returns empty array when minDate or maxDate is null', () => {
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
expect(buildMonthSequence(null, null)).toEqual([]);
});
});
describe('fillDensityGaps', () => {
it('returns empty array when minDate or maxDate is null', () => {
expect(fillDensityGaps([], null, null)).toEqual([]);
});
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-11', count: 2 }
];
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
expect(result).toEqual([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 },
{ month: '1915-11', count: 2 }
]);
});
it('returns all-zero sequence when buckets array is empty', () => {
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
expect(result).toEqual([
{ month: '1915-08', count: 0 },
{ month: '1915-09', count: 0 },
{ month: '1915-10', count: 0 }
]);
});
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
const buckets = [
{ month: '1915-10', count: 3 },
{ month: '1915-08', count: 1 }
];
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
});
});
describe('aggregateToYears', () => {
it('returns empty array for empty input', () => {
expect(aggregateToYears([])).toEqual([]);
});
it('sums counts within the same year', () => {
const result = aggregateToYears([
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
expect(result).toEqual([{ month: '1915', count: 15 }]);
});
it('produces one bucket per distinct year, sorted chronologically', () => {
const result = aggregateToYears([
{ month: '1916-01', count: 3 },
{ month: '1915-08', count: 5 },
{ month: '1916-04', count: 7 },
{ month: '1914-12', count: 1 }
]);
expect(result).toEqual([
{ month: '1914', count: 1 },
{ month: '1915', count: 5 },
{ month: '1916', count: 10 }
]);
});
});
describe('clipBucketsToRange', () => {
const buckets = [
{ month: '1915-08', count: 5 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 },
{ month: '1915-11', count: 3 }
];
it('returns the original buckets when range bounds are null', () => {
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
});
it('keeps only buckets whose month falls within the range', () => {
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
it('returns an empty array when the range excludes everything', () => {
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
});
it('treats partial dates correctly when bounds cross month boundaries', () => {
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 8 }
]);
});
});
describe('selectionBoundaryFrom / To', () => {
it('handles month labels (YYYY-MM)', () => {
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
});
it('handles year labels (YYYY)', () => {
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
});
});
describe('tickIndicesFor', () => {
it('returns no indices for an empty bucket list', () => {
expect(tickIndicesFor([])).toEqual([]);
});
it('picks years divisible by 25 when the year span exceeds 120', () => {
const buckets = Array.from({ length: 150 }, (_, i) => ({
month: String(1875 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
});
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
const buckets = Array.from({ length: 50 }, (_, i) => ({
month: String(1900 + i),
count: 1
}));
const ticks = tickIndicesFor(buckets);
const labels = ticks.map((i) => buckets[i].month);
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
});
it('picks January boundaries for long month ranges', () => {
const buckets = [
{ month: '1914-08', count: 1 },
{ month: '1914-09', count: 1 },
{ month: '1914-10', count: 1 },
{ month: '1914-11', count: 1 },
{ month: '1914-12', count: 1 },
{ month: '1915-01', count: 1 },
{ month: '1915-02', count: 1 },
{ month: '1915-03', count: 1 },
{ month: '1915-04', count: 1 },
{ month: '1915-05', count: 1 },
{ month: '1915-06', count: 1 },
{ month: '1915-07', count: 1 },
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 1 },
{ month: '1915-10', count: 1 },
{ month: '1915-11', count: 1 },
{ month: '1915-12', count: 1 },
{ month: '1916-01', count: 1 },
{ month: '1916-02', count: 1 }
];
const ticks = tickIndicesFor(buckets);
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
});
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
const buckets = Array.from({ length: 12 }, (_, i) => ({
month: `1905-${String(i + 1).padStart(2, '0')}`,
count: 1
}));
const ticks = tickIndicesFor(buckets);
expect(ticks.length).toBeGreaterThanOrEqual(5);
expect(ticks.length).toBeLessThanOrEqual(7);
expect(ticks[0]).toBe(0);
});
});
describe('formatTickLabel', () => {
it('returns the year string unchanged for year labels', () => {
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
});
it('formats month labels with the year by default', () => {
const result = formatTickLabel('1905-06', 'en-US');
expect(result).toMatch(/Jun/);
expect(result).toMatch(/1905/);
});
it('omits the year when omitYear is true', () => {
const result = formatTickLabel('1905-06', 'en-US', true);
expect(result).toMatch(/Jun/);
expect(result).not.toMatch(/1905/);
});
});

View File

@@ -0,0 +1,163 @@
import type { components } from '$lib/generated/api';
/**
* Pure month-bucket math shared by the document density chart (`lib/document/`)
* and the global timeline strip (`lib/timeline/`). Reuses the generated
* `MonthBucket` schema type so both surfaces stay coupled to the backend shape.
* No I/O, no DOM — relocated here so `lib/timeline/` never imports `lib/document/`.
*/
export type MonthBucket = components['schemas']['MonthBucket'];
export function monthBoundaryFrom(yearMonth: string): string {
return `${yearMonth}-01`;
}
export function monthBoundaryTo(yearMonth: string): string {
const [year, month] = yearMonth.split('-').map(Number);
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
}
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
if (!minDate || !maxDate) return [];
const [minY, minM] = minDate.split('-').map(Number);
const [maxY, maxM] = maxDate.split('-').map(Number);
const sequence: string[] = [];
let year = minY;
let month = minM;
while (year < maxY || (year === maxY && month <= maxM)) {
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return sequence;
}
export function fillDensityGaps(
buckets: MonthBucket[],
minDate: string | null,
maxDate: string | null
): MonthBucket[] {
const sequence = buildMonthSequence(minDate, maxDate);
if (sequence.length === 0) return [];
const counts = new Map(buckets.map((b) => [b.month, b.count]));
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
}
/**
* Returns only the month buckets whose YYYY-MM falls inside the provided
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
* input array is returned unchanged. Used by the timeline's zoom-in tool to
* narrow the visible bars without refetching data.
*
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
* unit suite (`monthBuckets.spec.ts`) can pin the boundary semantics directly.
*/
export function clipBucketsToRange(
buckets: MonthBucket[],
fromInclusive: string | null,
toInclusive: string | null
): MonthBucket[] {
if (!fromInclusive || !toInclusive) return buckets;
const fromMonth = fromInclusive.slice(0, 7);
const toMonth = toInclusive.slice(0, 7);
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
}
/**
* Aggregates month-granular buckets into one entry per year. Month strings are
* truncated to "YYYY" and counts are summed. Used when the date span is too
* long for month-granular bars to render at a clickable size.
*/
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
const totals = new Map<string, number>();
for (const b of buckets) {
const year = b.month.slice(0, 4);
totals.set(year, (totals.get(year) ?? 0) + b.count);
}
return Array.from(totals.entries())
.map(([year, count]) => ({ month: year, count }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/**
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
* (year) and return the matching LocalDate string.
*/
export function selectionBoundaryFrom(label: string): string {
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
}
export function selectionBoundaryTo(label: string): string {
if (label.length === 4) return `${label}-12-31`;
return monthBoundaryTo(label);
}
/**
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
* to whether bars are years or months and how many are visible:
* - Year bars: pick years divisible by a step that scales with range length
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
* one year zoomed in to months), fall back to evenly spaced ticks so we
* show ~6 labels even when no January boundary exists.
*/
export function tickIndicesFor(filled: MonthBucket[]): number[] {
if (filled.length === 0) return [];
const isYearMode = filled[0].month.length === 4;
const indices: number[] = [];
if (isYearMode) {
const years = filled.length;
const step =
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
for (let i = 0; i < filled.length; i++) {
const year = parseInt(filled[i].month, 10);
if (year % step === 0) indices.push(i);
}
return indices;
}
if (filled.length <= 18) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
return indices;
}
// Long month range — pick January boundaries (year breaks).
for (let i = 0; i < filled.length; i++) {
if (filled[i].month.endsWith('-01')) indices.push(i);
}
// Fallback if there's no January in the visible range (rare): even spacing.
if (indices.length === 0) {
const step = Math.max(1, Math.round(filled.length / 6));
for (let i = 0; i < filled.length; i += step) indices.push(i);
}
return indices;
}
/**
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
* "Jan", "Feb", … without repetition.
*/
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
if (label.length === 4) return label;
const [yearStr, monthStr] = label.split('-');
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
const opts: Intl.DateTimeFormatOptions = omitYear
? { month: 'short' }
: { month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(locale, opts).format(date);
}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Centered axis pill for a derived life-event or a curated PERSONAL event
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
* (REQ-018). An edit affordance shows only for a curated event with an eventId
* (never derived, never null — REQ-008).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const canEdit = $derived(!entry.derived && entry.eventId != null);
</script>
<div class="flex justify-center">
<div
class="inline-flex items-center gap-2 rounded-full bg-surface px-3 py-1 shadow-sm {config.accent ===
'curated'
? 'border-2 border-brand-mint'
: 'border border-brand-navy'}"
>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
</span>
<span class="text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
{#if dateLabel}
<span class="block font-sans text-xs text-ink-3">{dateLabel}</span>
{/if}
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-1 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}
</div>
</div>

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import EventPill from './EventPill.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
const EVENT_ID = '33333333-3333-3333-3333-333333333333';
function derived(derivedType: 'BIRTH' | 'DEATH' | 'MARRIAGE', title: string) {
return makeEntry({
kind: 'EVENT',
derived: true,
derivedType,
title,
senderName: '',
receiverName: '',
precision: 'YEAR',
eventDate: '1914-01-01',
documentId: undefined
});
}
describe('EventPill', () => {
it('renders a derived marriage as ⚭ + "Heirat" + title (REQ-007)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat: Karl & Elfriede') });
expect(document.body.textContent).toContain('⚭');
expect(document.body.textContent).toContain('Heirat');
expect(document.body.textContent).toContain('Heirat: Karl & Elfriede');
});
it('renders a derived birth as * + "Geburt" (REQ-007)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
expect(document.body.textContent).toContain('*');
expect(document.body.textContent).toContain('Geburt');
});
it('renders a derived death as † + "Tod" (REQ-007)', () => {
render(EventPill, { entry: derived('DEATH', 'Tod: Karl') });
expect(document.body.textContent).toContain('†');
expect(document.body.textContent).toContain('Tod');
});
it('wraps the glyph aria-hidden with an sr-only label sibling (REQ-018)', () => {
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('*');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Geburt');
});
it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: EVENT_ID,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
expect(edit).not.toBeNull();
expect(edit?.getAttribute('href')).toContain(EVENT_ID);
});
it('shows no edit affordance when eventId is null (REQ-008)', () => {
render(EventPill, {
entry: makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: undefined,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
});
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('shows no edit affordance for a derived event (REQ-008)', () => {
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
/**
* A folded run of fully-empty interior years (REQ-015), rendered as a thin
* dashed span so the scroll stays oriented. Collapses to a single year when the
* run has length 1.
*/
let { from, to }: { from: number; to: number } = $props();
const yearLabel = $derived(from === to ? `${from}` : `${from}${to}`);
</script>
<div
class="mx-auto my-2 flex max-w-md items-center gap-2 rounded-full border border-dashed border-line bg-canvas px-4 py-1 font-sans text-xs text-ink-3"
>
<span class="h-px flex-1 bg-line"></span>
<span><span class="font-serif text-ink-2">{yearLabel}</span> · {m.timeline_gap_empty()}</span>
<span class="h-px flex-1 bg-line"></span>
</div>

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import GapSpan from './GapSpan.svelte';
afterEach(() => cleanup());
describe('GapSpan', () => {
it('renders a multi-year empty run as "{from}{to} · keine Einträge" (REQ-015)', () => {
render(GapSpan, { from: 1910, to: 1914 });
expect(document.body.textContent).toContain('19101914');
expect(document.body.textContent).toContain('keine Einträge');
});
it('renders a single empty year as "{year} · keine Einträge" (REQ-015)', () => {
render(GapSpan, { from: 1912, to: 1912 });
expect(document.body.textContent).toContain('1912');
expect(document.body.textContent).not.toContain('19121912');
expect(document.body.textContent).toContain('keine Einträge');
});
});

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A single archive letter on the timeline: sender → receiver, title, and a
* precision-aware date chip, linking to the document. Names/titles are
* OCR/import-derived — rendered via default `{...}` escaping with
* `whitespace-pre-line` for line breaks (REQ-021); never the raw-HTML directive.
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const sender = $derived(entry.senderName === '' ? m.timeline_unknown_person() : entry.senderName);
const receiver = $derived(
entry.receiverName === '' ? m.timeline_unknown_person() : entry.receiverName
);
</script>
<!-- Box layout inline (not just utility classes) so the 44px touch target holds
even before the stylesheet loads — an <a> is inline by default and would
ignore min-height otherwise. WCAG 2.5.5 (REQ-020). -->
<a
href="/documents/{entry.documentId}"
style="display: flex; flex-direction: column; justify-content: center; min-height: 44px"
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{#if entry.title}
<span class="font-serif text-sm font-bold break-words whitespace-pre-line text-ink"
>{entry.title}</span
>
{/if}
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
<span class="font-serif whitespace-pre-line">{sender}</span>
<span aria-hidden="true"></span>
<span class="font-serif whitespace-pre-line">{receiver}</span>
{#if dateLabel}
<span data-testid="letter-date"> · {dateLabel}</span>
{/if}
</span>
</a>

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import LetterCard from './LetterCard.svelte';
import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
const DOC_ID = '22222222-2222-2222-2222-222222222222';
describe('LetterCard', () => {
it('renders sender, receiver, and title', () => {
render(LetterCard, {
entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' })
});
expect(document.body.textContent).toContain('Karl');
expect(document.body.textContent).toContain('Elfriede');
expect(document.body.textContent).toContain('Feldpost');
});
it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => {
const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' });
const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
expect(expected).toBeTruthy();
render(LetterCard, { entry });
expect(document.body.textContent).toContain(expected as string);
});
it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => {
const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined });
render(LetterCard, { entry });
const chip = document.querySelector('[data-testid="letter-date"]');
expect(chip).toBeNull();
});
it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => {
render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) });
expect(document.body.textContent).toContain('Unbekannt');
});
it('shows "Unbekannt" for an empty receiver (REQ-014)', () => {
render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) });
expect(document.body.textContent).toContain('Unbekannt');
});
it('links to exactly /documents/{documentId} with no target (REQ-023)', () => {
render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) });
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
expect(link.hasAttribute('target')).toBe(false);
});
it('has a touch target of at least 44px (REQ-020)', () => {
render(LetterCard, { entry: makeEntry() });
const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
});

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import YearBand from './YearBand.svelte';
import GapSpan from './GapSpan.svelte';
import LetterCard from './LetterCard.svelte';
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import { entryKey } from './entryKey';
import type { components } from '$lib/generated/api';
type TimelineDTO = components['schemas']['TimelineDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
/**
* Orchestrates the global timeline (REQ-001/003). Renders the year bands the DTO
* delivers in order — never re-sorting — interleaving a folded GapSpan for each
* interior run of empty years (REQ-015), then the undated bucket (REQ-016). An
* empty timeline shows a calm message (REQ-017). `personId` is a declared seam
* for the per-person rail (issue #10) and is undefined here; it is not passed to
* leaf cards (REQ-025). Owns no <main> — the layout does.
*/
let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
const years = timeline.years;
for (let i = 0; i < years.length; i++) {
if (i > 0) {
const prev = years[i - 1].year;
const cur = years[i].year;
if (cur - prev > 1) out.push({ t: 'gap', from: prev + 1, to: cur - 1 });
}
out.push({ t: 'band', year: years[i] });
}
return out;
});
const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length === 0);
</script>
{#if isEmpty}
<p class="py-12 text-center font-serif text-base text-ink-2">{m.timeline_empty_state()}</p>
{:else}
<!-- personId is a declared seam for the per-person Lebensweg rail (issue #10);
undefined in the global view, surfaced only on the root, never passed to
leaf cards (REQ-025). -->
<ol class="timeline-axis relative mx-auto max-w-3xl" data-person-id={personId}>
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} />
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}
</li>
{/each}
</ol>
{#if timeline.undated.length > 0}
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
<ul class="space-y-2">
<!-- The undated bucket is filtered from ALL entries, so it can hold
events as well as letters. Dispatch on kind/type exactly like
YearBand — an event rendered as a LetterCard would link to
/documents/undefined and read "Unknown → Unknown" (REQ-007/008/009). -->
{#each timeline.undated as entry (entryKey(entry))}
<li>
{#if entry.kind === 'EVENT'}
{#if entry.type === 'HISTORICAL'}
<WorldBand entry={entry} />
{:else}
<EventPill entry={entry} />
{/if}
{:else}
<LetterCard entry={entry} />
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<style>
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
centered spine the bands' alternating cards sit on either side of. The
spine is decorative — the chronology lives in the <ol> DOM order. */
.timeline-axis::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0.5rem;
width: 2px;
background: linear-gradient(var(--palette-mint), var(--palette-navy));
}
@media (min-width: 1024px) {
.timeline-axis::before {
left: 50%;
transform: translateX(-50%);
}
}
</style>

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TimelineView from './TimelineView.svelte';
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
afterEach(() => cleanup());
describe('TimelineView', () => {
it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
render(TimelineView, { timeline: makeTimelineDTO() });
expect(document.body.textContent).toContain('Noch keine Ereignisse.');
expect(document.querySelector('ol')).toBeNull();
expect(document.querySelector('section')).toBeNull();
});
it('renders the timeline as a single <ol> with each band a <section>, ascending (REQ-006)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [makeEntry({ documentId: 'a' })]),
makeYear(1916, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.querySelectorAll('ol')).toHaveLength(1);
const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
expect(headings.some((t) => t?.includes('1914'))).toBe(true);
const order = headings.map((t) => t?.trim());
expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
});
it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1909, [makeEntry({ documentId: 'a' })]),
makeYear(1915, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.body.textContent).toContain('19101914');
expect(document.body.textContent).toContain('keine Einträge');
});
it('folds a single empty interior year as a single year (REQ-015)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1911, [makeEntry({ documentId: 'a' })]),
makeYear(1913, [makeEntry({ documentId: 'b' })])
]
})
});
expect(document.body.textContent).toContain('1912');
expect(document.body.textContent).not.toContain('19121912');
});
it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
})
});
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Ohne Datum');
});
it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
});
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
});
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [makeEntry({ documentId: 'a' })]),
makeYear(1915, [makeEntry({ documentId: 'b' })])
],
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
}),
personId: undefined
});
// Two year bands inside the <ol>, plus the separate undated section.
expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
});
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: 'e1',
precision: 'UNKNOWN',
eventDate: undefined,
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
})
]
})
});
// The event renders inside the undated section…
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Auswanderung');
// …as an EventPill (its edit affordance), never as a letter card linking
// to /documents/undefined with "Unbekannt → Unbekannt".
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
expect(document.body.textContent).not.toContain('Unbekannt');
});
it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [
makeEntry({
kind: 'EVENT',
type: 'HISTORICAL',
derived: false,
precision: 'UNKNOWN',
eventDate: undefined,
title: 'Weltwirtschaftskrise',
senderName: '',
receiverName: '',
documentId: undefined
})
]
})
});
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Weltwirtschaftskrise');
// HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018),
// not a broken document link.
expect(document.body.textContent).toContain('Weltgeschehen');
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
});
it('still renders an undated LETTER as a letter card (REQ-016)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
})
});
expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull();
});
it('renders two derived events in one band without key collision (no-double-null-key)', () => {
const a = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Anna',
senderName: '',
receiverName: '',
documentId: undefined,
eventId: undefined,
linkedPersonIds: ['p1']
});
const b = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Bertha',
senderName: '',
receiverName: '',
documentId: undefined,
eventId: undefined,
linkedPersonIds: ['p2']
});
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
expect(document.body.textContent).toContain('Geburt: Anna');
expect(document.body.textContent).toContain('Geburt: Bertha');
});
it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
render(TimelineView, {
timeline: makeTimelineDTO({
years: [
makeYear(1914, [
makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'BIRTH',
title: 'Geburt: Hans',
senderName: '',
receiverName: '',
documentId: undefined
}),
makeEntry({
kind: 'EVENT',
derived: false,
type: 'PERSONAL',
eventId: 'e1',
title: 'Auswanderung',
senderName: '',
receiverName: '',
documentId: undefined
}),
makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
})
])
]
})
});
expect(document.body.textContent).toContain('Weltgeschehen');
expect(document.body.textContent).toContain('Familie');
expect(document.body.textContent).toContain('Geburt');
});
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
el.getAttribute('data-side')
);
expect(sides).toEqual(['left', 'right', 'left', 'right']);
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Full-width muted band for a HISTORICAL event, laid across the axis as context
* (REQ-009). A RANGE carries a visible span pill ("19141918") with a Zeitraum
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
* uses text-ink-2 to stay AA in both themes (REQ-019).
*/
let { entry }: { entry: TimelineEntryDTO } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
</script>
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
<span class="font-serif text-sm text-ink-2 italic">
<span aria-hidden="true" style="color: var(--c-tag-slate)">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
{entry.title}
</span>
{#if showSpan && fromYear && toYear}
<span
data-testid="world-range"
class="ml-2 inline-block rounded-full border border-line px-2 py-0.5 font-sans text-xs text-ink-2"
aria-label={m.timeline_range_aria({ from: fromYear, to: toYear })}
>
{fromYear}{toYear}
</span>
{:else if dateText}
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
{/if}
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import WorldBand from './WorldBand.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function historical(overrides = {}) {
return makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
documentId: undefined,
...overrides
});
}
describe('WorldBand', () => {
it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => {
render(WorldBand, { entry: historical() });
expect(document.body.textContent).toContain('Erster Weltkrieg');
const hidden = document.querySelector('[aria-hidden="true"]');
expect(hidden?.textContent).toBe('◍');
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('Weltgeschehen');
});
it('renders a RANGE span pill 19141918 with a Zeitraum aria-label (REQ-009)', () => {
render(WorldBand, { entry: historical() });
const pill = document.querySelector('[data-testid="world-range"]');
expect(pill).not.toBeNull();
expect(pill?.textContent).toContain('19141918');
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
});
it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => {
render(WorldBand, { entry: historical({ eventDateEnd: undefined }) });
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
expect(document.body.textContent).toContain('Erster Weltkrieg');
expect(document.body.textContent).toContain('1914');
});
});

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import EventPill from './EventPill.svelte';
import WorldBand from './WorldBand.svelte';
import LetterCard from './LetterCard.svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { isDense } from './timelineDensity';
import { entryKey } from './entryKey';
import type { components } from '$lib/generated/api';
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* One year of the timeline: a <section> with a sticky <h2> (REQ-006). Events
* render in DTO order as pills/bands; letters render as individual cards while
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
*/
let { year }: { year: TimelineYearDTO } = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
| { t: 'strip' };
const letters = $derived(year.entries.filter((e) => e.kind === 'LETTER'));
const dense = $derived(isDense(letters.length));
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
let stripInserted = false;
let letterIndex = 0;
for (const entry of year.entries) {
if (entry.kind === 'EVENT') {
out.push({ t: 'event', entry });
} else if (!dense) {
out.push({ t: 'letter', entry, side: letterIndex % 2 === 0 ? 'left' : 'right' });
letterIndex += 1;
} else if (!stripInserted) {
out.push({ t: 'strip' });
stripInserted = true;
}
}
return out;
});
</script>
<section class="py-2">
<h2
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
>
{year.year}
</h2>
<div class="mt-3 space-y-3">
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
{#if row.t === 'event'}
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} />
{:else}
<EventPill entry={row.entry} />
{/if}
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>
<LetterCard entry={row.entry} />
</div>
{:else}
<YearLetterStrip letters={letters} year={year.year} />
{/if}
{/each}
</div>
</section>
<style>
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
header is a 64px sticky nav). REQ-006. */
.year-heading {
position: sticky;
top: 4rem;
z-index: 1;
}
/* Phone (< 1024px): single left-anchored column, all letters on one side
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
so consecutive cards sit on opposite sides of the spine (REQ-004). */
@media (min-width: 1024px) {
.letter-row {
width: 50%;
}
.letter-row[data-side='left'] {
margin-right: auto;
padding-right: 1.75rem;
}
.letter-row[data-side='right'] {
margin-left: auto;
padding-left: 1.75rem;
}
}
</style>

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import YearBand from './YearBand.svelte';
import { makeEntry, makeYear } from './test-factories';
afterEach(() => cleanup());
function manyLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
);
}
describe('YearBand', () => {
it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
const section = document.querySelector('section');
expect(section).not.toBeNull();
const h2 = section?.querySelector('h2');
expect(h2?.textContent).toContain('1914');
const cs = getComputedStyle(h2 as HTMLElement);
expect(cs.position).toBe('sticky');
expect(cs.top).toBe('64px');
});
it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
expect(document.querySelectorAll('a')).toHaveLength(3);
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
});
it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
// collapsed: no individual letter links yet
expect(document.querySelectorAll('a')).toHaveLength(0);
});
it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
const dayLetter = makeEntry({
precision: 'DAY',
eventDate: '1923-04-12',
title: 'Tagesgenau',
documentId: 'day'
});
const yearLetter = makeEntry({
precision: 'YEAR',
eventDate: '1923-01-01',
title: 'Nur Jahr',
documentId: 'year'
});
render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
const links = Array.from(document.querySelectorAll('a'));
expect(links[0].getAttribute('href')).toBe('/documents/day');
expect(links[1].getAttribute('href')).toBe('/documents/year');
});
it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
const pill = makeEntry({
kind: 'EVENT',
derived: true,
derivedType: 'MARRIAGE',
title: 'Heirat',
senderName: '',
receiverName: '',
documentId: undefined
});
const band = makeEntry({
kind: 'EVENT',
derived: false,
type: 'HISTORICAL',
precision: 'RANGE',
eventDate: '1914-01-01',
eventDateEnd: '1918-12-31',
title: 'Erster Weltkrieg',
senderName: '',
receiverName: '',
documentId: undefined
});
render(YearBand, { year: makeYear(1914, [pill, band]) });
expect(document.body.textContent).toContain('Heirat');
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import Sparkline from '$lib/shared/primitives/Sparkline.svelte';
import LetterCard from './LetterCard.svelte';
import { monthHistogram } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Compact density view for a year with many letters (REQ-012): the letter count
* plus a 12-month density sparkline, and a ≥44px keyboard-focusable toggle that
* expands to that year's individual LetterCards.
*/
let { letters, year }: { letters: TimelineEntryDTO[]; year: number } = $props();
let expanded = $state(false);
const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
</script>
<div class="mx-auto max-w-md rounded-sm border border-line bg-surface p-3 shadow-sm">
<div class="flex items-center justify-between gap-3">
<span class="font-sans text-sm font-bold text-brand-navy"
>{m.timeline_letters_count({ count: letters.length })}</span
>
<button
type="button"
data-testid="strip-expand"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
style="display: inline-flex; align-items: center; min-height: 44px"
class="rounded-sm px-2 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.timeline_strip_expand()}
</button>
</div>
<Sparkline
values={counts}
label={m.timeline_letters_count({ count: letters.length })}
class="mt-2"
/>
{#if expanded}
<ul class="mt-3 space-y-2">
{#each letters as letter (letter.documentId)}
<li><LetterCard entry={letter} /></li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,42 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import YearLetterStrip from './YearLetterStrip.svelte';
import { makeEntry } from './test-factories';
afterEach(() => cleanup());
function denseLetters(year: number, count: number) {
return Array.from({ length: count }, (_, i) =>
makeEntry({
eventDate: `${year}-${String((i % 12) + 1).padStart(2, '0')}-10`,
documentId: `doc-${i}`
})
);
}
describe('YearLetterStrip', () => {
it('shows the letter count and a 12-bar sparkline (REQ-012)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
expect(document.body.textContent).toContain('30');
const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
expect(bars).toHaveLength(12);
});
it('has a keyboard-focusable expand toggle of at least 44px (REQ-012)', () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.tagName).toBe('BUTTON');
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it('reveals all letter cards when expanded (REQ-012)', async () => {
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
expect(document.querySelectorAll('a').length).toBe(0);
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
toggle.click();
await tick();
expect(document.querySelectorAll('a').length).toBe(30);
});
});

View File

@@ -0,0 +1,23 @@
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* Stable `{#each}` key for a timeline entry. Prefers the entry's own identity
* (`eventId` for curated events, `documentId` for letters); derived life-events
* carry neither, so they key on `derivedType` + their linked person ids — which
* keeps two derived births in the same year distinct. The `kind` prefix keeps an
* event and a letter that happen to share an id from colliding.
*
* Used by both `YearBand` (per-band rows) and `TimelineView` (the undated
* bucket), where entries can be events without a `documentId`.
*/
export function entryKey(entry: TimelineEntryDTO): string {
return (
entry.kind +
':' +
(entry.eventId ??
entry.documentId ??
`${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`)
);
}

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { getAccentConfig } from './eventCardConfig';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function event(overrides: Partial<TimelineEntryDTO>): TimelineEntryDTO {
return {
kind: 'EVENT',
precision: 'YEAR',
derived: false,
senderName: '',
receiverName: '',
...overrides
};
}
describe('getAccentConfig', () => {
it('maps a derived birth to the * glyph and "Geburt"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'BIRTH' }));
expect(cfg.glyph).toBe('*');
expect(cfg.label).toBe('Geburt');
expect(cfg.accent).toBe('derived');
});
it('maps a derived death to the † glyph and "Tod"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'DEATH' }));
expect(cfg.glyph).toBe('†');
expect(cfg.label).toBe('Tod');
expect(cfg.accent).toBe('derived');
});
it('maps a derived marriage to the ⚭ glyph and "Heirat"', () => {
const cfg = getAccentConfig(event({ derived: true, derivedType: 'MARRIAGE' }));
expect(cfg.glyph).toBe('⚭');
expect(cfg.label).toBe('Heirat');
expect(cfg.accent).toBe('derived');
});
it('maps a HISTORICAL event to the world glyph and "Weltgeschehen"', () => {
const cfg = getAccentConfig(event({ type: 'HISTORICAL' }));
expect(cfg.glyph).toBe('◍');
expect(cfg.label).toBe('Weltgeschehen');
expect(cfg.accent).toBe('historical');
});
it('maps a curated PERSONAL event to the ★ glyph and "Familie"', () => {
const cfg = getAccentConfig(event({ type: 'PERSONAL', eventId: 'e-1' }));
expect(cfg.glyph).toBe('★');
expect(cfg.label).toBe('Familie');
expect(cfg.accent).toBe('curated');
});
});

View File

@@ -0,0 +1,38 @@
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** Styling discriminant for an axis pill/band. */
export type TimelineAccent = 'derived' | 'curated' | 'historical';
export interface AccentConfig {
/** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */
glyph: string;
/** Localized layer/life-event label — used as the sr-only / aria text only. */
label: string;
accent: TimelineAccent;
}
/**
* Maps a timeline EVENT entry to its glyph, redundant non-color label, and accent
* (REQ-007/008/018). Derived life-events use the * / † / ⚭ glyphs that match
* `personLifeDates.ts`; HISTORICAL events get the muted world band; everything
* else (curated PERSONAL) gets the mint family pill.
*/
export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
if (entry.derived) {
switch (entry.derivedType) {
case 'BIRTH':
return { glyph: '*', label: m.timeline_derived_birth(), accent: 'derived' };
case 'DEATH':
return { glyph: '†', label: m.timeline_derived_death(), accent: 'derived' };
case 'MARRIAGE':
return { glyph: '⚭', label: m.timeline_derived_marriage(), accent: 'derived' };
}
}
if (entry.type === 'HISTORICAL') {
return { glyph: '◍', label: m.timeline_layer_world(), accent: 'historical' };
}
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
}

View File

@@ -0,0 +1,34 @@
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
type TimelineDTO = components['schemas']['TimelineDTO'];
/**
* Builds a `TimelineEntryDTO` mirroring the real wire shape (no `year`,
* `description`, or `snippet` fields). Defaults to a dated DAY-precision letter;
* override `kind`/`derived`/`type`/`derivedType` etc. for events.
*/
export function makeEntry(overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl Raddatz',
receiverName: 'Elfriede Raddatz',
eventDate: '1915-06-15',
title: 'Brief aus dem Feld',
documentId: '11111111-1111-1111-1111-111111111111',
...overrides
};
}
export function makeYear(year: number, entries: TimelineEntryDTO[]): TimelineYearDTO {
return { year, entries };
}
export function makeTimelineDTO(
opts: { years?: TimelineYearDTO[]; undated?: TimelineEntryDTO[] } = {}
): TimelineDTO {
return { years: opts.years ?? [], undated: opts.undated ?? [] };
}

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { isDense, monthHistogram, DENSE_THRESHOLD } from './timelineDensity';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
function letter(eventDate: string): TimelineEntryDTO {
return {
kind: 'LETTER',
precision: 'DAY',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede',
eventDate
};
}
describe('isDense', () => {
it('uses a threshold of 12', () => {
expect(DENSE_THRESHOLD).toBe(12);
});
it('is false at exactly 12 letters (still rendered as individual cards)', () => {
expect(isDense(12)).toBe(false);
});
it('is true above 12 letters (collapses to a strip)', () => {
expect(isDense(13)).toBe(true);
});
it('is false for empty and small bands', () => {
expect(isDense(0)).toBe(false);
expect(isDense(3)).toBe(false);
});
});
describe('monthHistogram', () => {
it('returns exactly 12 buckets for the band year, Jan..Dec', () => {
const buckets = monthHistogram([letter('1915-03-04')], 1915);
expect(buckets).toHaveLength(12);
expect(buckets.map((b) => b.month)).toEqual([
'1915-01',
'1915-02',
'1915-03',
'1915-04',
'1915-05',
'1915-06',
'1915-07',
'1915-08',
'1915-09',
'1915-10',
'1915-11',
'1915-12'
]);
});
it('counts each letter on its eventDate month; counts sum to the total', () => {
// 30 letters spread one-or-more per month across 1915.
const dist: Record<string, number> = {
'01': 1,
'02': 2,
'03': 3,
'04': 4,
'05': 1,
'06': 5,
'07': 2,
'08': 6,
'09': 1,
'10': 2,
'11': 2,
'12': 1
};
const letters: TimelineEntryDTO[] = [];
for (const [mm, n] of Object.entries(dist)) {
for (let i = 0; i < n; i++) letters.push(letter(`1915-${mm}-10`));
}
expect(letters).toHaveLength(30);
const buckets = monthHistogram(letters, 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(30);
for (const b of buckets) {
expect(b.count).toBe(dist[b.month.slice(5)]);
}
});
it('yields height 0 for the eleven empty months when letters cluster in one', () => {
const buckets = monthHistogram([letter('1915-03-01'), letter('1915-03-28')], 1915);
const march = buckets.find((b) => b.month === '1915-03');
expect(march?.count).toBe(2);
expect(buckets.filter((b) => b.month !== '1915-03').every((b) => b.count === 0)).toBe(true);
});
it('counts coarser-than-month precisions on their eventDate anchor month', () => {
const seasonLetter: TimelineEntryDTO = { ...letter('1915-07-01'), precision: 'SEASON' };
const buckets = monthHistogram([seasonLetter], 1915);
expect(buckets.find((b) => b.month === '1915-07')?.count).toBe(1);
});
it('ignores entries without an eventDate', () => {
const undated: TimelineEntryDTO = {
kind: 'LETTER',
precision: 'UNKNOWN',
derived: false,
senderName: 'Karl',
receiverName: 'Elfriede'
};
const buckets = monthHistogram([undated, letter('1915-05-01')], 1915);
expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(1);
});
});

View File

@@ -0,0 +1,32 @@
import type { components } from '$lib/generated/api';
import { fillDensityGaps, type MonthBucket } from '$lib/shared/utils/monthBuckets';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* A year band with more letters than this renders as a compact density strip
* (count + 12-month sparkline) instead of one card per letter (REQ-012).
*/
export const DENSE_THRESHOLD = 12;
export function isDense(letterCount: number): boolean {
return letterCount > DENSE_THRESHOLD;
}
/**
* Buckets a band's letters into exactly 12 month buckets (`{year}-01`..`{year}-12`)
* for the density sparkline. Each letter counts on its `eventDate` month; coarser
* precisions (SEASON/YEAR/APPROX) count on whatever anchor month the backend put
* in `eventDate`. Entries without an `eventDate` (e.g. UNKNOWN) are ignored — they
* live in the "Ohne Datum" bucket, not a dated band. (REQ-027)
*/
export function monthHistogram(letters: TimelineEntryDTO[], year: number): MonthBucket[] {
const counts = new Map<string, number>();
for (const l of letters) {
if (!l.eventDate) continue;
const month = l.eventDate.slice(0, 7); // YYYY-MM
counts.set(month, (counts.get(month) ?? 0) + 1);
}
const buckets = Array.from(counts.entries()).map(([month, count]) => ({ month, count }));
return fillDensityGaps(buckets, `${year}-01-01`, `${year}-12-31`);
}

View File

@@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
> >
{m.nav_geschichten()} {m.nav_geschichten()}
</a> </a>
<a
href="/zeitstrahl"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/zeitstrahl')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_zeitstrahl()}
</a>
{#if isAdmin} {#if isAdmin}
<a <a
href="/admin" href="/admin"
@@ -190,6 +200,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{m.nav_geschichten()} {m.nav_geschichten()}
</a> </a>
<a
href="/zeitstrahl"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/zeitstrahl')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_zeitstrahl()}
</a>
{#if isAdmin} {#if isAdmin}
<a <a
href="/admin" href="/admin"

View File

@@ -0,0 +1,19 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
// Global timeline: personId is undefined, so no query params (REQ-001). SSR-first
// via createApiClient so the session cookie is forwarded; no client-side fetch
// (REQ-002). The raw payload (correspondent names/titles) is PII — never logged.
export async function load({ fetch }) {
const api = createApiClient(fetch);
const result = await api.GET('/api/timeline');
if (result.response.status === 401) throw redirect(302, '/login');
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
}
return { timeline: result.data! };
}

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import TimelineView from '$lib/timeline/TimelineView.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{m.timeline_heading()}</title>
</svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8">
<h1 class="mb-8 font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
<TimelineView timeline={data.timeline} />
</div>

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
beforeEach(() => vi.clearAllMocks());
const TIMELINE = { years: [{ year: 1914, entries: [] }], undated: [] };
function mockApi(opts: { ok?: boolean; status?: number; data?: unknown; error?: unknown }) {
const { ok = true, status = 200, data = TIMELINE, error } = opts;
const GET = vi.fn().mockResolvedValue({
response: { ok, status },
data: ok ? data : undefined,
error
});
vi.mocked(createApiClient).mockReturnValue({ GET } as unknown as ReturnType<
typeof createApiClient
>);
return GET;
}
function callLoad() {
return load({
fetch: vi.fn() as unknown as typeof fetch,
url: new URL('http://localhost/zeitstrahl'),
request: new Request('http://localhost/zeitstrahl'),
route: { id: '/zeitstrahl' },
params: {}
} as unknown as Parameters<typeof load>[0]);
}
describe('zeitstrahl +page.server load', () => {
it('fetches GET /api/timeline and returns { timeline } on ok (REQ-001/002)', async () => {
const GET = mockApi({ data: TIMELINE });
const result = await callLoad();
expect(GET).toHaveBeenCalledWith('/api/timeline');
expect(result).toEqual({ timeline: TIMELINE });
});
it('redirects to /login on 401 (REQ-022)', async () => {
mockApi({ ok: false, status: 401 });
await expect(callLoad()).rejects.toMatchObject({ status: 302, location: '/login' });
});
it('throws a mapped error on 404 (REQ-022)', async () => {
mockApi({ ok: false, status: 404, error: { code: 'TIMELINE_EVENT_NOT_FOUND' } });
await expect(callLoad()).rejects.toMatchObject({
status: 404,
body: { message: getErrorMessage('TIMELINE_EVENT_NOT_FOUND') }
});
});
it('throws a mapped error on 500 (REQ-022)', async () => {
mockApi({ ok: false, status: 500, error: undefined });
await expect(callLoad()).rejects.toMatchObject({ status: 500 });
});
it('throws a mapped FORBIDDEN error on 403 (REQ-022)', async () => {
mockApi({ ok: false, status: 403, error: { code: 'FORBIDDEN' } });
await expect(callLoad()).rejects.toMatchObject({
status: 403,
body: { message: getErrorMessage('FORBIDDEN') }
});
});
});