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