Compare commits

..

16 Commits

Author SHA1 Message Date
Marcel
09b810afb6 test(dates): update top-bar specs to honest long DAY label
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m46s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
The top bar now renders document dates through formatDocumentDate, so a
DAY-precision date like 1923-04-15 renders as "15. April 1923" (de) via
Intl.DateTimeFormat — no longer the old short "15.04.1923". These two
browser-project specs still asserted the old short form and were never
updated (CI-only, not run locally by prior agents).

Refs #666

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

Refs #666
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:37:42 +02:00
Marcel
f99673321c test(dates): pin edit-form precision field binding to DocumentUpdateDTO
@WebMvcTest multipart PUT asserting metaDatePrecision / metaDateEnd /
metaDateRaw form field names bind to the DTO. A rename on either side
silently drops the precision edit; the captured DTO catches it.

Refs #666
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:36:51 +02:00
Marcel
728078f1e5 fix(dates): preserve stored date precision when edit omits it
updateDocument unconditionally set metaDatePrecision/End/Raw from the DTO,
so saving an unrelated edit (a multipart PUT where the form omits the
precision controls) clobbered the stored precision with null — fabricating
a precision the user never chose. Apply each field only when the DTO carries
it, mirroring the existing metadataComplete/scriptType guards.

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

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:22:46 +02:00
Marcel
6cc622b4db refactor(dates): type DocumentMultiSelect options without double-cast
The search results were mapped to a partial object then forced with
`as unknown as Document[]`. DocumentListItem already carries every field
the picker reads (id, title, documentDate, metaDatePrecision REQUIRED,
metaDateEnd), so introduce a DocumentOption Pick type and drop the
double-cast — the mapped objects are now honestly typed.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:22:06 +02:00
Marcel
4169373693 fix(dates): meet 48px touch target on RANGE end-date input
The end-date input used px-2 py-3 with no min-h while the sibling
precision select sets min-h-[48px]. Add min-h-[48px] so the RANGE form
is uniformly senior-friendly (WCAG 2.2 2.5.8, matches the select).

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:19:37 +02:00
Marcel
8ed5b1e9e3 fix(dates): make DAY precision locale-aware in formatDocumentDate
DAY precision routed through formatDate() which hard-coded de-DE, so an
en/es reader saw the German month name ("24. Dezember 1943"). Route DAY
through Intl.DateTimeFormat(locale, …) like the other branches, keeping
the T12:00:00 UTC-safety convention. Add en/es DAY+MONTH parity cases to
docs/date-label-fixtures.json (TS-only; the Java title formatter stays
German by design) and assert them in the spec.

Refs #666

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

Closes #666

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

Refs #666

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

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

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:04:14 +02:00
Marcel
b56b9dfa74 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 <noreply@anthropic.com>
2026-05-27 11:56:49 +02:00
Marcel
6538c9e59a feat(frontend): add accessible DocumentDate render component
Wraps formatDocumentDate with the accessible presentation layer: a non-color
UNKNOWN cue (decorative calendar-with-question icon, aria-hidden, since the
visible "Datum unbekannt" text is the textual cue — WCAG 1.4.1), and the
verbatim meta_date_raw shown as a VISIBLE secondary "Originaltext: …" line for
UNKNOWN/SEASON/APPROX (WCAG 1.4.13, not tooltip-only). raw is rendered via
Svelte default escaping, never {@html} (CWE-79); a component test asserts an
angle-bracket raw value stays inert. Browser test is CI-only.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:49:35 +02:00
Marcel
c816934391 feat(importing): build honest precision-aware document import titles
Wires DocumentTitleFormatter into DocumentImporter.buildDocument: the title
now reads "{index} – {honest date label} – {location}", so a MONTH-precision
letter's title says "Juni 1916" instead of a fabricated "1. Juni 1916", and an
UNKNOWN-date row keeps a bare index title. buildTitle stays under 20 lines by
delegating to the shared formatter (single source of truth with the UI label).

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

