Files
familienarchiv/frontend/src/lib/shared/utils/documentDate.ts
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

170 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
}