feat(timeline): global /zeitstrahl timeline (Concept A) — #779 #831
@@ -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 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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
frontend/e2e/zeitstrahl.spec.ts
Normal file
84
frontend/e2e/zeitstrahl.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -215,6 +215,7 @@ export default defineConfig(
|
|||||||
'ocr',
|
'ocr',
|
||||||
'activity',
|
'activity',
|
||||||
'conversation',
|
'conversation',
|
||||||
|
'timeline',
|
||||||
'shared'
|
'shared'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
38
frontend/src/lib/shared/primitives/Sparkline.svelte
Normal file
38
frontend/src/lib/shared/primitives/Sparkline.svelte
Normal 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>
|
||||||
28
frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts
Normal file
28
frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
267
frontend/src/lib/shared/utils/monthBuckets.spec.ts
Normal file
267
frontend/src/lib/shared/utils/monthBuckets.spec.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
frontend/src/lib/shared/utils/monthBuckets.ts
Normal file
163
frontend/src/lib/shared/utils/monthBuckets.ts
Normal 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);
|
||||||
|
}
|
||||||
59
frontend/src/lib/timeline/EventPill.svelte
Normal file
59
frontend/src/lib/timeline/EventPill.svelte
Normal 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>
|
||||||
90
frontend/src/lib/timeline/EventPill.svelte.spec.ts
Normal file
90
frontend/src/lib/timeline/EventPill.svelte.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
frontend/src/lib/timeline/GapSpan.svelte
Normal file
20
frontend/src/lib/timeline/GapSpan.svelte
Normal 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>
|
||||||
20
frontend/src/lib/timeline/GapSpan.svelte.spec.ts
Normal file
20
frontend/src/lib/timeline/GapSpan.svelte.spec.ts
Normal 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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/src/lib/timeline/LetterCard.svelte
Normal file
44
frontend/src/lib/timeline/LetterCard.svelte
Normal 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>
|
||||||
58
frontend/src/lib/timeline/LetterCard.svelte.spec.ts
Normal file
58
frontend/src/lib/timeline/LetterCard.svelte.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
frontend/src/lib/timeline/TimelineView.svelte
Normal file
107
frontend/src/lib/timeline/TimelineView.svelte
Normal 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>
|
||||||
237
frontend/src/lib/timeline/TimelineView.svelte.spec.ts
Normal file
237
frontend/src/lib/timeline/TimelineView.svelte.spec.ts
Normal 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('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 <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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/src/lib/timeline/WorldBand.svelte
Normal file
43
frontend/src/lib/timeline/WorldBand.svelte
Normal 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 ("1914–1918") 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>
|
||||||
48
frontend/src/lib/timeline/WorldBand.svelte.spec.ts
Normal file
48
frontend/src/lib/timeline/WorldBand.svelte.spec.ts
Normal 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 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
99
frontend/src/lib/timeline/YearBand.svelte
Normal file
99
frontend/src/lib/timeline/YearBand.svelte
Normal 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>
|
||||||
84
frontend/src/lib/timeline/YearBand.svelte.spec.ts
Normal file
84
frontend/src/lib/timeline/YearBand.svelte.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
frontend/src/lib/timeline/YearLetterStrip.svelte
Normal file
52
frontend/src/lib/timeline/YearLetterStrip.svelte
Normal 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>
|
||||||
42
frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts
Normal file
42
frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/src/lib/timeline/entryKey.ts
Normal file
23
frontend/src/lib/timeline/entryKey.ts
Normal 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('-')}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/lib/timeline/eventCardConfig.spec.ts
Normal file
53
frontend/src/lib/timeline/eventCardConfig.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
38
frontend/src/lib/timeline/eventCardConfig.ts
Normal file
38
frontend/src/lib/timeline/eventCardConfig.ts
Normal 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' };
|
||||||
|
}
|
||||||
34
frontend/src/lib/timeline/test-factories.ts
Normal file
34
frontend/src/lib/timeline/test-factories.ts
Normal 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 ?? [] };
|
||||||
|
}
|
||||||
110
frontend/src/lib/timeline/timelineDensity.spec.ts
Normal file
110
frontend/src/lib/timeline/timelineDensity.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
frontend/src/lib/timeline/timelineDensity.ts
Normal file
32
frontend/src/lib/timeline/timelineDensity.ts
Normal 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`);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
19
frontend/src/routes/zeitstrahl/+page.server.ts
Normal file
19
frontend/src/routes/zeitstrahl/+page.server.ts
Normal 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! };
|
||||||
|
}
|
||||||
16
frontend/src/routes/zeitstrahl/+page.svelte
Normal file
16
frontend/src/routes/zeitstrahl/+page.svelte
Normal 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>
|
||||||
72
frontend/src/routes/zeitstrahl/page.server.test.ts
Normal file
72
frontend/src/routes/zeitstrahl/page.server.test.ts
Normal 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') }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user