feat(timeline): root-tag color chip on /zeitstrahl letter cards (Datum mode) #838

Merged
marcel merged 11 commits from feat/issue-835-zeitstrahl-tag-chip into main 2026-06-14 18:16:33 +02:00
Owner

Closes #835.

Adds the root-tag color chip to /zeitstrahl letter cards (Datum mode): a thin vertical slice that enriches TimelineEntryDTO with each letter's primary root tag (id + name + color token) and renders one chip on LetterCard. No new table, migration, or endpoint; GET /api/timeline stays READ_ALL.

What & how

  • Backend resolver — new TagService.resolveRootTags(tags) -> Map<id, RootTag> walks each tag to its root via the existing recursive-CTE findAncestorIds, memoized per distinct tag + one batched findAllById (no per-letter N+1). Cross-domain access via the service, not the repo (constitution §1.3).
  • DTOTimelineEntryDTO gains nullable rootTagId/rootTagName/rootTagColor (LETTER-only; deliberately not @Schema(requiredMode = REQUIRED)), assembled in-transaction (ADR-036) — id + name + token only, never a serialized Tag. The primary tag is the root ancestor of the letter's alphabetically-first assigned tag (#827 Resolved Decision 3, confirmed with the maintainer).
  • Frontend — new timeline-local TagChip.svelte (aria-hidden colored square via var(--c-tag-{token}), neutral when null; escaped name; sr-only timeline_tag_chip_label prefix; inline truncation so a long name never forces horizontal scroll at 320px). LetterCard renders it beneath the meta line, so it appears wherever a LetterCard does (global timeline + expanded YearLetterStrip).
  • Regenerated api.ts; timeline_tag_chip_label added in de/en/es.

Requirements → tests (REQ-001..014, all Done in .specify/rtm.md)

  • REQ-001/002/005/006TimelineServiceTest (DTO population, untagged → null, deterministic single primary)
  • REQ-003/004/007TagServiceTest + TagServiceIntegrationTest (real-Postgres CTE walk, memoized no-N+1, null color)
  • REQ-008/008a/009/010/011TagChip.svelte.spec.ts + LetterCard.svelte.spec.ts
  • REQ-012 — chip inside an expanded YearLetterStrip
  • REQ-013messages.spec.ts (key parity de/en/es)
  • REQ-014 — unchanged READ_ALL controller path; no new endpoint/ErrorCode; assembly logs UUIDs only

Verification

  • BackendTagServiceTest (52), TagServiceIntegrationTest (2), TimelineServiceTest (28), TimelineEventServiceTest (26), TimelineControllerTest (8) all green; mvnw clean package -DskipTests OK.
  • FrontendTagChip (6), LetterCard (14), messages (7), + TimelineView/YearBand/YearLetterStrip (32) green; svelte-check adds no errors in touched files; @html/hex grep gates clean.
  • TDD throughout — every behavior driven by a failing test first.

Coordination

  • Builds on #833's LetterCard chrome (already on main).
  • Delivers the shared root-tag DTO fields + TagService resolver that #827 (Ereignis/Thema regroup) will consume#827's REQ-005 should descope to add only linkedEventId.

🤖 Generated with Claude Code

Closes #835. Adds the root-tag color chip to `/zeitstrahl` letter cards (Datum mode): a thin vertical slice that enriches `TimelineEntryDTO` with each letter's primary root tag (id + name + color token) and renders one chip on `LetterCard`. No new table, migration, or endpoint; `GET /api/timeline` stays `READ_ALL`. ## What & how - **Backend resolver** — new `TagService.resolveRootTags(tags) -> Map<id, RootTag>` walks each tag to its root via the existing recursive-CTE `findAncestorIds`, memoized per distinct tag + one batched `findAllById` (no per-letter N+1). Cross-domain access via the service, not the repo (constitution §1.3). - **DTO** — `TimelineEntryDTO` gains nullable `rootTagId`/`rootTagName`/`rootTagColor` (LETTER-only; deliberately not `@Schema(requiredMode = REQUIRED)`), assembled in-transaction (ADR-036) — id + name + token only, never a serialized `Tag`. The primary tag is the root ancestor of the letter's **alphabetically-first assigned tag** (#827 Resolved Decision 3, confirmed with the maintainer). - **Frontend** — new timeline-local `TagChip.svelte` (aria-hidden colored square via `var(--c-tag-{token})`, neutral when null; escaped name; sr-only `timeline_tag_chip_label` prefix; inline truncation so a long name never forces horizontal scroll at 320px). `LetterCard` renders it beneath the meta line, so it appears wherever a `LetterCard` does (global timeline + expanded `YearLetterStrip`). - Regenerated `api.ts`; `timeline_tag_chip_label` added in de/en/es. ## Requirements → tests (REQ-001..014, all `Done` in `.specify/rtm.md`) - **REQ-001/002/005/006** — `TimelineServiceTest` (DTO population, untagged → null, deterministic single primary) - **REQ-003/004/007** — `TagServiceTest` + `TagServiceIntegrationTest` (real-Postgres CTE walk, memoized no-N+1, null color) - **REQ-008/008a/009/010/011** — `TagChip.svelte.spec.ts` + `LetterCard.svelte.spec.ts` - **REQ-012** — chip inside an expanded `YearLetterStrip` - **REQ-013** — `messages.spec.ts` (key parity de/en/es) - **REQ-014** — unchanged `READ_ALL` controller path; no new endpoint/ErrorCode; assembly logs UUIDs only ## Verification - **Backend** — `TagServiceTest` (52), `TagServiceIntegrationTest` (2), `TimelineServiceTest` (28), `TimelineEventServiceTest` (26), `TimelineControllerTest` (8) all green; `mvnw clean package -DskipTests` OK. - **Frontend** — `TagChip` (6), `LetterCard` (14), `messages` (7), + TimelineView/YearBand/YearLetterStrip (32) green; `svelte-check` adds no errors in touched files; `@html`/hex grep gates clean. - TDD throughout — every behavior driven by a failing test first. ## Coordination - Builds on #833's `LetterCard` chrome (already on `main`). - Delivers the shared root-tag DTO fields + `TagService` resolver that **#827** (Ereignis/Thema regroup) will *consume* — #827's REQ-005 should descope to add only `linkedEventId`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 8 commits 2026-06-14 15:19:03 +02:00
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>
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>
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>
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>
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>
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>
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>
docs(rtm): trace #835 REQ-001..014 to their tests
All checks were successful
SDD Gate / Constitution Impact (pull_request) Successful in 16s
CI / Unit & Component Tests (pull_request) Successful in 4m21s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 5m0s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / RTM Check (pull_request) Successful in 16s
4859c77964
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>
marcel added 3 commits 2026-06-14 17:59:37 +02:00
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>
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>
refactor(timeline): de-duplicate the TagChip markup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m4s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m26s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 17s
SDD Gate / Contract Validate (pull_request) Successful in 28s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
CI / Unit & Component Tests (push) Successful in 4m21s
CI / OCR Service Tests (push) Successful in 25s
CI / Backend Unit Tests (push) Successful in 4m57s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 30s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
1cd6ffd5ca
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>
marcel merged commit 1cd6ffd5ca into main 2026-06-14 18:16:33 +02:00
marcel deleted branch feat/issue-835-zeitstrahl-tag-chip 2026-06-14 18:16:35 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#838