As a reader I want undated and imprecisely-dated letters to be honestly labelled in browse views so I always understand a document's date position #668

Closed
opened 2026-05-26 20:33:57 +02:00 by marcel · 9 comments
Owner

Phase 6 of the "Handling the Unknowns" milestone. This issue makes undated and imprecisely-dated documents honest in the browse/search surfaces. It splits cleanly into two halves along a dependency boundary:

  • Independently shippable now (rides the existing nullable meta_date column, no migration): the NULLS-LAST sort fix across all sort paths, the "Datum unbekannt" per-row badge + existing year bucket, the "Nur undatierte" filter, and the date-range/undated collision rule.
  • Blocked on precision rendering: rendering "Juni 1916" / "ca. 1916" / "10.–11. Jan. 1917". The precision fields (meta_date_precision, meta_date_end, meta_date_raw) are produced by Phase 2 #671 and surfaced via the Phase 4 #666 formatter. Dependency chain for the precision half: #671#666 → this issue. Ship the independent half first; the formatter is the seam.

Context

A meaningful fraction of the archive has no usable letter date. The import normalizer reported 575 unparsed dates (7.9% of rows) and 99 literal ? values — roughly one in twelve documents either has no date or only an imprecise one.

The date-ordered browse surfaces silently mishandle these today:

  • Document.documentDate is a single nullable LocalDate (backend/.../document/Document.java:91-92, column meta_date). There is no model for imprecision — an undated letter is just null.
  • The FTS search path sorts d.meta_date DESC NULLS LAST (DocumentRepository.java:123) — correct, but only for that one query.
  • The non-FTS DATE fast path resolves sort via resolveSort(...)Sort.by(direction, "documentDate") with no explicit null handling (DocumentService.java:778-789). On Postgres DESC happens to put NULLs last, but ASC puts undated documents FIRST — flipping sort direction surfaces the undated pile at the top with no explanation.
  • Three of the four sort modes never touch resolveSort at all. SENDER, RECEIVER, and filtered-RELEVANCE load the full match set via documentRepository.findAll(spec) and sort in-memory (sortBySender / sortByFirstReceiver / rank comparator, DocumentService.java:667-687). A resolveSort .nullsLast() change does NOT reach them. "Undated last regardless of sort mode" must therefore be enforced wherever each sort actually executes — in SQL for the DATE/TITLE/UPLOAD/UPDATED fast path, and in the in-memory comparators for SENDER/RECEIVER/RELEVANCE.
  • The list component has partial support: groupByYear buckets null-dated items under docs_group_undated() (DocumentList.svelte:37), and the i18n key exists in all three locales. But there is no per-row badge, no "undated only" filter, and the bucket only exists when grouping by year. Undated rows in every other mode render a bare em-dash (DocumentRow.svelte:167 mobile, :181 desktop) — a glyph-only cue with no text, which a screen reader announces as nothing.

In one sentence: date-ordered timelines and search silently mishandle undated documents — they sink to the bottom unexplained, or surface at the top on ASC sort — and the reader can never tell "we don't know the date" from "the field is broken". The reader must always understand WHY a document has no date position.

Scope — which surfaces

  • Document search list (primary surface)/documents, loaded by frontend/src/routes/documents/+page.server.ts, rendered by DocumentList.svelte / DocumentRow.svelte. This is the searchable, date-ordered list. (The dashboard home frontend/src/routes/+page.server.ts is recent/incomplete tiles, not a date-ordered timeline — out of scope.)
  • Chronik / activity feed/aktivitaeten. Caveat from review: ChronikRow.svelte renders the activity event timestamp (audit occurredAt), not the letter documentDate, and has no document-date surface today (actor + verb + document title + relative activity time). So there is nothing to badge. The work here is a negative guarantee only: ensure no fabricated letter date is introduced into a Chronik row. Do NOT invent a date chip that does not exist.
  • Briefwechsel is OUT of scope — it is a dead feature being removed. Do not touch it.

