From 1caae389467b213c1da1e3fcf2f47166e1f9cbf3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 11:45:57 +0200 Subject: [PATCH] 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()); + } +}