From f2a74a60644b6497d1d7b6816c79636a59a695c2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 11:43:32 +0200 Subject: [PATCH 01/16] feat(frontend): add precision-aware document date formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/date-label-fixtures.json | 101 +++++++++++ frontend/messages/de.json | 18 ++ frontend/messages/en.json | 18 ++ frontend/messages/es.json | 18 ++ .../src/lib/shared/utils/documentDate.spec.ts | 105 ++++++++++++ frontend/src/lib/shared/utils/documentDate.ts | 159 ++++++++++++++++++ 6 files changed, 419 insertions(+) create mode 100644 docs/date-label-fixtures.json create mode 100644 frontend/src/lib/shared/utils/documentDate.spec.ts create mode 100644 frontend/src/lib/shared/utils/documentDate.ts diff --git a/docs/date-label-fixtures.json b/docs/date-label-fixtures.json new file mode 100644 index 00000000..c1508829 --- /dev/null +++ b/docs/date-label-fixtures.json @@ -0,0 +1,101 @@ +{ + "_comment": "Single source of truth for the honest date-label rule set shared by the TS formatDocumentDate (frontend/src/lib/shared/utils/documentDate.ts) and the Java formatTitleDate (backend importing/DocumentTitleFormatter.java). Both test suites assert against THIS table so the two implementations cannot drift (en-dash vs hyphen, 'ca.' vs 'circa', season words, range collapse). Expected labels are the GERMAN (de) canonical form: import titles are always German, and the TS formatter defaults to the de locale. Do not edit one side's expectation without editing this file and both tests. See issue #666 and the Markus/Sara drift-guard decision.", + "cases": [ + { + "name": "DAY renders a full long date", + "precision": "DAY", + "anchor": "1943-12-24", + "end": null, + "raw": null, + "expected": "24. Dezember 1943" + }, + { + "name": "MONTH renders month and year only — never a fabricated day", + "precision": "MONTH", + "anchor": "1916-06-01", + "end": null, + "raw": "Juni 1916", + "expected": "Juni 1916" + }, + { + "name": "SEASON renders the season word from raw", + "precision": "SEASON", + "anchor": "1916-06-01", + "end": null, + "raw": "Sommer 1916", + "expected": "Sommer 1916" + }, + { + "name": "SEASON with null raw derives the season from the anchor month", + "precision": "SEASON", + "anchor": "1916-04-01", + "end": null, + "raw": null, + "expected": "Frühling 1916" + }, + { + "name": "YEAR renders the year only — suppresses month and day", + "precision": "YEAR", + "anchor": "1916-06-15", + "end": null, + "raw": null, + "expected": "1916" + }, + { + "name": "APPROX renders a ca. prefix before the year", + "precision": "APPROX", + "anchor": "1920-01-01", + "end": null, + "raw": null, + "expected": "ca. 1920" + }, + { + "name": "RANGE in the same month collapses the shared month and year", + "precision": "RANGE", + "anchor": "1917-01-10", + "end": "1917-01-11", + "raw": null, + "expected": "10.–11. Jan. 1917" + }, + { + "name": "RANGE across months expands both months, sharing the year", + "precision": "RANGE", + "anchor": "1917-01-30", + "end": "1917-02-02", + "raw": null, + "expected": "30. Jan. – 2. Feb. 1917" + }, + { + "name": "RANGE across a year boundary expands both full dates", + "precision": "RANGE", + "anchor": "1916-12-30", + "end": "1917-01-02", + "raw": null, + "expected": "30. Dez. 1916 – 2. Jan. 1917" + }, + { + "name": "RANGE where end equals start collapses to a single day", + "precision": "RANGE", + "anchor": "1917-01-10", + "end": "1917-01-10", + "raw": null, + "expected": "10. Jan. 1917" + }, + { + "name": "RANGE with a null end renders an open-range indicator, never a fabricated end", + "precision": "RANGE", + "anchor": "1917-01-10", + "end": null, + "raw": null, + "expected": "ab 10. Jan. 1917" + }, + { + "name": "UNKNOWN renders the unknown label regardless of anchor", + "precision": "UNKNOWN", + "anchor": null, + "end": null, + "raw": "?", + "expected": "Datum unbekannt" + } + ] +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index a54ab59e..0ac0a807 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -261,6 +261,24 @@ "doc_preview_iframe_title": "Dokumentvorschau", "doc_image_alt": "Original-Scan", "doc_no_date": "Kein Datum", + "date_precision_unknown": "Datum unbekannt", + "date_precision_approx_prefix": "ca.", + "date_range_open_prefix": "ab", + "date_season_spring": "Frühling", + "date_season_summer": "Sommer", + "date_season_autumn": "Herbst", + "date_season_winter": "Winter", + "date_original_label": "Originaltext:", + "date_unknown_icon_label": "Datum unbekannt", + "form_label_date_precision": "Datumsgenauigkeit", + "form_label_date_end": "Enddatum", + "date_precision_option_day": "Genauer Tag", + "date_precision_option_month": "Monat", + "date_precision_option_season": "Jahreszeit", + "date_precision_option_year": "Jahr", + "date_precision_option_range": "Zeitraum", + "date_precision_option_approx": "Ungefähr", + "date_precision_option_unknown": "Unbekannt", "person_merge_will_be_deleted": "wird gelöscht.", "comp_typeahead_placeholder": "Namen tippen...", "comp_typeahead_loading": "Suche...", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5c6ca80a..269e95d3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -261,6 +261,24 @@ "doc_preview_iframe_title": "Document Preview", "doc_image_alt": "Original scan", "doc_no_date": "No date", + "date_precision_unknown": "Date unknown", + "date_precision_approx_prefix": "c.", + "date_range_open_prefix": "from", + "date_season_spring": "Spring", + "date_season_summer": "Summer", + "date_season_autumn": "Autumn", + "date_season_winter": "Winter", + "date_original_label": "Original:", + "date_unknown_icon_label": "Date unknown", + "form_label_date_precision": "Date precision", + "form_label_date_end": "End date", + "date_precision_option_day": "Exact day", + "date_precision_option_month": "Month", + "date_precision_option_season": "Season", + "date_precision_option_year": "Year", + "date_precision_option_range": "Range", + "date_precision_option_approx": "Approximate", + "date_precision_option_unknown": "Unknown", "person_merge_will_be_deleted": "will be deleted.", "comp_typeahead_placeholder": "Type a name...", "comp_typeahead_loading": "Searching...", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index cbda7fab..1cbd3eda 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -261,6 +261,24 @@ "doc_preview_iframe_title": "Vista previa del documento", "doc_image_alt": "Escaneado original", "doc_no_date": "Sin fecha", + "date_precision_unknown": "Fecha desconocida", + "date_precision_approx_prefix": "ca.", + "date_range_open_prefix": "desde", + "date_season_spring": "Primavera", + "date_season_summer": "Verano", + "date_season_autumn": "Otoño", + "date_season_winter": "Invierno", + "date_original_label": "Texto original:", + "date_unknown_icon_label": "Fecha desconocida", + "form_label_date_precision": "Precisión de la fecha", + "form_label_date_end": "Fecha final", + "date_precision_option_day": "Día exacto", + "date_precision_option_month": "Mes", + "date_precision_option_season": "Estación", + "date_precision_option_year": "Año", + "date_precision_option_range": "Periodo", + "date_precision_option_approx": "Aproximada", + "date_precision_option_unknown": "Desconocida", "person_merge_will_be_deleted": "será eliminado.", "comp_typeahead_placeholder": "Escriba un nombre...", "comp_typeahead_loading": "Buscando...", diff --git a/frontend/src/lib/shared/utils/documentDate.spec.ts b/frontend/src/lib/shared/utils/documentDate.spec.ts new file mode 100644 index 00000000..3b2d13c2 --- /dev/null +++ b/frontend/src/lib/shared/utils/documentDate.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { formatDocumentDate } from './documentDate'; +import { m } from '$lib/paraglide/messages.js'; + +// ─── Shared drift-guard fixture ───────────────────────────────────────────── +// The same table is asserted by the Java DocumentTitleFormatter test so the two +// label implementations cannot drift. Expected values are the German canonical +// form (see docs/date-label-fixtures.json). +type FixtureCase = { + name: string; + precision: string; + anchor: string | null; + end: string | null; + raw: string | null; + expected: string; +}; + +const fixtures = JSON.parse( + readFileSync(resolve(process.cwd(), '../docs/date-label-fixtures.json'), 'utf-8') +) as { cases: FixtureCase[] }; + +describe('formatDocumentDate – shared fixture table (de)', () => { + for (const c of fixtures.cases) { + it(c.name, () => { + expect( + formatDocumentDate( + c.anchor, + c.precision as Parameters[1], + c.end, + c.raw, + 'de' + ) + ).toBe(c.expected); + }); + } +}); + +// ─── Anti-fabrication: suppressed components never leak ────────────────────── + +describe('formatDocumentDate – suppressed precision components', () => { + it('YEAR of a June date renders the year only, never the month', () => { + const label = formatDocumentDate('1916-06-15', 'YEAR'); + expect(label).toBe('1916'); + expect(label).not.toContain('Juni'); + expect(label).not.toContain('15'); + }); + + it('MONTH never renders the day-of-month', () => { + const label = formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916'); + expect(label).toBe('Juni 1916'); + expect(label).not.toMatch(/\b1\.\s/); + }); +}); + +// ─── i18n: localized structured label ─────────────────────────────────────── + +describe('formatDocumentDate – localization', () => { + it('localizes the UNKNOWN label per locale', () => { + expect(formatDocumentDate(null, 'UNKNOWN', null, '?', 'en')).toBe( + m.date_precision_unknown(undefined, { locale: 'en' }) + ); + }); + + it('localizes the APPROX prefix per locale', () => { + expect(formatDocumentDate('1920-01-01', 'APPROX', null, null, 'en')).toBe( + `${m.date_precision_approx_prefix(undefined, { locale: 'en' })} 1920` + ); + }); + + it('localizes the SEASON word per locale when raw is absent', () => { + expect(formatDocumentDate('1916-07-01', 'SEASON', null, null, 'en')).toBe( + `${m.date_season_summer(undefined, { locale: 'en' })} 1916` + ); + }); + + it('localizes the SEASON word even when the raw cell is verbatim German (Decision 4)', () => { + expect(formatDocumentDate('1916-06-01', 'SEASON', null, 'Sommer 1916', 'en')).toBe( + `${m.date_season_summer(undefined, { locale: 'en' })} 1916` + ); + }); +}); + +// ─── Security: untrusted raw must never influence the structured label ─────── + +describe('formatDocumentDate – security', () => { + it('ignores a malicious raw value for the structured label (raw is rendered separately, escaped)', () => { + const label = formatDocumentDate(null, 'UNKNOWN', null, ''); + expect(label).toBe('Datum unbekannt'); + expect(label).not.toContain(' { + it('renders the unknown label when the anchor is null but precision is not UNKNOWN', () => { + expect(formatDocumentDate(null, 'DAY')).toBe('Datum unbekannt'); + }); + + it('falls back to start-day only for a RANGE whose end is null', () => { + expect(formatDocumentDate('1917-01-10', 'RANGE', null)).toBe('ab 10. Jan. 1917'); + }); +}); diff --git a/frontend/src/lib/shared/utils/documentDate.ts b/frontend/src/lib/shared/utils/documentDate.ts new file mode 100644 index 00000000..7f401974 --- /dev/null +++ b/frontend/src/lib/shared/utils/documentDate.ts @@ -0,0 +1,159 @@ +import { formatDate, formatMCDate } from './date'; +import { m } from '$lib/paraglide/messages.js'; + +/** + * Precision of a document's date — mirrors the backend {@code DatePrecision} enum + * and the import normalizer's seven values verbatim. + */ +export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APPROX' | 'UNKNOWN'; + +/** + * Renders a document date at exactly the precision the data claims — never finer. + * + * Delegates to the {@link formatDate}/{@link formatMCDate} helpers (so the + * `T12:00:00` UTC-safety convention and the German Intl formatting are shared, + * not reimplemented) and routes every localized word through Paraglide. + * + * The label is the SINGLE SOURCE OF TRUTH shared with the Java + * {@code DocumentTitleFormatter}: both are asserted against + * `docs/date-label-fixtures.json` so they cannot drift. The untrusted `raw` + * cell is only used to derive a season word (a known German season token) — it + * is otherwise rendered separately by the caller via Svelte default escaping, + * never interpolated into HTML here. + * + * @param iso the sort/filter anchor day (`YYYY-MM-DD`), nullable for UNKNOWN rows + * @param precision descriptive precision metadata + * @param end the RANGE end day; null means an open-ended range + * @param raw the verbatim spreadsheet cell, used only for the SEASON word + * @param locale BCP 47 tag for the localized structured parts (default `de-DE`) + */ +export function formatDocumentDate( + iso: string | null | undefined, + precision: DatePrecision, + end?: string | null, + raw?: string | null, + locale: string = 'de-DE' +): string { + if (precision === 'UNKNOWN' || !iso) { + return m.date_precision_unknown(undefined, { locale: messageLocale(locale) }); + } + + const year = iso.slice(0, 4); + + switch (precision) { + case 'DAY': + return formatDate(iso, 'long'); + case 'MONTH': + return monthYear(iso, locale); + case 'SEASON': + return seasonLabel(iso, raw, locale, year); + case 'YEAR': + return year; + case 'APPROX': + return `${m.date_precision_approx_prefix(undefined, { locale: messageLocale(locale) })} ${year}`; + case 'RANGE': + return rangeLabel(iso, end, locale); + default: + return m.date_precision_unknown(undefined, { locale: messageLocale(locale) }); + } +} + +// ─── precision branches ────────────────────────────────────────────────────── + +function monthYear(iso: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(noon(iso)); +} + +function seasonLabel( + iso: string, + raw: string | null | undefined, + locale: string, + year: string +): string { + const month = Number(iso.slice(5, 7)); + // Prefer the season named in the raw cell; fall back to deriving it from the + // anchor month. Either way the WORD is localized (Decision 4) — the verbatim + // German raw cell is preserved separately as the visible secondary line. + const season = seasonFromRaw(raw) ?? seasonOfMonth(month); + return `${seasonWord(season, locale)} ${year}`; +} + +function rangeLabel(iso: string, end: string | null | undefined, locale: string): string { + if (!end) { + return `${m.date_range_open_prefix(undefined, { locale: messageLocale(locale) })} ${formatMCDate(iso, locale)}`; + } + if (end === iso) { + return formatMCDate(iso, locale); + } + const start = noon(iso); + const finish = noon(end); + if (start.getFullYear() === finish.getFullYear()) { + return sameYearRange(end, start, finish, locale); + } + return `${formatMCDate(iso, locale)} – ${formatMCDate(end, locale)}`; +} + +function sameYearRange(end: string, start: Date, finish: Date, locale: string): string { + if (start.getMonth() === finish.getMonth()) { + // Collapse the shared month/year: only the end carries "DD. Mon. YYYY". + return `${start.getDate()}.–${formatMCDate(end, locale)}`; + } + const startNoYear = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format( + start + ); + return `${startNoYear} – ${formatMCDate(end, locale)}`; +} + +// ─── season helpers ────────────────────────────────────────────────────────── + +type Season = 'spring' | 'summer' | 'autumn' | 'winter'; + +/** Quarter buckets; matches the normalizer's representative months (4/7/10/1). */ +function seasonOfMonth(month: number): Season { + if (month >= 3 && month <= 5) return 'spring'; + if (month >= 6 && month <= 8) return 'summer'; + if (month >= 9 && month <= 11) return 'autumn'; + return 'winter'; +} + +function seasonWord(season: Season, locale: string): string { + const opts = { locale: messageLocale(locale) }; + switch (season) { + case 'spring': + return m.date_season_spring(undefined, opts); + case 'summer': + return m.date_season_summer(undefined, opts); + case 'autumn': + return m.date_season_autumn(undefined, opts); + case 'winter': + return m.date_season_winter(undefined, opts); + } +} + +/** Maps a German season token at the start of the raw cell to a Season, else null. */ +function seasonFromRaw(raw: string | null | undefined): Season | null { + if (!raw) return null; + const token = raw.trim().split(/\s+/)[0].toLowerCase(); + const byToken: Record = { + frühling: 'spring', + frühjahr: 'spring', + sommer: 'summer', + herbst: 'autumn', + winter: 'winter' + }; + return byToken[token] ?? null; +} + +// ─── shared utilities ──────────────────────────────────────────────────────── + +function noon(iso: string): Date { + return new Date(iso + 'T12:00:00'); +} + +/** Paraglide expects a registered locale tag; map `de-DE` → `de` etc. */ +function messageLocale(locale: string): 'de' | 'en' | 'es' { + const base = locale.slice(0, 2); + if (base === 'en') return 'en'; + if (base === 'es') return 'es'; + return 'de'; +} -- 2.49.1 From 1caae389467b213c1da1e3fcf2f47166e1f9cbf3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 11:45:57 +0200 Subject: [PATCH 02/16] feat(importing): add precision-aware DocumentTitleFormatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../importing/DocumentTitleFormatter.java | 112 ++++++++++++++++++ .../importing/DocumentTitleFormatterTest.java | 49 ++++++++ 2 files changed, 161 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java new file mode 100644 index 00000000..65120004 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatter.java @@ -0,0 +1,112 @@ +package org.raddatz.familienarchiv.importing; + +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** + * Produces the honest German date label baked into an import title — at exactly + * the precision the data claims, never finer. This is the Java half of the + * single source of truth shared with the frontend {@code formatDocumentDate} + * (TypeScript): both are asserted against {@code docs/date-label-fixtures.json} + * so the two implementations cannot drift (see #666). + * + *

Import titles are always German, so the labels here are the German + * canonical form (mirroring the {@code de} Paraglide messages used by the UI). + */ +final class DocumentTitleFormatter { + + private static final DateTimeFormatter LONG = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN); + private static final DateTimeFormatter MONTH_YEAR = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN); + private static final DateTimeFormatter MEDIUM = DateTimeFormatter.ofPattern("d. MMM yyyy", Locale.GERMAN); + private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("d. MMM", Locale.GERMAN); + + private static final String UNKNOWN = "Datum unbekannt"; + private static final String APPROX_PREFIX = "ca."; + private static final String OPEN_RANGE_PREFIX = "ab"; + + private DocumentTitleFormatter() { + } + + /** + * @param date the sort/filter anchor day; null for UNKNOWN rows + * @param precision descriptive precision metadata + * @param end the RANGE end day; null means an open-ended range + * @param raw the verbatim spreadsheet cell, used only to pick a season word + * @return the honest German label + */ + static String formatTitleDate(LocalDate date, DatePrecision precision, LocalDate end, String raw) { + if (precision == DatePrecision.UNKNOWN || date == null) { + return UNKNOWN; + } + return switch (precision) { + case DAY -> LONG.format(date); + case MONTH -> MONTH_YEAR.format(date); + case SEASON -> seasonLabel(date, raw); + case YEAR -> String.valueOf(date.getYear()); + case APPROX -> APPROX_PREFIX + " " + date.getYear(); + case RANGE -> rangeLabel(date, end); + case UNKNOWN -> UNKNOWN; + }; + } + + private static String seasonLabel(LocalDate date, String raw) { + Season season = seasonFromRaw(raw); + if (season == null) { + season = seasonOfMonth(date.getMonthValue()); + } + return season.german + " " + date.getYear(); + } + + private static String rangeLabel(LocalDate start, LocalDate end) { + if (end == null) { + return OPEN_RANGE_PREFIX + " " + MEDIUM.format(start); + } + if (end.equals(start)) { + return MEDIUM.format(start); + } + if (start.getYear() != end.getYear()) { + return MEDIUM.format(start) + " – " + MEDIUM.format(end); + } + if (start.getMonthValue() == end.getMonthValue()) { + return start.getDayOfMonth() + ".–" + MEDIUM.format(end); + } + return DAY_MONTH.format(start) + " – " + MEDIUM.format(end); + } + + // ─── season mapping — mirrors the normalizer's representative months ───────────── + + private enum Season { + SPRING("Frühling"), + SUMMER("Sommer"), + AUTUMN("Herbst"), + WINTER("Winter"); + + private final String german; + + Season(String german) { + this.german = german; + } + } + + private static Season seasonOfMonth(int month) { + if (month >= 3 && month <= 5) return Season.SPRING; + if (month >= 6 && month <= 8) return Season.SUMMER; + if (month >= 9 && month <= 11) return Season.AUTUMN; + return Season.WINTER; + } + + private static Season seasonFromRaw(String raw) { + if (raw == null || raw.isBlank()) return null; + String token = raw.trim().split("\\s+")[0].toLowerCase(Locale.GERMAN); + return switch (token) { + case "frühling", "frühjahr" -> Season.SPRING; + case "sommer" -> Season.SUMMER; + case "herbst" -> Season.AUTUMN; + case "winter" -> Season.WINTER; + default -> null; + }; + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java new file mode 100644 index 00000000..d8f66b6e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentTitleFormatterTest.java @@ -0,0 +1,49 @@ +package org.raddatz.familienarchiv.importing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Asserts the Java title label against the SAME shared fixture table the TS + * formatter spec uses ({@code docs/date-label-fixtures.json}). This is the + * drift guard requested in #666 review: the two label implementations cannot + * silently diverge (en-dash vs hyphen, "ca." vs "circa", season words, range + * collapse) because both are pinned to one committed rule set. + */ +class DocumentTitleFormatterTest { + + @TestFactory + List matchesSharedFixtureTable() throws Exception { + // Maven runs tests from the backend/ module dir; the fixture lives at repo-root docs/. + Path fixture = Path.of("..", "docs", "date-label-fixtures.json"); + JsonNode root = new ObjectMapper().readTree(Files.readString(fixture)); + List tests = new ArrayList<>(); + for (JsonNode c : root.get("cases")) { + String name = c.get("name").asText(); + LocalDate anchor = parseDate(c.get("anchor")); + DatePrecision precision = DatePrecision.valueOf(c.get("precision").asText()); + LocalDate end = parseDate(c.get("end")); + String raw = c.get("raw").isNull() ? null : c.get("raw").asText(); + String expected = c.get("expected").asText(); + tests.add(DynamicTest.dynamicTest(name, () -> + assertThat(DocumentTitleFormatter.formatTitleDate(anchor, precision, end, raw)) + .isEqualTo(expected))); + } + return tests; + } + + private static LocalDate parseDate(JsonNode node) { + return node == null || node.isNull() ? null : LocalDate.parse(node.asText()); + } +} -- 2.49.1 From c816934391e7ce7533c9de2d1d1864292f7cf19a Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 11:47:51 +0200 Subject: [PATCH 03/16] feat(importing): build honest precision-aware document import titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../importing/DocumentImporter.java | 32 +++++++++++--- .../importing/DocumentImporterTest.java | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java index 6be566ab..085a4ff4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java @@ -159,7 +159,13 @@ public class DocumentImporter { Person sender = resolveSender(row.get("sender_person_id"), senderName); Set receivers = resolveReceivers(row.get("receiver_person_ids")); - doc.setTitle(index); + LocalDate date = parseIsoDate(row.get("date_iso")); + DatePrecision precision = parsePrecision(row.get("date_precision")); + LocalDate dateEnd = parseIsoDate(row.get("date_end")); + String dateRaw = blankToNull(row.get("date_raw")); + String location = blankToNull(row.get("location")); + + doc.setTitle(buildTitle(index, date, precision, dateEnd, dateRaw, location)); doc.setStatus(status); doc.setFilePath(s3Key); doc.setContentType(contentType); @@ -171,17 +177,31 @@ public class DocumentImporter { doc.getReceivers().clear(); doc.getReceivers().addAll(receivers); doc.setReceiverText(blankToNull(receiverNames)); - doc.setDocumentDate(parseIsoDate(row.get("date_iso"))); - doc.setMetaDatePrecision(parsePrecision(row.get("date_precision"))); - doc.setMetaDateEnd(parseIsoDate(row.get("date_end"))); - doc.setMetaDateRaw(blankToNull(row.get("date_raw"))); - doc.setLocation(blankToNull(row.get("location"))); + doc.setDocumentDate(date); + doc.setMetaDatePrecision(precision); + doc.setMetaDateEnd(dateEnd); + doc.setMetaDateRaw(dateRaw); + doc.setLocation(location); doc.setSummary(blankToNull(row.get("summary"))); attachTag(doc, row.get("tags")); doc.setMetadataComplete(doc.getDocumentDate() != null || sender != null || !receivers.isEmpty()); return doc; } + // The title carries the date at the HONEST precision (never a fabricated day) via the + // shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating. + private static String buildTitle(String index, LocalDate date, DatePrecision precision, + LocalDate end, String raw, String location) { + StringBuilder title = new StringBuilder(index); + if (date != null && precision != DatePrecision.UNKNOWN) { + title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw)); + } + if (location != null && !location.isBlank()) { + title.append(" – ").append(location); + } + return title.toString(); + } + // ─── attribution routing — register-first, always retain raw ───────────────────── private Person resolveSender(String slug, String rawName) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java index f0b2263b..99d7bd5c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java @@ -404,6 +404,50 @@ class DocumentImporterTest { d.getReceivers().isEmpty() && d.getTags().isEmpty())); } + // ─── title carries the honest date label — never a precision the data lacks ─────── + + @Test + void load_buildsTitleWithMonthLabel_whenPrecisionIsMonth(@TempDir Path tempDir) throws Exception { + ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); + when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty()); + when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "", "", + "1916-06-01", "Juni 1916", "MONTH", "")); + + importer.load(xlsx.toFile()); + + verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> + d.getTitle().contains("Juni 1916") && !d.getTitle().contains("1. Juni"))); + } + + @Test + void load_buildsTitleWithFullDate_whenPrecisionIsDay(@TempDir Path tempDir) throws Exception { + ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); + when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty()); + when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "", "", + "1943-12-24", "24.12.1943", "DAY", "")); + + importer.load(xlsx.toFile()); + + verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> + d.getTitle().contains("24. Dezember 1943"))); + } + + @Test + void load_buildsTitleFromIndexOnly_whenDateUnknown(@TempDir Path tempDir) throws Exception { + ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); + when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty()); + when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "", "", + "", "?", "UNKNOWN", "")); + + importer.load(xlsx.toFile()); + + verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> + d.getTitle().equals("W-0102"))); + } + // ─── helpers ───────────────────────────────────────────────────────────────────── private Map docRow(String index, String file, String senderId, String senderName, -- 2.49.1 From 6538c9e59abfd1b3e402451cef4033e36fe04680 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 11:49:35 +0200 Subject: [PATCH 04/16] feat(frontend): add accessible DocumentDate render component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/lib/document/DocumentDate.svelte | 60 +++++++++++++++++++ .../lib/document/DocumentDate.svelte.test.ts | 35 +++++++++++ 2 files changed, 95 insertions(+) create mode 100644 frontend/src/lib/document/DocumentDate.svelte create mode 100644 frontend/src/lib/document/DocumentDate.svelte.test.ts diff --git a/frontend/src/lib/document/DocumentDate.svelte b/frontend/src/lib/document/DocumentDate.svelte new file mode 100644 index 00000000..a3539c31 --- /dev/null +++ b/frontend/src/lib/document/DocumentDate.svelte @@ -0,0 +1,60 @@ + + + + + {#if isUnknown} + + + {/if} + {label} + + {#if showRawLine} + + {m.date_original_label()} {raw} + {/if} + diff --git a/frontend/src/lib/document/DocumentDate.svelte.test.ts b/frontend/src/lib/document/DocumentDate.svelte.test.ts new file mode 100644 index 00000000..fa842b7b --- /dev/null +++ b/frontend/src/lib/document/DocumentDate.svelte.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentDate from './DocumentDate.svelte'; + +// Browser-project (Playwright) tests — CI only. + +afterEach(cleanup); + +describe('DocumentDate', () => { + it('renders a DAY date as a full long date', async () => { + render(DocumentDate, { props: { iso: '1943-12-24', precision: 'DAY' } }); + await expect.element(page.getByText('24. Dezember 1943')).toBeInTheDocument(); + }); + + it('renders MONTH precision as month + year, never a day', async () => { + render(DocumentDate, { props: { iso: '1916-06-01', precision: 'MONTH', raw: 'Juni 1916' } }); + await expect.element(page.getByText('Juni 1916')).toBeInTheDocument(); + }); + + it('shows the verbatim raw cell as a visible secondary line for UNKNOWN (not tooltip-only)', async () => { + render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: 'Sommer?' } }); + // Real, visible text — not hidden behind a title attribute. + await expect.element(page.getByText('Datum unbekannt')).toBeInTheDocument(); + await expect.element(page.getByText(/Sommer\?/)).toBeVisible(); + }); + + it('renders a malicious raw value as inert escaped text (no element injected)', async () => { + const malicious = ''; + render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: malicious } }); + // The payload appears as literal text, and no is created in the DOM. + await expect.element(page.getByText(/ Date: Wed, 27 May 2026 11:56:49 +0200 Subject: [PATCH 05/16] feat(frontend): render honest precision dates in detail, list and search 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 --- .../document/DocumentMetadataDrawer.svelte | 22 ++++++++++++-- .../lib/document/DocumentMultiSelect.svelte | 13 +++++--- .../DocumentMultiSelect.svelte.spec.ts | 4 ++- frontend/src/lib/document/DocumentRow.svelte | 24 +++++++++++++-- .../lib/document/DocumentRow.svelte.spec.ts | 1 + .../lib/document/DocumentRow.svelte.test.ts | 1 + .../src/lib/document/DocumentTopBar.svelte | 10 +++++++ .../lib/document/DocumentTopBarTitle.svelte | 30 +++++++++++++------ 8 files changed, 86 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/document/DocumentMetadataDrawer.svelte b/frontend/src/lib/document/DocumentMetadataDrawer.svelte index 01ecc62a..4b8081e9 100644 --- a/frontend/src/lib/document/DocumentMetadataDrawer.svelte +++ b/frontend/src/lib/document/DocumentMetadataDrawer.svelte @@ -4,6 +4,8 @@ import { formatDate } from '$lib/shared/utils/date'; import { formatDocumentStatus } from '$lib/document/documentStatusLabel'; import { getInitials, personAvatarColor } from '$lib/person/personFormat'; import RelationshipPill from '$lib/person/relationship/RelationshipPill.svelte'; +import DocumentDate from './DocumentDate.svelte'; +import type { DatePrecision } from '$lib/shared/utils/documentDate'; type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Tag = { id: string; name: string }; @@ -16,6 +18,9 @@ type GeschichteSummary = { type Props = { documentDate: string | null; + metaDatePrecision?: DatePrecision | null; + metaDateEnd?: string | null; + metaDateRaw?: string | null; location: string | null; status: string; sender: Person | null; @@ -29,6 +34,9 @@ type Props = { let { documentDate, + metaDatePrecision = null, + metaDateEnd = null, + metaDateRaw = null, location, status, sender, @@ -59,7 +67,6 @@ function formatGeschichteDate(g: GeschichteSummary): string { return formatDate(g.publishedAt.slice(0, 10), 'short'); } -const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—'); const displayLocation = $derived(location ?? '—'); const statusLabel = $derived(formatDocumentStatus(status)); const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT)); @@ -105,7 +112,18 @@ function getFullName(person: Person): string {

