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 = { 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'; }