Refs #666

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

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:45:57 +02:00
Marcel
f2a74a6064 feat(frontend): add precision-aware document date formatter
Adds formatDocumentDate — a pure, branch-per-precision label function that
renders a document date at exactly the precision the data claims (DAY → full
date, MONTH → "Juni 1916", SEASON → localized season word, YEAR → "1916",
APPROX → "ca. 1916", RANGE with collapse/expand/open-ended, UNKNOWN → "Datum
unbekannt"). Delegates to the existing date.ts helpers (shared T12:00:00
convention) and routes every localized word through Paraglide.

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

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

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:43:32 +02:00
31 changed files with 1203 additions and 37 deletions

View File

@@ -65,6 +65,29 @@ jobs:
exit 1
fi
- name: Assert no raw document date rendered via {@html} (CWE-79 — #666)
shell: bash
run: |
# meta_date_raw is untrusted verbatim spreadsheet text — it must render via
# Svelte default escaping, never {@html}. This guard flags any {@html ...}
# whose expression references a raw-date variable. A comment mentioning
# "{@html}" without a raw token inside the braces does NOT match.
# The token list MUST cover every variable that carries the raw value:
# DocumentDate.svelte exposes it via the `raw` prop, so `\braw\b` is included.
# Grow this list whenever a new raw-bearing variable name is introduced.
pattern='\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b)'
# Self-test: the regex must catch the dangerous forms and ignore the comment form.
printf '{@html doc.metaDateRaw}\n' | grep -qP "$pattern" \
|| { echo "FAIL: guard self-test — regex missed the unsafe {@html metaDateRaw} form"; exit 1; }
printf '{@html raw}\n' | grep -qP "$pattern" \
|| { echo "FAIL: guard self-test — regex missed the unsafe {@html raw} form (DocumentDate prop)"; exit 1; }
printf 'never use {@html} for this\n' | grep -qvP "$pattern" \
|| { echo "FAIL: guard self-test — regex wrongly flagged a {@html} comment"; exit 1; }
if grep -rPln "$pattern" --include='*.svelte' frontend/src/; then
echo "FAIL: meta_date_raw rendered via {@html} — use default {…} escaping (CWE-79, #666)."
exit 1
fi
- name: Assert no (upload|download)-artifact past v3
shell: bash
run: |

View File

@@ -272,6 +272,7 @@ For multipart/form-data (file uploads): bypass the typed client and use `event.f
| Form display | German `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()` |
| Wire format | ISO 8601 via a hidden `<input type="hidden" name="documentDate" value={dateIso}>` |
| Display | `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` |
| Honest precision display | `formatDocumentDate(iso, precision, end?, raw?, locale?)` (`$lib/shared/utils/documentDate.ts`) or the `<DocumentDate>` component — renders a document date at exactly its `meta_date_precision` (MONTH → "Juni 1916", never a fabricated day). It mirrors the Java `DocumentTitleFormatter`; both are pinned to `docs/date-label-fixtures.json` so the title and UI labels can't drift. `meta_date_raw` is untrusted — render it via default escaping, never `{@html}` (a CI guard enforces this). |
### Security checklist (new endpoint)

View File

@@ -378,6 +378,7 @@ public class DocumentService {
// 1. Einfache Felder Update
doc.setTitle(dto.getTitle());
doc.setDocumentDate(dto.getDocumentDate());
applyDatePrecision(doc, dto);
doc.setLocation(dto.getLocation());
doc.setTranscription(dto.getTranscription());
doc.setSummary(dto.getSummary());
@@ -446,6 +447,25 @@ public class DocumentService {
return saved;
}
/**
* Applies the three date-precision fields only when the DTO carries them.
* A null field means "not submitted" — overwriting the stored value with null
* would fabricate a precision the user never chose, the exact dishonesty #666
* exists to prevent. A row with a genuinely-unknown precision must keep it when
* an unrelated edit (e.g. a location typo) is saved.
*/
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
if (dto.getMetaDatePrecision() != null) {
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
}
if (dto.getMetaDateEnd() != null) {
doc.setMetaDateEnd(dto.getMetaDateEnd());
}
if (dto.getMetaDateRaw() != null) {
doc.setMetaDateRaw(dto.getMetaDateRaw());
}
}
@Transactional
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId)

View File

@@ -159,7 +159,13 @@ public class DocumentImporter {
Person sender = resolveSender(row.get("sender_person_id"), senderName);
Set<Person> 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) {

View File

@@ -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).
*
* <p>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;
};
}
}

View File

@@ -294,6 +294,34 @@ class DocumentControllerTest {
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateDocument_bindsPrecisionFormFields_toDTO() throws Exception {
// Pins the wire contract: the edit form's metaDatePrecision / metaDateEnd /
// metaDateRaw multipart field names must bind to DocumentUpdateDTO. A rename
// on either side silently drops the precision edit; this captures the DTO.
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Brief").originalFilename("brief.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.ArgumentCaptor<DocumentUpdateDTO> captor =
org.mockito.ArgumentCaptor.forClass(DocumentUpdateDTO.class);
when(documentService.updateDocument(eq(id), captor.capture(), any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents/" + id)
.param("metaDatePrecision", "RANGE")
.param("metaDateEnd", "1917-01-11")
.param("metaDateRaw", "10.11. Januar 1917")
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
.andExpect(status().isOk());
DocumentUpdateDTO bound = captor.getValue();
org.assertj.core.api.Assertions.assertThat(bound.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
org.assertj.core.api.Assertions.assertThat(bound.getMetaDateEnd())
.isEqualTo(java.time.LocalDate.of(1917, 1, 11));
org.assertj.core.api.Assertions.assertThat(bound.getMetaDateRaw()).isEqualTo("10.11. Januar 1917");
}
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
@Test

View File

@@ -144,6 +144,53 @@ class DocumentServiceTest {
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
}
@Test
void updateDocument_persistsDatePrecisionEndAndRaw() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setDocumentDate(LocalDate.of(1917, 1, 10));
dto.setMetaDatePrecision(DatePrecision.RANGE);
dto.setMetaDateEnd(LocalDate.of(1917, 1, 11));
dto.setMetaDateRaw("10.11. Januar 1917");
documentService.updateDocument(id, dto, null, null);
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
assertThat(doc.getMetaDateRaw()).isEqualTo("10.11. Januar 1917");
}
@Test
void updateDocument_preservesStoredPrecision_whenDtoOmitsIt() throws Exception {
// Editing a doc (e.g. fixing a location typo) without touching the precision
// controls must NOT fabricate a precision. The form omits the three precision
// fields → they arrive null on the DTO → the stored values must be preserved.
UUID id = UUID.randomUUID();
Document doc = Document.builder()
.id(id)
.metaDatePrecision(DatePrecision.MONTH)
.metaDateEnd(LocalDate.of(1916, 6, 30))
.metaDateRaw("Juni 1916")
.receivers(new HashSet<>())
.tags(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setLocation("Berlin"); // unrelated edit; precision fields left null
documentService.updateDocument(id, dto, null, null);
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH);
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
}
// ─── deleteTagCascading ───────────────────────────────────────────────────
@Test

View File

@@ -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<String, String> docRow(String index, String file, String senderId, String senderName,

View File

@@ -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<DynamicTest> 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<DynamicTest> 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());
}
}

View File

@@ -16,7 +16,8 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
Component(tagTreeLoader, "TagTreeImporter", "Spring Component", "Upserts the tag hierarchy from canonical-tag-tree.xlsx via TagService (by canonical tag_path).")
Component(personRegLoader, "PersonRegisterImporter", "Spring Component", "Upserts register persons from canonical-persons.xlsx via PersonService (by normalizer person_id).")
Component(personTreeLoader, "PersonTreeImporter", "Spring Component", "Upserts tree persons + relationships from canonical-persons-tree.json via PersonService and RelationshipService.")
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, keeps the S3 upload + thumbnail plumbing, and ports the path-traversal / homoglyph / absolute-path / %PDF magic-byte security guards.")
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds an honest precision-aware title via DocumentTitleFormatter, keeps the S3 upload + thumbnail plumbing, and ports the path-traversal / homoglyph / absolute-path / %PDF magic-byte security guards.")
Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.")
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
@@ -43,6 +44,7 @@ Rel(importOrch, docLoader, "4. Loads documents")
Rel(tagTreeLoader, sheetReader, "Reads canonical .xlsx")
Rel(personRegLoader, sheetReader, "Reads canonical .xlsx")
Rel(docLoader, sheetReader, "Reads canonical .xlsx")
Rel(docLoader, titleFmt, "Builds honest title date")
Rel(tagTreeLoader, tagSvc, "Upserts tags by source_ref")
Rel(personRegLoader, personSvc, "Upserts persons by source_ref")
Rel(personTreeLoader, personSvc, "Upserts persons by source_ref")

View File

@@ -0,0 +1,140 @@
{
"_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). The 'cases' array holds the GERMAN (de) canonical form and is asserted by BOTH suites — that is the Java<->TS drift guard (en-dash vs hyphen, 'ca.' vs 'circa', season words, range collapse). The Java title formatter intentionally renders German server-side (import titles are always German); only the TS UI formatter is locale-aware, so 'localeCases' (en/es month-name output) is asserted by the TS spec ONLY and must NOT be fed to the Java test. Do not edit one side's expectation without editing this file and the relevant test(s). Season->month mapping note: the Python import normalizer (tools/import-normalizer) is the UPSTREAM authority for which representative month a season maps to (4/7/10/1); both formatters mirror it but it sits OUTSIDE this Java<->TS guard, so a normalizer change is not caught here. 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"
}
],
"localeComment": "TS-only locale parity for the read path (the younger phone audience may use en/es). Asserted ONLY by documentDate.spec.ts — the Java title formatter is German-only by design, so these MUST NOT be fed to DocumentTitleFormatterTest. Each case pins the localized month-name output for DAY and MONTH so a locale regression (e.g. a future de-DE hard-coding) is caught by the drift table, not just by ad-hoc tests.",
"localeCases": [
{
"name": "DAY in English renders the English month name",
"precision": "DAY",
"anchor": "1943-12-24",
"end": null,
"raw": null,
"locale": "en",
"expected": "December 24, 1943"
},
{
"name": "DAY in Spanish renders the Spanish month name",
"precision": "DAY",
"anchor": "1943-12-24",
"end": null,
"raw": null,
"locale": "es",
"expected": "24 de diciembre de 1943"
},
{
"name": "MONTH in English renders the English month name, never a day",
"precision": "MONTH",
"anchor": "1916-06-01",
"end": null,
"raw": "Juni 1916",
"locale": "en",
"expected": "June 1916"
},
{
"name": "MONTH in Spanish renders the Spanish month name, never a day",
"precision": "MONTH",
"anchor": "1916-06-01",
"end": null,
"raw": "Juni 1916",
"locale": "es",
"expected": "junio de 1916"
}
]
}

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
import { m } from '$lib/paraglide/messages.js';
type Props = {
iso?: string | null;
precision?: DatePrecision | null;
end?: string | null;
raw?: string | null;
/** Show the verbatim "Originaltext: …" secondary line when raw is present. */
showRaw?: boolean;
};
let { iso = null, precision = null, end = null, raw = null, showRaw = true }: Props = $props();
const effectivePrecision = $derived<DatePrecision>(precision ?? (iso ? 'DAY' : 'UNKNOWN'));
const label = $derived(formatDocumentDate(iso, effectivePrecision, end, raw, getLocale()));
const isUnknown = $derived(effectivePrecision === 'UNKNOWN' || !iso);
// Only show the verbatim raw line where it adds information the label can't: the
// season word's source, or the original cell behind an "unknown"/approx date.
const showRawLine = $derived(
showRaw &&
!!raw &&
raw.trim().length > 0 &&
(isUnknown || effectivePrecision === 'SEASON' || effectivePrecision === 'APPROX')
);
</script>
<span class="inline-flex flex-col">
<span class="inline-flex items-center gap-1">
{#if isUnknown}
<!-- Non-color cue (WCAG 1.4.1): a calendar-with-question glyph. The visible
"Datum unbekannt" text is the redundant textual cue, so the icon is
decorative and hidden from assistive tech (per Leonie's a11y note). -->
<svg
class="h-3.5 w-3.5 shrink-0 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18" />
<path d="M9 16a1.5 1.5 0 0 1 3 0c0 1-1.5 1.2-1.5 2.2" />
<path d="M10.5 21h.01" />
</svg>
{/if}
<span>{label}</span>
</span>
{#if showRawLine}
<!-- Visible secondary line (WCAG 1.4.13 — not tooltip-only). raw is untrusted
verbatim spreadsheet text; rendered via default Svelte interpolation, which
HTML-escapes it (never {@html}; CWE-79). -->
<span class="font-sans text-xs text-ink-2">{m.date_original_label()} {raw}</span>
{/if}
</span>

View File

@@ -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 = '<img src=x onerror="alert(1)">';
render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: malicious } });
// The payload appears as literal text, and no <img> is created in the DOM.
await expect.element(page.getByText(/<img/)).toBeInTheDocument();
expect(document.querySelector('img')).toBeNull();
});
});

View File

@@ -13,6 +13,7 @@ import WhoWhenSection from '$lib/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/document/DescriptionSection.svelte';
import type { Tag } from '$lib/tag/TagInput.svelte';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type Person = components['schemas']['Person'];
type Doc = components['schemas']['Document'];
@@ -26,6 +27,8 @@ let {
senderId = $bindable(''),
selectedReceivers = $bindable<Person[]>([]),
dateIso = $bindable(''),
datePrecision = $bindable<DatePrecision>('DAY'),
dateEndIso = $bindable(''),
currentTitle = $bindable(''),
topbar,
actionbar
@@ -38,6 +41,8 @@ let {
senderId?: string;
selectedReceivers?: Person[];
dateIso?: string;
datePrecision?: DatePrecision;
dateEndIso?: string;
currentTitle?: string;
topbar: Snippet;
actionbar: Snippet;
@@ -47,6 +52,8 @@ tags = untrack(() => (doc.tags as Tag[]) ?? []);
senderId = untrack(() => doc.sender?.id ?? '');
selectedReceivers = untrack(() => (doc.receivers as Person[]) ?? []);
dateIso = untrack(() => doc.documentDate ?? '');
datePrecision = untrack(() => doc.metaDatePrecision ?? (doc.documentDate ? 'DAY' : 'UNKNOWN'));
dateEndIso = untrack(() => doc.metaDateEnd ?? '');
currentTitle = untrack(() => doc.title ?? '');
const fileLoader = createFileLoader();
@@ -199,6 +206,9 @@ async function handleReplaceFile(e: Event) {
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
bind:precision={datePrecision}
bind:endDateIso={dateEndIso}
rawDate={doc.metaDateRaw ?? ''}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender?.displayName ?? ''}

View File

@@ -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 {
<dl class="space-y-3 font-serif text-sm">
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
<dd class="text-ink">{formattedDate}</dd>
<dd class="text-ink">
{#if documentDate || metaDateRaw}
<DocumentDate
iso={documentDate}
precision={metaDatePrecision}
end={metaDateEnd}
raw={metaDateRaw}
/>
{:else}
{/if}
</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>

View File

@@ -2,13 +2,23 @@
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'];
/**
* Exactly the fields this picker reads — id for selection/dedup, the rest for
* the honest date label. A full `Document` and a `DocumentListItem` are both
* structurally assignable, so the search results need no cast.
*/
type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
interface Props {
selectedDocuments?: Document[];
selectedDocuments?: DocumentOption[];
placeholder?: string;
hiddenInputName?: string;
}
@@ -20,7 +30,7 @@ let {
}: Props = $props();
let searchTerm = $state('');
let results: Document[] = $state([]);
let results: DocumentOption[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
@@ -46,11 +56,13 @@ function handleInput() {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs = body.items.map((it) => ({
const docs: DocumentOption[] = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate
})) as unknown as Document[];
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}));
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
@@ -61,7 +73,7 @@ function handleInput() {
}, 300);
}
function selectDocument(doc: Document) {
function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
@@ -72,9 +84,16 @@ function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: Document): string {
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
return doc.title;
function formatDocLabel(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}
</script>

View File

@@ -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 () => {

View File

@@ -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,20 @@ function safeTagColor(color: string | null | undefined): string {
<!-- Mobile-only metadata -->
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
<!-- Product decision (#666): raw provenance (meta_date_raw) is shown on the
document DETAIL page, never in list/search rows — list rows surface only the
honest label to keep scan-rows compact. showRaw={false} enforces this; the
DocumentListItem payload also intentionally omits metaDateRaw. -->
{#if doc.documentDate}
<DocumentDate
iso={doc.documentDate}
precision={doc.metaDatePrecision}
end={doc.metaDateEnd}
showRaw={false}
/>
{:else}
{/if}
</div>
<div class="flex items-start gap-2">
<ProgressRing percentage={item.completionPercentage} />
@@ -178,7 +191,16 @@ function safeTagColor(color: string | null | undefined): string {
<!-- Right column — desktop only -->
<div class="hidden flex-col gap-2 pl-4 font-sans text-sm text-ink-2 sm:flex sm:w-44 lg:w-56">
<div>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
{#if doc.documentDate}
<DocumentDate
iso={doc.documentDate}
precision={doc.metaDatePrecision}
end={doc.metaDateEnd}
showRaw={false}
/>
{:else}
{/if}
</div>
<div>
<span class="font-bold tracking-wide text-ink-3 uppercase">{m.docs_list_from()}</span>

View File

@@ -22,6 +22,7 @@ function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15',
metaDatePrecision: 'DAY',
sender: undefined,
receivers: [],
tags: [],

View File

@@ -49,6 +49,7 @@ const baseItem = (overrides: Record<string, unknown> = {}) => ({
title: 'Brief 1923',
originalFilename: 'b.pdf',
documentDate: '1923-04-15',
metaDatePrecision: 'DAY' as const,
sender,
receivers: [receiver],
tags: [],

View File

@@ -8,6 +8,7 @@ import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
import DocumentMobileMenu from './DocumentMobileMenu.svelte';
import BackButton from '$lib/shared/primitives/BackButton.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 };
@@ -17,6 +18,9 @@ type Doc = {
title?: string | null;
originalFilename?: string | null;
documentDate?: string | null;
metaDatePrecision?: DatePrecision | null;
metaDateEnd?: string | null;
metaDateRaw?: string | null;
sender?: Person | null;
receivers?: Person[] | null;
filePath?: string | null;
@@ -81,6 +85,9 @@ const overflowPersons = $derived(receivers.slice(2));
title={doc.title}
originalFilename={doc.originalFilename}
documentDate={doc.documentDate}
metaDatePrecision={doc.metaDatePrecision}
metaDateEnd={doc.metaDateEnd}
metaDateRaw={doc.metaDateRaw}
/>
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
@@ -151,6 +158,9 @@ const overflowPersons = $derived(receivers.slice(2));
<div transition:slide={{ duration: 200 }}>
<DocumentMetadataDrawer
documentDate={doc.documentDate ?? null}
metaDatePrecision={doc.metaDatePrecision ?? null}
metaDateEnd={doc.metaDateEnd ?? null}
metaDateRaw={doc.metaDateRaw ?? null}
location={doc.location ?? null}
status={doc.status ?? 'PLACEHOLDER'}
sender={doc.sender ?? null}

View File

@@ -46,10 +46,12 @@ describe('DocumentTopBar', () => {
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
});
it('renders the short documentDate when one is present', async () => {
it('renders the precision-aware long documentDate when one is present', async () => {
render(DocumentTopBar, { props: baseProps() });
await expect.element(page.getByText('15.04.1923')).toBeVisible();
// documentDate '1923-04-15' with default DAY precision renders the honest
// long German label via formatDocumentDate (Refs #666), not the old short form.
await expect.element(page.getByText('15. April 1923')).toBeVisible();
});
it('omits the date paragraph entirely when documentDate is null', async () => {

View File

@@ -1,17 +1,32 @@
<script lang="ts">
import { formatDate } from '$lib/shared/utils/date';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type Props = {
title?: string | null;
originalFilename?: string | null;
documentDate?: string | null;
metaDatePrecision?: DatePrecision | null;
metaDateEnd?: string | null;
metaDateRaw?: string | null;
};
let { title, originalFilename, documentDate }: Props = $props();
let {
title,
originalFilename,
documentDate,
metaDatePrecision = null,
metaDateEnd = null,
metaDateRaw = null
}: Props = $props();
const displayTitle = $derived(title || originalFilename || '');
const shortDate = $derived(documentDate ? formatDate(documentDate, 'short') : null);
const longDate = $derived(documentDate ? formatDate(documentDate, 'long') : null);
const precision = $derived<DatePrecision>(metaDatePrecision ?? (documentDate ? 'DAY' : 'UNKNOWN'));
const dateLabel = $derived(
documentDate
? formatDocumentDate(documentDate, precision, metaDateEnd, metaDateRaw, getLocale())
: null
);
</script>
<div class="min-w-0 flex-1 overflow-hidden">
@@ -21,10 +36,7 @@ const longDate = $derived(documentDate ? formatDate(documentDate, 'long') : null
>
{displayTitle}
</h1>
{#if shortDate}
<p class="font-sans text-[16px] text-ink-2">
<span class="lg:hidden">{shortDate}</span>
<span class="hidden lg:inline">{longDate}</span>
</p>
{#if dateLabel}
<p class="font-sans text-[16px] text-ink-2">{dateLabel}</p>
{/if}
</div>

View File

@@ -32,10 +32,12 @@ describe('DocumentTopBarTitle', () => {
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
});
it('renders the short date format when a documentDate is supplied', async () => {
it('renders the precision-aware long date when a documentDate is supplied', async () => {
render(DocumentTopBarTitle, { props: baseProps });
await expect.element(page.getByText('15.04.1923')).toBeVisible();
// '1923-04-15' defaults to DAY precision and renders the honest long German
// label via formatDocumentDate (Refs #666), not the old short form.
await expect.element(page.getByText('15. April 1923')).toBeVisible();
});
it('omits the date paragraph entirely when documentDate is null', async () => {

View File

@@ -6,6 +6,7 @@ import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type Person = components['schemas']['Person'];
@@ -13,6 +14,9 @@ let {
senderId = $bindable(''),
selectedReceivers = $bindable<Person[]>([]),
dateIso = $bindable(''),
precision = $bindable<DatePrecision>('DAY'),
endDateIso = $bindable(''),
rawDate = '',
initialDateIso = '',
initialLocation = '',
initialSenderName = '',
@@ -24,6 +28,9 @@ let {
senderId?: string;
selectedReceivers?: Person[];
dateIso?: string;
precision?: DatePrecision;
endDateIso?: string;
rawDate?: string;
initialDateIso?: string;
initialLocation?: string;
initialSenderName?: string;
@@ -33,11 +40,24 @@ let {
editMode?: boolean;
} = $props();
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.date_precision_option_day },
{ value: 'MONTH', label: m.date_precision_option_month },
{ value: 'SEASON', label: m.date_precision_option_season },
{ value: 'YEAR', label: m.date_precision_option_year },
{ value: 'RANGE', label: m.date_precision_option_range },
{ value: 'APPROX', label: m.date_precision_option_approx },
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
];
const showEndDate = $derived(precision === 'RANGE');
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
// and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false);
let endDisplay = $state('');
onMount(() => {
const seed = dateIso || initialDateIso;
@@ -45,6 +65,7 @@ onMount(() => {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
if (endDateIso) endDisplay = isoToGerman(endDateIso);
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
@@ -56,6 +77,12 @@ function handleDateInput(e: Event) {
dateDirty = true;
}
function handleEndDateInput(e: Event) {
const result = handleGermanDateInput(e);
endDisplay = result.display;
endDateIso = result.iso;
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
@@ -96,6 +123,53 @@ $effect(() => {
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
<!-- Datumsgenauigkeit (precision) -->
<div data-testid="who-when-precision">
<label for="metaDatePrecision" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_precision()}
</label>
<select
id="metaDatePrecision"
name="metaDatePrecision"
bind:value={precision}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#each PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
<div aria-live="polite">
{#if showEndDate}
<div data-testid="who-when-end-date">
<label for="metaDateEnd" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_date_end()}
</label>
<input
id="metaDateEnd"
type="text"
inputmode="numeric"
value={endDisplay}
oninput={handleEndDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
</div>
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
<!-- Originaltext (read-only raw cell): labelled static text, not a disabled input. -->
{#if rawDate && rawDate.trim().length > 0}
<div data-testid="who-when-raw">
<p class="mb-1 block text-sm font-medium text-ink-2">{m.date_original_label()}</p>
<p class="font-sans text-sm text-ink">{rawDate}</p>
<input type="hidden" name="metaDateRaw" value={rawDate} />
</div>
{/if}
{/if}
<!-- Absender (required in upload mode — row 1, col 2) -->

View File

@@ -72,3 +72,33 @@ describe('WhoWhenSection — date input behavior', () => {
expect(label?.textContent).toContain('*');
});
});
describe('WhoWhenSection — precision controls', () => {
it('renders a labelled precision select', async () => {
render(WhoWhenSection, {});
const label = document.querySelector('label[for="metaDatePrecision"]');
const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]');
expect(label).not.toBeNull();
expect(select).not.toBeNull();
});
it('hides the end-date field unless precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'DAY' });
expect(document.querySelector('input#metaDateEnd')).toBeNull();
});
it('reveals the end-date field when precision is RANGE', async () => {
render(WhoWhenSection, { precision: 'RANGE' });
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
});
it('renders the raw cell as static text (not an editable input) and escapes it', async () => {
render(WhoWhenSection, { rawDate: '<b>Sommer</b> 1916' });
const raw = document.querySelector('[data-testid="who-when-raw"]');
expect(raw).not.toBeNull();
// Verbatim shown as escaped text; no injected <b> element.
expect(raw?.textContent).toContain('<b>Sommer</b> 1916');
expect(raw?.querySelector('b')).toBeNull();
});
});

View File

@@ -0,0 +1,159 @@
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;
};
type LocaleFixtureCase = FixtureCase & { locale: string };
const fixtures = JSON.parse(
readFileSync(resolve(process.cwd(), '../docs/date-label-fixtures.json'), 'utf-8')
) as { cases: FixtureCase[]; localeCases: LocaleFixtureCase[] };
describe('formatDocumentDate shared fixture table (de)', () => {
for (const c of fixtures.cases) {
it(c.name, () => {
expect(
formatDocumentDate(
c.anchor,
c.precision as Parameters<typeof formatDocumentDate>[1],
c.end,
c.raw,
'de'
)
).toBe(c.expected);
});
}
});
// TS-only locale parity (the Java title formatter is German-only by design, so
// localeCases are asserted here and never fed to DocumentTitleFormatterTest).
describe('formatDocumentDate shared fixture table (en/es locale parity)', () => {
for (const c of fixtures.localeCases) {
it(`${c.name} [${c.locale}]`, () => {
expect(
formatDocumentDate(
c.anchor,
c.precision as Parameters<typeof formatDocumentDate>[1],
c.end,
c.raw,
c.locale
)
).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`
);
});
// DAY precision must honour the active locale (regression: it was hard-wired
// to de-DE, so an English/Spanish reader saw "24. Dezember 1943").
it('localizes the DAY month name in English', () => {
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'en')).toBe(
new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date('1943-12-24T12:00:00')
)
);
});
it('localizes the DAY month name in Spanish', () => {
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'es')).toBe(
new Intl.DateTimeFormat('es', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date('1943-12-24T12:00:00')
)
);
});
it('localizes the MONTH month name in English', () => {
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'en')).toBe(
new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric' }).format(
new Date('1916-06-01T12:00:00')
)
);
});
it('localizes the MONTH month name in Spanish', () => {
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'es')).toBe(
new Intl.DateTimeFormat('es', { month: 'long', year: 'numeric' }).format(
new Date('1916-06-01T12:00:00')
)
);
});
});
// ─── 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, '<img src=x onerror=alert(1)>');
expect(label).toBe('Datum unbekannt');
expect(label).not.toContain('<img');
});
});
// ─── Defensive null handling ─────────────────────────────────────────────────
describe('formatDocumentDate defensive null handling', () => {
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');
});
});

View File

@@ -0,0 +1,169 @@
import { 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.
*
* Every structured part (month name, day-of-month text, season word, prefixes)
* is rendered in the active `locale` — DAY, MONTH and RANGE all go through
* `Intl.DateTimeFormat(locale, …)` and the localized words through Paraglide —
* so an `en`/`es` reader never sees a German month name. The `T12:00:00`
* UTC-safety convention is kept via {@link noon}.
*
* 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 longDate(iso, locale);
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 longDate(iso: string, locale: string): string {
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(noon(iso));
}
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<string, Season> = {
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';
}