{m.doc_details_field_date()}
-
{formattedDate}
+
+ {#if documentDate || metaDateRaw} + + {:else} + — + {/if} +
{m.form_label_location()}
diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte b/frontend/src/lib/document/DocumentMultiSelect.svelte index 0196544b..dcb4ecca 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte @@ -2,7 +2,8 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/shared/actions/clickOutside'; -import { formatDate } from '$lib/shared/utils/date'; +import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; +import { getLocale } from '$lib/paraglide/runtime.js'; type Document = components['schemas']['Document']; type DocumentListItem = components['schemas']['DocumentListItem']; @@ -49,7 +50,9 @@ function handleInput() { const docs = body.items.map((it) => ({ id: it.id, title: it.title, - documentDate: it.documentDate + documentDate: it.documentDate, + metaDatePrecision: it.metaDatePrecision, + metaDateEnd: it.metaDateEnd })) as unknown as Document[]; results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id)); } @@ -73,8 +76,10 @@ function removeDocument(id: string | undefined) { } function formatDocLabel(doc: Document): string { - if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`; - return doc.title; + if (!doc.documentDate) return doc.title; + const precision = (doc.metaDatePrecision as DatePrecision | undefined) ?? 'DAY'; + const label = formatDocumentDate(doc.documentDate, precision, doc.metaDateEnd, null, getLocale()); + return `${doc.title} · ${label}`; } diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts b/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts index 6514ab55..d348026c 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts @@ -9,6 +9,7 @@ const docFactory = (id: string, title: string, date = '1880-01-01') => ({ id, title, documentDate: date, + metaDatePrecision: 'DAY' as const, originalFilename: `${title}.pdf`, receivers: [], tags: [], @@ -55,7 +56,8 @@ describe('DocumentMultiSelect — rendering', () => { selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')] }); await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument(); - await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument(); + // DAY precision renders the honest long date (formatDocumentDate), not 01.05.1882. + await expect.element(page.getByText(/1\. Mai 1882/)).toBeInTheDocument(); }); it('emits a hidden documentIds input for each pre-selected document', async () => { diff --git a/frontend/src/lib/document/DocumentRow.svelte b/frontend/src/lib/document/DocumentRow.svelte index 903ed727..ac9fde0a 100644 --- a/frontend/src/lib/document/DocumentRow.svelte +++ b/frontend/src/lib/document/DocumentRow.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import type { components } from '$lib/generated/api'; import { applyOffsets } from '$lib/document/search'; -import { formatDate } from '$lib/shared/utils/date'; +import DocumentDate from './DocumentDate.svelte'; import * as m from '$lib/paraglide/messages.js'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte'; @@ -164,7 +164,16 @@ function safeTagColor(color: string | null | undefined): string {
- {doc.documentDate ? formatDate(doc.documentDate) : '—'} + {#if doc.documentDate} + + {:else} + — + {/if}
@@ -178,7 +187,16 @@ function safeTagColor(color: string | null | undefined): string {