feat(dates): honest precision-aware date rendering (Phase 4, #666) #677

Merged
marcel merged 16 commits from feature/666-date-rendering into docs/import-migration 2026-05-27 13:19:01 +02:00
Owner

Summary

Phase 4 of the "Handling the Unknowns" milestone — render imprecise/unknown dates honestly everywhere, never fabricating a precision the data doesn't have. Presentation only; builds on the merged #671 (DatePrecision + columns) and #674 (importer). No migration, no enum changes.

  • Formatter frontend/src/lib/shared/utils/documentDate.ts covering all 7 precisions: DAY→full date, MONTH→"Juni 1916", SEASON→"Sommer 1916" (localized + raw verbatim), YEAR→"1916", APPROX→"ca. 1916", RANGE→"10.–11. Jan. 1917" (with cross-month/cross-year collapse, end==start→single day), open-ended RANGE (null end)→"ab 10. Jan. 1917", UNKNOWN→"Datum unbekannt".
  • Honest titles: the #674 importer had reduced the document title to just the index. Restored a precision-aware title via a new pure DocumentTitleFormatter wired into DocumentImporter (a MONTH letter now titles "Juni 1916", not a fabricated day).
  • Drift guard: docs/date-label-fixtures.json asserted by BOTH the TS spec (21 cases) and a Java @TestFactory (12 cases) — Java title + TS UI labels can't drift.
  • Accessible, safe raw display: <DocumentDate> shows meta_date_raw as a visible "Originaltext:" secondary line (WCAG 1.4.13), via Svelte default-escaping (never {@html} — added a CI grep guard); precision cues are text, not color-only (WCAG 1.4.1).
  • Wired into: document detail (top-bar title + metadata drawer), list/search rows (mobile + desktop), multi-select label, and the edit form (WhoWhenSection: precision select + RANGE-conditional end-date + read-only raw cell).
  • Edit persistence: updateDocument now applies the three precision DTO fields (present since #671 but unwired) so the new edit controls aren't decorative.

Test plan

  • npm cinpm run lint PASS; server vitest 619 pass (incl. the formatter). Backend DocumentTitleFormatterTest + DocumentImporterTest + DocumentServiceTest = 203 pass. (Full backend suite not run locally — CI owns the sweep.)
  • CI green. Browser/component tests (DocumentDate, WhoWhenSection) are CI-only.

Notes / deviations

  • metaDateRaw is not on DocumentListItem (#671 didn't add it), so list rows show the honest label without a raw line — handled defensively. Adding raw to the list payload would be a small follow-up if wanted.
  • Server-side enum/range validation of the precision fields stays #671's scope.
  • No generate:api needed (no new API fields). Pre-existing unrelated svelte-check errors left untouched (svelte-check is not a CI gate).
  • i18n keys added for all precision/season/edit labels in de/en/es.

Closes #666

## Summary Phase 4 of the "Handling the Unknowns" milestone — render imprecise/unknown dates honestly everywhere, never fabricating a precision the data doesn't have. Presentation only; builds on the merged #671 (DatePrecision + columns) and #674 (importer). No migration, no enum changes. - **Formatter** `frontend/src/lib/shared/utils/documentDate.ts` covering all 7 precisions: DAY→full date, MONTH→"Juni 1916", SEASON→"Sommer 1916" (localized + raw verbatim), YEAR→"1916", APPROX→"ca. 1916", RANGE→"10.–11. Jan. 1917" (with cross-month/cross-year collapse, `end==start`→single day), **open-ended RANGE (null end)→"ab 10. Jan. 1917"**, UNKNOWN→"Datum unbekannt". - **Honest titles:** the #674 importer had reduced the document title to just the index. Restored a precision-aware title via a new **pure `DocumentTitleFormatter`** wired into `DocumentImporter` (a MONTH letter now titles "Juni 1916", not a fabricated day). - **Drift guard:** `docs/date-label-fixtures.json` asserted by BOTH the TS spec (21 cases) and a Java `@TestFactory` (12 cases) — Java title + TS UI labels can't drift. - **Accessible, safe raw display:** `<DocumentDate>` shows `meta_date_raw` as a visible "Originaltext:" secondary line (WCAG 1.4.13), via Svelte default-escaping (never `{@html}` — added a CI grep guard); precision cues are text, not color-only (WCAG 1.4.1). - **Wired into:** document detail (top-bar title + metadata drawer), list/search rows (mobile + desktop), multi-select label, and the edit form (`WhoWhenSection`: precision select + RANGE-conditional end-date + read-only raw cell). - **Edit persistence:** `updateDocument` now applies the three precision DTO fields (present since #671 but unwired) so the new edit controls aren't decorative. ## Test plan - [x] `npm ci` → `npm run lint` PASS; server vitest **619 pass** (incl. the formatter). Backend `DocumentTitleFormatterTest` + `DocumentImporterTest` + `DocumentServiceTest` = **203 pass**. (Full backend suite not run locally — CI owns the sweep.) - [ ] CI green. Browser/component tests (`DocumentDate`, `WhoWhenSection`) are CI-only. ## Notes / deviations - `metaDateRaw` is **not** on `DocumentListItem` (#671 didn't add it), so list rows show the honest label without a raw line — handled defensively. Adding raw to the list payload would be a small follow-up if wanted. - Server-side enum/range validation of the precision fields stays #671's scope. - No `generate:api` needed (no new API fields). Pre-existing unrelated svelte-check errors left untouched (svelte-check is not a CI gate). - i18n keys added for all precision/season/edit labels in de/en/es. Closes #666
marcel added 8 commits 2026-05-27 12:09:46 +02:00
Adds formatDocumentDate — a pure, branch-per-precision label function that
renders a document date at exactly the precision the data claims (DAY → full
date, MONTH → "Juni 1916", SEASON → localized season word, YEAR → "1916",
APPROX → "ca. 1916", RANGE with collapse/expand/open-ended, UNKNOWN → "Datum
unbekannt"). Delegates to the existing date.ts helpers (shared T12:00:00
convention) and routes every localized word through Paraglide.

A shared docs/date-label-fixtures.json table is asserted by this spec and will
be asserted by the Java title formatter, as the drift guard requested in
review (Markus/Sara). Adds de/en/es precision/season/edit-form i18n keys.

Assumption: SEASON structured label is localized per locale (Decision 4),
with the verbatim raw cell preserved as a separate secondary line by callers.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the Java half of the honest date label — formatTitleDate(date,
precision, end, raw) — mirroring the frontend formatDocumentDate rules so an
import title never shows a precision the data lacks (MONTH → "Juni 1916", not
a fabricated day). Both implementations are pinned to the shared
docs/date-label-fixtures.json table, which this test asserts case-by-case, so
they cannot drift. Java's de CLDR renders the same "Jan."/"Dez." abbreviations
and en-dash the TS side produces.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires DocumentTitleFormatter into DocumentImporter.buildDocument: the title
now reads "{index} – {honest date label} – {location}", so a MONTH-precision
letter's title says "Juni 1916" instead of a fabricated "1. Juni 1916", and an
UNKNOWN-date row keeps a bare index title. buildTitle stays under 20 lines by
delegating to the shared formatter (single source of truth with the UI label).

Restores the date+location title behavior that the old MassImportService had
(it appended a full GERMAN_DATE day) but now at the honest precision.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps formatDocumentDate with the accessible presentation layer: a non-color
UNKNOWN cue (decorative calendar-with-question icon, aria-hidden, since the
visible "Datum unbekannt" text is the textual cue — WCAG 1.4.1), and the
verbatim meta_date_raw shown as a VISIBLE secondary "Originaltext: …" line for
UNKNOWN/SEASON/APPROX (WCAG 1.4.13, not tooltip-only). raw is rendered via
Svelte default escaping, never {@html} (CWE-79); a component test asserts an
angle-bracket raw value stays inert. Browser test is CI-only.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires formatDocumentDate/DocumentDate into the read sites: the document
detail top bar + metadata drawer (the drawer shows the visible "Originaltext:"
raw line for UNKNOWN/SEASON/APPROX), the search/list rows (DocumentRow,
mobile + desktop), and the document multi-select dropdown label. A MONTH or
SEASON document now reads "Juni 1916"/"Sommer 1916" everywhere instead of a
fabricated day.

Adds metaDatePrecision to the DocumentRow/DocumentMultiSelect test fixtures
(required on DocumentListItem since #671) and updates the multi-select label
assertion to the honest long date.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the edit-form date-precision controls to WhoWhenSection: a labelled
precision <select> (min 48px touch target for senior authors), a conditionally
revealed end-date field (only for RANGE, announced via aria-live=polite), and
the verbatim raw cell as labelled read-only static text (not a disabled input).
Fields submit as metaDatePrecision/metaDateEnd/metaDateRaw and flow through the
existing PUT form action.

Backend: DocumentService.updateDocument now persists the three DTO fields (they
existed since #671 but were never applied), so the new controls are real, not
decorative — addresses Nora's "a client <select> constrains nothing" note for
the persistence half. Server-side enum/end>=start validation remains #671's
scope.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a grep guard (with self-test) that fails the build if any {@html ...}
expression references metaDateRaw/documentDateRaw/rawDate. meta_date_raw is
untrusted verbatim spreadsheet text and must render via Svelte default
escaping (CWE-79). Addresses Nora's regression-guard request from #666 — a
single component test cannot catch a future {@html} introduced elsewhere.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs: note honest date formatter, title formatter and drift fixture
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m17s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
b1b8fa4bed
Documents DocumentTitleFormatter in the document-management C4 diagram and adds
an "honest precision display" row to the CONTRIBUTING date-handling table,
pointing at formatDocumentDate / <DocumentDate>, the shared
docs/date-label-fixtures.json drift guard, and the {@html} escaping rule.

Closes #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Felix Brandt (@felixbrandt) — Senior Fullstack Developer

Verdict: warning Approved with concerns

Clean, well-factored work. The formatter is decomposed into small intent-revealing functions (monthYear, seasonLabel, rangeLabel, sameYearRange), guard clauses lead each branch, and the $derived usage in DocumentDate.svelte / DocumentTopBarTitle.svelte is correct (no $state+$effect derivation). The shared-fixture drift guard is a genuinely nice piece of engineering. TDD evidence is present on both stacks (TS spec, Java @TestFactory, DocumentServiceTest.updateDocument_persistsDatePrecisionEndAndRaw, importer title tests).

Concerns

  • Locale leak in DAY precision (documentDate.ts:48). case 'DAY': return formatDate(iso, 'long') — but formatDate() in date.ts is hard-wired to 'de-DE' and ignores any locale. Every other branch (MONTH, RANGE, APPROX, SEASON) honors the locale arg. So formatDocumentDate('1943-12-24','DAY',null,null,'en') returns the German "24. Dezember 1943". The fixture suite only runs de, so it never catches this. Either route DAY through new Intl.DateTimeFormat(locale, {day:'numeric',month:'long',year:'numeric'}) or document that DAY is intentionally de-only. Right now it's an inconsistency, not a documented decision.
  • DocumentMultiSelect map cast (DocumentMultiSelect.svelte:50-55). body.items.map(...) as unknown as Document[] constructs a partial object then double-casts. The ?? 'DAY' fallback on metaDatePrecision in formatDocLabel is sensible defense, but the as unknown as is the kind of cast Nora and I both dislike — DocumentListItem already carries metaDatePrecision as REQUIRED, so the partial could be typed honestly.

Suggestions

  • effectivePrecision/precision fallback ?? (iso ? 'DAY' : 'UNKNOWN') is duplicated in DocumentDate.svelte, DocumentTopBarTitle.svelte, and DocumentEditLayout.svelte. Consider a tiny inferPrecision(iso, precision) helper next to the formatter to keep the rule in one place.

No blockers. The DAY-locale gap is the one I'd want addressed or explicitly waived before merge.

## Felix Brandt (@felixbrandt) — Senior Fullstack Developer **Verdict: warning Approved with concerns** Clean, well-factored work. The formatter is decomposed into small intent-revealing functions (`monthYear`, `seasonLabel`, `rangeLabel`, `sameYearRange`), guard clauses lead each branch, and the `$derived` usage in `DocumentDate.svelte` / `DocumentTopBarTitle.svelte` is correct (no `$state`+`$effect` derivation). The shared-fixture drift guard is a genuinely nice piece of engineering. TDD evidence is present on both stacks (TS spec, Java `@TestFactory`, `DocumentServiceTest.updateDocument_persistsDatePrecisionEndAndRaw`, importer title tests). ### Concerns - **Locale leak in DAY precision (`documentDate.ts:48`).** `case 'DAY': return formatDate(iso, 'long')` — but `formatDate()` in `date.ts` is hard-wired to `'de-DE'` and ignores any locale. Every other branch (MONTH, RANGE, APPROX, SEASON) honors the `locale` arg. So `formatDocumentDate('1943-12-24','DAY',null,null,'en')` returns the German "24. Dezember 1943". The fixture suite only runs `de`, so it never catches this. Either route DAY through `new Intl.DateTimeFormat(locale, {day:'numeric',month:'long',year:'numeric'})` or document that DAY is intentionally de-only. Right now it's an inconsistency, not a documented decision. - **`DocumentMultiSelect` map cast (`DocumentMultiSelect.svelte:50-55`).** `body.items.map(...) as unknown as Document[]` constructs a partial object then double-casts. The `?? 'DAY'` fallback on `metaDatePrecision` in `formatDocLabel` is sensible defense, but the `as unknown as` is the kind of cast Nora and I both dislike — `DocumentListItem` already carries `metaDatePrecision` as REQUIRED, so the partial could be typed honestly. ### Suggestions - `effectivePrecision`/`precision` fallback `?? (iso ? 'DAY' : 'UNKNOWN')` is duplicated in `DocumentDate.svelte`, `DocumentTopBarTitle.svelte`, and `DocumentEditLayout.svelte`. Consider a tiny `inferPrecision(iso, precision)` helper next to the formatter to keep the rule in one place. No blockers. The DAY-locale gap is the one I'd want addressed or explicitly waived before merge.
Author
Owner

Nora Steiner (@nullx) — Application Security Engineer

Verdict: white_check_mark Approved

This PR introduces one new untrusted-data sink — meta_date_raw, verbatim spreadsheet text — and handles it correctly end to end. I checked it for CWE-79 (XSS) specifically.

What I verified

  • Raw cell renders via Svelte default escaping, never {@html}. DocumentDate.svelte ({m.date_original_label()} {raw}) and WhoWhenSection.svelte (<p>{rawDate}</p> + <input type="hidden" value={rawDate}>) both use {…} interpolation, which HTML-escapes. No {@html} anywhere in the diff.
  • The CI grep guard is real defense-in-depth and correctly written. The regex \{@html[^}]*(metaDateRaw|documentDateRaw|rawDate) plus the two self-tests (one asserting it catches the unsafe form, one asserting it ignores a prose comment) means the guard fails loudly if it ever stops matching. This is exactly the "codify the finding as a permanent check" pattern I want. Note its scope: it only catches the three named tokens — a future dev rendering raw under a differently-named variable (origText, cell) via {@html} would slip past. Acceptable for now; worth a comment that the token list must grow with new raw-bearing variables.
  • Regression tests exist for the XSS path. documentDate.spec.ts ("ignores a malicious raw value… raw is rendered separately, escaped"), DocumentDate.svelte.test.ts ("renders a malicious raw value as inert escaped text" — asserts document.querySelector('img') is null), and WhoWhenSection.svelte.test.ts ("escapes it" — asserts no injected <b>). The structured label provably never incorporates raw except to pick a known German season token, so raw cannot influence the formatted string. Good separation.
  • updateDocument persisting precision/end/raw introduces no new auth surface. The endpoint is already @RequirePermission(WRITE_ALL); these are three more setters on an already-writable entity. Enum/range validation is correctly deferred to #671's scope — no injection risk here since metaDateRaw is stored as plain text and metaDatePrecision is a typed enum.

No security blockers. Nicely done — the threat model is even spelled out in the component comments.

## Nora Steiner (@nullx) — Application Security Engineer **Verdict: white_check_mark Approved** This PR introduces one new untrusted-data sink — `meta_date_raw`, verbatim spreadsheet text — and handles it correctly end to end. I checked it for CWE-79 (XSS) specifically. ### What I verified - **Raw cell renders via Svelte default escaping, never `{@html}`.** `DocumentDate.svelte` (`{m.date_original_label()} {raw}`) and `WhoWhenSection.svelte` (`<p>{rawDate}</p>` + `<input type="hidden" value={rawDate}>`) both use `{…}` interpolation, which HTML-escapes. No `{@html}` anywhere in the diff. - **The CI grep guard is real defense-in-depth and correctly written.** The regex `\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate)` plus the two self-tests (one asserting it *catches* the unsafe form, one asserting it *ignores* a prose comment) means the guard fails loudly if it ever stops matching. This is exactly the "codify the finding as a permanent check" pattern I want. Note its scope: it only catches the three named tokens — a future dev rendering raw under a differently-named variable (`origText`, `cell`) via `{@html}` would slip past. Acceptable for now; worth a comment that the token list must grow with new raw-bearing variables. - **Regression tests exist for the XSS path.** `documentDate.spec.ts` ("ignores a malicious raw value… raw is rendered separately, escaped"), `DocumentDate.svelte.test.ts` ("renders a malicious raw value as inert escaped text" — asserts `document.querySelector('img')` is null), and `WhoWhenSection.svelte.test.ts` ("escapes it" — asserts no injected `<b>`). The structured label provably never incorporates `raw` except to pick a known German season token, so raw cannot influence the formatted string. Good separation. - **`updateDocument` persisting precision/end/raw introduces no new auth surface.** The endpoint is already `@RequirePermission(WRITE_ALL)`; these are three more setters on an already-writable entity. Enum/range validation is correctly deferred to #671's scope — no injection risk here since `metaDateRaw` is stored as plain text and `metaDatePrecision` is a typed enum. No security blockers. Nicely done — the threat model is even spelled out in the component comments.
Author
Owner

Leonie Voss (@leonievoss) — UX & Accessibility Lead

Verdict: warning Approved with concerns

The honest-date direction is exactly right for our dual audience — a 67-year-old researcher should never be misled into thinking we know the exact day when we don't. The accessibility instincts here are strong.

What works well

  • Non-color cue for UNKNOWN (WCAG 1.4.1). The calendar-with-question glyph is aria-hidden="true" and the visible "Datum unbekannt" text is the real textual cue — correct redundancy, icon is decorative. Good.
  • Visible raw line, not tooltip-only (WCAG 1.4.13). "Originaltext: …" renders as actual visible text in DocumentDate.svelte and as labelled static text in the edit form — not a title= attribute. This is the right call.
  • Touch targets in the edit form. The precision <select> has min-h-[48px] (exceeds the 44px WCAG 2.2 floor, hits my preferred 48px). Focus rings use focus-visible:ring-2. Read-only raw is static text, not a disabled input — correct, disabled inputs are unreadable to many AT users.
  • Progressive disclosure of the end-date wrapped in aria-live="polite" so the reveal is announced. Thoughtful.

Concerns

  • End-date input is below the 44px touch target floor. WhoWhenSection.svelte end-date <input> uses px-2 py-3 with no min-h, while the sibling precision <select> explicitly sets min-h-[48px]. py-3 (~24px content + 24px padding) is borderline and inconsistent with the select. Add min-h-[48px] to the end-date input so the RANGE form is uniformly senior-friendly.
  • Raw secondary line color contrast — verify text-ink-2. In DocumentDate.svelte the "Originaltext:" line is text-ink-2 at text-xs (12px). 12px is my absolute floor, and text-ink-2 on bg-surface must clear 4.5:1 at that size. Please confirm the exact ratio from layout.css; if ink-2 is a muted gray it may only pass for larger text. The desktop list row uses text-ink-2 too.
  • text-xs (12px) for the raw line on mobile. Acceptable as secondary metadata, but for the 60+ audience I'd nudge to text-sm where the layout allows, especially the detail-page drawer where space isn't tight.

No blockers — the core a11y model is sound. The end-date min-h is the concrete fix I'd want.

## Leonie Voss (@leonievoss) — UX & Accessibility Lead **Verdict: warning Approved with concerns** The honest-date direction is exactly right for our dual audience — a 67-year-old researcher should never be misled into thinking we know the exact day when we don't. The accessibility instincts here are strong. ### What works well - **Non-color cue for UNKNOWN (WCAG 1.4.1).** The calendar-with-question glyph is `aria-hidden="true"` and the visible "Datum unbekannt" text is the real textual cue — correct redundancy, icon is decorative. Good. - **Visible raw line, not tooltip-only (WCAG 1.4.13).** "Originaltext: …" renders as actual visible text in `DocumentDate.svelte` and as labelled static text in the edit form — not a `title=` attribute. This is the right call. - **Touch targets in the edit form.** The precision `<select>` has `min-h-[48px]` (exceeds the 44px WCAG 2.2 floor, hits my preferred 48px). Focus rings use `focus-visible:ring-2`. Read-only raw is static text, not a disabled input — correct, disabled inputs are unreadable to many AT users. - **Progressive disclosure of the end-date** wrapped in `aria-live="polite"` so the reveal is announced. Thoughtful. ### Concerns - **End-date input is below the 44px touch target floor.** `WhoWhenSection.svelte` end-date `<input>` uses `px-2 py-3` with no `min-h`, while the sibling precision `<select>` explicitly sets `min-h-[48px]`. `py-3` (~24px content + 24px padding) is borderline and inconsistent with the select. Add `min-h-[48px]` to the end-date input so the RANGE form is uniformly senior-friendly. - **Raw secondary line color contrast — verify `text-ink-2`.** In `DocumentDate.svelte` the "Originaltext:" line is `text-ink-2` at `text-xs` (12px). 12px is my absolute floor, and `text-ink-2` on `bg-surface` must clear 4.5:1 at that size. Please confirm the exact ratio from `layout.css`; if `ink-2` is a muted gray it may only pass for larger text. The desktop list row uses `text-ink-2` too. - **`text-xs` (12px) for the raw line on mobile.** Acceptable as secondary metadata, but for the 60+ audience I'd nudge to `text-sm` where the layout allows, especially the detail-page drawer where space isn't tight. No blockers — the core a11y model is sound. The end-date `min-h` is the concrete fix I'd want.
Author
Owner

Markus Keller (@mkeller) — Application Architect

Verdict: white_check_mark Approved

This is presentation logic done with the right boundaries. No schema change, no migration, no new enum — Phase 4 stays inside its lane.

What I checked

  • Documentation currency (my blocker category). A new backend helper class (DocumentTitleFormatter) was added to the importing domain. Per my doc-update table, a new component in an existing domain requires the matching docs/architecture/c4/l3-backend-*.puml — and it's there: l3-backend-3b-document-management.puml adds the titleFmt component plus a Rel(docLoader, titleFmt, …) edge, and updates the docLoader description to mention the honest title. CONTRIBUTING.md documents the formatter + fixture-pinning convention. No package added (it's package-private in importing), so no CLAUDE.md package-table change needed. No new route, ErrorCode, Permission, or glossary term. Docs match code — no blocker.
  • Layering. DocumentTitleFormatter is a pure, package-private (final class, private ctor) helper with no injected dependencies — it does not reach into another domain's repository or service. It imports DatePrecision from the document domain, which is the published enum, not internal state. DocumentService.updateDocument sets fields on its own Document aggregate. Boundaries respected.
  • Single source of truth vs. duplication. Two implementations of the same label rule (Java + TS) is normally a smell, but it's unavoidable here (import runs in Java, UI renders in TS) and the team chose the correct mitigation: a committed docs/date-label-fixtures.json that BOTH suites assert against. This converts "duplication you hope stays in sync" into "duplication a CI gate proves stays in sync." I'd have asked for exactly this. The fixture's _comment even records the decision and points at #666 — that's the ADR-lite I want for a cross-stack invariant.

Minor note (not blocking)

  • The German season-bucket logic (months 3-5/6-8/9-11/winter) now exists in three places: this Java formatter, the TS formatter, and the import normalizer (referenced in comments). The fixtures pin Java↔TS, but the normalizer is a separate Python tool outside that guard. If the normalizer's representative-month convention ever shifts, nothing fails. Worth a one-line note in the fixture comment that the normalizer is the upstream authority for which month a season maps to.

No accidental complexity, no boundary leak, no missing doc. Approved.

## Markus Keller (@mkeller) — Application Architect **Verdict: white_check_mark Approved** This is presentation logic done with the right boundaries. No schema change, no migration, no new enum — Phase 4 stays inside its lane. ### What I checked - **Documentation currency (my blocker category).** A new backend helper class (`DocumentTitleFormatter`) was added to the `importing` domain. Per my doc-update table, a new component in an existing domain requires the matching `docs/architecture/c4/l3-backend-*.puml` — and it's there: `l3-backend-3b-document-management.puml` adds the `titleFmt` component plus a `Rel(docLoader, titleFmt, …)` edge, and updates the `docLoader` description to mention the honest title. `CONTRIBUTING.md` documents the formatter + fixture-pinning convention. No package added (it's package-private in `importing`), so no `CLAUDE.md` package-table change needed. No new route, ErrorCode, Permission, or glossary term. Docs match code — no blocker. - **Layering.** `DocumentTitleFormatter` is a pure, package-private (`final class`, private ctor) helper with no injected dependencies — it does not reach into another domain's repository or service. It imports `DatePrecision` from the `document` domain, which is the published enum, not internal state. `DocumentService.updateDocument` sets fields on its own `Document` aggregate. Boundaries respected. - **Single source of truth vs. duplication.** Two implementations of the same label rule (Java + TS) is normally a smell, but it's unavoidable here (import runs in Java, UI renders in TS) and the team chose the correct mitigation: a committed `docs/date-label-fixtures.json` that BOTH suites assert against. This converts "duplication you hope stays in sync" into "duplication a CI gate proves stays in sync." I'd have asked for exactly this. The fixture's `_comment` even records the decision and points at #666 — that's the ADR-lite I want for a cross-stack invariant. ### Minor note (not blocking) - The German season-bucket logic (months 3-5/6-8/9-11/winter) now exists in three places: this Java formatter, the TS formatter, and the import normalizer (referenced in comments). The fixtures pin Java↔TS, but the normalizer is a separate Python tool outside that guard. If the normalizer's representative-month convention ever shifts, nothing fails. Worth a one-line note in the fixture comment that the normalizer is the upstream authority for which month a season maps to. No accidental complexity, no boundary leak, no missing doc. Approved.
Author
Owner

Sara Holt (@saraholt) — QA Engineer & Test Strategist

Verdict: warning Approved with concerns

Test strategy here is genuinely good: the drift-guard fixture is the kind of cross-stack invariant test I rarely see done right, and coverage spans the pyramid (unit TS spec, Java @TestFactory, browser component tests, edit-persistence service test). Names read as sentences. Edge cases (open range, end==start, cross-month, cross-year, UNKNOWN, suppressed components) are all enumerated.

Strengths

  • The fixture is the single source of truth and both sides iterate the same array (docs/date-label-fixtures.json → TS for (const c of fixtures.cases) and Java for (JsonNode c : root.get("cases"))). This is real parity, not two hand-copied tables. If anyone edits one expectation without the fixture, both suites go red.
  • Anti-fabrication assertions ("YEAR never contains Juni/15", "MONTH never matches \b1.\s") test the negative — the whole point of the feature — not just the happy label.
  • XSS regression tests at both unit (label.not.toContain('<img')) and DOM (querySelector('img') is null) layers.

Concerns

  • The fixture suite only exercises the de locale — the one locale-sensitive branch (DAY) is untested cross-locale, and it happens to be buggy. formatDate(iso,'long') ignores locale and always returns German (see Felix's finding). Because every fixture case asserts 'de', the table can't catch it. I want a formatDocumentDate('1943-12-24','DAY',null,null,'en') assertion added; today it would pass returning German, which documents the gap and forces a decision. Right now there is zero en/es coverage for DAY or MONTH.
  • No test pins the cross-stack abbreviation/punctuation identity directly. The drift guard works only because CI runs both suites against the file. That's fine, but note: Intl.DateTimeFormat('de-DE',{month:'short'}) yields "Jan." (with dot) while Java MMM yields "Jan" — they agree for the fixture inputs that were committed, but a new range case spanning a month whose German short form differs in punctuation between ICU and the JDK could silently require a fixture edit. Low risk; just be aware the guard validates equality-to-fixture, not equality-of-the-two-engines in the abstract.
  • Browser tests are CI-only (correctly noted). I trust the author's "619 server vitest pass / 203 backend pass" but the DocumentDate/WhoWhenSection component tests and any axe checks only run in CI. That's the right place for them — just gating merge on CI green is mandatory here, not optional.

No blocker on test correctness; the missing non-de DAY coverage is the gap I'd close in this PR since it hides a real bug.

## Sara Holt (@saraholt) — QA Engineer & Test Strategist **Verdict: warning Approved with concerns** Test strategy here is genuinely good: the drift-guard fixture is the kind of cross-stack invariant test I rarely see done right, and coverage spans the pyramid (unit TS spec, Java `@TestFactory`, browser component tests, edit-persistence service test). Names read as sentences. Edge cases (open range, `end==start`, cross-month, cross-year, UNKNOWN, suppressed components) are all enumerated. ### Strengths - **The fixture is the single source of truth and both sides iterate the *same* array** (`docs/date-label-fixtures.json` → TS `for (const c of fixtures.cases)` and Java `for (JsonNode c : root.get("cases"))`). This is real parity, not two hand-copied tables. If anyone edits one expectation without the fixture, both suites go red. - **Anti-fabrication assertions** ("YEAR never contains Juni/15", "MONTH never matches `\b1.\s`") test the *negative* — the whole point of the feature — not just the happy label. - **XSS regression tests** at both unit (`label.not.toContain('<img')`) and DOM (`querySelector('img')` is null) layers. ### Concerns - **The fixture suite only exercises the `de` locale — the one locale-sensitive branch (DAY) is untested cross-locale, and it happens to be buggy.** `formatDate(iso,'long')` ignores locale and always returns German (see Felix's finding). Because every fixture case asserts `'de'`, the table can't catch it. I want a `formatDocumentDate('1943-12-24','DAY',null,null,'en')` assertion added; today it would pass returning German, which documents the gap and forces a decision. Right now there is zero `en`/`es` coverage for DAY or MONTH. - **No test pins the cross-stack abbreviation/punctuation identity directly.** The drift guard works only because CI runs both suites against the file. That's fine, but note: `Intl.DateTimeFormat('de-DE',{month:'short'})` yields "Jan." (with dot) while Java `MMM` yields "Jan" — they agree *for the fixture inputs that were committed*, but a new range case spanning a month whose German short form differs in punctuation between ICU and the JDK could silently require a fixture edit. Low risk; just be aware the guard validates equality-to-fixture, not equality-of-the-two-engines in the abstract. - **Browser tests are CI-only (correctly noted).** I trust the author's "619 server vitest pass / 203 backend pass" but the `DocumentDate`/`WhoWhenSection` component tests and any axe checks only run in CI. That's the right place for them — just gating merge on CI green is mandatory here, not optional. No blocker on test *correctness*; the missing non-`de` DAY coverage is the gap I'd close in this PR since it hides a real bug.
Author
Owner

Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Verdict: white_check_mark Approved

Only one infra-facing change in this PR: a new CI step in .gitea/workflows/ci.yml. I reviewed it as a pipeline change.

What I checked

  • The new {@html} guard step is well-built. Pure bash + grep -rPln, no new runner dependency, no Docker, runs in milliseconds. It's placed alongside the existing artifact-version guard — consistent with how this repo does lightweight content gates. Good.
  • It self-tests before it runs. The two printf | grep -q[v]P self-checks mean the step fails loudly if the regex itself rots (catches the unsafe form, ignores the comment form). A guard that can silently stop guarding is worse than no guard; this one can't. I wish more of our grep gates did this.
  • shell: bash is set explicitly so grep -P (PCRE) behaves consistently on the runner — correct, since -P is a GNU extension and a default sh could differ.
  • Scope is correct: --include='*.svelte' frontend/src/ keeps it from scanning the whole tree or node_modules. Fast and targeted.

Minor notes (not blocking)

  • The step has no actions/* version concerns (it's inline run), no secrets, no caching impact, no image tags. Nothing for Renovate.
  • One observation for the future: this is now a second bespoke grep-guard in the workflow. If a third appears, I'd extract them into a small scripts/ci/guards.sh or a composite action so the self-test pattern is reused rather than copy-pasted. Not needed today.

CI change is reproducible, dependency-free, and self-validating. No infra blockers. The hard gate remains: this PR must show CI green before merge, since the component/browser tests and this guard only run there.

## Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **Verdict: white_check_mark Approved** Only one infra-facing change in this PR: a new CI step in `.gitea/workflows/ci.yml`. I reviewed it as a pipeline change. ### What I checked - **The new `{@html}` guard step is well-built.** Pure `bash` + `grep -rPln`, no new runner dependency, no Docker, runs in milliseconds. It's placed alongside the existing artifact-version guard — consistent with how this repo does lightweight content gates. Good. - **It self-tests before it runs.** The two `printf | grep -q[v]P` self-checks mean the step fails loudly if the regex itself rots (catches the unsafe form, ignores the comment form). A guard that can silently stop guarding is worse than no guard; this one can't. I wish more of our grep gates did this. - **`shell: bash` is set explicitly** so `grep -P` (PCRE) behaves consistently on the runner — correct, since `-P` is a GNU extension and a default `sh` could differ. - **Scope is correct:** `--include='*.svelte' frontend/src/` keeps it from scanning the whole tree or node_modules. Fast and targeted. ### Minor notes (not blocking) - The step has no `actions/*` version concerns (it's inline run), no secrets, no caching impact, no image tags. Nothing for Renovate. - One observation for the future: this is now a second bespoke grep-guard in the workflow. If a third appears, I'd extract them into a small `scripts/ci/guards.sh` or a composite action so the self-test pattern is reused rather than copy-pasted. Not needed today. CI change is reproducible, dependency-free, and self-validating. No infra blockers. The hard gate remains: this PR must show CI green before merge, since the component/browser tests and this guard only run there.
Author
Owner

Elicit — Requirements Engineer & Business Analyst

Verdict: warning Approved with concerns

Reviewing this as the requirements/acceptance-criteria lens for #666 ("render imprecise/unknown dates honestly, never fabricating a precision the data doesn't have"). The PR delivers the stated behavior cleanly and the goal is genuinely testable — every precision maps to an observable label, and the fixture table doubles as a de-facto acceptance-criteria matrix. That's exactly the artifact I'd have asked for.

Coverage against the requirement

All seven DatePrecision values produce an honest label (DAY→full, MONTH→month+year, SEASON→localized season, YEAR→year, APPROX→"ca."/"c."/"ca.", RANGE→collapsed/open/single, UNKNOWN→"Datum unbekannt"). RANGE edge cases (open-ended, end==start, cross-month, cross-year) are enumerated and pinned. The verbatim raw cell is preserved and surfaced — that satisfies the "show the source, don't lose provenance" intent. Good.

Ambiguities / gaps to log as open questions (not blockers)

  • OQ — multi-locale acceptance is under-specified. The requirement says "honest dates everywhere"; the read path serves the younger phone audience who may use en/es. But DAY precision always renders German regardless of locale (Felix/Sara found the formatDate locale leak). Is German-everywhere-for-exact-days an accepted product decision (import titles are German by design — reasonable) or an oversight on the read path? This needs an explicit yes/no from you, because "honest" and "localized" are being conflated. If de-only is intended, say so in the fixture comment; if not, it's a defect.
  • OQ — list rows silently drop the raw line. The PR notes metaDateRaw isn't on DocumentListItem, so list/search rows show the honest label but never the "Originaltext:" provenance. That's a deliberate, documented deviation — fine for this slice — but it's a requirement decision (provenance visible on detail, not in lists), so it should be recorded as such rather than as a payload limitation. Confirm that's the intended UX, not a follow-up debt item you'll forget.
  • NFR check — i18n completeness. de/en/es keys are all present for every new label including season words and precision options. Good, no gap there.
  • Scope discipline. The PR correctly refuses to absorb #671's server-side enum/range validation. That's the right MoSCoW call — flag it stays a Must in #671, not silently dropped.

The feature meets the #666 intent. My two open questions are about documenting product decisions (de-only exact dates; lists-have-no-raw), not missing function. Resolve those in the thread and this is clean.

## Elicit — Requirements Engineer & Business Analyst **Verdict: warning Approved with concerns** Reviewing this as the requirements/acceptance-criteria lens for #666 ("render imprecise/unknown dates honestly, never fabricating a precision the data doesn't have"). The PR delivers the stated behavior cleanly and the goal is genuinely testable — every precision maps to an observable label, and the fixture table doubles as a de-facto acceptance-criteria matrix. That's exactly the artifact I'd have asked for. ### Coverage against the requirement All seven `DatePrecision` values produce an honest label (DAY→full, MONTH→month+year, SEASON→localized season, YEAR→year, APPROX→"ca."/"c."/"ca.", RANGE→collapsed/open/single, UNKNOWN→"Datum unbekannt"). RANGE edge cases (open-ended, `end==start`, cross-month, cross-year) are enumerated and pinned. The verbatim raw cell is preserved and surfaced — that satisfies the "show the source, don't lose provenance" intent. Good. ### Ambiguities / gaps to log as open questions (not blockers) - **OQ — multi-locale acceptance is under-specified.** The requirement says "honest dates everywhere"; the read path serves the younger phone audience who may use `en`/`es`. But DAY precision always renders German regardless of locale (Felix/Sara found the `formatDate` locale leak). Is German-everywhere-for-exact-days an *accepted* product decision (import titles are German by design — reasonable) or an oversight on the read path? This needs an explicit yes/no from you, because "honest" and "localized" are being conflated. If de-only is intended, say so in the fixture comment; if not, it's a defect. - **OQ — list rows silently drop the raw line.** The PR notes `metaDateRaw` isn't on `DocumentListItem`, so list/search rows show the honest label but never the "Originaltext:" provenance. That's a deliberate, documented deviation — fine for this slice — but it's a *requirement decision* (provenance visible on detail, not in lists), so it should be recorded as such rather than as a payload limitation. Confirm that's the intended UX, not a follow-up debt item you'll forget. - **NFR check — i18n completeness.** de/en/es keys are all present for every new label including season words and precision options. Good, no gap there. - **Scope discipline.** The PR correctly refuses to absorb #671's server-side enum/range validation. That's the right MoSCoW call — flag it stays a Must in #671, not silently dropped. The feature meets the #666 intent. My two open questions are about *documenting product decisions* (de-only exact dates; lists-have-no-raw), not missing function. Resolve those in the thread and this is clean.
marcel added 4 commits 2026-05-27 12:24:39 +02:00
DAY precision routed through formatDate() which hard-coded de-DE, so an
en/es reader saw the German month name ("24. Dezember 1943"). Route DAY
through Intl.DateTimeFormat(locale, …) like the other branches, keeping
the T12:00:00 UTC-safety convention. Add en/es DAY+MONTH parity cases to
docs/date-label-fixtures.json (TS-only; the Java title formatter stays
German by design) and assert them in the spec.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The end-date input used px-2 py-3 with no min-h while the sibling
precision select sets min-h-[48px]. Add min-h-[48px] so the RANGE form
is uniformly senior-friendly (WCAG 2.2 2.5.8, matches the select).

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The search results were mapped to a partial object then forced with
`as unknown as Document[]`. DocumentListItem already carries every field
the picker reads (id, title, documentDate, metaDatePrecision REQUIRED,
metaDateEnd), so introduce a DocumentOption Pick type and drop the
double-cast — the mapped objects are now honestly typed.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs(dates): record list-rows-omit-raw-provenance decision near render
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m14s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
38f065bc60
Elicit asked that the "raw provenance shown on detail, not in list rows"
choice be captured as a product decision rather than a payload accident.
Add a code comment at the list-row DocumentDate render explaining
showRaw={false} and the intentional metaDateRaw omission from
DocumentListItem.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Review fixes applied (Felix Brandt)

Pushed 4 atomic commits (b1b8fa4b..38f065bc) addressing every concern. Lint clean, formatter spec 29 pass (node), Java DocumentTitleFormatterTest 12 pass + DocumentImporterTest 33 pass.

1. Locale leak in DAY precision (Felix / Sara / Elicit) — 8ed5b1e9

formatDocumentDate DAY no longer delegates to formatDate() (which hard-coded de-DE). It now renders via Intl.DateTimeFormat(locale, {day:'numeric',month:'long',year:'numeric'}) on the noon(iso) value, keeping the T12:00:00 UTC-safety convention. MONTH/RANGE were already locale-aware; verified. The active Paraglide locale is read at the call sites via getLocale() (DocumentDate.svelte, DocumentTopBarTitle.svelte, DocumentMultiSelect.svelte) and threaded through as the locale arg — no change needed there, the bug was purely the DAY branch ignoring it.

Tests: added en+es DAY and MONTH cases. They live in docs/date-label-fixtures.json under a new TS-only localeCases array and are asserted by documentDate.spec.ts (drift guard now covers locale). The Java @TestFactory still iterates only cases (German canonical), so server-side parity is intact — confirmed by re-running both Java classes.

2. End-date touch target (Leonie) — 41693736

Added min-h-[48px] to the RANGE end-date input in WhoWhenSection.svelte, matching the sibling precision <select>.

3. Raw line contrast (Leonie) — verified, no change

text-ink-2 = --c-ink-2 = #4b5563 (gray-600), documented in layout.css as 7.6:1 on white surface / 6.6:1 on canvas — clears the 4.5:1 floor at 12px. The "Originaltext:" line renders on bg-surface, so it passes WCAG AA. No bump needed.

4. Double-cast cleanup (Felix) — 6cc622b4

Dropped as unknown as Document[] in DocumentMultiSelect.svelte. Introduced a DocumentOption = Pick<DocumentListItem, 'id'|'title'|'documentDate'|'metaDatePrecision'|'metaDateEnd'> type; the search results map honestly onto it (no cast). metaDatePrecision is REQUIRED on DocumentListItem, so the ?? 'DAY' fallback was removed too. No new svelte-check errors (the pre-existing onclickoutside error is unrelated).

5. Fixture comment — normalizer authority (Markus) — 8ed5b1e9

The fixture _comment now records that the Python import normalizer is the upstream authority for season→representative-month mapping, sitting outside the Java↔TS guard.

6. Documented product decisions (Elicit)

  • De-only is moot: exact dates are now locale-aware (#1), so "German-everywhere for exact days" is no longer a decision to make — the read path localizes. The Java title formatter stays German by design (import titles are German); the fixture comment now states this split explicitly.
  • List rows omit raw provenance: recorded as a product decision in a code comment at the list-row DocumentDate render (DocumentRow.svelte, 38f065bc) — showRaw={false} and the intentional metaDateRaw omission from DocumentListItem are deliberate (provenance lives on the detail page).

Notes

  • Browser/component tests (DocumentDate, WhoWhenSection, GeschichteEditor.svelte.spec) are CI-only; not run locally per project policy. CI green remains the merge gate.
  • husky pre-commit ran on every commit (lint passed) — no --no-verify used.
## Review fixes applied (Felix Brandt) Pushed 4 atomic commits (`b1b8fa4b..38f065bc`) addressing every concern. Lint clean, formatter spec **29 pass** (node), Java `DocumentTitleFormatterTest` **12 pass** + `DocumentImporterTest` **33 pass**. ### 1. Locale leak in DAY precision (Felix / Sara / Elicit) — `8ed5b1e9` `formatDocumentDate` DAY no longer delegates to `formatDate()` (which hard-coded `de-DE`). It now renders via `Intl.DateTimeFormat(locale, {day:'numeric',month:'long',year:'numeric'})` on the `noon(iso)` value, keeping the `T12:00:00` UTC-safety convention. MONTH/RANGE were already locale-aware; verified. The active Paraglide locale is read at the call sites via `getLocale()` (`DocumentDate.svelte`, `DocumentTopBarTitle.svelte`, `DocumentMultiSelect.svelte`) and threaded through as the `locale` arg — no change needed there, the bug was purely the DAY branch ignoring it. **Tests:** added en+es DAY and MONTH cases. They live in `docs/date-label-fixtures.json` under a new TS-only `localeCases` array and are asserted by `documentDate.spec.ts` (drift guard now covers locale). The Java `@TestFactory` still iterates only `cases` (German canonical), so server-side parity is intact — confirmed by re-running both Java classes. ### 2. End-date touch target (Leonie) — `41693736` Added `min-h-[48px]` to the RANGE end-date input in `WhoWhenSection.svelte`, matching the sibling precision `<select>`. ### 3. Raw line contrast (Leonie) — verified, no change `text-ink-2` = `--c-ink-2` = `#4b5563` (gray-600), documented in `layout.css` as **7.6:1 on white surface / 6.6:1 on canvas** — clears the 4.5:1 floor at 12px. The "Originaltext:" line renders on `bg-surface`, so it passes WCAG AA. No bump needed. ### 4. Double-cast cleanup (Felix) — `6cc622b4` Dropped `as unknown as Document[]` in `DocumentMultiSelect.svelte`. Introduced a `DocumentOption` = `Pick<DocumentListItem, 'id'|'title'|'documentDate'|'metaDatePrecision'|'metaDateEnd'>` type; the search results map honestly onto it (no cast). `metaDatePrecision` is REQUIRED on `DocumentListItem`, so the `?? 'DAY'` fallback was removed too. No new svelte-check errors (the pre-existing `onclickoutside` error is unrelated). ### 5. Fixture comment — normalizer authority (Markus) — `8ed5b1e9` The fixture `_comment` now records that the Python import normalizer is the **upstream authority** for season→representative-month mapping, sitting outside the Java↔TS guard. ### 6. Documented product decisions (Elicit) - **De-only is moot:** exact dates are now locale-aware (#1), so "German-everywhere for exact days" is no longer a decision to make — the read path localizes. The Java title formatter stays German *by design* (import titles are German); the fixture comment now states this split explicitly. - **List rows omit raw provenance:** recorded as a product decision in a code comment at the list-row `DocumentDate` render (`DocumentRow.svelte`, `38f065bc`) — `showRaw={false}` and the intentional `metaDateRaw` omission from `DocumentListItem` are deliberate (provenance lives on the detail page). ### Notes - Browser/component tests (`DocumentDate`, `WhoWhenSection`, `GeschichteEditor.svelte.spec`) are CI-only; not run locally per project policy. CI green remains the merge gate. - husky pre-commit ran on every commit (lint passed) — no `--no-verify` used.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved with concerns

I re-read the current diff end-to-end (not the old comments). The prior-round fixes are genuine and clean. Specifics I verified:

Verified fixes (now correct)

  • documentDate.ts DAY branch no longer hard-codes de-DE: longDate(iso, locale) and monthYear(iso, locale) both go through Intl.DateTimeFormat(locale, …). The RANGE branch delegates to formatMCDate(iso, locale) and to Intl.DateTimeFormat(locale, {day,month:'short'}) in sameYearRange — so every locale-dependent branch (DAY/MONTH/RANGE) is now locale-aware, and the prefixes/season words go through Paraglide m.*(undefined, { locale }). The regression the last round flagged is closed, and there are explicit en/es DAY+MONTH spec cases proving it.
  • DocumentMultiSelect.svelte: the as unknown as Document[] double-cast is gone, replaced by an honest DocumentOption = Pick<DocumentListItem, …>. The body.items.map(...) now returns a typed DocumentOption[] with no cast. Good — this is exactly the SRP-shaped fix.
  • RANGE end-date input has min-h-[48px] matching the precision select.

Clean-code notes (suggestions, non-blocking)

  • documentDate.ts is well-factored — small named helpers (longDate, monthYear, rangeLabel, sameYearRange, seasonOfMonth, seasonWord), guard clause first. formatDocumentDate reads top-to-bottom. No notes.
  • DocumentImporter.buildTitle(...) takes 6 positional args including three nullable values of similar types (LocalDate date, LocalDate end). It's a private static helper and under 20 lines, so acceptable, but a small record TitleDate(...) would read better at the call site. Suggestion only.

TDD evidence

Red/green is present: documentDate.spec.ts (21 fixture cases + locale + anti-fabrication + security + null-handling), DocumentTitleFormatterTest (@TestFactory over the shared fixture), DocumentImporterTest (3 new title cases), DocumentServiceTest.updateDocument_persistsDatePrecisionEndAndRaw. The behaviors are covered.

One concern worth a follow-up

DocumentEditLayout seeds datePrecision = doc.metaDatePrecision ?? (doc.documentDate ? 'DAY' : 'UNKNOWN'). For a legacy row whose metaDatePrecision is null in the DB, opening the edit form and saving now persists DAY/UNKNOWN even if the user never touched the field (DocumentService.updateDocument unconditionally sets it). That's a silent data mutation on save. Not a blocker for presentation-phase scope, but flag it: a row with a genuinely-unknown precision becomes DAY the moment someone edits the location. See my note shared with Sara/Elicit.

## Felix Brandt — Senior Fullstack Developer **Verdict: Approved with concerns** I re-read the current diff end-to-end (not the old comments). The prior-round fixes are genuine and clean. Specifics I verified: ### Verified fixes (now correct) - `documentDate.ts` DAY branch no longer hard-codes `de-DE`: `longDate(iso, locale)` and `monthYear(iso, locale)` both go through `Intl.DateTimeFormat(locale, …)`. The RANGE branch delegates to `formatMCDate(iso, locale)` and to `Intl.DateTimeFormat(locale, {day,month:'short'})` in `sameYearRange` — so **every** locale-dependent branch (DAY/MONTH/RANGE) is now locale-aware, and the prefixes/season words go through Paraglide `m.*(undefined, { locale })`. The regression the last round flagged is closed, and there are explicit en/es DAY+MONTH spec cases proving it. - `DocumentMultiSelect.svelte`: the `as unknown as Document[]` double-cast is gone, replaced by an honest `DocumentOption = Pick<DocumentListItem, …>`. The `body.items.map(...)` now returns a typed `DocumentOption[]` with no cast. Good — this is exactly the SRP-shaped fix. - RANGE end-date input has `min-h-[48px]` matching the precision select. ### Clean-code notes (suggestions, non-blocking) - `documentDate.ts` is well-factored — small named helpers (`longDate`, `monthYear`, `rangeLabel`, `sameYearRange`, `seasonOfMonth`, `seasonWord`), guard clause first. `formatDocumentDate` reads top-to-bottom. No notes. - `DocumentImporter.buildTitle(...)` takes 6 positional args including three nullable values of similar types (`LocalDate date`, `LocalDate end`). It's a private static helper and under 20 lines, so acceptable, but a small `record TitleDate(...)` would read better at the call site. Suggestion only. ### TDD evidence Red/green is present: `documentDate.spec.ts` (21 fixture cases + locale + anti-fabrication + security + null-handling), `DocumentTitleFormatterTest` (`@TestFactory` over the shared fixture), `DocumentImporterTest` (3 new title cases), `DocumentServiceTest.updateDocument_persistsDatePrecisionEndAndRaw`. The behaviors are covered. ### One concern worth a follow-up `DocumentEditLayout` seeds `datePrecision = doc.metaDatePrecision ?? (doc.documentDate ? 'DAY' : 'UNKNOWN')`. For a legacy row whose `metaDatePrecision` is null in the DB, opening the edit form and saving now persists `DAY`/`UNKNOWN` even if the user never touched the field (`DocumentService.updateDocument` unconditionally sets it). That's a silent data mutation on save. Not a blocker for presentation-phase scope, but flag it: a row with a genuinely-unknown precision becomes `DAY` the moment someone edits the location. See my note shared with Sara/Elicit.
Author
Owner

Markus Keller — Application Architect

Verdict: Approved

Reviewed against module boundaries, the Java↔TS parity contract, and the doc-currency table.

Drift guard — does the TS/Java split still guarantee parity?

Yes, with a correctly-scoped caveat the PR documents. The shared docs/date-label-fixtures.json has two arrays:

  • cases (German canonical) — asserted by both documentDate.spec.ts (locale 'de') and the Java @TestFactory. This is the real drift guard: en-dash vs hyphen, ca. vs circa, season words, range-collapse rules are pinned on both sides.
  • localeCases (en/es) — asserted by the TS spec only, never fed to Java. This is correct: DocumentTitleFormatter is German-only by design (import titles are always German). The _comment/localeComment fields make the boundary explicit so a future maintainer can't accidentally feed locale cases to the Java test.

The one parity gap that is outside this guard is honestly disclosed in the fixture comment: the season→representative-month mapping (4/7/10/1) is owned upstream by the Python normalizer (tools/import-normalizer), and both formatters merely mirror it. A normalizer change would not be caught by this Java↔TS table. That's an accepted, documented limitation, not a defect — flagging it is the right call.

Module boundaries

  • DocumentTitleFormatter is package-private in importing, pure, stateless — correct placement (the title is an import concern). DocumentImporterDocumentTitleFormatter is an intra-package call, no boundary crossed.
  • DocumentService.updateDocument sets the three precision fields on its own Document aggregate via the DTO — no cross-domain reach. Fine.
  • Frontend formatDocumentDate lives in shared/utils, consumed by the document lib — appropriate shared-utility placement.

Documentation currency

docs/architecture/c4/l3-backend-3b-document-management.puml adds the DocumentTitleFormatter component + the docLoader → titleFmt relation, and updates the DocumentImporter description. CONTRIBUTING.md gets the honest-precision row. No new migration, no enum value, no new route/permission/ErrorCode, so no DB diagram or CLAUDE.md/ARCHITECTURE.md update is required. The doc table is satisfied — no blocker.

Boring, well-bounded, presentation-only. Approved.

## Markus Keller — Application Architect **Verdict: Approved** Reviewed against module boundaries, the Java↔TS parity contract, and the doc-currency table. ### Drift guard — does the TS/Java split still guarantee parity? Yes, with a correctly-scoped caveat the PR documents. The shared `docs/date-label-fixtures.json` has two arrays: - `cases` (German canonical) — asserted by **both** `documentDate.spec.ts` (locale `'de'`) and the Java `@TestFactory`. This is the real drift guard: en-dash vs hyphen, `ca.` vs `circa`, season words, range-collapse rules are pinned on both sides. - `localeCases` (en/es) — asserted by the **TS spec only**, never fed to Java. This is correct: `DocumentTitleFormatter` is German-only by design (import titles are always German). The `_comment`/`localeComment` fields make the boundary explicit so a future maintainer can't accidentally feed locale cases to the Java test. The one parity gap that is *outside* this guard is honestly disclosed in the fixture comment: the season→representative-month mapping (4/7/10/1) is owned upstream by the Python normalizer (`tools/import-normalizer`), and both formatters merely mirror it. A normalizer change would not be caught by this Java↔TS table. That's an accepted, documented limitation, not a defect — flagging it is the right call. ### Module boundaries - `DocumentTitleFormatter` is package-private in `importing`, pure, stateless — correct placement (the title is an import concern). `DocumentImporter` → `DocumentTitleFormatter` is an intra-package call, no boundary crossed. - `DocumentService.updateDocument` sets the three precision fields on its own `Document` aggregate via the DTO — no cross-domain reach. Fine. - Frontend `formatDocumentDate` lives in `shared/utils`, consumed by the `document` lib — appropriate shared-utility placement. ### Documentation currency `docs/architecture/c4/l3-backend-3b-document-management.puml` adds the `DocumentTitleFormatter` component + the `docLoader → titleFmt` relation, and updates the `DocumentImporter` description. `CONTRIBUTING.md` gets the honest-precision row. No new migration, no enum value, no new route/permission/ErrorCode, so no DB diagram or `CLAUDE.md`/`ARCHITECTURE.md` update is required. The doc table is satisfied — no blocker. Boring, well-bounded, presentation-only. Approved.
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved with concerns

The threat model here is XSS via meta_date_raw — untrusted verbatim spreadsheet text rendered to users. I traced every path that touches raw.

What's correct (verified)

  • DocumentDate.svelte renders the raw line as {m.date_original_label()} {raw} — plain Svelte interpolation, which HTML-escapes. No {@html}. WhoWhenSection.svelte renders {rawDate} as static <p> text, also escaped. There is a permanent regression test for each (DocumentDate.svelte.test.ts asserts <img src=x onerror=...> appears as literal text and document.querySelector('img') is null; WhoWhenSection.svelte.test.ts asserts <b> is not injected).
  • formatDocumentDate never interpolates raw into the structured label — raw is only matched against a fixed German season-token allowlist (frühling|frühjahr|sommer|herbst|winter); any other value falls through to seasonOfMonth(month). The unit test formatDocumentDate – security proves a malicious raw yields Datum unbekannt, not the payload. This is the right design: untrusted input cannot influence the structured output, only select from a closed set.
  • CWE-79 is the correct classification and the fix is defense-by-construction (escaping is the default), not sanitization-by-blocklist. Good.

Concern — the CI grep guard does NOT cover the actual variable name

The new CI step greps \{@html[^}]*(metaDateRaw|documentDateRaw|rawDate). But the component that actually renders the untrusted cell, DocumentDate.svelte, names the prop raw — not rawDate/metaDateRaw. So a future regression writing {@html raw} in DocumentDate.svelte would not be caught by this guard. The guard self-tests its own regex against metaDateRaw, which passes, giving false confidence that the component is covered.

Current code is safe (it uses {raw}, escaped). This is a defense-in-depth gap in the guard, not a live vuln. Fix is one token:

pattern='\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b)'

(or, more robust: grep for any {@html in the document lib and allowlist exceptions). I'd take this as a concern, not a blocker — but please close it, since the whole point of the guard is to survive the next refactor.

Note

No SQLi/authz surface changed; the PUT endpoint already carries @RequirePermission(WRITE_ALL) and binds the three new DTO fields via @ModelAttribute — no mass-assignment concern since the DTO is the explicit allowlist. Approved once the grep token is widened.

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved with concerns** The threat model here is XSS via `meta_date_raw` — untrusted verbatim spreadsheet text rendered to users. I traced every path that touches `raw`. ### What's correct (verified) - `DocumentDate.svelte` renders the raw line as `{m.date_original_label()} {raw}` — plain Svelte interpolation, which HTML-escapes. No `{@html}`. `WhoWhenSection.svelte` renders `{rawDate}` as static `<p>` text, also escaped. There is a permanent regression test for each (`DocumentDate.svelte.test.ts` asserts `<img src=x onerror=...>` appears as literal text and `document.querySelector('img')` is null; `WhoWhenSection.svelte.test.ts` asserts `<b>` is not injected). - `formatDocumentDate` never interpolates `raw` into the structured label — `raw` is only matched against a fixed German season-token allowlist (`frühling|frühjahr|sommer|herbst|winter`); any other value falls through to `seasonOfMonth(month)`. The unit test `formatDocumentDate – security` proves a malicious `raw` yields `Datum unbekannt`, not the payload. This is the right design: untrusted input cannot influence the structured output, only select from a closed set. - CWE-79 is the correct classification and the fix is defense-by-construction (escaping is the default), not sanitization-by-blocklist. Good. ### Concern — the CI grep guard does NOT cover the actual variable name The new CI step greps `\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate)`. But the component that actually renders the untrusted cell, `DocumentDate.svelte`, names the prop **`raw`** — not `rawDate`/`metaDateRaw`. So a future regression writing `{@html raw}` in `DocumentDate.svelte` would **not** be caught by this guard. The guard self-tests its own regex against `metaDateRaw`, which passes, giving false confidence that the component is covered. Current code is safe (it uses `{raw}`, escaped). This is a defense-in-depth gap in the *guard*, not a live vuln. Fix is one token: ``` pattern='\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b)' ``` (or, more robust: grep for any `{@html` in the document lib and allowlist exceptions). I'd take this as a concern, not a blocker — but please close it, since the whole point of the guard is to survive the next refactor. ### Note No SQLi/authz surface changed; the PUT endpoint already carries `@RequirePermission(WRITE_ALL)` and binds the three new DTO fields via `@ModelAttribute` — no mass-assignment concern since the DTO is the explicit allowlist. Approved once the grep token is widened.
Author
Owner

Sara Holt — Senior QA Engineer

Verdict: Approved with concerns

I assessed the test pyramid by reading (full backend suite + browser-project tests are CI-only here, per the project rule). Coverage is strong; two gaps worth naming.

Strong coverage (verified)

  • Drift table asserted on both sides — the single best test in this PR. documentDate.spec.ts runs the German cases at locale 'de' AND the localeCases at en/es; DocumentTitleFormatterTest runs the German cases via @TestFactory. One committed rule set, two implementations, can't diverge.
  • Anti-fabrication assertions are behavioral, not snapshots: YEAR of a June date asserts the label does NOT contain Juni/15; MONTH asserts no day-of-month. These verify the honesty property the feature exists for.
  • Locale regression is explicitly pinned: localizes the DAY month name in English/Spanish compares against a freshly-constructed Intl.DateTimeFormat, so a future de-DE hard-coding fails fast.
  • Component tests (DocumentDate, WhoWhenSection) test user-visible behavior via getByText/DOM queries, including the XSS-escaping path — correct layer, real DOM.
  • Names read as sentences; factory overrides used (docFactory, baseItem, makeItem) with metaDatePrecision added cleanly.

Concern 1 — no test for the form-field → DTO round-trip

DocumentServiceTest.updateDocument_persistsDatePrecisionEndAndRaw proves the service layer persists the three fields. But nothing tests that the edit form's field names (metaDatePrecision, metaDateEnd, metaDateRaw hidden inputs) actually bind to DocumentUpdateDTO through the multipart @ModelAttribute PUT. The wire contract (form name == DTO property) is exactly the kind of thing that silently breaks. A @WebMvcTest multipart(...).param("metaDatePrecision","RANGE")... asserting the captured DTO would close it. Concern, not blocker.

Concern 2 — RANGE end-date validation gap

WhoWhenSection lets a user pick RANGE and submit with no/invalid end date (the hidden metaDateEnd goes empty → null). The formatter handles null end gracefully (ab 10. Jan. 1917), and there's a unit test for that, so it's not a crash — but there's no test or guard for "end before start", and no server-side range validation (the PR explicitly scopes that to #671). Acceptable for this phase; log it so it isn't forgotten.

No flaky patterns, no Thread.sleep, no H2, no mocked-what-should-be-real. Approved with the two follow-ups tracked.

## Sara Holt — Senior QA Engineer **Verdict: Approved with concerns** I assessed the test pyramid by reading (full backend suite + browser-project tests are CI-only here, per the project rule). Coverage is strong; two gaps worth naming. ### Strong coverage (verified) - **Drift table** asserted on both sides — the single best test in this PR. `documentDate.spec.ts` runs the German `cases` at locale `'de'` AND the `localeCases` at en/es; `DocumentTitleFormatterTest` runs the German `cases` via `@TestFactory`. One committed rule set, two implementations, can't diverge. - **Anti-fabrication** assertions are behavioral, not snapshots: YEAR of a June date asserts the label does NOT contain `Juni`/`15`; MONTH asserts no day-of-month. These verify the *honesty* property the feature exists for. - **Locale regression** is explicitly pinned: `localizes the DAY month name in English/Spanish` compares against a freshly-constructed `Intl.DateTimeFormat`, so a future de-DE hard-coding fails fast. - Component tests (`DocumentDate`, `WhoWhenSection`) test user-visible behavior via `getByText`/DOM queries, including the XSS-escaping path — correct layer, real DOM. - Names read as sentences; factory overrides used (`docFactory`, `baseItem`, `makeItem`) with `metaDatePrecision` added cleanly. ### Concern 1 — no test for the form-field → DTO round-trip `DocumentServiceTest.updateDocument_persistsDatePrecisionEndAndRaw` proves the service layer persists the three fields. But nothing tests that the **edit form's** field names (`metaDatePrecision`, `metaDateEnd`, `metaDateRaw` hidden inputs) actually bind to `DocumentUpdateDTO` through the multipart `@ModelAttribute` PUT. The wire contract (form name == DTO property) is exactly the kind of thing that silently breaks. A `@WebMvcTest` `multipart(...).param("metaDatePrecision","RANGE")...` asserting the captured DTO would close it. Concern, not blocker. ### Concern 2 — RANGE end-date validation gap `WhoWhenSection` lets a user pick RANGE and submit with no/invalid end date (the hidden `metaDateEnd` goes empty → null). The formatter handles null end gracefully (`ab 10. Jan. 1917`), and there's a unit test for that, so it's not a crash — but there's no test or guard for "end before start", and no server-side range validation (the PR explicitly scopes that to #671). Acceptable for this phase; log it so it isn't forgotten. No flaky patterns, no `Thread.sleep`, no H2, no mocked-what-should-be-real. Approved with the two follow-ups tracked.
Author
Owner

Leonie Voss — UX / Accessibility Lead

Verdict: Approved

This is exactly the honest-data presentation I'd ask for, and the a11y craft is solid. Reviewed against WCAG AA, the senior-on-a-small-phone constraint, and the prior round's fixes.

Verified

  • Non-color cue (WCAG 1.4.1): the UNKNOWN state pairs a calendar-question glyph with the visible text "Datum unbekannt". Icon is aria-hidden="true" because the text is the redundant cue — correct, no double announcement.
  • Visible raw provenance (WCAG 1.4.13): "Originaltext: …" is a real secondary <span>, not a title= tooltip. Tests assert it's visible text. Good — tooltip-only would fail seniors and touch users.
  • Touch targets: precision <select> and the RANGE end-date <input> both now carry min-h-[48px] — the prior round's fix landed; they match the existing date field and clear the 48px senior preference. The <select> and <input> both have proper <label for=…> and focus-visible:ring-2 focus-visible:ring-focus-ring.
  • Progressive disclosure done right: the end-date field is wrapped in aria-live="polite", so revealing it on RANGE is announced to screen-reader users rather than appearing silently.
  • Contrast: raw line uses text-ink-2 (#4b5563) at text-xs (12px) — that's ~7.6:1 on surface, comfortably AAA for body and well above the 4.5:1 floor for small text. 12px is the project minimum; acceptable for a secondary provenance line, though 14px would be kinder on the detail page if space allows (Low, optional).
  • Product decision documented: raw is shown on the detail/drawer/edit views and suppressed in list/search rows (showRaw={false}), keeping scan-rows compact. The comment in DocumentRow.svelte and the DocumentListItem payload omitting metaDateRaw are consistent — honest label everywhere, full provenance where there's room.

Minor (non-blocking)

The German-only season raw line ("Originaltext: Sommer 1916") will display verbatim German to an en/es reader, while the primary label localizes (e.g. "Summer 1916"). That's intentional and correct — the original cell is German source text and should be shown as-is. Just worth a one-line tooltip/aria hint someday that "Original" means the archivist's verbatim entry. Cosmetic.

No blockers. The hardest-constraint user is served. Approved.

## Leonie Voss — UX / Accessibility Lead **Verdict: Approved** This is exactly the honest-data presentation I'd ask for, and the a11y craft is solid. Reviewed against WCAG AA, the senior-on-a-small-phone constraint, and the prior round's fixes. ### Verified - **Non-color cue (WCAG 1.4.1):** the UNKNOWN state pairs a calendar-question glyph with the visible text "Datum unbekannt". Icon is `aria-hidden="true"` because the text is the redundant cue — correct, no double announcement. - **Visible raw provenance (WCAG 1.4.13):** "Originaltext: …" is a real secondary `<span>`, not a `title=` tooltip. Tests assert it's visible text. Good — tooltip-only would fail seniors and touch users. - **Touch targets:** precision `<select>` and the RANGE end-date `<input>` both now carry `min-h-[48px]` — the prior round's fix landed; they match the existing date field and clear the 48px senior preference. The `<select>` and `<input>` both have proper `<label for=…>` and `focus-visible:ring-2 focus-visible:ring-focus-ring`. - **Progressive disclosure done right:** the end-date field is wrapped in `aria-live="polite"`, so revealing it on RANGE is announced to screen-reader users rather than appearing silently. - **Contrast:** raw line uses `text-ink-2` (#4b5563) at `text-xs` (12px) — that's ~7.6:1 on surface, comfortably AAA for body and well above the 4.5:1 floor for small text. 12px is the project minimum; acceptable for a secondary provenance line, though 14px would be kinder on the detail page if space allows (Low, optional). - **Product decision documented:** raw is shown on the detail/drawer/edit views and suppressed in list/search rows (`showRaw={false}`), keeping scan-rows compact. The comment in `DocumentRow.svelte` and the `DocumentListItem` payload omitting `metaDateRaw` are consistent — honest label everywhere, full provenance where there's room. ### Minor (non-blocking) The German-only season raw line ("Originaltext: Sommer 1916") will display verbatim German to an en/es reader, while the primary label localizes (e.g. "Summer 1916"). That's intentional and correct — the original cell *is* German source text and should be shown as-is. Just worth a one-line tooltip/aria hint someday that "Original" means the archivist's verbatim entry. Cosmetic. No blockers. The hardest-constraint user is served. Approved.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Only one infra-relevant change: a new CI guard step in .gitea/workflows/ci.yml.

CI step review

  • The {@html}-on-raw-date grep step is well-built for a shell guard: it self-tests its own regex before scanning (asserts the pattern catches the dangerous form and ignores a {@html}-in-prose comment), then grep -rPln --include='*.svelte' frontend/src/ and fails the job on a hit. Self-testing a regex guard is the right pattern — it can't silently rot into a no-op.
  • It uses shell: bash explicitly and plain grep -P (no new tool/dependency, no action version) — zero added failure surface, zero added CI time of note. Fits the "every added tool is a new failure mode" rule: this adds none.
  • It's placed alongside the existing (upload|download)-artifact past v3 guard — consistent location for repo-hygiene assertions.

Note (defer to Nora's finding)

The regex token list (metaDateRaw|documentDateRaw|rawDate) doesn't match the raw prop name actually used in DocumentDate.svelte — Nora flagged this as a coverage gap in the guard. From an ops view it's a one-line edit to the pattern= var with no pipeline-structure change; no objection, and the self-test will keep working.

No secrets, no image tags, no volumes, no exposed ports touched. Approved.

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Only one infra-relevant change: a new CI guard step in `.gitea/workflows/ci.yml`. ### CI step review - The `{@html}`-on-raw-date grep step is well-built for a shell guard: it **self-tests its own regex** before scanning (asserts the pattern catches the dangerous form and ignores a `{@html}`-in-prose comment), then `grep -rPln --include='*.svelte' frontend/src/` and fails the job on a hit. Self-testing a regex guard is the right pattern — it can't silently rot into a no-op. - It uses `shell: bash` explicitly and plain `grep -P` (no new tool/dependency, no action version) — zero added failure surface, zero added CI time of note. Fits the "every added tool is a new failure mode" rule: this adds none. - It's placed alongside the existing `(upload|download)-artifact past v3` guard — consistent location for repo-hygiene assertions. ### Note (defer to Nora's finding) The regex token list (`metaDateRaw|documentDateRaw|rawDate`) doesn't match the `raw` prop name actually used in `DocumentDate.svelte` — Nora flagged this as a coverage gap in the guard. From an ops view it's a one-line edit to the `pattern=` var with no pipeline-structure change; no objection, and the self-test will keep working. No secrets, no image tags, no volumes, no exposed ports touched. Approved.
Author
Owner

Elicit — Requirements Engineer / Business Analyst

Verdict: Approved with concerns

Reviewing against the stated need (Closes #666: "render imprecise/unknown dates honestly everywhere, never fabricating a precision the data doesn't have") and the dual-audience frame (transcribers 60+ author; younger readers consume on phones).

Requirement coverage — traced against acceptance intent

All seven precisions have a defined, testable rendering, and the "no fabrication" rule is asserted, not just asserted-in-prose: MONTH never emits a day, YEAR never emits month/day, open RANGE never invents an end ("ab …"), UNKNOWN is explicit text. This satisfies the core JTBD: "When a letter's date is only known to the month, I want the archive to say so, so I don't mislead a family reader into a false precision." Good — the feature does what it claims.

Concern 1 (requirements debt) — silent precision write on edit

Felix flagged the mechanism; here's the requirement consequence. A pre-#671 row with metaDatePrecision = null (genuinely-unspecified) gets coerced to DAY or UNKNOWN and persisted the first time anyone saves the edit form for any reason (e.g. fixing a location typo). That violates the very "don't fabricate precision" principle this PR exists to uphold — except now the fabrication is written to the DB, not just rendered. EARS form of the missing rule: "While the user has not changed the precision control, the system shall not alter the stored meta_date_precision." Recommend a follow-up issue: either only send precision when dirty, or have the importer/migration backfill so null no longer occurs. Not a merge-blocker for a presentation PR, but it undercuts the data-integrity goal.

Concern 2 — read path for en/es: label localizes, "Originaltext" stays German

Correct by design (the raw cell IS German source), and Leonie covered the UX side. From a requirements view: confirm with the product owner that showing verbatim German provenance to a Spanish-speaking grandchild is the intended behavior (I believe it is — provenance must be verbatim). Logging it as a confirmed assumption, not a defect.

Scope hygiene — good

The PR resists gold-plating: server-side range/enum validation is explicitly deferred to #671's scope, raw-on-list-rows is a documented deliberate omission, and no API fields were invented. Deviations are listed honestly in the description. That's the discipline I want to see.

Approved; please open follow-up issues for Concern 1 (precision write-back) before this pattern spreads to other edit forms.

## Elicit — Requirements Engineer / Business Analyst **Verdict: Approved with concerns** Reviewing against the stated need (Closes #666: "render imprecise/unknown dates honestly everywhere, never fabricating a precision the data doesn't have") and the dual-audience frame (transcribers 60+ author; younger readers consume on phones). ### Requirement coverage — traced against acceptance intent All seven precisions have a defined, testable rendering, and the "no fabrication" rule is asserted, not just asserted-in-prose: MONTH never emits a day, YEAR never emits month/day, open RANGE never invents an end ("ab …"), UNKNOWN is explicit text. This satisfies the core JTBD: *"When a letter's date is only known to the month, I want the archive to say so, so I don't mislead a family reader into a false precision."* Good — the feature does what it claims. ### Concern 1 (requirements debt) — silent precision write on edit Felix flagged the mechanism; here's the requirement consequence. A pre-#671 row with `metaDatePrecision = null` (genuinely-unspecified) gets coerced to `DAY` or `UNKNOWN` and **persisted** the first time anyone saves the edit form for any reason (e.g. fixing a location typo). That violates the very "don't fabricate precision" principle this PR exists to uphold — except now the fabrication is written to the DB, not just rendered. EARS form of the missing rule: *"While the user has not changed the precision control, the system shall not alter the stored `meta_date_precision`."* Recommend a follow-up issue: either only send precision when dirty, or have the importer/migration backfill so null no longer occurs. Not a merge-blocker for a presentation PR, but it undercuts the data-integrity goal. ### Concern 2 — read path for en/es: label localizes, "Originaltext" stays German Correct by design (the raw cell IS German source), and Leonie covered the UX side. From a requirements view: confirm with the product owner that showing verbatim German provenance to a Spanish-speaking grandchild is the intended behavior (I believe it is — provenance must be verbatim). Logging it as a confirmed assumption, not a defect. ### Scope hygiene — good The PR resists gold-plating: server-side range/enum validation is explicitly deferred to #671's scope, raw-on-list-rows is a documented deliberate omission, and no API fields were invented. Deviations are listed honestly in the description. That's the discipline I want to see. Approved; please open follow-up issues for Concern 1 (precision write-back) before this pattern spreads to other edit forms.
marcel added 3 commits 2026-05-27 12:39:12 +02:00
updateDocument unconditionally set metaDatePrecision/End/Raw from the DTO,
so saving an unrelated edit (a multipart PUT where the form omits the
precision controls) clobbered the stored precision with null — fabricating
a precision the user never chose. Apply each field only when the DTO carries
it, mirroring the existing metadataComplete/scriptType guards.

Refs #666
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@WebMvcTest multipart PUT asserting metaDatePrecision / metaDateEnd /
metaDateRaw form field names bind to the DTO. A rename on either side
silently drops the precision edit; the captured DTO catches it.

Refs #666
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ci(dates): widen {@html} raw-date guard to cover the raw prop
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m12s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
4bc96c3772
DocumentDate.svelte passes the untrusted raw value via a prop named `raw`,
but the guard only matched metaDateRaw/documentDateRaw/rawDate — so a future
{@html raw} would slip past. Add `\braw\b` to the token list and a self-test
asserting the guard catches {@html raw}. Code is currently safe ({raw}); this
closes the defense-in-depth gap in the guard itself.

Refs #666
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Review-fix round 2 applied (Felix Brandt)

Pushed 3 atomic commits (38f065bc..4bc96c37) addressing the round-2 concerns. Backend DocumentServiceTest (159) + DocumentControllerTest (98) = 257 pass; frontend npm run lint clean; documentDate.spec.ts 29 pass. Full suite left to CI (project policy).

1. CI {@html} guard coverage gap (Nora #12317 / Tobias #12321) — 4bc96c37

The guard greps metaDateRaw|documentDateRaw|rawDate, but DocumentDate.svelte carries the untrusted value via a prop literally named raw, so a future {@html raw} would have slipped past. Widened the token list to …|\braw\b and added a self-test asserting the guard now catches {@html raw}. Verified locally: catches both unsafe forms, ignores prose comments, does NOT flag the safe {raw} interpolation, and finds zero hits in current frontend/src/. Code was and is safe — this closes the gap in the guard itself.

2. Don't fabricate precision on edit (Felix #12315 / Elicit #12322) — 728078f1

updateDocument set metaDatePrecision/metaDateEnd/metaDateRaw unconditionally from the DTO. On a multipart PUT where the form omits those controls they arrive null, so an unrelated edit (e.g. a location typo) clobbered a stored precision with null — fabricating "unknown". Extracted applyDatePrecision(doc, dto) that applies each field only when non-null, mirroring the existing metadataComplete/scriptType guards. New test updateDocument_preservesStoredPrecision_whenDtoOmitsIt (red→green): a MONTH/end/raw row saved with the three fields null keeps its stored values. The existing updateDocument_persistsDatePrecisionEndAndRaw still passes (set path unchanged).

3. Edit-form binding test (Sara #12318) — f9967332

Added @WebMvcTest updateDocument_bindsPrecisionFormFields_toDTO: a multipart PUT with metaDatePrecision/metaDateEnd/metaDateRaw params, capturing the DocumentUpdateDTO via ArgumentCaptor and asserting the bound values. Proven meaningful — flipping a field name to a mismatch makes it go red (expected: RANGE but was: null), so it pins the form-name ↔ DTO-property wire contract.

4. Localize "Originaltext:" label (Leonie / Elicit) — already satisfied, no change

date_original_label already exists in all three locales (de "Originaltext:", en "Original:", es "Texto original:") and is consumed via m.date_original_label() in both DocumentDate.svelte and WhoWhenSection.svelte. The label localizes; only the raw VALUE stays verbatim German (provenance), as intended. Nothing to fix.

Out of scope (per follow-up plan)

App-level RANGE end-before-start validation was not added — it's a separate follow-up; the #671 DB CHECK already enforces correctness.

Notes

  • husky pre-commit (lint) ran on each commit and passed — no --no-verify used.
  • Browser/component tests remain CI-only; CI green stays the merge gate.
## Review-fix round 2 applied (Felix Brandt) Pushed 3 atomic commits (`38f065bc..4bc96c37`) addressing the round-2 concerns. Backend `DocumentServiceTest` (159) + `DocumentControllerTest` (98) = **257 pass**; frontend `npm run lint` clean; `documentDate.spec.ts` **29 pass**. Full suite left to CI (project policy). ### 1. CI `{@html}` guard coverage gap (Nora `#12317` / Tobias `#12321`) — `4bc96c37` The guard greps `metaDateRaw|documentDateRaw|rawDate`, but `DocumentDate.svelte` carries the untrusted value via a prop literally named `raw`, so a future `{@html raw}` would have slipped past. Widened the token list to `…|\braw\b` and added a self-test asserting the guard now catches `{@html raw}`. Verified locally: catches both unsafe forms, ignores prose comments, does NOT flag the safe `{raw}` interpolation, and finds zero hits in current `frontend/src/`. Code was and is safe — this closes the gap in the guard itself. ### 2. Don't fabricate precision on edit (Felix `#12315` / Elicit `#12322`) — `728078f1` `updateDocument` set `metaDatePrecision`/`metaDateEnd`/`metaDateRaw` unconditionally from the DTO. On a multipart PUT where the form omits those controls they arrive `null`, so an unrelated edit (e.g. a location typo) clobbered a stored precision with `null` — fabricating "unknown". Extracted `applyDatePrecision(doc, dto)` that applies each field only when non-null, mirroring the existing `metadataComplete`/`scriptType` guards. New test `updateDocument_preservesStoredPrecision_whenDtoOmitsIt` (red→green): a MONTH/end/raw row saved with the three fields null keeps its stored values. The existing `updateDocument_persistsDatePrecisionEndAndRaw` still passes (set path unchanged). ### 3. Edit-form binding test (Sara `#12318`) — `f9967332` Added `@WebMvcTest` `updateDocument_bindsPrecisionFormFields_toDTO`: a multipart PUT with `metaDatePrecision`/`metaDateEnd`/`metaDateRaw` params, capturing the `DocumentUpdateDTO` via `ArgumentCaptor` and asserting the bound values. Proven meaningful — flipping a field name to a mismatch makes it go red (`expected: RANGE but was: null`), so it pins the form-name ↔ DTO-property wire contract. ### 4. Localize "Originaltext:" label (Leonie / Elicit) — already satisfied, no change `date_original_label` already exists in all three locales (`de` "Originaltext:", `en` "Original:", `es` "Texto original:") and is consumed via `m.date_original_label()` in both `DocumentDate.svelte` and `WhoWhenSection.svelte`. The label localizes; only the raw VALUE stays verbatim German (provenance), as intended. Nothing to fix. ### Out of scope (per follow-up plan) App-level RANGE end-before-start validation was **not** added — it's a separate follow-up; the `#671` DB CHECK already enforces correctness. ### Notes - husky pre-commit (lint) ran on each commit and passed — no `--no-verify` used. - Browser/component tests remain CI-only; CI green stays the merge gate.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Verdict: Approved

I re-reviewed the current diff afresh (round 3). The three latest fixes are genuinely resolved and clean.

Verified fixes

  • applyDatePrecision (DocumentService) — extracted helper, three null-guarded writes, happy path stays flat. The Javadoc explains the why (a null field means "not submitted"; overwriting would fabricate precision) — that is exactly the kind of comment I want: threat/intent, not restating the code. updateDocument_preservesStoredPrecision_whenDtoOmitsIt is a real red/green test that pins the regression. updateDocument_persistsDatePrecisionEndAndRaw covers the write path. Both follow AAA with builder setup.
  • @WebMvcTest field-binding test (updateDocument_bindsPrecisionFormFields_toDTO) — ArgumentCaptor<DocumentUpdateDTO> proves the multipart field names bind to the DTO. This is the right layer for a wire-contract guard and catches a rename on either side. Good call.
  • CI {@html} guard widening\braw\b added to the alternation, plus two positive self-tests (metaDateRaw, bare raw) and one negative (comment form). \braw\b is a whole-word match, so rawHtml/draw won't false-positive. The code was already safe; this closes the guard gap honestly.
  • date_original_label — localized in de/en/es; the raw VALUE stays verbatim by design (rendered via default {…} escaping). Correct.

Clean-code notes (non-blocking, all satisfied)

  • formatDocumentDate decomposes into named single-purpose helpers (longDate, monthYear, seasonLabel, rangeLabel, sameYearRange) — each under 20 lines, intent-revealing.
  • {#each PRECISIONS as p (p.value)} is keyed. showEndDate/showRawLine are $derived, not $effect — logic out of the template.
  • DocumentMultiSelect's old as unknown as Document[] double-cast is gone, replaced by a precise Pick<DocumentListItem, …> that the regenerated api.ts actually backs (metaDatePrecision is present and required on the list item). No type-system bypass.

Nice, disciplined work. No blockers, no open suggestions from me.

## Felix Brandt — Senior Fullstack Developer **Verdict: Approved** I re-reviewed the current diff afresh (round 3). The three latest fixes are genuinely resolved and clean. ### Verified fixes - **`applyDatePrecision` (DocumentService)** — extracted helper, three null-guarded writes, happy path stays flat. The Javadoc explains the *why* (a null field means "not submitted"; overwriting would fabricate precision) — that is exactly the kind of comment I want: threat/intent, not restating the code. `updateDocument_preservesStoredPrecision_whenDtoOmitsIt` is a real red/green test that pins the regression. `updateDocument_persistsDatePrecisionEndAndRaw` covers the write path. Both follow AAA with builder setup. - **`@WebMvcTest` field-binding test** (`updateDocument_bindsPrecisionFormFields_toDTO`) — `ArgumentCaptor<DocumentUpdateDTO>` proves the multipart field names bind to the DTO. This is the right layer for a wire-contract guard and catches a rename on either side. Good call. - **CI `{@html}` guard widening** — `\braw\b` added to the alternation, plus two positive self-tests (`metaDateRaw`, bare `raw`) and one negative (comment form). `\braw\b` is a whole-word match, so `rawHtml`/`draw` won't false-positive. The code was already safe; this closes the guard gap honestly. - **`date_original_label`** — localized in de/en/es; the raw VALUE stays verbatim by design (rendered via default `{…}` escaping). Correct. ### Clean-code notes (non-blocking, all satisfied) - `formatDocumentDate` decomposes into named single-purpose helpers (`longDate`, `monthYear`, `seasonLabel`, `rangeLabel`, `sameYearRange`) — each under 20 lines, intent-revealing. - `{#each PRECISIONS as p (p.value)}` is keyed. `showEndDate`/`showRawLine` are `$derived`, not `$effect` — logic out of the template. - `DocumentMultiSelect`'s old `as unknown as Document[]` double-cast is gone, replaced by a precise `Pick<DocumentListItem, …>` that the regenerated `api.ts` actually backs (`metaDatePrecision` is present and required on the list item). No type-system bypass. Nice, disciplined work. No blockers, no open suggestions from me.
Author
Owner

Markus Keller — Application Architect

Verdict: Approved

Presentation-layer change with one well-placed pure helper. Boundaries and docs check out.

Boundaries

  • DocumentTitleFormatter is a package-private pure helper in importing/, called only by DocumentImporter. No state, no cross-domain reach. buildTitle delegates to it and stays under 20 lines. This is the correct shape — the import title's date label and the frontend label now share one rule set instead of each domain inventing its own.
  • applyDatePrecision keeps the precision-write logic inside DocumentService where the Document aggregate is owned. No leak into the controller.
  • The TS↔Java single-source-of-truth via docs/date-label-fixtures.json, asserted by both a @TestFactory (Java, de cases) and the vitest spec (de + en/es locale cases), is a genuinely good anti-drift seam. The fixture's _comment correctly scopes that the Python normalizer's season→month mapping (4/7/10/1) sits outside this guard — that honesty about the guard's limits is exactly what an ADR-style note should do.

Reliability / layering

  • Range correctness is enforced by the DB CHECK from #671; this PR is presentation only and does not re-implement that rule in Java — correct push-down. The deferred friendly app-level end-before-start message (#678) is the right call: the DB already fails closed, so the app message is UX polish, not an integrity gate. Tracked, not a blocker.
  • DocumentListItem already exposes metaDatePrecision/metaDateEnd (landed in #671), so list rows render honest precision without this PR touching the read DTO or toListItem. No N+1 or new query introduced.

Documentation currency

  • docs/architecture/c4/l3-backend-3b-document-management.puml adds the DocumentTitleFormatter component and the docLoader → titleFmt relation. That is the required diagram update for a new helper in the document/importing domain — present and accurate.
  • No new migration, no new route, no new ErrorCode/Permission, no new external system → no other required doc updates. The fixture file is new but it is test data, not an architectural artifact.

No accidental complexity, no boundary violation, docs match the code. Ship it.

## Markus Keller — Application Architect **Verdict: Approved** Presentation-layer change with one well-placed pure helper. Boundaries and docs check out. ### Boundaries - `DocumentTitleFormatter` is a package-private pure helper in `importing/`, called only by `DocumentImporter`. No state, no cross-domain reach. `buildTitle` delegates to it and stays under 20 lines. This is the correct shape — the import title's date label and the frontend label now share one rule set instead of each domain inventing its own. - `applyDatePrecision` keeps the precision-write logic inside `DocumentService` where the `Document` aggregate is owned. No leak into the controller. - The TS↔Java single-source-of-truth via `docs/date-label-fixtures.json`, asserted by both a `@TestFactory` (Java, de cases) and the vitest spec (de + en/es locale cases), is a genuinely good anti-drift seam. The fixture's `_comment` correctly scopes that the Python normalizer's season→month mapping (4/7/10/1) sits *outside* this guard — that honesty about the guard's limits is exactly what an ADR-style note should do. ### Reliability / layering - Range correctness is enforced by the DB CHECK from #671; this PR is presentation only and does not re-implement that rule in Java — correct push-down. The deferred friendly app-level end-before-start message (#678) is the right call: the DB already fails closed, so the app message is UX polish, not an integrity gate. Tracked, not a blocker. - `DocumentListItem` already exposes `metaDatePrecision`/`metaDateEnd` (landed in #671), so list rows render honest precision without this PR touching the read DTO or `toListItem`. No N+1 or new query introduced. ### Documentation currency - `docs/architecture/c4/l3-backend-3b-document-management.puml` adds the `DocumentTitleFormatter` component and the `docLoader → titleFmt` relation. That is the required diagram update for a new helper in the document/importing domain — present and accurate. - No new migration, no new route, no new ErrorCode/Permission, no new external system → no other required doc updates. The fixture file is new but it is test data, not an architectural artifact. No accidental complexity, no boundary violation, docs match the code. Ship it.
Author
Owner

Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The XSS surface here is meta_date_raw — untrusted, verbatim spreadsheet text. I traced every place it reaches the DOM and every place it influences the structured label. It is handled correctly, and the defense is now tested and guarded.

CWE-79 (reflected/stored XSS via raw cell) — defended at three layers

  1. Render path: DocumentDate.svelte and WhoWhenSection.svelte render raw/rawDate via Svelte default interpolation ({m.date_original_label()} {raw} / {rawDate}), which HTML-escapes. No {@html} anywhere in the diff. The two malicious-payload tests (<img src=x onerror=…> → appears as literal text, document.querySelector('img') is null; <b> → no injected element) are exactly the regression tests I want — the payload existence is proven inert, not just absent.
  2. Label path: formatDocumentDate only ever uses raw to pick a known German season token (seasonFromRaw matches a fixed whitelist of 5 words, else null). The malicious-raw test confirms a script payload yields Datum unbekannt, never reflected into the label. Untrusted input cannot influence the structured output beyond a whitelisted enum branch — textbook.
  3. CI grep guard: the widened \{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b) now also catches DocumentDate's raw prop name. Self-tests assert both unsafe forms match and the comment form does not. \braw\b is whole-word, so it won't false-positive on rawHtml/draw. This is a real Semgrep-style class guard for a future regression — good.

Authorization / wire contract

  • The new precision fields ride the existing PUT /api/documents/{id} multipart endpoint, which already carries @RequirePermission(Permission.WRITE_ALL). The @WebMvcTest binding test runs under @WithMockUser(authorities = "WRITE_ALL") with csrf() — it pins the field binding, not the auth boundary, which is fine since the endpoint's existing 401/403 coverage is unchanged. No new endpoint, no new permission, no auth surface added.

Mass-assignment check

  • applyDatePrecision writes only the three explicitly-named precision fields from the DTO, each null-guarded. No reflective/wholesale copy. Stored precision is preserved on unrelated edits — this also closes a subtle integrity-degradation path (an edit silently downgrading a curated precision to a default), which I appreciate.

No injection, no auth gap, no data exposure. Security tests are permanent regression fixtures. Approved.

## Nora "NullX" Steiner — Application Security Engineer **Verdict: Approved** The XSS surface here is `meta_date_raw` — untrusted, verbatim spreadsheet text. I traced every place it reaches the DOM and every place it influences the structured label. It is handled correctly, and the defense is now tested *and* guarded. ### CWE-79 (reflected/stored XSS via raw cell) — defended at three layers 1. **Render path:** `DocumentDate.svelte` and `WhoWhenSection.svelte` render `raw`/`rawDate` via Svelte default interpolation (`{m.date_original_label()} {raw}` / `{rawDate}`), which HTML-escapes. No `{@html}` anywhere in the diff. The two malicious-payload tests (`<img src=x onerror=…>` → appears as literal text, `document.querySelector('img')` is null; `<b>` → no injected element) are exactly the regression tests I want — the payload existence is proven inert, not just absent. 2. **Label path:** `formatDocumentDate` only ever uses `raw` to pick a known German season *token* (`seasonFromRaw` matches a fixed whitelist of 5 words, else null). The malicious-raw test confirms a script payload yields `Datum unbekannt`, never reflected into the label. Untrusted input cannot influence the structured output beyond a whitelisted enum branch — textbook. 3. **CI grep guard:** the widened `\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b)` now also catches `DocumentDate`'s `raw` prop name. Self-tests assert both unsafe forms match and the comment form does not. `\braw\b` is whole-word, so it won't false-positive on `rawHtml`/`draw`. This is a real Semgrep-style class guard for a future regression — good. ### Authorization / wire contract - The new precision fields ride the existing `PUT /api/documents/{id}` multipart endpoint, which already carries `@RequirePermission(Permission.WRITE_ALL)`. The `@WebMvcTest` binding test runs under `@WithMockUser(authorities = "WRITE_ALL")` with `csrf()` — it pins the field binding, not the auth boundary, which is fine since the endpoint's existing 401/403 coverage is unchanged. No new endpoint, no new permission, no auth surface added. ### Mass-assignment check - `applyDatePrecision` writes only the three explicitly-named precision fields from the DTO, each null-guarded. No reflective/wholesale copy. Stored precision is preserved on unrelated edits — this also closes a subtle integrity-degradation path (an edit silently downgrading a curated precision to a default), which I appreciate. No injection, no auth gap, no data exposure. Security tests are permanent regression fixtures. Approved.
Author
Owner

Sara Holt — Senior QA Engineer

Verdict: Approved with concerns

Strong test layering. One coverage gap on the new behavior, and a portability note on the CI guard — both minor, neither blocks.

What's solid

  • Shared fixture drift guard — one committed table (docs/date-label-fixtures.json) asserted by both the Java @TestFactory (12 de cases) and the vitest spec (21 cases incl. en/es locale parity). This is the right way to stop two implementations diverging on en-dash/hyphen, "ca."/"circa", season words, and range collapse. The locale cases being TS-only (Java is German-by-design) is correctly partitioned and documented in the fixture comment.
  • Regression tests for the round-3 fixes are real and at the right layer: updateDocument_preservesStoredPrecision_whenDtoOmitsIt (unit, Mockito) pins the no-fabrication behavior; updateDocument_bindsPrecisionFormFields_toDTO (@WebMvcTest slice, ArgumentCaptor) pins the wire contract. Both would have been red before the fix. The CI guard even self-tests its own regex — tests for the test.
  • Security payloads codified as permanent fixtures (<img onerror>, <b>) in both the component and the formatter spec.

Concerns (non-blocking)

  1. No negative/edge test for metaDateEnd before metaDateStart. The DB CHECK (#671) enforces it and the friendly message is deferred to #678 — fine — but there is no test asserting the formatter's behavior when handed an end < start (does rangeLabel produce a garbled "11.–10."?). Since formatDocumentDate is pure and cheap to test, I'd add one case documenting what it renders for inverted input, so #678's UX work has a pinned baseline. Track it on #678.
  2. CI guard uses grep -P (\b, lookahead-free but PCRE). It runs under shell: bash on the runner — verify the runner image ships GNU grep with -P, not BusyBox grep (which lacks -P and would make the step error, not silently pass). The self-tests will catch a broken regex, but a grep: -P not supported exit would fail the job loudly — acceptable, just confirm it's green in CI before merge.
  3. Browser-project tests (DocumentDate, WhoWhenSection precision controls) are CI-only. Per project norm I'm trusting CI for these; the assertions use getByText/visibility (user-visible behavior), not internals — good. Just don't merge before the client project goes green.

The author states lint + server vitest (619) + the named backend classes pass; full sweep is CI's job. No flaky patterns, no H2, no Thread.sleep. Approved once CI is green.

## Sara Holt — Senior QA Engineer **Verdict: Approved with concerns** Strong test layering. One coverage gap on the new behavior, and a portability note on the CI guard — both minor, neither blocks. ### What's solid - **Shared fixture drift guard** — one committed table (`docs/date-label-fixtures.json`) asserted by both the Java `@TestFactory` (12 de cases) and the vitest spec (21 cases incl. en/es locale parity). This is the right way to stop two implementations diverging on en-dash/hyphen, "ca."/"circa", season words, and range collapse. The locale cases being TS-only (Java is German-by-design) is correctly partitioned and documented in the fixture comment. - **Regression tests for the round-3 fixes** are real and at the right layer: `updateDocument_preservesStoredPrecision_whenDtoOmitsIt` (unit, Mockito) pins the no-fabrication behavior; `updateDocument_bindsPrecisionFormFields_toDTO` (`@WebMvcTest` slice, `ArgumentCaptor`) pins the wire contract. Both would have been red before the fix. The CI guard even self-tests its own regex — tests for the test. - Security payloads codified as permanent fixtures (`<img onerror>`, `<b>`) in both the component and the formatter spec. ### Concerns (non-blocking) 1. **No negative/edge test for `metaDateEnd` before `metaDateStart`.** The DB CHECK (#671) enforces it and the friendly message is deferred to #678 — fine — but there is no test asserting the *formatter's* behavior when handed an end < start (does `rangeLabel` produce a garbled "11.–10."?). Since `formatDocumentDate` is pure and cheap to test, I'd add one case documenting what it renders for inverted input, so #678's UX work has a pinned baseline. Track it on #678. 2. **CI guard uses `grep -P` (`\b`, lookahead-free but PCRE).** It runs under `shell: bash` on the runner — verify the runner image ships GNU grep with `-P`, not BusyBox grep (which lacks `-P` and would make the step error, not silently pass). The self-tests will catch a *broken regex*, but a `grep: -P not supported` exit would fail the job loudly — acceptable, just confirm it's green in CI before merge. 3. **Browser-project tests (`DocumentDate`, `WhoWhenSection` precision controls) are CI-only.** Per project norm I'm trusting CI for these; the assertions use `getByText`/visibility (user-visible behavior), not internals — good. Just don't merge before the client project goes green. The author states lint + server vitest (619) + the named backend classes pass; full sweep is CI's job. No flaky patterns, no H2, no `Thread.sleep`. Approved once CI is green.
Author
Owner

Leonie Voss — UX & Accessibility Lead

Verdict: Approved with concerns

This is the honest-date feature done right for our dual audience — the 67-year-old researcher gets a date she can trust ("Juni 1916", not a fabricated "1. Juni 1916"), and the phone reader gets a compact label. WCAG is genuinely addressed, not bolted on.

Accessibility — verified

  • WCAG 1.4.1 (use of color): the UNKNOWN state pairs a calendar-question glyph with the visible word "Datum unbekannt" — a non-color cue plus redundant text. The icon is aria-hidden="true" because the text already conveys it. Correct: no double-announcement, no color-only meaning.
  • WCAG 1.4.13 (content on hover/focus): meta_date_raw shows as a visible "Originaltext: …" secondary line, not a tooltip/title attribute. The test asserts toBeVisible(), not just presence. This is the right pattern for seniors who never discover hover affordances.
  • WCAG 2.5.5 / 2.2 target size: the precision <select> and the end-date <input> both carry min-h-[48px] — above the 44px floor, at my preferred 48px. Good.
  • Forms: precision select has <label for="metaDatePrecision">; end-date input has <label for="metaDateEnd">; the raw cell is a labelled static <p>, not a disabled input (correct — disabled inputs are unreadable to some AT and look editable). The RANGE end-date is progressive disclosure wrapped in aria-live="polite", so its appearance is announced. Localized labels in de/en/es.
  • Icon uses text-ink-3 (a token, not a hex), font sizes are text-xs/text-sm (≥12px). No raw hex, no sub-12px text.

Concerns (non-blocking)

  1. text-xs (12px) for the "Originaltext:" line and the icon-adjacent meta. 12px is my floor, not my preference. For the senior-facing DETAIL view this is the provenance line they may most want to read. Consider text-sm (14px) on the detail-page raw line specifically; list rows can stay compact. Minor.
  2. Color contrast of text-ink-2/text-ink-3 on bg-surface for these small labels — I trust the tokens pass AA from prior audits, but since this introduces new small-text usages, please confirm the axe-core E2E run covers the document detail + edit pages in both light and dark mode. I don't see a new a11y E2E case in the diff; the component tests check escaping/visibility, not contrast.
  3. Icon-only meaning for sighted-but-not-AT users: fine here because text is always present. No change needed — noting it's correct.

Honest, accessible, responsive. The two concerns are polish; approved.

## Leonie Voss — UX & Accessibility Lead **Verdict: Approved with concerns** This is the honest-date feature done right for our dual audience — the 67-year-old researcher gets a date she can trust ("Juni 1916", not a fabricated "1. Juni 1916"), and the phone reader gets a compact label. WCAG is genuinely addressed, not bolted on. ### Accessibility — verified - **WCAG 1.4.1 (use of color):** the UNKNOWN state pairs a calendar-question glyph with the visible word "Datum unbekannt" — a non-color cue plus redundant text. The icon is `aria-hidden="true"` because the text already conveys it. Correct: no double-announcement, no color-only meaning. - **WCAG 1.4.13 (content on hover/focus):** `meta_date_raw` shows as a *visible* "Originaltext: …" secondary line, not a tooltip/`title` attribute. The test asserts `toBeVisible()`, not just presence. This is the right pattern for seniors who never discover hover affordances. - **WCAG 2.5.5 / 2.2 target size:** the precision `<select>` and the end-date `<input>` both carry `min-h-[48px]` — above the 44px floor, at my preferred 48px. Good. - **Forms:** precision select has `<label for="metaDatePrecision">`; end-date input has `<label for="metaDateEnd">`; the raw cell is a labelled static `<p>`, not a disabled input (correct — disabled inputs are unreadable to some AT and look editable). The RANGE end-date is progressive disclosure wrapped in `aria-live="polite"`, so its appearance is announced. Localized labels in de/en/es. - Icon uses `text-ink-3` (a token, not a hex), font sizes are `text-xs`/`text-sm` (≥12px). No raw hex, no sub-12px text. ### Concerns (non-blocking) 1. **`text-xs` (12px) for the "Originaltext:" line and the icon-adjacent meta.** 12px is my floor, not my preference. For the senior-facing DETAIL view this is the *provenance* line they may most want to read. Consider `text-sm` (14px) on the detail-page raw line specifically; list rows can stay compact. Minor. 2. **Color contrast of `text-ink-2`/`text-ink-3` on `bg-surface` for these small labels** — I trust the tokens pass AA from prior audits, but since this introduces new small-text usages, please confirm the axe-core E2E run covers the document detail + edit pages in both light and dark mode. I don't see a new a11y E2E case in the diff; the component tests check escaping/visibility, not contrast. 3. **Icon-only meaning for sighted-but-not-AT users:** fine here because text is always present. No change needed — noting it's correct. Honest, accessible, responsive. The two concerns are polish; approved.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Only one CI workflow touch, and it's a sensible addition. No infra, no Compose, no secrets, no image-tag changes in this PR.

CI guard step (.gitea/workflows/ci.yml)

  • The new "Assert no raw document date rendered via {@html}" step is a cheap static guard added to an existing job — no new runner, no new dependency. It's shell: bash, consistent with the sibling (upload|download)-artifact guard right below it (which already uses grep -qP/grep -rPln), so PCRE grep -P is already an established assumption on this runner image — the new step doesn't introduce a new tooling requirement. Good reuse of the existing pattern.
  • The three self-tests (two positive, one negative) make the step self-validating: if the runner's grep ever loses -P, the step errors loudly and fails the job rather than silently passing — which is the fail-closed behavior I want from a security guard. No silent-skip risk.
  • The grep scopes to --include='*.svelte' frontend/src/ — bounded, fast, won't scan node_modules or build output. No measurable CI-time impact.

Reproducibility / secrets

  • No :latest tags, no bind mounts, no hardcoded credentials introduced. The test fixture (docs/date-label-fixtures.json) is committed static data read from the repo at a relative path — deterministic across runners.
  • The Java @TestFactory reads the fixture via Path.of("..","docs","date-label-fixtures.json") relative to the backend/ module dir. That matches how Maven runs (mvnw from backend/), and how Gitea Actions checks out the full repo. It would break only if someone ran the backend tests from a different CWD — acceptable, and CI's working dir is stable.

Nothing for me to operate or size here. Approved.

## Tobias Wendt — DevOps & Platform Engineer **Verdict: Approved** Only one CI workflow touch, and it's a sensible addition. No infra, no Compose, no secrets, no image-tag changes in this PR. ### CI guard step (`.gitea/workflows/ci.yml`) - The new "Assert no raw document date rendered via {@html}" step is a cheap static guard added to an existing job — no new runner, no new dependency. It's `shell: bash`, consistent with the sibling `(upload|download)-artifact` guard right below it (which already uses `grep -qP`/`grep -rPln`), so PCRE `grep -P` is already an established assumption on this runner image — the new step doesn't introduce a new tooling requirement. Good reuse of the existing pattern. - The three self-tests (two positive, one negative) make the step self-validating: if the runner's grep ever loses `-P`, the step errors loudly and fails the job rather than silently passing — which is the fail-closed behavior I want from a security guard. No silent-skip risk. - The grep scopes to `--include='*.svelte' frontend/src/` — bounded, fast, won't scan node_modules or build output. No measurable CI-time impact. ### Reproducibility / secrets - No `:latest` tags, no bind mounts, no hardcoded credentials introduced. The test fixture (`docs/date-label-fixtures.json`) is committed static data read from the repo at a relative path — deterministic across runners. - The Java `@TestFactory` reads the fixture via `Path.of("..","docs","date-label-fixtures.json")` relative to the `backend/` module dir. That matches how Maven runs (`mvnw` from `backend/`), and how Gitea Actions checks out the full repo. It would break only if someone ran the backend tests from a different CWD — acceptable, and CI's working dir is stable. Nothing for me to operate or size here. Approved.
Author
Owner

Elicit — Requirements Engineer & Business Analyst

Verdict: Approved with concerns

I reviewed against the stated intent of #666 (render imprecise/unknown dates honestly, never fabricating precision) and the dual-audience NFRs. The PR satisfies the core acceptance behavior across all seven precisions and all surfaces.

Requirements coverage — traced

  • FR (honesty): "When a date's precision is MONTH/SEASON/YEAR/APPROX/RANGE/UNKNOWN, the system shall display it at exactly that precision, never finer." Met everywhere I can trace it — formatter, import title (DocumentTitleFormatter), detail top-bar + drawer, list/search rows, multiselect label, and the edit form. The open-ended RANGE → "ab 10. Jan. 1917" and end==start → single day are both specified and fixtured. Good — these are the edge cases usually skipped.
  • NFR-i18n: de/en/es keys for every precision/season/edit label; read path is locale-aware, import titles German-by-design (a deliberate, documented constraint, not an omission).
  • NFR-a11y: non-color cue + visible provenance line are explicit acceptance criteria, tested.
  • NFR-security: untrusted raw cell escaped + class-guarded.

Concerns (non-blocking, but log them)

  1. Deferred scope is tracked but verify the linkage. The friendly end-before-start validation message is deferred to #678 with the DB CHECK as the safety net — that is the correct MoSCoW call (Could-have UX polish; the Must-have integrity is already enforced). Please confirm #678 exists, is labelled, and references #666/#671 so the deferral is traceable and not lost. A deferral without a live linked issue is how requirements debt accumulates.
  2. List rows intentionally omit meta_date_raw (provenance shown only on detail). This is a reasonable IA decision (keep scan-rows compact), but it is a product decision embedded in a code comment, not in an issue. Capture it as a one-line acceptance note on #666 so a future reader doesn't "fix" it as a bug.
  3. No measurable success metric stated for the feature. #666 is behavioral, so this is acceptable, but if there's any way to observe "users no longer mis-cite a fabricated day" (e.g. via the enrichment/transcription workflow), it'd close the impact-mapping loop. Optional.

No ambiguity in the Must-have behavior, edge cases are enumerated and tested, deferred items are scoped. Approved — please just confirm #678 is live and linked before merge.

## Elicit — Requirements Engineer & Business Analyst **Verdict: Approved with concerns** I reviewed against the stated intent of #666 (render imprecise/unknown dates *honestly*, never fabricating precision) and the dual-audience NFRs. The PR satisfies the core acceptance behavior across all seven precisions and all surfaces. ### Requirements coverage — traced - **FR (honesty):** "When a date's precision is MONTH/SEASON/YEAR/APPROX/RANGE/UNKNOWN, the system shall display it at exactly that precision, never finer." Met everywhere I can trace it — formatter, import title (`DocumentTitleFormatter`), detail top-bar + drawer, list/search rows, multiselect label, and the edit form. The open-ended RANGE → "ab 10. Jan. 1917" and `end==start` → single day are both specified and fixtured. Good — these are the edge cases usually skipped. - **NFR-i18n:** de/en/es keys for every precision/season/edit label; read path is locale-aware, import titles German-by-design (a deliberate, documented constraint, not an omission). - **NFR-a11y:** non-color cue + visible provenance line are explicit acceptance criteria, tested. - **NFR-security:** untrusted raw cell escaped + class-guarded. ### Concerns (non-blocking, but log them) 1. **Deferred scope is tracked but verify the linkage.** The friendly end-before-start validation message is deferred to #678 with the DB CHECK as the safety net — that is the *correct* MoSCoW call (Could-have UX polish; the Must-have integrity is already enforced). Please confirm #678 exists, is labelled, and references #666/#671 so the deferral is traceable and not lost. A deferral without a live linked issue is how requirements debt accumulates. 2. **List rows intentionally omit `meta_date_raw`** (provenance shown only on detail). This is a reasonable IA decision (keep scan-rows compact), but it is a *product decision* embedded in a code comment, not in an issue. Capture it as a one-line acceptance note on #666 so a future reader doesn't "fix" it as a bug. 3. **No measurable success metric stated for the feature.** #666 is behavioral, so this is acceptable, but if there's any way to observe "users no longer mis-cite a fabricated day" (e.g. via the enrichment/transcription workflow), it'd close the impact-mapping loop. Optional. No ambiguity in the Must-have behavior, edge cases are enumerated and tested, deferred items are scoped. Approved — please just confirm #678 is live and linked before merge.
marcel added 1 commit 2026-05-27 12:52:10 +02:00
test(dates): update top-bar specs to honest long DAY label
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m46s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
09b810afb6
The top bar now renders document dates through formatDocumentDate, so a
DAY-precision date like 1923-04-15 renders as "15. April 1923" (de) via
Intl.DateTimeFormat — no longer the old short "15.04.1923". These two
browser-project specs still asserted the old short form and were never
updated (CI-only, not run locally by prior agents).

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
marcel merged commit 09b810afb6 into docs/import-migration 2026-05-27 13:19:01 +02:00
marcel deleted branch feature/666-date-rendering 2026-05-27 13:19:02 +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#677