Playwright spec for /zeitstrahl: the primary journey (hide Letters → letter
cards vanish + trigger reports "1 aktiv" → reset restores) and a 375px axe pass
with the collapsible open in light and dark mode. Not skipped — #779 ships the
route. E2E is not wired into CI, so this runs locally only for now.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A static boundary gate (mirroring the project's no-`{@html}` greps) that reads
TimelineFilters.svelte and /zeitstrahl/+page.svelte and fails if either ever
reintroduces goto(, url.searchParams, api.GET, or fetch( — the filter must stay
presentation-only and fully client-side.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The route holds the three layer toggles in $state, binds them into
TimelineFilters, and derives a client-side filtered view of the SSR-loaded
timeline that it passes to TimelineView — no goto, no URL param, no extra
fetch. When the active toggles leave nothing visible it renders a calm
filtered-empty message plus a one-click reset below the still-open filter bar,
never a blank page and never the generic "no events" state. The meta-line keeps
counting the unfiltered timeline (D1 known limitation).
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dumb, client-side layer-filter bar for /zeitstrahl: three $bindable layer
toggles (Personal/Historical/Letters) in a fieldset/legend, a sticky
"Filter (N aktiv)" trigger driven by hiddenLayerCount, and a reset text button
shown only when a layer is off. Toggles mirror the SearchFilterBar undated-toggle
markup (aria-pressed, ✓ glyph, 44px touch target, semantic tokens). The
collapsible slide honours prefers-reduced-motion by zeroing its duration. No
goto, no fetch — the route owns the state and the filtered view.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eight Paraglide keys for the /zeitstrahl layer filter — layer labels, the
fieldset legend, the sticky trigger (distinct timeline_filter_trigger and
timeline_filter_trigger_active({count}) so it never reads "Filter (0 aktiv)"),
the reset button, and the filtered-empty message — added to all three locales.
messages.spec asserts their presence and the {count} signature.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure helpers for the /zeitstrahl layer filter: isDefaultState and
hiddenLayerCount drive the "Filter (N active)" trigger, and filterTimeline
derives a client-side view that hides personal/historical/letter layers and
drops year bands left empty. Letters ride the Letters layer, HISTORICAL events
the Historical layer, and curated PERSONAL plus derived life-events the
Personal layer.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes#837
Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.
Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).
## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.
## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.
## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.
## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
The npm-audit job filed its tracking issue via `curl -sf`, which collapses
every HTTP >=400 into a bare "exit 22". When the NIGHTLY_AUDIT_TOKEN secret is
missing, expired, or under-scoped, the step failed with an opaque
`exitcode '22'` and no hint at the cause (run #6707).
Route all five API calls through an `api()` helper that reads the HTTP status
and, on >=400, emits an actionable `::error::` naming the status and the token
secret before failing — without ever echoing the token value. Extend the
in-workflow self-test (mocked curl) to cover both the success and HTTP-error
paths.
Closes#839
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two cleanups flagged in review, both behaviour-preserving:
- collapse the {#if color}/{:else} square-marker branches (identical but for the
neutral fill) into one element via class:bg-ink-3={!color}; squareStyle is
already empty when color is null, so no var(--c-tag-) leaks into the neutral
chip.
- drop the redundant `truncate` class from the name span — the inline
overflow/ellipsis trio (kept so it applies before the stylesheet loads,
REQ-008a) already expresses exactly what `truncate` would.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveRoot silently falls back to returning the tag itself when no null-parent
ancestor surfaces — an orphaned parent_id or a chain deeper than the
findAncestorIds CTE depth guard. The chip then renders a non-root tag as if it
were the theme, with no trace. Log a warning (UUIDs only, per REQ-014) before
the fallback so the anomaly is diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mapDocument re-ran the alphabetical min() scan over the letter's tag set to
look up its already-resolved root, duplicating the work resolveLetterRootTags
had just done and leaving two independent definitions of "primary tag" that
could silently diverge. Key the resolved-root map by document id and compute
the primary tag exactly once per letter; drop the redundant resolvePrimaryRoot
helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add one row per requirement for the zeitstrahl-tag-chips feature, each mapped
to its implementation file(s) and the test(s) that prove it, Status=Done.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc comment described escaping by naming the raw-HTML directive literally,
which trips the lib/timeline grep gate that forbids that token. Reword it the
way LetterCard already does — behavior unchanged.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LetterCard now renders a TagChip beneath the sender→receiver/date line
whenever the entry carries a rootTagName, mapping rootTagColor to the chip
(neutral when null). Because the chip lives on LetterCard it shows up wherever
a LetterCard does — the global timeline and the expanded YearLetterStrip — with
no per-surface special-casing; a tagless letter shows no chip. A long name
truncates inline so the card never overflows at 320px.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagChip renders a letter's primary root tag as a small rounded pill — a
decorative aria-hidden colored square (var(--c-tag-{token}), neutral when the
color is null) plus the escaped tag name, prefixed by the sr-only theme label
so color is never the only cue. Truncation is set inline so a long name
ellipsizes without forcing the card into horizontal scroll, and the full name
stays reachable via the chip title. Timeline-local by design — lib/timeline may
not import lib/tag (eslint boundary).
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
timeline_tag_chip_label (de "Thema" / en "Topic" / es "Tema") is the sr-only
prefix the /zeitstrahl letter tag chip reads out so color is never the only
cue. Pinned per locale in messages.spec.ts; the tag name itself is rendered as
data, never translated.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
openapi-typescript pickup of TimelineEntryDTO.rootTagId/rootTagName/
rootTagColor (all optional), so the SvelteKit timeline can read the new
letter chip fields. Regenerated from the live dev spec; only the additive
fields differ from the committed baseline.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TimelineEntryDTO gains three nullable letter-only fields — rootTagId,
rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService
(ADR-036): id + name + token only, never a serialized Tag entity. A letter's
primary tag is the root ancestor of its alphabetically-first assigned tag
(#827 Resolved Decision 3); roots are resolved through TagService in one
batched pass over the distinct primary tags (no per-letter N+1). The fields are
null for non-letter entries, untagged letters, and (color only) a colorless
root, so they are deliberately not @Schema(requiredMode = REQUIRED).
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagService.resolveRootTags(tags) maps each tag to its root ancestor as a
RootTag (id, name, color token), keyed by the input tag id. A root maps to
itself; a child is walked to the parentless ancestor via the existing
recursive-CTE findAncestorIds — one CTE per distinct non-root tag (memoized),
plus a single batched findAllById — so a timeline of many letters sharing few
tags costs O(distinct tags) queries, never O(letters). The color is read from
the resolved root's stored token (null when the root has none).
This is the shared enrichment the /zeitstrahl tag chip (#835) and, later, the
Thema buckets (#827) both consume. Unit-tested in TagServiceTest; the
DB-dependent ancestry walk is pinned against real Postgres in
TagServiceIntegrationTest.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine offset (0.5rem phone / 50% desktop) was hard-coded in both
TimelineView's .timeline-axis::before and YearBand's .year-node/.letter-dot,
kept in sync only by a comment. Declare --spine-x once on .timeline-axis
and have the markers consume it by inheritance, so a change to the spine
position moves the markers with it. Add a test that the year-node tracks
the token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The aria-hidden glyph + sr-only label markup was hand-copied in LetterCard
and YearLetterStrip. Extract a small GlyphLabel component and use it at
both sites so the accessibility idiom has a single owner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flatMap intermediate array plus two filter passes with one
walk over the year bands and the undated bucket. Same counts, no
throwaway allocation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "· historisch" register was emitted in all three date branches, with
the dateless branch dropping the leading separator. Render the span pill
or date as a conditional prefix, then a single trailing "· historisch"
span — one render site, consistent separator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A count of one rendered "1 Briefe" / "1 Ereignisse". Add _singular
companion keys (de/en/es) and select them when the count is exactly one,
following the project's _singular/_plural convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A timeline with content in only one category rendered a misleading
"0 Briefe" / "0 Ereignisse" segment. Only push a count segment when its
count is greater than zero; the grouping label and (when present) the
range are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The provenance token (abgeleitet/kuratiert) was nested inside the
{#if dateLabel} block, so an undated or UNKNOWN-precision event — e.g.
one in the undated bucket — rendered no provenance at all. Compose the
subtitle as an optional "{date} · " prefix in front of the always-present
provenance instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The absolutely-positioned spine ::before painted above the in-flow centered
content (density strips, event pills), drawing the line through them. Give
.timeline-axis a stacking context and the spine z-index:-1 so the line is
always background; cards, pills, strips, dots and badges ride on top.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The node marker carried a higher stacking order than the (non-positioned)
badge, so on the centered desktop axis the navy node painted over the white
year digits. Make the badge positioned with the higher z-index; the node now
sits behind the centered pill (which is itself the axis interruption) and
shows only to the badge's left on phone. Guarded by a z-index assertion.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All sixteen visual-fidelity requirements have a green test (142 timeline +
zeitstrahl + messages tests pass); record the implementation files and the
test that proves each.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pass the full PageData shape (layout auth fields + timeline) through a
pageData() helper so the route spec is svelte-check-clean; the component
still only reads data.timeline.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine now runs mint → navy → slate, matching the spec's life-thread,
using --palette-mint / --palette-navy / --c-tag-slate (no --palette-slate
token exists). Semantic tokens only — no raw hex.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The year badge now centers on the axis at ≥1024px and hugs the left spine
below that (sticky top:4rem preserved), with a navy node marker so it
visibly interrupts the spine. Each letter row gains a connector dot (white
fill, mint ring) on the spine: centered between card and axis on desktop,
on the left spine clear of the indented card on phone. Spine geometry is
commented to track TimelineView's spine so the markers can't silently desync.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline now sits inside a bordered, rounded bg-canvas sheet. Below the
heading a sub-line composes the year range, the letter and event counts
(from timelineMeta), and the static "Gruppierung: Datum" — joined by " · "
so the range drops out when there are no year bands and the whole line is
absent for an empty timeline. Semantic tokens only; AA-legible text-xs.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pure timelineMeta() returns the year range (first/last band, null when
there are no bands) and the letter/event totals across all year bands plus
the undated bucket — the single place these counts are computed.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Ohne Datum" section now renders inside a dashed-bordered surface box
whose heading reads "Ohne Datum · {count}", matching the spec's .undated
treatment. The kind/type dispatch (events as pills/bands, letters as cards)
is unchanged; the section stays absent when there are no undated entries.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dense-year strip count now carries the ✉ glyph (aria-hidden + sr-only
"Brief"), and beneath the sparkline a "Monats-Dichte" caption sits between
the two endpoint month labels (Jan/Dez {year}) at the ≥10px micro-axis
floor, localized via the shared month formatter. The ≥44px keyboard-
focusable "Briefe anzeigen" expand toggle is preserved unchanged.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A HISTORICAL band's subtitle now carries the visible "historisch" register
inline as plain text: "{date} · historisch", or — for a RANGE — after the
existing 1914–1918 span pill (whose Zeitraum aria-label is unchanged). The
descriptor is a text node, never a second pill. Every WorldBand is
historical, so the suffix also trails an undated band on its own.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A present LetterCard title now reads "✉ {title}" with an aria-hidden glyph
and an sr-only "Brief" label rendered as sibling nodes — never interpolated
into the escaped user title, which keeps its own pre-line span for
multi-line OCR text. No title → no glyph, no label (the row still shows
sender → receiver and the date). An XSS regression pins the no-{@html}
contract: an HTML-bearing title renders verbatim as text.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The derived/curated pill subtitle now reads "{date} · abgeleitet" or
"{date} · kuratiert", keyed off entry.derived so a reader sees both the
date and whether the event was derived from Person data or curated. Only
the single provenance token ships; the spec sheet's "· persönlich" /
"· SEASON" annotations stay out (already covered by the ★ glyph + mint
border and not production UI).
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven new timeline_* keys feeding the upcoming chrome: the header meta
line (grouping label + events count), the event-pill provenance token,
the ✉ sr-only label, the world-band "historisch" suffix, and the strip
density caption — present in de/en/es with matching key sets, pinned by a
new parity test.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trace the visual-fidelity requirements end to end before implementation,
matching the timeline-feature convention of landing RTM rows in the first
commit of the branch. Status: Planned; flipped to Done as each task lands.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The submitted type comes from EventTypeSelect's own hidden input; EventForm's
`type` $state was only read to seed `value=` and the onchange reassignment
had no downstream reader. Inline the seed expression and pass markDirty
directly, removing the second source of truth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A RANGE event with a blank end date passed validateEventForm and reached
the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end
must not be before start" — wrong for a missing end date, and shown only as
a top-of-form alert. Validate it before the API call and surface a dedicated
event_editor_end_date_required message on the end-date field via a new
DatePrecisionField endDateError prop (defaults '', so the document form is
unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
preservedFormFields echoed back only title/type/description/pickers, and
EventForm seeded dateIso/precision/endDateIso solely from `event` (undefined
on /new). So a no-JS validation-error reload silently dropped the entire
When-section the curator had entered, while every other field survived.
Echo eventDate/precision/eventDateEnd in the fail payload and seed the date
controls from `form` ahead of `event`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
titleError/dateError short-circuited on the server fail payload
(`form?.titleError ?? …`, `form?.dateError ?? ''`), so after a fail(400)
the red border and message stuck until the next submit even once the user
typed a valid value. Derive both from the current field value instead: the
server error still seeds the message, but a non-empty title/date clears it
immediately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The delete <form> combined onsubmit={confirmDelete} with use:enhance.
SvelteKit's enhance ignores event.defaultPrevented, so the DELETE fired on
the bare click — before the dialog was answered — and the post-DELETE
redirect ran regardless of the user's choice. Reading getConfirmService()
lazily inside the handler also threw (Svelte context is init-only), so the
dialog never appeared even with <ConfirmDialog> mounted.
Capture confirm at init and run the gate inside the enhance submit phase,
calling cancel() on "no". Clear dirty in the result callback so the
beforeNavigate guard no longer prompts "unsaved changes" on the post-delete
redirect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The EventForm onsubmit guard called e.preventDefault() on a blank title,
but use:enhance ignores defaultPrevented (forms.js only bails on cancel()),
so a blank-title Save still fired a network POST. In a component unit test
the resulting update() -> applyAction() dereferenced an undefined root
($set on undefined), surfacing as an unhandled rejection.
Move the guard into the enhance submit phase and call cancel() so the POST
is actually stopped; the server still owns the authoritative fail(400).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-2 dirty-guard used an $effect that both read and wrote its `dirtyArmed`
$state, so the self-write re-triggered the effect and `dirty` flipped true on
mount — the beforeNavigate confirm then fired on every navigation away from an
untouched form (caught by the round-3 clean-agent review + the Svelte autofixer,
which flags assigning state inside $effect).
Replace it with the component's existing idiomatic pattern: DatePrecisionField,
PersonMultiSelect, and DocumentMultiSelect each gain an optional `onchange`
callback fired on a real user edit, and EventForm passes `markDirty` to all
three. Now date/precision/end-date and picker add/remove mark the form dirty
exactly like title/type/description already did — no effect, no mount-timing
trap. The new props are optional, so the other consumers (WhoWhenSection, the
document forms) are unaffected. Svelte autofixer: clean.
Addresses PR #832 review (round-3 clean-agent concern).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The beforeNavigate unsaved-changes guard only fired for title/type/description
(their oninput/onchange hooks set `dirty`). Editing only the date, precision,
end-date, or the linked persons/documents left `dirty` false, so a curator could
navigate away and silently lose those edits — defeating the guard for the senior
author audience. Add an $effect that watches those values and marks dirty on any
change after the initial prop snapshot (first run only arms the watcher).
Addresses PR #832 review (round-2 clean-agent concern).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>