Files
familienarchiv/frontend/src/lib/shared/utils/documentDate.ts
Marcel 6d81471294 docs(timeline): flag DatePrecision as a hand-maintained backend mirror
Note above the DatePrecision type that it mirrors the Java DatePrecision enum,
must be updated manually in lockstep with that enum, and must not be migrated
to the OpenAPI-generated type — it drives the shared client-side formatter
shared by documents and the timeline date-label facade.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:17:24 +02:00

175 lines
6.4 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.
*
* DRIFT RISK: this is a hand-maintained mirror of the Java {@code DatePrecision}
* enum, NOT an OpenAPI-generated type. It must be updated manually whenever the
* Java enum changes, and must NOT be migrated to the generated API type — the
* generated enum is request/response-shaped, while this drives the shared
* client-side formatter (used by both documents and the timeline façade). Keep
* the two in lockstep by hand.
*/
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 never displayed and 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).
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';
}