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>
This commit is contained in:
Marcel
2026-05-27 12:19:09 +02:00
parent b1b8fa4bed
commit 8ed5b1e9e3
3 changed files with 110 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
{
"_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). Both test suites assert against THIS table so the two implementations cannot drift (en-dash vs hyphen, 'ca.' vs 'circa', season words, range collapse). Expected labels are the GERMAN (de) canonical form: import titles are always German, and the TS formatter defaults to the de locale. Do not edit one side's expectation without editing this file and both tests. See issue #666 and the Markus/Sara drift-guard decision.",
"_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",
@@ -97,5 +97,44 @@
"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

@@ -17,9 +17,11 @@ type FixtureCase = {
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[] };
) as { cases: FixtureCase[]; localeCases: LocaleFixtureCase[] };
describe('formatDocumentDate shared fixture table (de)', () => {
for (const c of fixtures.cases) {
@@ -37,6 +39,24 @@ describe('formatDocumentDate shared fixture table (de)', () => {
}
});
// 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', () => {
@@ -80,6 +100,40 @@ describe('formatDocumentDate localization', () => {
`${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 ───────

View File

@@ -1,4 +1,4 @@
import { formatDate, formatMCDate } from './date';
import { formatMCDate } from './date';
import { m } from '$lib/paraglide/messages.js';
/**
@@ -10,9 +10,11 @@ export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APP
/**
* Renders a document date at exactly the precision the data claims — never finer.
*
* Delegates to the {@link formatDate}/{@link formatMCDate} helpers (so the
* `T12:00:00` UTC-safety convention and the German Intl formatting are shared,
* not reimplemented) and routes every localized word through Paraglide.
* 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
@@ -42,7 +44,7 @@ export function formatDocumentDate(
switch (precision) {
case 'DAY':
return formatDate(iso, 'long');
return longDate(iso, locale);
case 'MONTH':
return monthYear(iso, locale);
case 'SEASON':
@@ -60,6 +62,14 @@ export function formatDocumentDate(
// ─── 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));
}