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>
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
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';
|
||
}
|