Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
38f065bc60 docs(dates): record list-rows-omit-raw-provenance decision near render
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m14s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Elicit asked that the "raw provenance shown on detail, not in list rows"
choice be captured as a product decision rather than a payload accident.
Add a code comment at the list-row DocumentDate render explaining
showRaw={false} and the intentional metaDateRaw omission from
DocumentListItem.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:22:46 +02:00
Marcel
6cc622b4db refactor(dates): type DocumentMultiSelect options without double-cast
The search results were mapped to a partial object then forced with
`as unknown as Document[]`. DocumentListItem already carries every field
the picker reads (id, title, documentDate, metaDatePrecision REQUIRED,
metaDateEnd), so introduce a DocumentOption Pick type and drop the
double-cast — the mapped objects are now honestly typed.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:22:06 +02:00
Marcel
4169373693 fix(dates): meet 48px touch target on RANGE end-date input
The end-date input used px-2 py-3 with no min-h while the sibling
precision select sets min-h-[48px]. Add min-h-[48px] so the RANGE form
is uniformly senior-friendly (WCAG 2.2 2.5.8, matches the select).

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:19:37 +02:00
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
6 changed files with 138 additions and 17 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

@@ -5,11 +5,20 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type Document = components['schemas']['Document'];
type DocumentListItem = components['schemas']['DocumentListItem'];
/**
* Exactly the fields this picker reads — id for selection/dedup, the rest for
* the honest date label. A full `Document` and a `DocumentListItem` are both
* structurally assignable, so the search results need no cast.
*/
type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
interface Props {
selectedDocuments?: Document[];
selectedDocuments?: DocumentOption[];
placeholder?: string;
hiddenInputName?: string;
}
@@ -21,7 +30,7 @@ let {
}: Props = $props();
let searchTerm = $state('');
let results: Document[] = $state([]);
let results: DocumentOption[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
@@ -47,13 +56,13 @@ function handleInput() {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs = body.items.map((it) => ({
const docs: DocumentOption[] = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
})) as unknown as Document[];
}));
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
@@ -64,7 +73,7 @@ function handleInput() {
}, 300);
}
function selectDocument(doc: Document) {
function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
@@ -75,10 +84,15 @@ function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: Document): string {
function formatDocLabel(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const precision = (doc.metaDatePrecision as DatePrecision | undefined) ?? 'DAY';
const label = formatDocumentDate(doc.documentDate, precision, doc.metaDateEnd, null, getLocale());
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}
</script>

View File

@@ -164,6 +164,10 @@ function safeTagColor(color: string | null | undefined): string {
<!-- Mobile-only metadata -->
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
<div>
<!-- Product decision (#666): raw provenance (meta_date_raw) is shown on the
document DETAIL page, never in list/search rows — list rows surface only the
honest label to keep scan-rows compact. showRaw={false} enforces this; the
DocumentListItem payload also intentionally omits metaDateRaw. -->
{#if doc.documentDate}
<DocumentDate
iso={doc.documentDate}

View File

@@ -155,7 +155,7 @@ $effect(() => {
oninput={handleEndDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}

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));
}