Replaces the in-bucket month-density sparkline with a first-5 preview + show-more
/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12
YearLetterStrip.
Refs #827
Records the visual-brainstorm outcome for #827's grouped view: a cluster becomes one
contained card (event/tag as header, first 5 letters + show-more), the leftover bin
collapses to a count-only drawer, derived/world fixtures stay plain, and REQ-001/003/014/020
are amended. Mockups under .superpowers/brainstorm/ (gitignored).
Refs #827#847
REQ-014 now nests event-clustered letters under their pill (no duplicate title);
REQ-015 keeps the bucket-header label in a fixed ink for AA contrast; new REQ-020
records the colour-rail containment + density-strip collapse that replaces the
flooding flat card list.
Refs #827#847
In Ereignis mode the curated event showed twice — once as its axis pill and again
as a repeated "✉ <event>" bucket header below. Letters that link to a curated event
whose pill is in the same year band now nest directly under that pill (headerless),
so the title reads once. A cluster whose pill lives in another band keeps its header,
and unlinked letters still fall to the single "Weitere Briefe" bucket. Thema mode is
unchanged (tags have no axis pill). REQ-001 holds — the pills render identically.
Refs #827
The grouped view flooded: buckets had no visual containment (a tiny floating pill
over cards identical to the ungrouped view) and the >12-letter density collapse was
gone, so "Weitere Briefe · 325" / "Sonstiges · 10" dumped every card.
LetterBucket now binds each cluster with a coloured left rail (tag colour in Thema,
mint for an Ereignis cluster, neutral for the fallback), renders compact cards, and
— above BUCKET_DENSE_THRESHOLD (6) — collapses to the existing month-density
YearLetterStrip instead of a flood. Adds a `nested` mode (no header) for letters that
sit under their event pill, and shares the tag-colour token allow-list via tagColorVar.
Refs #827
Grouped-mode buckets stack many letters; the full two-line card with its own date
chip floods the view. The compact variant tightens the padding and, when the
letter has a title, drops the redundant date chip (the per-year bucket already
frames the time and these archive titles embed the date). Datum mode is untouched
— compact defaults to false.
Refs #827
The tinted bucket-header chip painted the saturated --c-tag-* token AS its label
text over a 18% wash of the same token. For the light tokens that fails WCAG AA:
amber ≈3.0:1, sand ≈3.2:1, sage ≈3.4:1 (only sienna, the one the test used,
passed). Move the tint to the chip fill + dot and render the label in a fixed
dark ink so every token clears 4.5:1 while the chip still reads as tinted.
Refs #827
Record the three #827 forks (client-side regroup transport, computed letter→event
link reusing timeline_event_documents, filter-then-group composition with #780)
as ADR-045, trace REQ-001..019 (+005b) into the RTM as Done, and list the new
timeline components in the frontend domain inventory.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
REQ-012 coverage: the new grouping/segment/bucket keys are present in de/en/es
and the pre-existing timeline_grouping_date / timeline_tag_chip_label are reused,
not re-declared.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the #780 layer-filter e2e for #827: switching Datum/Ereignis/Thema
issues zero extra GET /api/timeline (REQ-002), the control stays overflow-free
and ≥44px with full-word aria-labels at 320px (REQ-011), and a 320px axe pass
holds in light and dark mode (REQ-010g). Local-only like the filter e2e (E2E is
not yet in CI).
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the REQ-001 structural-identity check (the event pills/world-bands render
identically across all three grouping modes — the no-VRT-harness equivalent of
the pixel-diff) and the REQ-009 grep gate (no lib/timeline component reaches for
the raw-HTML directive). Reword the BucketHeaderChip doc to describe the
directive by name so the gate stays literal.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the grouping $state beside the #780 layer-filter state, render the
GroupingControl stacked above the filter trigger (disabled, but kept in place,
when no loose letters remain), make the meta-line grouping label track the
active mode, and thread groupingMode into TimelineView — filter-then-group,
no refetch.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thread groupingMode through TimelineView → YearBand. TimelineView resolves the
event lookup once over the filtered view (so Ereignis clusters never reference a
filtered-out event). In non-Datum modes YearBand keeps its event pills/world-bands
identical (REQ-001) and replaces the loose letters with per-year LetterBuckets
(REQ-002/003/004); Datum keeps the original card/strip path. The undated bucket is
unchanged in every mode.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An ARIA radiogroup with roving tabindex (#827, REQ-010/011/018): three
arrow-key-navigable ≥44px segments with text labels, full-word aria-labels and
≤360px abbreviations, semantic-token colours that hold contrast in dark mode,
defaulting to Datum. When disabled it stays in place, retains its selection, and
announces a screen-reader reason — deliberately distinct from #780's
aria-pressed layer toggles.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renders one loose-letter cluster for Ereignis/Thema mode (#827): an
"✉ <event> · <n>" header over .lcard.ev cards in Ereignis, a tinted
BucketHeaderChip over chip-suppressed cards in Thema, and a localized
"Weitere Briefe"/"Ohne Thema" header with plain cards for the fallback bucket.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New de/en/es keys for #827: the Datum·Ereignis·Thema segment labels and their
≤320px abbreviations, the dynamic meta-line grouping labels
(timeline_grouping_event/_thema), the "Weitere Briefe"/"Ohne Thema" bucket
labels, the radiogroup aria-label, the letters-hidden disabled reason, and the
multi-tag hint. Reuses the existing timeline_grouping_date / timeline_tag_chip_label.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a `variant="event"` that marks the card `.lcard.ev` for Ereignis-mode event
clusters (#827, REQ-014) and a `suppressTagChip` that hides the per-letter
TagChip inside its own Thema bucket where the header already conveys the topic
(REQ-017). Datum/Ereignis keep the #838 per-letter chip behaviour.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fully-tinted root-tag chip for Thema-mode bucket headers (#827, REQ-015):
fill and label both derive from the tag's --c-tag-* token via a color-mix wash
so the label keeps ≥4.5:1 contrast in light and dark mode. A null or unknown
token falls back to a neutral chip with no broken colour. Curator text is
{...}-escaped (REQ-009). Distinct from the neutral per-letter TagChip.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure module powering the #827 Datum·Ereignis·Thema toggle: buildEventLookup
(curated events that survived the #780 layer filter), hasLooseLetters (the
control's enabled state), and bucketLetters (cluster loose letters by
linkedEventId or primary root tag, with a "Weitere Briefe"/"Ohne Thema"
fallback). Filter-then-group, no refetch.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerated frontend/src/lib/generated/api.ts from the live OpenAPI spec after
adding the nullable linkedEventId field — keeps the CI type-check green for the
#827 grouping UI that consumes it.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a nullable linkedEventId to TimelineEntryDTO — the curated event whose
documents set contains the letter — resolved in one batched membership pass
over the already-loaded events (no per-letter query, no new column). This is
the single backend field the #827 Ereignis grouping mode consumes.
Refs #827
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The person-add-event link wraps a single text label, so the flex gap
never applies — only the sibling edit link (icon + text) needs it.
Removing the dead utility per the UI/UX review nit.
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
REQ-011 covers direct nav to both /zeitstrahl/events/new and
/{id}/edit, but the row cited only the /new guard + test. The
[id]/edit route shares the same requireWriteAll helper and already
carries its own 403 gating test (shipped with #781); cite both so the
traceability matches the requirement. Closes the Tester/Security
review note (no new test needed — the guard test already exists).
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add REQ-001..011 rows for the timeline-curator-affordances feature (all
Done) and a messages.spec parity guard pinning timeline_add_event +
person_add_event across de/en/es. REQ-010/011 cite the existing #781
new-event route + its 403 guard test (no new production code).
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A curator on a person's page can now seed a timeline event from that
person: a gated link to #781's /zeitstrahl/events/new?personId={id},
which prefills the person and returns to /persons/{id} on save. Hidden
for a Reader. New i18n key person_add_event in de/en/es.
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap the heading in a flex-wrap header so the gated add-event link drops
below the title at narrow widths instead of overflowing; the CTA targets
#781's /zeitstrahl/events/new and only shows for a WRITE_ALL viewer. Also
thread canWrite into TimelineView so a curator sees the edit pencils on
the real page. New i18n key timeline_add_event in de/en/es.
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A curated HISTORICAL band had no edit affordance at all. Mirror the
EventPill pencil inline at the end of the band (data-testid="event-edit",
/edit href, aria-hidden glyph + sr-only Bearbeiten), gated on
canWrite && eventId != null. Thread canWrite to WorldBand through both
the year-band path and the undated bucket.
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thread a gate-closed canWrite prop through TimelineView -> YearBand ->
EventPill and the undated bucket so a Reader never sees a dead-end edit
link. canEdit now also requires canWrite; the default false keeps an
un-threaded caller closed. The real boundary stays the #781 route guard
plus the backend permission -- this only hides the link.
Refs #842
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /zeitstrahl header sub-line counted the unfiltered timeline, so a
hidden layer (e.g. Letters off) still showed its entries in the totals
("1 Brief" with no letters on screen) — the documented D1 limitation.
Derive the meta from filteredTimeline so the range and letter/event
counts always match what is actually rendered. hasContent stays on the
full timeline so the filter bar and meta line still appear whenever the
archive has content.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the ten REQ-001…REQ-010 rows for the /zeitstrahl layer filter, each linked
to its implementation file(s) and test(s), all Done.
Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>