diff --git a/.specify/rtm.md b/.specify/rtm.md index a8a79d6c..12b8a524 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -79,3 +79,30 @@ | REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done | | REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done | | REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done | +| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done | +| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done | +| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single
    ` | Done | +| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done | +| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done | +| REQ-006 | single `
      ` chronological; each band a `
      ` with sticky `

      ` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single
        `, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done | +| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done | +| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done | +| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 1914–1918 with a Zeitraum aria-label` | Done | +| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done | +| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done | +| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done | +| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done | +| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done | +| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done | +| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done | +| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done | +| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done | +| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done | +| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done | +| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done | +| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done | +| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done | +| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels 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 | diff --git a/CLAUDE.md b/CLAUDE.md index 8fbfe119..19998f96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,7 @@ frontend/src/routes/ ├── aktivitaeten/ Unified activity feed (Chronik) ├── geschichten/ Stories — list, [id], [id]/edit, new ├── stammbaum/ Family tree (Stammbaum) +├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode) ├── themen/ Topics directory — browsable tag index ├── enrich/ Enrichment workflow — [id], done ├── admin/ User, group, tag, OCR, system management diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 29cc18e1..bc6dabac 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -170,6 +170,8 @@ _Not to be confused with a document item's optional note_ — a document item's **Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain. +**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view). + **derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043). _Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database. diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index cf424d63..6ba983ff 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") + Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.") @@ -27,6 +28,8 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications" Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") +Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser") +Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a6fa8df7..3d367b4d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -34,6 +34,7 @@ src/ │ ├── api/ # Internal API proxies (server-side only) │ ├── geschichten/ # Stories (list, [id], [id]/edit, new) │ ├── stammbaum/ # Family tree +│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline │ ├── enrich/ # Enrichment workflow ([id], done) │ ├── hilfe/transkription/ # Transcription help page │ ├── profile/ # User profile settings @@ -49,6 +50,7 @@ src/ │ │ ├── relationship/ # Relationship form + chip components │ │ └── genealogy/ # Stammbaum (family tree) components │ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker +│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/) │ ├── geschichte/ # Geschichte (story) domain: editor + card │ ├── notification/ # Notification bell + dropdown + store │ ├── activity/ # Activity feed (Chronik) components @@ -59,8 +61,8 @@ src/ │ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.) │ │ ├── server/ # Server-only utilities (locale, session) │ │ ├── services/ # Client-side service helpers -│ │ ├── utils/ # Pure utility functions (date, search, etc.) -│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.) +│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip) +│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.) │ │ ├── dashboard/ # Dashboard stat components │ │ ├── discussion/ # CommentThread + shared discussion UI │ │ ├── help/ # Help/FAQ page components diff --git a/frontend/e2e/zeitstrahl.spec.ts b/frontend/e2e/zeitstrahl.spec.ts new file mode 100644 index 00000000..18c633e0 --- /dev/null +++ b/frontend/e2e/zeitstrahl.spec.ts @@ -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
        ) 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
        ', 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
          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
            in the DOM. + await expect(page.getByRole('main').locator('ol')).toHaveCount(1); + + const scrollWidth = await page.evaluate(() => document.body.scrollWidth); + expect(scrollWidth).toBe(320); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 40aee11b..b7d3975b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -215,6 +215,7 @@ export default defineConfig( 'ocr', 'activity', 'conversation', + 'timeline', 'shared' ] } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c306a1a3..ec66eced 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1032,6 +1032,20 @@ "bulk_edit_count_pill": "{count} werden bearbeitet", "nav_stammbaum": "Stammbaum", "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_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.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9f3bb8e0..234437ad 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1032,6 +1032,20 @@ "bulk_edit_count_pill": "{count} will be edited", "nav_stammbaum": "Family tree", "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_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.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d4d8544d..aab54c4d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1032,6 +1032,20 @@ "bulk_edit_count_pill": "Se editarán {count}", "nav_stammbaum": "Árbol genealógico", "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_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.", diff --git a/frontend/src/lib/document/README.md b/frontend/src/lib/document/README.md index 16885de7..b0485d76 100644 --- a/frontend/src/lib/document/README.md +++ b/frontend/src/lib/document/README.md @@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`. - `tag/TagInput.svelte` — tag chip input - `ocr/OcrProgress.svelte` — job status indicator in the document header - `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI +- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`) ## Backend counterpart diff --git a/frontend/src/lib/document/TimelineBars.svelte b/frontend/src/lib/document/TimelineBars.svelte index fb0db1ca..c5784028 100644 --- a/frontend/src/lib/document/TimelineBars.svelte +++ b/frontend/src/lib/document/TimelineBars.svelte @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts new file mode 100644 index 00000000..228302b0 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts @@ -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('[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'); + }); +}); diff --git a/frontend/src/lib/shared/utils/monthBuckets.spec.ts b/frontend/src/lib/shared/utils/monthBuckets.spec.ts new file mode 100644 index 00000000..11f18e23 --- /dev/null +++ b/frontend/src/lib/shared/utils/monthBuckets.spec.ts @@ -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/); + }); +}); diff --git a/frontend/src/lib/shared/utils/monthBuckets.ts b/frontend/src/lib/shared/utils/monthBuckets.ts new file mode 100644 index 00000000..46a28212 --- /dev/null +++ b/frontend/src/lib/shared/utils/monthBuckets.ts @@ -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(); + 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); +} diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte new file mode 100644 index 00000000..9b125cb7 --- /dev/null +++ b/frontend/src/lib/timeline/EventPill.svelte @@ -0,0 +1,59 @@ + + +
            +
            + + + {config.label} + + + {#if entry.title} + {entry.title} + {/if} + {#if dateLabel} + {dateLabel} + {/if} + + {#if canEdit} + + + {m.btn_edit()} + + {/if} +
            +
            diff --git a/frontend/src/lib/timeline/EventPill.svelte.spec.ts b/frontend/src/lib/timeline/EventPill.svelte.spec.ts new file mode 100644 index 00000000..945ea65f --- /dev/null +++ b/frontend/src/lib/timeline/EventPill.svelte.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/timeline/GapSpan.svelte b/frontend/src/lib/timeline/GapSpan.svelte new file mode 100644 index 00000000..ba8aa18a --- /dev/null +++ b/frontend/src/lib/timeline/GapSpan.svelte @@ -0,0 +1,20 @@ + + +
            + + {yearLabel} · {m.timeline_gap_empty()} + +
            diff --git a/frontend/src/lib/timeline/GapSpan.svelte.spec.ts b/frontend/src/lib/timeline/GapSpan.svelte.spec.ts new file mode 100644 index 00000000..57df8a33 --- /dev/null +++ b/frontend/src/lib/timeline/GapSpan.svelte.spec.ts @@ -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('1910–1914'); + 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('1912–1912'); + expect(document.body.textContent).toContain('keine Einträge'); + }); +}); diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte new file mode 100644 index 00000000..11a1b787 --- /dev/null +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -0,0 +1,44 @@ + + + + + {#if entry.title} + {entry.title} + {/if} + + {sender} + + {receiver} + {#if dateLabel} + · {dateLabel} + {/if} + + diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts new file mode 100644 index 00000000..28df3886 --- /dev/null +++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte new file mode 100644 index 00000000..2eef69f3 --- /dev/null +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -0,0 +1,107 @@ + + +{#if isEmpty} +

            {m.timeline_empty_state()}

            +{:else} + +
              + {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)} +
            1. + {#if row.t === 'band'} + + {:else} + + {/if} +
            2. + {/each} +
            + + {#if timeline.undated.length > 0} +
            +

            {m.timeline_undated_section()}

            +
              + + {#each timeline.undated as entry (entryKey(entry))} +
            • + {#if entry.kind === 'EVENT'} + {#if entry.type === 'HISTORICAL'} + + {:else} + + {/if} + {:else} + + {/if} +
            • + {/each} +
            +
            + {/if} +{/if} + + diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts new file mode 100644 index 00000000..ec878a5a --- /dev/null +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -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
              with each band a
              , 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('1910–1914'); + 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('1912–1912'); + }); + + 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
                , 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']); + }); +}); diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte new file mode 100644 index 00000000..92519535 --- /dev/null +++ b/frontend/src/lib/timeline/WorldBand.svelte @@ -0,0 +1,43 @@ + + +
                + + + {config.label} + {entry.title} + + {#if showSpan && fromYear && toYear} + + {fromYear}–{toYear} + + {:else if dateText} + {dateText} + {/if} +
                diff --git a/frontend/src/lib/timeline/WorldBand.svelte.spec.ts b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts new file mode 100644 index 00000000..497f51b3 --- /dev/null +++ b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts @@ -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 1914–1918 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('1914–1918'); + 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'); + }); +}); diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte new file mode 100644 index 00000000..a612a750 --- /dev/null +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -0,0 +1,99 @@ + + +
                +

                + {year.year} +

                + +
                + {#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))} + {#if row.t === 'event'} + {#if row.entry.type === 'HISTORICAL'} + + {:else} + + {/if} + {:else if row.t === 'letter'} +
                + +
                + {:else} + + {/if} + {/each} +
                +
                + + diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts new file mode 100644 index 00000000..5d43a6e5 --- /dev/null +++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/timeline/YearLetterStrip.svelte b/frontend/src/lib/timeline/YearLetterStrip.svelte new file mode 100644 index 00000000..8409734c --- /dev/null +++ b/frontend/src/lib/timeline/YearLetterStrip.svelte @@ -0,0 +1,52 @@ + + +
                +
                + {m.timeline_letters_count({ count: letters.length })} + +
                + + + + {#if expanded} +
                  + {#each letters as letter (letter.documentId)} +
                • + {/each} +
                + {/if} +
                diff --git a/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts b/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts new file mode 100644 index 00000000..4c78ea0e --- /dev/null +++ b/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/timeline/entryKey.ts b/frontend/src/lib/timeline/entryKey.ts new file mode 100644 index 00000000..4ebd74de --- /dev/null +++ b/frontend/src/lib/timeline/entryKey.ts @@ -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('-')}`) + ); +} diff --git a/frontend/src/lib/timeline/eventCardConfig.spec.ts b/frontend/src/lib/timeline/eventCardConfig.spec.ts new file mode 100644 index 00000000..8fd33355 --- /dev/null +++ b/frontend/src/lib/timeline/eventCardConfig.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts new file mode 100644 index 00000000..cb11d7b2 --- /dev/null +++ b/frontend/src/lib/timeline/eventCardConfig.ts @@ -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' }; +} diff --git a/frontend/src/lib/timeline/test-factories.ts b/frontend/src/lib/timeline/test-factories.ts new file mode 100644 index 00000000..25bc3026 --- /dev/null +++ b/frontend/src/lib/timeline/test-factories.ts @@ -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 { + 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 ?? [] }; +} diff --git a/frontend/src/lib/timeline/timelineDensity.spec.ts b/frontend/src/lib/timeline/timelineDensity.spec.ts new file mode 100644 index 00000000..d7157241 --- /dev/null +++ b/frontend/src/lib/timeline/timelineDensity.spec.ts @@ -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 = { + '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); + }); +}); diff --git a/frontend/src/lib/timeline/timelineDensity.ts b/frontend/src/lib/timeline/timelineDensity.ts new file mode 100644 index 00000000..5dab1641 --- /dev/null +++ b/frontend/src/lib/timeline/timelineDensity.ts @@ -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(); + 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`); +} diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index 260c96c8..e70340b5 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) { > {m.nav_geschichten()} + + + {m.nav_zeitstrahl()} + {#if isAdmin} + + {m.nav_zeitstrahl()} + + {#if isAdmin} +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(); + + + + {m.timeline_heading()} + + +
                +

                {m.timeline_heading()}

                + +
                diff --git a/frontend/src/routes/zeitstrahl/page.server.test.ts b/frontend/src/routes/zeitstrahl/page.server.test.ts new file mode 100644 index 00000000..e7613eec --- /dev/null +++ b/frontend/src/routes/zeitstrahl/page.server.test.ts @@ -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[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') } + }); + }); +});