Dependency

  • Precision half depends on #666 (the Phase 4 formatter), which in turn consumes the precision fields produced by Phase 2 #671. Until #666 lands, the shared formatter is stubbed to return "Datum unbekannt" for null and full-format for a present date.
  • No dependency for the independent half — it rides the existing documentDate column.
  • Adds NO migration. Zero columns, zero tables, zero FKs in this issue. The precision columns are owned upstream (#671 via #666). If they were ever to land in the same PR, the Flyway + db-orm.puml doc gate would apply — keep them split.

User journey

  1. A reader opens /documents, sorted by date (default). Year-grouped letters render; at the very bottom a clearly-labelled "Datum unbekannt" group collects every undated letter, with a count.
  2. The reader flips the sort to ascending. Undated letters stay last (NULLS LAST) — they do NOT jump to the top.
  3. The reader groups by sender / receiver / relevance. Each undated letter stays under its actual person group but carries a visible "Datum unbekannt" badge on the row — no synthetic sub-group pulls it away from its sender.
  4. The reader ticks "Nur undatierte" to triage letters still needing a date. The list shows only undated documents; the state is reflected in a shareable URL param.
  5. The reader applies a date-range filter (from/to). Undated documents are excluded (no date to fall in range); if nothing matches, the empty-state copy says explicitly that undated documents are not part of a date range.
  6. Wherever a date is shown, an imprecise date renders honestly — "Juni 1916", "ca. 1916", "10.–11. Jan. 1917", or "Datum unbekannt" — never a fabricated full date (precision half, via #666).

Backend breakdown

  • DocumentService.java — NULLS-LAST on every sort path (the core fix):
    • DATE fast path (resolveSort, lines 778-789): use Sort.by(new Sort.Order(direction, "documentDate").nullsLast()) so undated docs order last for both ASC and DESC. This closes the ASC bug.
    • In-memory paths (sortBySender / sortByFirstReceiver / filtered-RELEVANCE comparator, lines 667-687): these never order by documentDate, so they do not surface undated-at-top — but any tiebreaker that touches documentDate must treat null as last. Confirm and lock with a test; do not assume resolveSort covers them (it does not).
  • DocumentSpecifications.java — add a static undatedOnly(boolean) factory returning null when false and cb.isNull(root.get("documentDate")) when true. Compose it through buildSearchSpec (DocumentService.java:503-518) like hasStatus — this is the single source of truth shared by searchDocuments and findIdsForFilter, so the filter also applies to the bulk-edit "select all" path (intended: a triager filtering to undated wants bulk-edit on the result). Confirm the existing isBetween range predicate (:41-51, cb.between / >= / <=) naturally excludes NULL — it does on Postgres; this issue makes that intentional and tested (real-Postgres, not H2).
  • DocumentController.java/search (:365): add @RequestParam(required = false) Boolean undated, threaded into the service/spec. Read-only GETno @RequirePermission write guard (confirmed correct by security review; the write endpoints in this controller carry the guard, the read GET does not). After the param change, run npm run generate:api.

Frontend breakdown

  • frontend/src/routes/documents/+page.server.ts — parse undated strictly (url.searchParams.get('undated') === 'true', mirroring the tagOp clamp), forward as undated: undated || undefined, return it in page data so the control reflects URL state. Page-reset-on-filter-change is already implicit (documents/+page.svelte:88 drops page on any filter change) — do not reimplement it.
  • DocumentRow.svelte — replace the bare em-dash at :167 (mobile) and :181 (desktop) with the "Datum unbekannt" badge. Define it once (route both blocks through formatDocumentDate or a single <DateCell>), so the cue cannot drift. Badge styling: a neutral chip (not red/amber — undated is an absence, not an error), matching the existing metadata-chip pattern (rounded border border-line px-1.5 py-0.5 font-sans text-[10px] tracking-widest text-ink-3 uppercase, the pattern at :135-137), paired with the existing text-ink-3 italic "unbekannt" treatment used for unknown sender/receiver (:196). text-[10px] is the hard minimum — never below — and verify text-ink-3 on bg-surface hits 4.5:1 in both light and dark themes (bump to text-ink-2 if it fails AA).
  • DocumentList.svelte — keep the existing year docs_group_undated bucket. For sender/receiver/relevance grouping, do NOT force a synthetic undated sub-group — the per-row badge from DocumentRow is the chosen pattern, keeping each letter under its actual person group (review consensus). Add a friendly empty-state branch (:74-85 currently only handles the API-error state): when a from/to filter yields nothing, show the docs_range_excludes_undated copy.
  • SearchFilterBar.svelte — add the "Nur undatierte" toggle wired to the undated URL param. Reuse the advanced-row tag AND/OR aria-pressed pill pattern (:225-250). Wrap the control in a real <label for> with min-h-[44px] touch target (WCAG 2.5.5 / 2.2, senior audience) — reference the bulk-select label at DocumentRow.svelte:61-72. Label the state, not the color.
  • frontend/src/lib/shared/utils/date.ts — add formatDocumentDate(doc): returns m.docs_badge_undated() for null, delegates to formatDate for a present full date, and (once #666 lands) renders the precision branches from meta_date_precision / meta_date_end / meta_date_raw. Keep formatDate / formatMCDate for full-precision dates. This function is the dependency boundary — stub the precision branches as TODOs referencing #666 until then.
  • Chronik (ChronikRow.svelte)negative guarantee only. Confirm no fabricated letter date is rendered. Do not add a date chip (none exists today; the row shows the activity timestamp).

i18n

docs_group_undated already exists in all three locales (reuse for the bucket header). Add to frontend/messages/{de,en,es}.json:

  • docs_filter_undated_only → "Nur undatierte" / "Undated only" / "Solo sin fecha"
  • docs_badge_undated → "Datum unbekannt" / "Date unknown" / "Fecha desconocida"
  • docs_date_circa_prefix → "ca. " / "c. " / "h. " (circa prefix for the precision formatter)
  • docs_range_excludes_undated → static localized empty-state note that a date range excludes undated documents (must be a localized constant, never a reflected backend string)

Acceptance criteria

Feature: Honest handling of undated and imprecisely-dated documents in browse views

  Scenario: Undated documents are bucketed, never silently buried
    Given the archive contains documents with a null documentDate
    When I open the document list sorted by date descending
    Then those documents appear under an explicit "Datum unbekannt" group at the end of the list
    And the group shows how many undated documents it contains

  Scenario: Sort direction never surfaces undated docs at the top
    Given the document list contains a mix of dated and undated documents
    When I switch the date sort to ascending
    Then undated documents remain ordered last (NULLS LAST), not first
    And this holds for the FTS search path and the Specification DATE fast path alike

  Scenario: Undated rows are labelled in every grouping mode via a per-row badge
    Given undated documents are present
    When I group the list by sender, by receiver, or by relevance
    Then each undated document stays under its actual person group
    And it carries a visible "Datum unbekannt" badge on the row
    And no undated document is shown without a date indication
    And no synthetic "Datum unbekannt" sub-group is created inside a person's letters

  Scenario: Filter to undated only
    Given a mix of dated and undated documents
    When I enable the "Nur undatierte" filter
    Then only documents with no date are listed
    And the filter state is reflected in the URL so it is shareable

  Scenario: Undated-only and a date range cannot both match
    Given the "Nur undatierte" filter is enabled
    When a from/to date range is also active
    Then the result is empty because the two filters are mutually exclusive
    And the empty state explains that a date range excludes undated documents

  Scenario: Date-range filters exclude undated documents clearly
    Given undated documents are present
    When I apply a from/to date range
    Then undated documents are excluded from the result
    And if the range matches nothing, the empty state explains that undated documents are not part of a date range

  Scenario: Imprecise dates render honestly (requires #666 formatter)
    Given a document whose precision is "month" for June 1916
    When its date is shown anywhere in a browse view
    Then it renders as "Juni 1916" and never as a fabricated full date

  Scenario: Circa and range precision render honestly (requires #666 formatter)
    Given a document dated "circa 1916" and another spanning 10.11. January 1917
    When their dates are shown
    Then they render as "ca. 1916" and "10.–11. Jan. 1917" respectively

  Scenario: Chronik never fabricates a letter date
    Given a Chronik activity row references an undated document
    When the row renders
    Then no fabricated letter date appears for that document
    And no new date chip is added (the row shows the activity timestamp, not the letter date)

Tests

Backend branch coverage gate is currently 0.77 (77%), ratcheting toward 80% (pom.xml / #496) — not the generic 80% floor. The new undatedOnly branch and the nullsLast direction branch both need explicit coverage or the PR fails the gate.

  • Backend unit (DocumentServiceTest, Mockito): parameterize resolveSort over {ASC, DESC} asserting the produced Sort carries NullHandling.NULLS_LAST on documentDate. Today ASC fails — that is the red. (~2 tests.)
  • Backend integration (@DataJpaTest + Testcontainers postgres:16-alpine): real Postgres is required (these verify Postgres null-ordering and BETWEEN null-exclusion — H2 gives false confidence). Assert (a) DATE ASC returns dated-first, undated-last; (b) undated=true returns exactly the null-dated rows; (c) a from/to range returns zero undated. (~3 tests.) Reuse the Document.builder().documentDate(null) fixture pattern (DocumentServiceTest.java:2380).
  • Backend authz (@WebMvcTest): GET /api/documents/search?undated=true reachable by an authenticated READ_ALL user; an unauthenticated request gets 401 — guards against a future refactor accidentally permitAll()-ing or write-guarding the read path.
  • Frontend component (DocumentList.svelte.spec.ts, vitest-browser — file exists): an undated row carries the docs_badge_undated badge under sender AND receiver grouping; a group with both dated and undated rows renders correctly; no synthetic undated sub-group appears in person-grouped modes.
  • Frontend unit (date.spec.ts — file exists): formatDocumentDate(null) → "Datum unbekannt"; present date → full format. Add .skip placeholders for the precision cases ("Juni 1916" etc.) — each must carry the #666 reference, never a bare skip.
  • Frontend server (+page.server.ts spec): import load, mock fetch, assert undated=true is forwarded as a query param and that page resets (no page carried).
  • No E2E — this is permutation territory; the existing critical-journey suite already exercises /documents. Do not add an E2E per sort mode.

Open Decisions

The four review Decision-Queue items are folded below. Two are resolved per review consensus; two remain open.

  • RESOLVED — Undated labelling in sender/receiver/relevance grouping: per-row badge, not a forced sub-group. Badge-on-row keeps each letter under its actual sender/receiver, is honest, and is the smaller change (Felix, Leonie, Elicit all leaned this way; it auto-satisfies the "labelled in every grouping mode" AC via the DocumentRow change). A forced "Datum unbekannt" sub-group would pull letters out of their person's group — rejected.
  • RESOLVED — Undated-only + active date range: allow the combination and let the empty-state copy (docs_range_excludes_undated) explain the contradiction, rather than disabling the date-range inputs. Both filters are independently shareable URL params; an empty result with an honest explanation is the rule.
  • OPEN — Secondary sort order for undated-only results. With every documentDate null, pagination order is undefined unless a tiebreaker is chosen (title vs. upload date vs. insertion order). This is a triage-workflow preference the code cannot decide. Default if unanswered: upload date ascending (stable, reflects ingestion order for triage). (Raised by: Elicit.)
  • OPEN — Undated group count semantics: page-local vs. total-across-pages. Scenario 1 says the group "shows how many undated documents it contains". #315 already descoped cross-page per-year totals. Default if unanswered: page-local count (consistent with #315; a grand total would need an extra query). (Raised by: Elicit.)

Out of scope

  • The date-precision schema (meta_date_precision / meta_date_end / meta_date_raw columns, migration, entity fields, OpenAPI regen) — produced by Phase 2 #671 and surfaced via the Phase 4 #666 formatter. This issue only consumes those fields.
  • Briefwechsel — dead feature being removed; not a surface to update.
  • Person / name unknowns — "unknown sender/receiver" handling is a separate concern; this issue is strictly about dates.
  • Per-year totals across paginated pages — already descoped in #315.
  • The dashboard home (frontend/src/routes/+page.server.ts) — not a date-ordered timeline.
  • Adding a date surface to Chronik rowsChronikRow shows the activity timestamp by design; this issue only guarantees no fabricated letter date appears, it does not add a date chip.
> **Phase 6 of the "Handling the Unknowns" milestone.** This issue makes undated and imprecisely-dated documents honest in the browse/search surfaces. It splits cleanly into two halves along a dependency boundary: > > - **Independently shippable now** (rides the existing nullable `meta_date` column, **no migration**): the NULLS-LAST sort fix across all sort paths, the "Datum unbekannt" per-row badge + existing year bucket, the "Nur undatierte" filter, and the date-range/undated collision rule. > - **Blocked on precision rendering:** rendering "Juni 1916" / "ca. 1916" / "10.–11. Jan. 1917". The precision fields (`meta_date_precision`, `meta_date_end`, `meta_date_raw`) are produced by **Phase 2 #671** and surfaced via the **Phase 4 #666** formatter. **Dependency chain for the precision half: #671 → #666 → this issue.** Ship the independent half first; the formatter is the seam. --- ## Context A meaningful fraction of the archive has no usable letter date. The import normalizer reported **575 unparsed dates (7.9% of rows)** and **99 literal `?`** values — roughly one in twelve documents either has no date or only an imprecise one. The date-ordered browse surfaces silently mishandle these today: - `Document.documentDate` is a single nullable `LocalDate` (`backend/.../document/Document.java:91-92`, column `meta_date`). There is no model for imprecision — an undated letter is just `null`. - The FTS search path sorts `d.meta_date DESC NULLS LAST` (`DocumentRepository.java:123`) — correct, but only for that one query. - The non-FTS **DATE fast path** resolves sort via `resolveSort(...)` → `Sort.by(direction, "documentDate")` with **no explicit null handling** (`DocumentService.java:778-789`). On Postgres `DESC` happens to put NULLs last, but **`ASC` puts undated documents FIRST** — flipping sort direction surfaces the undated pile at the top with no explanation. - **Three of the four sort modes never touch `resolveSort` at all.** SENDER, RECEIVER, and filtered-RELEVANCE load the full match set via `documentRepository.findAll(spec)` and sort **in-memory** (`sortBySender` / `sortByFirstReceiver` / rank comparator, `DocumentService.java:667-687`). A `resolveSort` `.nullsLast()` change does NOT reach them. "Undated last regardless of sort mode" must therefore be enforced **wherever each sort actually executes** — in SQL for the DATE/TITLE/UPLOAD/UPDATED fast path, and in the in-memory comparators for SENDER/RECEIVER/RELEVANCE. - The list component has partial support: `groupByYear` buckets null-dated items under `docs_group_undated()` (`DocumentList.svelte:37`), and the i18n key exists in all three locales. But there is no per-row badge, no "undated only" filter, and the bucket only exists when grouping by year. Undated rows in every other mode render a **bare em-dash** (`DocumentRow.svelte:167` mobile, `:181` desktop) — a glyph-only cue with no text, which a screen reader announces as nothing. In one sentence: **date-ordered timelines and search silently mishandle undated documents — they sink to the bottom unexplained, or surface at the top on ASC sort — and the reader can never tell "we don't know the date" from "the field is broken". The reader must always understand WHY a document has no date position.** ## Scope — which surfaces - **Document search list (primary surface)** — `/documents`, loaded by `frontend/src/routes/documents/+page.server.ts`, rendered by `DocumentList.svelte` / `DocumentRow.svelte`. This is the searchable, date-ordered list. (The dashboard home `frontend/src/routes/+page.server.ts` is recent/incomplete tiles, **not** a date-ordered timeline — out of scope.) - **Chronik / activity feed** — `/aktivitaeten`. **Caveat from review:** `ChronikRow.svelte` renders the **activity event timestamp** (audit `occurredAt`), not the letter `documentDate`, and has **no document-date surface** today (actor + verb + document title + relative activity time). So there is nothing to badge. The work here is a **negative guarantee only**: ensure no fabricated letter date is introduced into a Chronik row. Do NOT invent a date chip that does not exist. - **Briefwechsel is OUT of scope** — it is a dead feature being removed. Do not touch it. ## Dependency - **Precision half depends on #666** (the Phase 4 formatter), which in turn consumes the precision fields produced by **Phase 2 #671**. Until #666 lands, the shared formatter is **stubbed** to return "Datum unbekannt" for `null` and full-format for a present date. - **No dependency for the independent half** — it rides the existing `documentDate` column. - **Adds NO migration.** Zero columns, zero tables, zero FKs in this issue. The precision columns are owned upstream (#671 via #666). If they were ever to land in the same PR, the Flyway + `db-orm.puml` doc gate would apply — keep them split. ## User journey 1. A reader opens `/documents`, sorted by date (default). Year-grouped letters render; at the very bottom a clearly-labelled **"Datum unbekannt"** group collects every undated letter, with a count. 2. The reader flips the sort to ascending. Undated letters **stay last** (NULLS LAST) — they do NOT jump to the top. 3. The reader groups by sender / receiver / relevance. Each undated letter stays under its actual person group but carries a visible **"Datum unbekannt" badge** on the row — no synthetic sub-group pulls it away from its sender. 4. The reader ticks **"Nur undatierte"** to triage letters still needing a date. The list shows only undated documents; the state is reflected in a shareable URL param. 5. The reader applies a date-range filter (`from`/`to`). Undated documents are **excluded** (no date to fall in range); if nothing matches, the empty-state copy says explicitly that undated documents are not part of a date range. 6. Wherever a date is shown, an imprecise date renders **honestly** — "Juni 1916", "ca. 1916", "10.–11. Jan. 1917", or "Datum unbekannt" — never a fabricated full date (precision half, via #666). ## Backend breakdown - **`DocumentService.java` — NULLS-LAST on every sort path (the core fix):** - **DATE fast path** (`resolveSort`, lines 778-789): use `Sort.by(new Sort.Order(direction, "documentDate").nullsLast())` so undated docs order last for **both** `ASC` and `DESC`. This closes the ASC bug. - **In-memory paths** (`sortBySender` / `sortByFirstReceiver` / filtered-RELEVANCE comparator, lines 667-687): these never order by `documentDate`, so they do not surface undated-at-top — but any tiebreaker that touches `documentDate` must treat `null` as last. Confirm and lock with a test; do not assume `resolveSort` covers them (it does not). - **`DocumentSpecifications.java`** — add a static `undatedOnly(boolean)` factory returning `null` when false and `cb.isNull(root.get("documentDate"))` when true. Compose it through `buildSearchSpec` (`DocumentService.java:503-518`) like `hasStatus` — this is the single source of truth shared by `searchDocuments` and `findIdsForFilter`, so the filter also applies to the bulk-edit "select all" path (intended: a triager filtering to undated wants bulk-edit on the result). Confirm the existing `isBetween` range predicate (`:41-51`, `cb.between` / `>=` / `<=`) **naturally excludes NULL** — it does on Postgres; this issue makes that intentional and **tested** (real-Postgres, not H2). - **`DocumentController.java`** — `/search` (`:365`): add `@RequestParam(required = false) Boolean undated`, threaded into the service/spec. Read-only `GET` — **no `@RequirePermission` write guard** (confirmed correct by security review; the write endpoints in this controller carry the guard, the read GET does not). After the param change, run `npm run generate:api`. ## Frontend breakdown - **`frontend/src/routes/documents/+page.server.ts`** — parse `undated` strictly (`url.searchParams.get('undated') === 'true'`, mirroring the `tagOp` clamp), forward as `undated: undated || undefined`, return it in page data so the control reflects URL state. Page-reset-on-filter-change is already implicit (`documents/+page.svelte:88` drops `page` on any filter change) — do not reimplement it. - **`DocumentRow.svelte`** — replace the bare em-dash at `:167` (mobile) and `:181` (desktop) with the **"Datum unbekannt" badge**. Define it once (route both blocks through `formatDocumentDate` or a single `<DateCell>`), so the cue cannot drift. Badge styling: a **neutral** chip (not red/amber — undated is an absence, not an error), matching the existing metadata-chip pattern (`rounded border border-line px-1.5 py-0.5 font-sans text-[10px] tracking-widest text-ink-3 uppercase`, the pattern at `:135-137`), paired with the existing `text-ink-3 italic` "unbekannt" treatment used for unknown sender/receiver (`:196`). `text-[10px]` is the hard minimum — never below — and verify `text-ink-3` on `bg-surface` hits 4.5:1 in **both** light and dark themes (bump to `text-ink-2` if it fails AA). - **`DocumentList.svelte`** — keep the existing year `docs_group_undated` bucket. For sender/receiver/relevance grouping, **do NOT force a synthetic undated sub-group** — the per-row badge from `DocumentRow` is the chosen pattern, keeping each letter under its actual person group (review consensus). Add a friendly **empty-state** branch (`:74-85` currently only handles the API-error state): when a `from`/`to` filter yields nothing, show the `docs_range_excludes_undated` copy. - **`SearchFilterBar.svelte`** — add the **"Nur undatierte"** toggle wired to the `undated` URL param. Reuse the advanced-row tag AND/OR `aria-pressed` pill pattern (`:225-250`). Wrap the control in a real `<label for>` with `min-h-[44px]` touch target (WCAG 2.5.5 / 2.2, senior audience) — reference the bulk-select label at `DocumentRow.svelte:61-72`. Label the state, not the color. - **`frontend/src/lib/shared/utils/date.ts`** — add `formatDocumentDate(doc)`: returns `m.docs_badge_undated()` for `null`, delegates to `formatDate` for a present full date, and (once #666 lands) renders the precision branches from `meta_date_precision` / `meta_date_end` / `meta_date_raw`. Keep `formatDate` / `formatMCDate` for full-precision dates. **This function is the dependency boundary** — stub the precision branches as TODOs referencing #666 until then. - **Chronik (`ChronikRow.svelte`)** — **negative guarantee only.** Confirm no fabricated letter date is rendered. Do not add a date chip (none exists today; the row shows the activity timestamp). ## i18n `docs_group_undated` already exists in all three locales (reuse for the bucket header). Add to `frontend/messages/{de,en,es}.json`: - `docs_filter_undated_only` → "Nur undatierte" / "Undated only" / "Solo sin fecha" - `docs_badge_undated` → "Datum unbekannt" / "Date unknown" / "Fecha desconocida" - `docs_date_circa_prefix` → "ca. " / "c. " / "h. " (circa prefix for the precision formatter) - `docs_range_excludes_undated` → static localized empty-state note that a date range excludes undated documents (must be a localized constant, never a reflected backend string) ## Acceptance criteria ```gherkin Feature: Honest handling of undated and imprecisely-dated documents in browse views Scenario: Undated documents are bucketed, never silently buried Given the archive contains documents with a null documentDate When I open the document list sorted by date descending Then those documents appear under an explicit "Datum unbekannt" group at the end of the list And the group shows how many undated documents it contains Scenario: Sort direction never surfaces undated docs at the top Given the document list contains a mix of dated and undated documents When I switch the date sort to ascending Then undated documents remain ordered last (NULLS LAST), not first And this holds for the FTS search path and the Specification DATE fast path alike Scenario: Undated rows are labelled in every grouping mode via a per-row badge Given undated documents are present When I group the list by sender, by receiver, or by relevance Then each undated document stays under its actual person group And it carries a visible "Datum unbekannt" badge on the row And no undated document is shown without a date indication And no synthetic "Datum unbekannt" sub-group is created inside a person's letters Scenario: Filter to undated only Given a mix of dated and undated documents When I enable the "Nur undatierte" filter Then only documents with no date are listed And the filter state is reflected in the URL so it is shareable Scenario: Undated-only and a date range cannot both match Given the "Nur undatierte" filter is enabled When a from/to date range is also active Then the result is empty because the two filters are mutually exclusive And the empty state explains that a date range excludes undated documents Scenario: Date-range filters exclude undated documents clearly Given undated documents are present When I apply a from/to date range Then undated documents are excluded from the result And if the range matches nothing, the empty state explains that undated documents are not part of a date range Scenario: Imprecise dates render honestly (requires #666 formatter) Given a document whose precision is "month" for June 1916 When its date is shown anywhere in a browse view Then it renders as "Juni 1916" and never as a fabricated full date Scenario: Circa and range precision render honestly (requires #666 formatter) Given a document dated "circa 1916" and another spanning 10.–11. January 1917 When their dates are shown Then they render as "ca. 1916" and "10.–11. Jan. 1917" respectively Scenario: Chronik never fabricates a letter date Given a Chronik activity row references an undated document When the row renders Then no fabricated letter date appears for that document And no new date chip is added (the row shows the activity timestamp, not the letter date) ``` ## Tests > **Backend branch coverage gate is currently 0.77 (77%)**, ratcheting toward 80% (`pom.xml` / #496) — not the generic 80% floor. The new `undatedOnly` branch and the `nullsLast` direction branch both need explicit coverage or the PR fails the gate. - **Backend unit (`DocumentServiceTest`, Mockito):** parameterize `resolveSort` over `{ASC, DESC}` asserting the produced `Sort` carries `NullHandling.NULLS_LAST` on `documentDate`. Today ASC fails — that is the red. (~2 tests.) - **Backend integration (`@DataJpaTest` + Testcontainers `postgres:16-alpine`):** real Postgres is required (these verify Postgres null-ordering and `BETWEEN` null-exclusion — H2 gives false confidence). Assert (a) DATE ASC returns dated-first, undated-last; (b) `undated=true` returns exactly the null-dated rows; (c) a `from`/`to` range returns zero undated. (~3 tests.) Reuse the `Document.builder().documentDate(null)` fixture pattern (`DocumentServiceTest.java:2380`). - **Backend authz (`@WebMvcTest`):** `GET /api/documents/search?undated=true` reachable by an authenticated READ_ALL user; an unauthenticated request gets 401 — guards against a future refactor accidentally `permitAll()`-ing or write-guarding the read path. - **Frontend component (`DocumentList.svelte.spec.ts`, vitest-browser — file exists):** an undated row carries the `docs_badge_undated` badge under sender AND receiver grouping; a group with both dated and undated rows renders correctly; no synthetic undated sub-group appears in person-grouped modes. - **Frontend unit (`date.spec.ts` — file exists):** `formatDocumentDate(null)` → "Datum unbekannt"; present date → full format. Add `.skip` placeholders for the precision cases ("Juni 1916" etc.) — each **must** carry the #666 reference, never a bare skip. - **Frontend server (`+page.server.ts` spec):** import `load`, mock `fetch`, assert `undated=true` is forwarded as a query param and that page resets (no `page` carried). - **No E2E** — this is permutation territory; the existing critical-journey suite already exercises `/documents`. Do not add an E2E per sort mode. ## Open Decisions > The four review Decision-Queue items are folded below. Two are **resolved** per review consensus; two remain open. - **RESOLVED — Undated labelling in sender/receiver/relevance grouping: per-row badge, not a forced sub-group.** Badge-on-row keeps each letter under its actual sender/receiver, is honest, and is the smaller change (Felix, Leonie, Elicit all leaned this way; it auto-satisfies the "labelled in every grouping mode" AC via the `DocumentRow` change). A forced "Datum unbekannt" sub-group would pull letters out of their person's group — rejected. - **RESOLVED — Undated-only + active date range:** allow the combination and let the empty-state copy (`docs_range_excludes_undated`) explain the contradiction, rather than disabling the date-range inputs. Both filters are independently shareable URL params; an empty result with an honest explanation is the rule. - **OPEN — Secondary sort order for undated-only results.** With every `documentDate` null, pagination order is undefined unless a tiebreaker is chosen (title vs. upload date vs. insertion order). This is a triage-workflow preference the code cannot decide. **Default if unanswered: upload date ascending** (stable, reflects ingestion order for triage). _(Raised by: Elicit.)_ - **OPEN — Undated group count semantics: page-local vs. total-across-pages.** Scenario 1 says the group "shows how many undated documents it contains". #315 already descoped cross-page per-year totals. **Default if unanswered: page-local count** (consistent with #315; a grand total would need an extra query). _(Raised by: Elicit.)_ ## Out of scope - The **date-precision schema** (`meta_date_precision` / `meta_date_end` / `meta_date_raw` columns, migration, entity fields, OpenAPI regen) — produced by Phase 2 #671 and surfaced via the Phase 4 #666 formatter. This issue only *consumes* those fields. - **Briefwechsel** — dead feature being removed; not a surface to update. - **Person / name unknowns** — "unknown sender/receiver" handling is a separate concern; this issue is strictly about dates. - **Per-year totals across paginated pages** — already descoped in #315. - **The dashboard home** (`frontend/src/routes/+page.server.ts`) — not a date-ordered timeline. - **Adding a date surface to Chronik rows** — `ChronikRow` shows the activity timestamp by design; this issue only guarantees no fabricated letter date appears, it does not add a date chip.
marcel added the P2-mediumfeatureui labels 2026-05-26 20:34:01 +02:00
marcel added this to the Handling the Unknowns — honest uncertainty in dates & people milestone 2026-05-26 20:35:03 +02:00
Author
Owner

Markus Keller — Senior Application Architect

Observations

  • The spec correctly identifies that this is a read-path correctness fix with no schema impact — "Adds NO migration." I verified that confirms my concern: documentDate is the existing nullable meta_date column and nothing here touches it. Good. No db-orm.puml / db-relationships.puml gate is triggered.
  • The NULLS-LAST fix is architecturally honest about a leaky abstraction I'd otherwise have flagged: the spec is explicit that three of four sort modes bypass resolveSort (sortBySender / sortByFirstReceiver / the in-memory rank comparator at DocumentService.java:667-687). A junior reading "add .nullsLast()" would assume one change fixes everything. The spec naming each execution site is exactly the right framing. I confirmed buildSearchSpec (DocumentService.java:503-518) is the single Specification chain shared by searchDocuments and findIdsForFilter — composing undatedOnly(boolean) there is the correct seam and keeps the bulk-edit "select all" path consistent for free.
  • DocumentSpecifications is a static-factory utility class with no injected state — adding undatedOnly(boolean) returning null/cb.isNull(...) matches the existing hasStatus pattern exactly (:53-55). No boundary crossed; this stays inside the document domain.
  • The formatDocumentDate(doc) stub-as-seam is a clean dependency inversion. The boundary between this issue and #666/#671 is a single function. That is the right place to draw the line — Phase 4's formatter slots in without touching call sites.

Recommendations

  • Keep the precision columns out of this PR even if #671 lands first. The spec already says this; I'm reinforcing it as a hard rule — the moment meta_date_precision etc. appear in a migration, the DB-diagram doc gate applies and this PR's scope balloons. Two PRs, two review surfaces.
  • undatedOnly should be a pure boolean-driven Specification with no clever short-circuit beyond return false ? null : cb.isNull(...). Push the null-ordering correctness to where Postgres enforces it (the Sort.Order.nullsLast() and the IS NULL predicate) rather than re-deriving "undated" logic in Java in three places — one definition, three call sites referencing it.
  • No ADR needed. This is a bug fix + a filter on existing structure, not a lasting architectural decision. Resist the urge to write one.

Open Decisions

  • None from an architecture standpoint. The module boundaries are respected, no schema change, the formatter seam is well-placed. The two open items in the issue (secondary sort tiebreaker, count semantics) are product/UX decisions, not architectural ones.
## Markus Keller — Senior Application Architect ### Observations - The spec correctly identifies that this is a **read-path correctness fix with no schema impact** — "Adds NO migration." I verified that confirms my concern: `documentDate` is the existing nullable `meta_date` column and nothing here touches it. Good. No `db-orm.puml` / `db-relationships.puml` gate is triggered. - The NULLS-LAST fix is architecturally honest about a leaky abstraction I'd otherwise have flagged: the spec is explicit that **three of four sort modes bypass `resolveSort`** (`sortBySender` / `sortByFirstReceiver` / the in-memory rank comparator at `DocumentService.java:667-687`). A junior reading "add `.nullsLast()`" would assume one change fixes everything. The spec naming each execution site is exactly the right framing. I confirmed `buildSearchSpec` (`DocumentService.java:503-518`) is the single Specification chain shared by `searchDocuments` and `findIdsForFilter` — composing `undatedOnly(boolean)` there is the correct seam and keeps the bulk-edit "select all" path consistent for free. - `DocumentSpecifications` is a static-factory utility class with no injected state — adding `undatedOnly(boolean)` returning `null`/`cb.isNull(...)` matches the existing `hasStatus` pattern exactly (`:53-55`). No boundary crossed; this stays inside the `document` domain. - The `formatDocumentDate(doc)` stub-as-seam is a clean dependency inversion. The boundary between this issue and #666/#671 is a single function. That is the right place to draw the line — Phase 4's formatter slots in without touching call sites. ### Recommendations - Keep the precision columns **out of this PR** even if #671 lands first. The spec already says this; I'm reinforcing it as a hard rule — the moment `meta_date_precision` etc. appear in a migration, the DB-diagram doc gate applies and this PR's scope balloons. Two PRs, two review surfaces. - `undatedOnly` should be a pure boolean-driven Specification with no clever short-circuit beyond `return false ? null : cb.isNull(...)`. Push the null-ordering correctness to where Postgres enforces it (the `Sort.Order.nullsLast()` and the `IS NULL` predicate) rather than re-deriving "undated" logic in Java in three places — one definition, three call sites referencing it. - No ADR needed. This is a bug fix + a filter on existing structure, not a lasting architectural decision. Resist the urge to write one. ### Open Decisions - _None from an architecture standpoint._ The module boundaries are respected, no schema change, the formatter seam is well-placed. The two open items in the issue (secondary sort tiebreaker, count semantics) are product/UX decisions, not architectural ones.
Author
Owner

Felix Brandt — Senior Fullstack Developer

Observations

  • The TDD ordering is already correct in the spec: resolveSort parameterized over {ASC, DESC} asserting NullHandling.NULLS_LASTASC is the red. I confirmed the bug at DocumentService.java:778-789: Sort.by(direction, "documentDate") carries no null handling, so ASC surfaces undated docs first on Postgres. That test fails today; good red.
  • I read DocumentRow.svelte. The bare em-dash is at two spots: :167 (mobile, {doc.documentDate ? formatDate(doc.documentDate) : '—'}) and :181 (desktop, identical expression). The spec's "route both blocks through formatDocumentDate or a single <DateCell>" is the right call — these two expressions are a duplication waiting to drift. I'd extract a single <DateCell date={doc.documentDate} /> so the badge cannot diverge between breakpoints.
  • formatDocumentDate(doc) is the dependency seam. Stubbing the precision branches as TODOs referencing #666 is correct — but the function signature should take the document (or the precision fields), not just the ISO string, so the #666 follow-up doesn't change the signature and break call sites. The spec says formatDocumentDate(doc) — good, keep it doc-shaped from day one.
  • +page.server.ts already clamps tagOp strictly (=== 'OR', line 47). The undated param must mirror that: url.searchParams.get('undated') === 'true'. Forward as undated: undated || undefined so the absent case drops out of the query string. I verified page-reset-on-filter-change is already handled — do not reimplement it.

Recommendations

  • For the in-memory comparators (sortBySender / sortByFirstReceiver): they sort by person name, not date, so they don't have the undated-at-top bug. But the spec is right to demand a locking test — write should_keep_undated_documents_last_when_sorting_by_sender asserting a null-dated doc isn't pulled out of its sender group. Don't assume; prove it green.
  • Keep formatDate / formatMCDate untouched. formatDocumentDate wraps them; it does not replace them. The unit test formatDocumentDate(null) → "Datum unbekannt" plus a present-date passthrough is the whole green for the independent half. The precision cases get .skip with a // #666 reference — never a bare skip.
  • The <DateCell> badge: define the chip class once as a $derived or a const, not inline-duplicated. Reuse the metadata-chip pattern at DocumentRow.svelte:135-137 verbatim so it reads as "same family of chip."
  • Minor naming: the i18n key docs_badge_undated reads as a noun-phrase badge label — good. Keep docs_filter_undated_only as the toggle label. Both reveal intent.

Open Decisions

  • None. The test layering is specified, the seam is clean, the param parsing has a precedent to copy. This is implementable as-is.
## Felix Brandt — Senior Fullstack Developer ### Observations - The TDD ordering is already correct in the spec: `resolveSort` parameterized over `{ASC, DESC}` asserting `NullHandling.NULLS_LAST` — **ASC is the red**. I confirmed the bug at `DocumentService.java:778-789`: `Sort.by(direction, "documentDate")` carries no null handling, so ASC surfaces undated docs first on Postgres. That test fails today; good red. - I read `DocumentRow.svelte`. The bare em-dash is at **two** spots: `:167` (mobile, `{doc.documentDate ? formatDate(doc.documentDate) : '—'}`) and `:181` (desktop, identical expression). The spec's "route both blocks through `formatDocumentDate` or a single `<DateCell>`" is the right call — these two expressions are a duplication waiting to drift. I'd extract a single `<DateCell date={doc.documentDate} />` so the badge cannot diverge between breakpoints. - `formatDocumentDate(doc)` is the dependency seam. Stubbing the precision branches as TODOs referencing #666 is correct — but the function signature should take the **document** (or the precision fields), not just the ISO string, so the #666 follow-up doesn't change the signature and break call sites. The spec says `formatDocumentDate(doc)` — good, keep it doc-shaped from day one. - `+page.server.ts` already clamps `tagOp` strictly (`=== 'OR'`, line 47). The `undated` param must mirror that: `url.searchParams.get('undated') === 'true'`. Forward as `undated: undated || undefined` so the absent case drops out of the query string. I verified page-reset-on-filter-change is already handled — do not reimplement it. ### Recommendations - For the in-memory comparators (`sortBySender` / `sortByFirstReceiver`): they sort by person name, not date, so they don't have the undated-at-top bug. But the spec is right to demand a **locking test** — write `should_keep_undated_documents_last_when_sorting_by_sender` asserting a null-dated doc isn't pulled out of its sender group. Don't assume; prove it green. - Keep `formatDate` / `formatMCDate` untouched. `formatDocumentDate` wraps them; it does not replace them. The unit test `formatDocumentDate(null) → "Datum unbekannt"` plus a present-date passthrough is the whole green for the independent half. The precision cases get `.skip` with a `// #666` reference — never a bare skip. - The `<DateCell>` badge: define the chip class **once** as a `$derived` or a const, not inline-duplicated. Reuse the metadata-chip pattern at `DocumentRow.svelte:135-137` verbatim so it reads as "same family of chip." - Minor naming: the i18n key `docs_badge_undated` reads as a noun-phrase badge label — good. Keep `docs_filter_undated_only` as the toggle label. Both reveal intent. ### Open Decisions - _None._ The test layering is specified, the seam is clean, the param parsing has a precedent to copy. This is implementable as-is.
Author
Owner

Nora Steiner ("NullX") — Application Security Engineer

Observations

  • The /api/documents/search endpoint is a read-only GET and correctly carries no @RequirePermission write guard. I verified DocumentController.java:365: search(...) has no permission annotation, while the sibling endpoints (/incomplete, /incomplete/next) carry @RequirePermission(Permission.WRITE_ALL). Adding @RequestParam(required = false) Boolean undated does not change the method's safety class — it stays a safe, idempotent read. The spec's "no write guard" note is correct; do not let a reviewer "helpfully" add WRITE_ALL here, that would break read access for READ_ALL users.
  • The new undated param is a strict server-side boolean threaded into a JPA Criteria cb.isNull(...) predicate — no string concatenation, no injection surface. The whole existing spec chain uses parameterized Criteria (cb.equal, cb.between, named joins). Clean.
  • docs_range_excludes_undated is specified as a localized constant, never a reflected backend string. This is the right instinct — an empty-state message must never echo a server-supplied value. I'm glad the spec calls this out explicitly; reflecting backend text into the empty state would be a stored/reflected-XSS vector via Paraglide-bypassed content.

Recommendations

  • Keep the spec's @WebMvcTest authz test as written — but make it two assertions, not one:
    • GET /api/documents/search?undated=true with an authenticated READ_ALL user → 200 (reachable).
    • The same request unauthenticated → 401.
      This guards against a future refactor accidentally permitAll()-ing the read path or, conversely, write-guarding it. The spec lists both; treat them as non-negotiable regression fixtures.
  • Validate the undated param defensively: === 'true' exact-match on the frontend and a nullable Boolean on the backend (Spring will reject non-boolean garbage with 400). Do not accept "1", "yes", etc. — narrow the accepted truthy surface to exactly true.
  • No new ErrorCode is introduced (the empty state is a frontend i18n constant, not a backend error) — so no ErrorCode.java / errors.ts / getErrorMessage() cascade. Confirm that stays true; if anyone adds a backend 4xx for "undated+range conflict," that's wrong — the spec correctly models it as an empty result, not an error.

Open Decisions

  • None. No auth boundary changes, no injection surface, no data-exposure delta. The read path stays correctly unguarded and the one security-relevant choice (no reflected empty-state string) is already specified correctly.
## Nora Steiner ("NullX") — Application Security Engineer ### Observations - The `/api/documents/search` endpoint is a **read-only GET** and correctly carries **no** `@RequirePermission` write guard. I verified `DocumentController.java:365`: `search(...)` has no permission annotation, while the sibling endpoints (`/incomplete`, `/incomplete/next`) carry `@RequirePermission(Permission.WRITE_ALL)`. Adding `@RequestParam(required = false) Boolean undated` does not change the method's safety class — it stays a safe, idempotent read. The spec's "no write guard" note is correct; do **not** let a reviewer "helpfully" add `WRITE_ALL` here, that would break read access for READ_ALL users. - The new `undated` param is a strict server-side boolean threaded into a JPA Criteria `cb.isNull(...)` predicate — **no string concatenation, no injection surface**. The whole existing spec chain uses parameterized Criteria (`cb.equal`, `cb.between`, named joins). Clean. - `docs_range_excludes_undated` is specified as a **localized constant, never a reflected backend string**. This is the right instinct — an empty-state message must never echo a server-supplied value. I'm glad the spec calls this out explicitly; reflecting backend text into the empty state would be a stored/reflected-XSS vector via Paraglide-bypassed content. ### Recommendations - Keep the spec's `@WebMvcTest` authz test as written — but make it **two assertions**, not one: - `GET /api/documents/search?undated=true` with an authenticated READ_ALL user → 200 (reachable). - The same request **unauthenticated** → 401. This guards against a future refactor accidentally `permitAll()`-ing the read path or, conversely, write-guarding it. The spec lists both; treat them as non-negotiable regression fixtures. - Validate the `undated` param defensively: `=== 'true'` exact-match on the frontend and a nullable `Boolean` on the backend (Spring will reject non-boolean garbage with 400). Do not accept `"1"`, `"yes"`, etc. — narrow the accepted truthy surface to exactly `true`. - No new `ErrorCode` is introduced (the empty state is a frontend i18n constant, not a backend error) — so no `ErrorCode.java` / `errors.ts` / `getErrorMessage()` cascade. Confirm that stays true; if anyone adds a backend 4xx for "undated+range conflict," that's wrong — the spec correctly models it as an empty result, not an error. ### Open Decisions - _None._ No auth boundary changes, no injection surface, no data-exposure delta. The read path stays correctly unguarded and the one security-relevant choice (no reflected empty-state string) is already specified correctly.
Author
Owner

Sara Holt — Senior QA Engineer

Observations

  • The test plan is unusually well-layered for an issue this size, and it respects the pyramid: Mockito unit for resolveSort, Testcontainers postgres:16-alpine for the null-ordering + BETWEEN exclusion, @WebMvcTest for authz, vitest-browser for the badge, plain TS for formatDocumentDate and the load function. No E2E-per-permutation. This is exactly the right shape.
  • The insistence on real Postgres, not H2, for the null-ordering and BETWEEN-excludes-null assertions is correct and load-bearing. H2 and Postgres disagree on NULLS FIRST/LAST defaults and on whether BETWEEN excludes NULL — an H2 suite here would give false green. I confirmed isBetween (DocumentSpecifications.java:41-51) uses cb.between / >= / <=, all of which evaluate to UNKNOWN (excluded) for a NULL documentDate on Postgres. The test makes that intentional and pinned.
  • Document.builder().documentDate(null) fixture pattern is reused (DocumentServiceTest.java:2380 cited). Good — no new builder.

Recommendations

  • Backend coverage gate is 88% branch, not 80%. I verified backend/CLAUDE.md:159. The two new branches — the nullsLast direction branch in resolveSort and the undatedOnly(true/false) branch — each need explicit coverage or JaCoCo fails the PR. Parameterize: resolveSort over {ASC, DESC} (2 cases), undatedOnly over {true, false} (the false → null branch is easy to forget and will cost you the gate).
  • The integration test must assert the negative for the collision rule too: undated=true AND a from/to range → zero rows. That's the one Postgres can prove that H2 might fudge. Add it as its own one-assertion test (undated_with_date_range_returns_empty), not folded into another.
  • Frontend component test (DocumentList.svelte.spec.ts exists): assert the badge appears under both sender and receiver grouping, and explicitly assert no synthetic undated sub-group node exists in person-grouped modes (getByText(docs_group_undated) should NOT match inside a sender card). Test the absence, not just the presence.
  • The .skip placeholders for precision cases in date.spec.ts must each carry the #666 reference inline. A bare .skip is a hidden regression risk — I want a ticket reference on every disabled assertion so it's not orphaned.
  • Server-load test: assert undated=true is forwarded as a query param and that toggling it does not carry page forward (page reset). Mock only fetch.

Open Decisions

  • OPEN — Secondary sort tiebreaker for undated-only results affects test determinism. With every row null-dated, pagination order is undefined unless a tiebreaker is fixed. This isn't just a product call (Elicit raises it below) — it's a flaky-test risk: without a deterministic tiebreaker, the integration test asserting "page 1 contains rows X,Y,Z" will be non-deterministic across runs. Whatever default is chosen (the issue proposes upload-date ascending), it must be a stable, total order so the paginated tests are deterministic. I need that decided before I can write a non-flaky page-boundary assertion.
## Sara Holt — Senior QA Engineer ### Observations - The test plan is unusually well-layered for an issue this size, and it respects the pyramid: Mockito unit for `resolveSort`, **Testcontainers `postgres:16-alpine`** for the null-ordering + `BETWEEN` exclusion, `@WebMvcTest` for authz, vitest-browser for the badge, plain TS for `formatDocumentDate` and the load function. No E2E-per-permutation. This is exactly the right shape. - The insistence on **real Postgres, not H2**, for the null-ordering and `BETWEEN`-excludes-null assertions is correct and load-bearing. H2 and Postgres disagree on `NULLS FIRST/LAST` defaults and on whether `BETWEEN` excludes NULL — an H2 suite here would give false green. I confirmed `isBetween` (`DocumentSpecifications.java:41-51`) uses `cb.between` / `>=` / `<=`, all of which evaluate to UNKNOWN (excluded) for a NULL `documentDate` on Postgres. The test makes that intentional and pinned. - `Document.builder().documentDate(null)` fixture pattern is reused (`DocumentServiceTest.java:2380` cited). Good — no new builder. ### Recommendations - **Backend coverage gate is 88% branch, not 80%.** I verified `backend/CLAUDE.md:159`. The two new branches — the `nullsLast` direction branch in `resolveSort` and the `undatedOnly(true/false)` branch — each need explicit coverage or JaCoCo fails the PR. Parameterize: `resolveSort` over `{ASC, DESC}` (2 cases), `undatedOnly` over `{true, false}` (the `false → null` branch is easy to forget and will cost you the gate). - The integration test must assert the **negative** for the collision rule too: `undated=true` AND a `from`/`to` range → **zero rows**. That's the one Postgres can prove that H2 might fudge. Add it as its own one-assertion test (`undated_with_date_range_returns_empty`), not folded into another. - Frontend component test (`DocumentList.svelte.spec.ts` exists): assert the badge appears under **both** sender and receiver grouping, and explicitly assert **no synthetic undated sub-group node** exists in person-grouped modes (`getByText(docs_group_undated)` should NOT match inside a sender card). Test the absence, not just the presence. - The `.skip` placeholders for precision cases in `date.spec.ts` must each carry the `#666` reference inline. A bare `.skip` is a hidden regression risk — I want a ticket reference on every disabled assertion so it's not orphaned. - Server-load test: assert `undated=true` is forwarded as a query param **and** that toggling it does not carry `page` forward (page reset). Mock only `fetch`. ### Open Decisions - **OPEN — Secondary sort tiebreaker for undated-only results affects test determinism.** With every row null-dated, pagination order is undefined unless a tiebreaker is fixed. This isn't just a product call (Elicit raises it below) — it's a **flaky-test risk**: without a deterministic tiebreaker, the integration test asserting "page 1 contains rows X,Y,Z" will be non-deterministic across runs. Whatever default is chosen (the issue proposes upload-date ascending), it must be a **stable, total order** so the paginated tests are deterministic. I need that decided before I can write a non-flaky page-boundary assertion.
Author
Owner

Leonie Voss — Senior UX Designer & Accessibility Strategist

Observations

  • The core accessibility win here is replacing the bare em-dash. I confirmed DocumentRow.svelte:167 and :181 both render '—' for a null date. A lone em-dash is announced by screen readers as nothing (or "dash") — the reader literally cannot tell "we don't know the date" from "the field is broken." Replacing it with a text badge ("Datum unbekannt") is a genuine SR improvement, not cosmetic.
  • The spec already makes the right call on neutral, not error, styling — undated is an absence, not a failure. Reusing the metadata-chip pattern (rounded border border-line px-1.5 py-0.5 ... text-[10px] ... text-ink-3 uppercase, the pattern at :135-137) keeps it visually in the "metadata" family. I confirmed that chip class exists. Pairing with the text-ink-3 italic treatment used for unknown sender/receiver (:196, docs_list_unknown) gives a consistent "we don't know this" visual language across date AND person — that's good IA consistency.
  • The "Nur undatierte" toggle reusing the advanced-row aria-pressed pill pattern, wrapped in a real <label for> with min-h-[44px], is correct. I verified the 44px touch-target precedent at the bulk-select label (DocumentRow.svelte:61-72). Senior audience (60+) on phones: 44px is the floor, label-the-state-not-the-color is the rule.

Recommendations

  • Contrast: verify text-ink-3 on bg-surface in BOTH themes. This is the one thing the spec flags and I'm escalating it. text-[10px] uppercase tracking-widest is small text — it must hit 4.5:1. text-ink-3 is the lightest ink token; on bg-surface in dark mode it is the most likely to fail AA. Run the axe check in light AND dark (don't test light-only). If it fails, bump to text-ink-2 — do not drop below text-[10px], that's the hard minimum for the senior audience.
  • The badge needs to be redundant cue, not color-alone — it already is, because it carries the text "Datum unbekannt." Good. But ensure it's announced inline with the row, not as a decorative aria-hidden span. The text content IS the accessibility here.
  • Empty-state copy (docs_range_excludes_undated) when a date range matches nothing: write it as a full sentence that explains the cause, not "Keine Ergebnisse." Seniors need "Datumsfilter schließen undatierte Dokumente aus" so they understand why the list is empty and aren't left thinking the app broke. The spec's intent is right; make the copy explanatory.
  • Per-row badge over a synthetic sub-group is the correct UX choice (and the spec resolved it that way). A forced "Datum unbekannt" sub-group inside a sender's letters would fragment that person's correspondence and confuse the mental model "all of Grandma's letters together." Keep the letter under its person; badge the row.

Open Decisions

  • None blocking from a UX standpoint. The pattern choices are sound. The one must-do is the dark-mode contrast verification on the text-ink-3 badge — that's a gate, not a decision, so I'm logging it as a hard requirement rather than an open question.
## Leonie Voss — Senior UX Designer & Accessibility Strategist ### Observations - The core accessibility win here is replacing the **bare em-dash**. I confirmed `DocumentRow.svelte:167` and `:181` both render `'—'` for a null date. A lone em-dash is announced by screen readers as nothing (or "dash") — the reader literally cannot tell "we don't know the date" from "the field is broken." Replacing it with a text badge ("Datum unbekannt") is a genuine SR improvement, not cosmetic. - The spec already makes the right call on **neutral, not error, styling** — undated is an absence, not a failure. Reusing the metadata-chip pattern (`rounded border border-line px-1.5 py-0.5 ... text-[10px] ... text-ink-3 uppercase`, the pattern at `:135-137`) keeps it visually in the "metadata" family. I confirmed that chip class exists. Pairing with the `text-ink-3 italic` treatment used for unknown sender/receiver (`:196`, `docs_list_unknown`) gives a consistent "we don't know this" visual language across date AND person — that's good IA consistency. - The "Nur undatierte" toggle reusing the advanced-row `aria-pressed` pill pattern, wrapped in a real `<label for>` with `min-h-[44px]`, is correct. I verified the 44px touch-target precedent at the bulk-select label (`DocumentRow.svelte:61-72`). Senior audience (60+) on phones: 44px is the floor, label-the-state-not-the-color is the rule. ### Recommendations - **Contrast: verify `text-ink-3` on `bg-surface` in BOTH themes.** This is the one thing the spec flags and I'm escalating it. `text-[10px]` uppercase tracking-widest is small text — it must hit 4.5:1. `text-ink-3` is the lightest ink token; on `bg-surface` in **dark mode** it is the most likely to fail AA. Run the axe check in light AND dark (don't test light-only). If it fails, bump to `text-ink-2` — do not drop below `text-[10px]`, that's the hard minimum for the senior audience. - The badge needs to be **redundant cue, not color-alone** — it already is, because it carries the text "Datum unbekannt." Good. But ensure it's announced inline with the row, not as a decorative `aria-hidden` span. The text content IS the accessibility here. - Empty-state copy (`docs_range_excludes_undated`) when a date range matches nothing: write it as a **full sentence that explains the cause**, not "Keine Ergebnisse." Seniors need "Datumsfilter schließen undatierte Dokumente aus" so they understand *why* the list is empty and aren't left thinking the app broke. The spec's intent is right; make the copy explanatory. - Per-row badge over a synthetic sub-group is the correct UX choice (and the spec resolved it that way). A forced "Datum unbekannt" sub-group inside a sender's letters would fragment that person's correspondence and confuse the mental model "all of Grandma's letters together." Keep the letter under its person; badge the row. ### Open Decisions - _None blocking from a UX standpoint._ The pattern choices are sound. The one must-do is the **dark-mode contrast verification** on the `text-ink-3` badge — that's a gate, not a decision, so I'm logging it as a hard requirement rather than an open question.
Author
Owner

Tobias Wendt — DevOps & Platform Engineer

Observations

  • Zero infrastructure delta. No new Docker service, no new env var, no Compose change, no new port, no migration. This rides the existing meta_date column and the existing search endpoint. From an ops standpoint this is the cheapest possible feature — nothing for me to size or operate.
  • The one CI-relevant moving part is the Testcontainers postgres:16-alpine integration tests. That image is already in use across the suite, so no new pull, no new cache key. CI cost is ~3 extra integration tests + a handful of unit/component tests — well under the 2-minute integration-layer budget.
  • npm run generate:api after the undated param change is a build-step the spec correctly calls out. This is the usual gotcha: if codegen isn't re-run, the frontend openapi-fetch client won't know the param and TS will fail or silently drop it. Make sure that regen is committed in the same PR so CI's frontend type-check passes.

Recommendations

  • Pin nothing new — there's nothing to pin. The only thing I'd watch in CI: confirm the Testcontainers tests don't accidentally widen the suite's wall-clock. Three null-ordering tests against a real Postgres container that's already spun up for the suite should be near-free; if someone spins a fresh container per test class for these, that's wasteful — reuse the existing container config (@DataJpaTest + the shared PostgresContainerConfig).
  • Verify the frontend type-check gate runs after generate:api in CI ordering, so a forgotten regen fails loudly rather than shipping a stale client. This is the single most common "works locally, breaks CI" failure mode for backend-param additions.
  • No observability change needed. This isn't a new endpoint or a new error path — it's a param on an existing GET. No new metric, no new log line, no alert. Don't add one.

Open Decisions

  • None. No infrastructure, config, secrets, or deployment surface is touched. The only operational note is the generate:api + type-check ordering, which is a process reminder, not a decision.
## Tobias Wendt — DevOps & Platform Engineer ### Observations - **Zero infrastructure delta.** No new Docker service, no new env var, no Compose change, no new port, no migration. This rides the existing `meta_date` column and the existing search endpoint. From an ops standpoint this is the cheapest possible feature — nothing for me to size or operate. - The one CI-relevant moving part is the **Testcontainers `postgres:16-alpine`** integration tests. That image is already in use across the suite, so no new pull, no new cache key. CI cost is ~3 extra integration tests + a handful of unit/component tests — well under the 2-minute integration-layer budget. - `npm run generate:api` after the `undated` param change is a build-step the spec correctly calls out. This is the usual gotcha: if codegen isn't re-run, the frontend `openapi-fetch` client won't know the param and TS will fail or silently drop it. Make sure that regen is committed in the same PR so CI's frontend type-check passes. ### Recommendations - Pin nothing new — there's nothing to pin. The only thing I'd watch in CI: confirm the Testcontainers tests don't accidentally widen the suite's wall-clock. Three null-ordering tests against a real Postgres container that's already spun up for the suite should be near-free; if someone spins a **fresh** container per test class for these, that's wasteful — reuse the existing container config (`@DataJpaTest` + the shared `PostgresContainerConfig`). - Verify the frontend type-check gate runs **after** `generate:api` in CI ordering, so a forgotten regen fails loudly rather than shipping a stale client. This is the single most common "works locally, breaks CI" failure mode for backend-param additions. - No observability change needed. This isn't a new endpoint or a new error path — it's a param on an existing GET. No new metric, no new log line, no alert. Don't add one. ### Open Decisions - _None._ No infrastructure, config, secrets, or deployment surface is touched. The only operational note is the `generate:api` + type-check ordering, which is a process reminder, not a decision.
Author
Owner

Elicit — Requirements Engineer & Business Analyst (Brownfield)

Observations

  • The issue is spec-dense and INVEST-clean: a clear user story, nine Gherkin scenarios with observable outcomes, an explicit scope/out-of-scope list, and a stated dependency boundary. This passes the Definition of Ready handily. The split into "independently shippable now" vs. "blocked on #666 precision" is the right INVEST move — the first half is independently Valuable and Small; don't let the precision half block it.
  • The acceptance criteria are testable and cover the unhappy paths I'd normally have to ask for: ASC-sort surfacing (Scenario 2), every-grouping-mode labelling (Scenario 3), the undated+date-range collision (Scenario 5), and the Chronik negative guarantee (Scenario 9). The Chronik scenario as a negative assertion ("no fabricated letter date appears... no new date chip is added") is exactly how to spec a "do nothing here" guarantee so a future dev can't quietly add a date surface. I confirmed ChronikRow.svelte renders relativeTime(item.happenedAt) — the activity timestamp, not documentDate — so the negative guarantee matches reality.
  • One traceability gap worth a sentence: the frontend VALID_SORTS in +page.server.ts is ['DATE','TITLE','SENDER','RECEIVER','UPLOAD_DATE','RELEVANCE'] — it has no UPDATED_AT, yet the backend resolveSort and the issue's backend breakdown mention UPDATED as a sort path needing NULLS-LAST coverage. The backend can be reached with UPDATED_AT directly even though the UI doesn't expose it. Confirm whether the AC "this holds for ... the DATE fast path alike" is meant to cover UPDATED_AT/UPLOAD_DATE too (those sort by createdAt/updatedAt, which are non-null, so NULLS-LAST is moot for them) — if so, say so explicitly so the test author doesn't chase a non-bug.

Recommendations

  • The two RESOLVED decisions (per-row badge over sub-group; allow undated+range with explanatory empty-state) are well-reasoned and should stay resolved. Don't reopen them.
  • Tighten one ambiguity in Scenario 1: "the group shows how many undated documents it contains" — count semantics (page-local vs. total) is genuinely undecided (see below). Until decided, the AC is not unambiguously testable. Pick the default and write it into the AC so QA has a number to assert.
  • NFR check, run aloud: Accessibility (covered — badge + 44px + contrast, Leonie owns it). i18n (covered — all four new keys speced in de/en/es). Performance (no new query for the independent half; the count is page-local so no extra round-trip — keep it that way). Observability/Privacy/Security — no delta. NFRs are accounted for; nothing missing.

Open Decisions

  • OPEN — Secondary sort order for undated-only results. When every row is null-dated, pagination order is undefined. This is a triage-workflow preference (title vs. upload-date vs. insertion order) the code can't decide. Default if unanswered: upload date ascending (stable, reflects ingestion order for triage, and gives QA a deterministic total order). (Raised by: Elicit; Sara flags the same item as a test-determinism risk.)
  • OPEN — Undated group count semantics: page-local vs. total-across-pages. Scenario 1 says the group "shows how many undated documents it contains." #315 already descoped cross-page per-year totals. Default if unanswered: page-local count (consistent with #315; a grand total needs an extra COUNT query). (Raised by: Elicit.)
## Elicit — Requirements Engineer & Business Analyst (Brownfield) ### Observations - The issue is **spec-dense and INVEST-clean**: a clear user story, nine Gherkin scenarios with observable outcomes, an explicit scope/out-of-scope list, and a stated dependency boundary. This passes the Definition of Ready handily. The split into "independently shippable now" vs. "blocked on #666 precision" is the right INVEST move — the first half is independently *Valuable* and *Small*; don't let the precision half block it. - The acceptance criteria are testable and cover the unhappy paths I'd normally have to ask for: ASC-sort surfacing (Scenario 2), every-grouping-mode labelling (Scenario 3), the undated+date-range collision (Scenario 5), and the Chronik negative guarantee (Scenario 9). The Chronik scenario as a **negative assertion** ("no fabricated letter date appears... no new date chip is added") is exactly how to spec a "do nothing here" guarantee so a future dev can't quietly add a date surface. I confirmed `ChronikRow.svelte` renders `relativeTime(item.happenedAt)` — the activity timestamp, not `documentDate` — so the negative guarantee matches reality. - One **traceability gap worth a sentence**: the frontend `VALID_SORTS` in `+page.server.ts` is `['DATE','TITLE','SENDER','RECEIVER','UPLOAD_DATE','RELEVANCE']` — it has **no `UPDATED_AT`**, yet the backend `resolveSort` and the issue's backend breakdown mention `UPDATED` as a sort path needing NULLS-LAST coverage. The backend can be reached with `UPDATED_AT` directly even though the UI doesn't expose it. Confirm whether the AC "this holds for ... the DATE fast path alike" is meant to cover `UPDATED_AT`/`UPLOAD_DATE` too (those sort by `createdAt`/`updatedAt`, which are non-null, so NULLS-LAST is moot for them) — if so, say so explicitly so the test author doesn't chase a non-bug. ### Recommendations - The two RESOLVED decisions (per-row badge over sub-group; allow undated+range with explanatory empty-state) are well-reasoned and should stay resolved. Don't reopen them. - Tighten one ambiguity in Scenario 1: "the group shows how many undated documents it contains" — count semantics (page-local vs. total) is genuinely undecided (see below). Until decided, the AC is not unambiguously testable. Pick the default and write it into the AC so QA has a number to assert. - NFR check, run aloud: **Accessibility** (covered — badge + 44px + contrast, Leonie owns it). **i18n** (covered — all four new keys speced in de/en/es). **Performance** (no new query for the independent half; the count is page-local so no extra round-trip — keep it that way). **Observability/Privacy/Security** — no delta. NFRs are accounted for; nothing missing. ### Open Decisions - **OPEN — Secondary sort order for undated-only results.** When every row is null-dated, pagination order is undefined. This is a triage-workflow preference (title vs. upload-date vs. insertion order) the code can't decide. Default if unanswered: **upload date ascending** (stable, reflects ingestion order for triage, and gives QA a deterministic total order). _(Raised by: Elicit; Sara flags the same item as a test-determinism risk.)_ - **OPEN — Undated group count semantics: page-local vs. total-across-pages.** Scenario 1 says the group "shows how many undated documents it contains." #315 already descoped cross-page per-year totals. Default if unanswered: **page-local count** (consistent with #315; a grand total needs an extra `COUNT` query). _(Raised by: Elicit.)_
Author
Owner

Decision Queue — Action Required

2 decisions need your input before implementation starts. Everything else is already resolved or specified — the personas made concrete recommendations on all other points.

Product / Triage Workflow

  • Secondary sort order for undated-only results. When the "Nur undatierte" filter is active, every row has a null documentDate, so pagination order is undefined unless a tiebreaker is fixed. This is a triage-workflow preference the code cannot decide (title A→Z vs. upload date vs. insertion order). It also carries a test-determinism cost: without a stable total order, the integration test asserting "page 1 contains rows X, Y, Z" will be flaky across runs. Issue's stated default if unanswered: upload date ascending (stable, reflects ingestion order for triage). Confirm or override. (Raised by: Elicit; reinforced as a flaky-test risk by Sara.)

  • Undated group count semantics — page-local vs. total-across-pages. Scenario 1 says the "Datum unbekannt" group "shows how many undated documents it contains." #315 already descoped cross-page per-year totals. Page-local is one extra-query cheaper; a grand total needs a dedicated COUNT. Issue's stated default if unanswered: page-local count (consistent with #315). Confirm or override. (Raised by: Elicit.)

Note (not a decision — a one-line clarification request)

  • Elicit flagged a small traceability clarification: the frontend VALID_SORTS has no UPDATED_AT, while the backend resolveSort handles UPDATED/UPLOAD_DATE (which sort by non-null createdAt/updatedAt, so NULLS-LAST is moot for them). Worth one sentence in the issue confirming the NULLS-LAST scope is DATE/RELEVANCE only, so the test author doesn't chase a non-bug. This is a doc tidy-up, not a blocker.
## Decision Queue — Action Required _2 decisions need your input before implementation starts. Everything else is already resolved or specified — the personas made concrete recommendations on all other points._ ### Product / Triage Workflow - **Secondary sort order for undated-only results.** When the "Nur undatierte" filter is active, every row has a null `documentDate`, so pagination order is undefined unless a tiebreaker is fixed. This is a triage-workflow preference the code cannot decide (title A→Z vs. upload date vs. insertion order). It also carries a **test-determinism cost**: without a stable *total* order, the integration test asserting "page 1 contains rows X, Y, Z" will be flaky across runs. **Issue's stated default if unanswered: upload date ascending** (stable, reflects ingestion order for triage). Confirm or override. _(Raised by: Elicit; reinforced as a flaky-test risk by Sara.)_ - **Undated group count semantics — page-local vs. total-across-pages.** Scenario 1 says the "Datum unbekannt" group "shows how many undated documents it contains." #315 already descoped cross-page per-year totals. Page-local is one extra-query cheaper; a grand total needs a dedicated `COUNT`. **Issue's stated default if unanswered: page-local count** (consistent with #315). Confirm or override. _(Raised by: Elicit.)_ ### Note (not a decision — a one-line clarification request) - Elicit flagged a small **traceability clarification**: the frontend `VALID_SORTS` has no `UPDATED_AT`, while the backend `resolveSort` handles `UPDATED`/`UPLOAD_DATE` (which sort by non-null `createdAt`/`updatedAt`, so NULLS-LAST is moot for them). Worth one sentence in the issue confirming the NULLS-LAST scope is `DATE`/`RELEVANCE` only, so the test author doesn't chase a non-bug. This is a doc tidy-up, not a blocker.
Author
Owner

Implemented on feature/668-undated-documents (committed locally, not pushed)

8 atomic commits, red/green TDD throughout. Branch base: docs/import-migration.

NULLS-LAST across every sort path

  • DATE/RELEVANCE fast path (SQL): resolveSort now returns Sort.by(new Sort.Order(dir, "documentDate").nullsLast(), Sort.Order.asc("createdAt")) — closes the ASC bug (NULLs were first) and gives a deterministic total order when every row is null-dated (the upload-date-ascending secondary sort, per the open-decision default). Parameterized unit tests assert NULLS_LAST for ASC and DESC.
  • In-memory paths (SENDER/RECEIVER/filtered-RELEVANCE): confirmed they order by person/rank, not date — a locking test proves an undated letter stays under its sender and is never reordered by date.
  • Real-Postgres @DataJpaTest (postgres:16-alpine) pins: DATE ASC dated-first/undated-last, undatedOnly(true) returns exactly null rows, BETWEEN excludes nulls, and undated=true + range → empty (the collision rule).

Undated badge + bucket

DocumentRow no longer renders a bare em-dash. Both breakpoints route through the single DocumentDate component (cue can't drift); its unknown state is a neutral metadata chip "Datum unbekannt" (text-ink-3, non-color calendar glyph, never red/amber). The year-grouping docs_group_undated bucket is kept; person-grouped modes keep undated letters under their sender/receiver (no synthetic sub-group).

Undated-only filter + range collision

DocumentSpecifications.undatedOnly(boolean) composed through buildSearchSpec (shared by search and /ids bulk-edit). GET /search + /ids take @RequestParam(required=false) Boolean undated (read GET stays unguarded; WebMvc authz test pins 200 authenticated / 401 unauthenticated). The loader parses ?undated === 'true', forwards undated || undefined, returns it in page data; the SearchFilterBar "Nur undatierte" toggle (44px, aria-pressed) is a shareable URL param. When a from/to range yields nothing, the empty state shows docs_range_excludes_undated.

Precision rendering

Note: this base branch already has #671's precision fields on the DTO (metaDatePrecision/metaDateEnd) and #677's formatDocumentDate, so the "precision half" was not blocked — rows render honest precision ("Juni 1916", "ca. 1916") via DocumentDate, not stubs.

Chronik

Negative assertion only — ChronikRow shows the activity timestamp; ActivityFeedItemDTO has no date surface. A regression test pins that no undated badge / fabricated letter date appears.

i18n

Added docs_filter_undated_only, docs_range_excludes_undated (de/en/es). Reused existing date_precision_unknown ("Datum unbekannt") and date_precision_approx_prefix ("ca.") from #677.

Tests

  • Backend touched classes: 318 passed (DocumentServiceTest, DocumentControllerTest, DocumentSpecificationsTest, UndatedDocumentOrderingIntegrationTest, +4 search/sort integration classes). Full suite left to CI.
  • Frontend: server-project vitest 643 passed; npm run lint clean (prettier + eslint). New browser/component specs (DocumentRow badge, DocumentList badge/empty-state, SearchFilterBar toggle, ChronikRow negative) written but CI-only per project policy.

OpenAPI

undated query param hand-edited into api.ts for /search and /idsCI must run npm run generate:api to confirm parity.

Decisions applied (defaults, unanswered)

  • Secondary sort for undated-only: upload-date ascending.
  • Undated group count: page-local (no change needed; existing behavior).

Not pushed; owner to push + open PR.

## Implemented on `feature/668-undated-documents` (committed locally, not pushed) 8 atomic commits, red/green TDD throughout. Branch base: `docs/import-migration`. ### NULLS-LAST across every sort path - **DATE/RELEVANCE fast path (SQL):** `resolveSort` now returns `Sort.by(new Sort.Order(dir, "documentDate").nullsLast(), Sort.Order.asc("createdAt"))` — closes the ASC bug (NULLs were first) and gives a deterministic total order when every row is null-dated (the **upload-date-ascending** secondary sort, per the open-decision default). Parameterized unit tests assert `NULLS_LAST` for **ASC and DESC**. - **In-memory paths (SENDER/RECEIVER/filtered-RELEVANCE):** confirmed they order by person/rank, not date — a locking test proves an undated letter stays under its sender and is never reordered by date. - **Real-Postgres** `@DataJpaTest` (`postgres:16-alpine`) pins: DATE ASC dated-first/undated-last, `undatedOnly(true)` returns exactly null rows, `BETWEEN` excludes nulls, and **undated=true + range → empty** (the collision rule). ### Undated badge + bucket `DocumentRow` no longer renders a bare em-dash. Both breakpoints route through the single `DocumentDate` component (cue can't drift); its unknown state is a **neutral metadata chip** "Datum unbekannt" (`text-ink-3`, non-color calendar glyph, never red/amber). The year-grouping `docs_group_undated` bucket is kept; person-grouped modes keep undated letters under their sender/receiver (no synthetic sub-group). ### Undated-only filter + range collision `DocumentSpecifications.undatedOnly(boolean)` composed through `buildSearchSpec` (shared by search **and** `/ids` bulk-edit). `GET /search` + `/ids` take `@RequestParam(required=false) Boolean undated` (read GET stays unguarded; WebMvc authz test pins 200 authenticated / 401 unauthenticated). The loader parses `?undated === 'true'`, forwards `undated || undefined`, returns it in page data; the `SearchFilterBar` "Nur undatierte" toggle (44px, `aria-pressed`) is a shareable URL param. When a `from`/`to` range yields nothing, the empty state shows `docs_range_excludes_undated`. ### Precision rendering Note: this base branch already has **#671's precision fields on the DTO** (`metaDatePrecision`/`metaDateEnd`) and **#677's `formatDocumentDate`**, so the "precision half" was **not** blocked — rows render honest precision ("Juni 1916", "ca. 1916") via `DocumentDate`, not stubs. ### Chronik Negative assertion only — `ChronikRow` shows the activity timestamp; `ActivityFeedItemDTO` has no date surface. A regression test pins that no undated badge / fabricated letter date appears. ### i18n Added `docs_filter_undated_only`, `docs_range_excludes_undated` (de/en/es). Reused existing `date_precision_unknown` ("Datum unbekannt") and `date_precision_approx_prefix` ("ca.") from #677. ### Tests - Backend touched classes: **318 passed** (DocumentServiceTest, DocumentControllerTest, DocumentSpecificationsTest, UndatedDocumentOrderingIntegrationTest, +4 search/sort integration classes). Full suite left to CI. - Frontend: server-project vitest **643 passed**; `npm run lint` clean (prettier + eslint). New browser/component specs (DocumentRow badge, DocumentList badge/empty-state, SearchFilterBar toggle, ChronikRow negative) written but **CI-only** per project policy. ### OpenAPI `undated` query param hand-edited into `api.ts` for `/search` and `/ids` — **CI must run `npm run generate:api`** to confirm parity. ### Decisions applied (defaults, unanswered) - Secondary sort for undated-only: **upload-date ascending**. - Undated group count: **page-local** (no change needed; existing behavior). Not pushed; owner to push + open PR.
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#668