Merge pull request #386 from MBombeck/release/v1.25.4

Release v1.25.4 — time picker honors the 24-hour preference
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-29 19:24:47 +02:00
commit b78529c5be
20 files changed
+854 -246

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.25.4] — 2026-06-29 — Time picker honors the 24-hour preference
A focused fix release. No schema change.
### Fixed
- **The time picker now follows your 24-hour preference.** The earlier fix corrected the times shown across the app, but tapping a date/time field still opened the browser's own time picker — which renders its clock (and the AM/PM toggle) by the browser's language, not the app's. On an English-language browser you saw AM/PM even with 24-hour selected. The field now uses its own time picker, so hours/minutes always follow your preference (24-hour, 12-hour, or automatic) in every browser. The date half keeps the familiar native calendar.
- The doctor-report PDF's "generated at" timestamp now honors the time-format preference as well, instead of falling back to the locale default.
- When adding a blood-pressure reading, the systolic / diastolic / pulse field labels no longer wrap onto a second line in the three-column layout — the metric name stays prominent and its unit (mmHg / bpm) sits beside it as a compact affix.
## [1.25.3] — 2026-06-29 — Mental wellbeing, labs depth, and fixes
A patch release: a clearer mental-wellbeing check-in, more useful lab pages, two visible bug fixes, and a couple of settings tidy-ups. No schema change.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.25.3
version: 1.25.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+6
View File
@@ -21,6 +21,12 @@
"disabled": "Deaktiviert",
"networkError": "Netzwerkfehler",
"openDatePicker": "Datumsauswahl öffnen",
"openTimePicker": "Uhrzeitauswahl öffnen",
"hours": "Stunden",
"minutes": "Minuten",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
"unknownError": "Unbekannter Fehler",
"copied": "Kopiert!",
"or": "oder",
+6
View File
@@ -21,6 +21,12 @@
"disabled": "Disabled",
"networkError": "Network error",
"openDatePicker": "Open date picker",
"openTimePicker": "Open time picker",
"hours": "Hours",
"minutes": "Minutes",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
"unknownError": "Unknown error",
"copied": "Copied!",
"or": "or",
+6
View File
@@ -21,6 +21,12 @@
"disabled": "Desactivado",
"networkError": "Error de red",
"openDatePicker": "Abrir el selector de fecha",
"openTimePicker": "Abrir el selector de hora",
"hours": "Horas",
"minutes": "Minutos",
"period": "a. m./p. m.",
"am": "a. m.",
"pm": "p. m.",
"unknownError": "Error desconocido",
"copied": "¡Copiado!",
"or": "o",
+6
View File
@@ -21,6 +21,12 @@
"disabled": "Désactivé",
"networkError": "Erreur réseau",
"openDatePicker": "Ouvrir le sélecteur de date",
"openTimePicker": "Ouvrir le sélecteur d’heure",
"hours": "Heures",
"minutes": "Minutes",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
"unknownError": "Erreur inconnue",
"copied": "Copié !",
"or": "ou",
+6
View File
@@ -21,6 +21,12 @@
"disabled": "Disabilitato",
"networkError": "Errore di rete",
"openDatePicker": "Apri il selettore di data",
"openTimePicker": "Apri il selettore di orario",
"hours": "Ore",
"minutes": "Minuti",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
"unknownError": "Errore sconosciuto",
"copied": "Copiato!",
"or": "o",
+6
View File
@@ -21,6 +21,12 @@
"disabled": "Wyłączone",
"networkError": "Błąd sieci",
"openDatePicker": "Otwórz wybór daty",
"openTimePicker": "Otwórz wybór godziny",
"hours": "Godziny",
"minutes": "Minuty",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
"unknownError": "Nieznany błąd",
"copied": "Skopiowano!",
"or": "lub",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.25.3",
"version": "1.25.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.25.0";
/* @sw-version-fallback */ "v1.25.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -75,6 +75,9 @@ function isMaskPlaceholder(words: string[]): boolean {
* reason. Prune an entry only when the value genuinely diverges in a locale.
*/
const LEGIT_IDENTICAL = new Set<string>([
// The AM/PM marker label — the affix abbreviations themselves are the same
// 12-hour-clock notation across the shipped Latin-script locales.
"common.period",
// Clinical abbreviations / SI units — identical across locales.
"charts.vo2Max",
"cycle.insights.crosstab.unitGlucose",
+6 -1
View File
@@ -119,7 +119,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
resolveUserTimezone(user.id),
prisma.user.findUnique({
where: { id: user.id },
select: { insuranceNumberEncrypted: true, insightsCachedText: true },
select: {
insuranceNumberEncrypted: true,
insightsCachedText: true,
timeFormat: true,
},
}),
// v1.25 (W-RECORDS) — structured records are always-available reference
// data (not time-windowed), so they ride alongside the report rather
@@ -203,6 +207,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
t,
locale,
userTz,
timeFormat: userRow?.timeFormat ?? "AUTO",
insuranceNumber,
includeCharts,
aiSummary,
@@ -190,6 +190,30 @@ function getDefaultMeasuredAtValue() {
return local.toISOString().slice(0, 16);
}
/**
* Field label that keeps the "Name (unit)" translation on a single line even
* in the narrow three-column blood-pressure grid: the metric name truncates if
* it must, while the unit renders as a small muted affix that never wraps. The
* label strings stay localised — we split the trailing "(unit)" the catalogue
* already carries rather than dropping it (so "mmHg" / "bpm" / locale variants
* like "lpm" survive).
*/
function UnitLabel({ htmlFor, label }: { htmlFor: string; label: string }) {
const match = /^(.*?)\s*\(([^)]*)\)\s*$/.exec(label);
const name = match ? match[1] : label;
const unit = match ? match[2] : null;
return (
<Label htmlFor={htmlFor} className="flex items-baseline gap-1">
<span className="truncate">{name}</span>
{unit ? (
<span className="text-muted-foreground shrink-0 text-xs font-normal">
{unit}
</span>
) : null}
</Label>
);
}
export function MeasurementForm({
onSuccess,
onCancel,
@@ -383,7 +407,7 @@ export function MeasurementForm({
{isBpMode ? (
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="sys">{t("measurements.systolicLabel")}</Label>
<UnitLabel htmlFor="sys" label={t("measurements.systolicLabel")} />
<Input
id="sys"
type="number"
@@ -402,7 +426,7 @@ export function MeasurementForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor="dia">{t("measurements.diastolicLabel")}</Label>
<UnitLabel htmlFor="dia" label={t("measurements.diastolicLabel")} />
<Input
id="dia"
type="number"
@@ -421,7 +445,7 @@ export function MeasurementForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor="puls">{t("measurements.pulseLabel")}</Label>
<UnitLabel htmlFor="puls" label={t("measurements.pulseLabel")} />
<Input
id="puls"
type="number"
@@ -20,7 +20,7 @@
import { useCallback, useId, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { TimeField } from "@/components/ui/time-field";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -159,37 +159,31 @@ export function DoseWindowEditor({
<Label htmlFor={startId} className="sr-only">
{t("medications.detail.zeitplan.window.startLabel")}
</Label>
<Input
<TimeField
id={startId}
type="time"
value={row.start}
disabled={disabled}
onChange={(e) =>
setRow(row.timeOfDay, { start: e.target.value })
}
className="h-11 w-32 sm:h-9"
onChange={(v) => setRow(row.timeOfDay, { start: v })}
className="h-11 w-32 sm:h-9 sm:min-h-9"
aria-label={t(
"medications.detail.zeitplan.window.startLabel",
)}
data-slot="dose-window-start"
data-testid="dose-window-start"
/>
<span className="text-muted-foreground text-sm">–</span>
<Label htmlFor={endId} className="sr-only">
{t("medications.detail.zeitplan.window.endLabel")}
</Label>
<Input
<TimeField
id={endId}
type="time"
value={row.end}
disabled={disabled}
onChange={(e) =>
setRow(row.timeOfDay, { end: e.target.value })
}
className="h-11 w-32 sm:h-9"
onChange={(v) => setRow(row.timeOfDay, { end: v })}
className="h-11 w-32 sm:h-9 sm:min-h-9"
aria-label={t(
"medications.detail.zeitplan.window.endLabel",
)}
data-slot="dose-window-end"
data-testid="dose-window-end"
/>
</div>
{isValidHhmm(row.start) &&
@@ -33,7 +33,7 @@ import { useCallback, useId, useMemo, useState } from "react";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TimeField } from "@/components/ui/time-field";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -145,13 +145,11 @@ export function TimesOfDayChips({
<Label htmlFor={inputId} className="sr-only">
{t(`${i18nPrefix}.label`)}
</Label>
<Input
<TimeField
id={inputId}
type="time"
value={single}
disabled={disabled}
onChange={(e) => {
const v = e.target.value;
onChange={(v) => {
if (!v) {
onChange([]);
return;
@@ -159,7 +157,7 @@ export function TimesOfDayChips({
if (!TIME_RE.test(v)) return;
onChange([v]);
}}
className="h-11 w-32 sm:h-9"
className="h-11 w-32 sm:h-9 sm:min-h-9"
aria-label={t(`${i18nPrefix}.label`)}
/>
</div>
@@ -253,15 +251,14 @@ export function TimesOfDayChips({
<Label htmlFor={inputId} className="sr-only">
{t(`${i18nPrefix}.add`)}
</Label>
<Input
<TimeField
id={inputId}
type="time"
value={draft}
disabled={disabled || atCap}
onChange={(e) => setDraft(e.target.value)}
className="h-11 w-32 sm:h-9"
onChange={(v) => setDraft(v)}
className="h-11 w-32 sm:h-9 sm:min-h-9"
aria-label={t(`${i18nPrefix}.add`)}
data-slot="times-of-day-draft-input"
data-testid="times-of-day-draft-input"
/>
<Button
type="button"
@@ -1,19 +1,22 @@
/**
* `<DateTimeField>` contract (v1.21.0).
* `<DateTimeField>` contract (v1.25.4).
*
* Like `<DateField>`, the SSR-only convention (`renderToStaticMarkup`,
* The field now composes a native-calendar `<DateField>` (date part) with the
* app-controlled `<TimeField>` (time part) so the time picker always follows
* the user's hour-cycle preference rather than the browser UI language. Like
* `<DateField>`, the SSR-only convention (`renderToStaticMarkup`,
* `environment: "node"`) means the interactive picker path can't be driven
* here. What the static markup pins is the load-bearing contract:
*
* - the committed VALUE stays a local `yyyy-MM-ddTHH:mm` string on a hidden
* native datetime-local input, so this is a drop-in for `<DateTimeInput>`;
* - the visible overlay paints the date in the date-order preference and the
* time in the hour-cycle preference;
* - disabled / min / max / placeholder thread through;
* - height + target-size parity classes are present.
* input carrying `name`, so this is a drop-in for the old field;
* - the date overlay paints the date-order preference; the time overlay paints
* the hour-cycle preference;
* - disabled threads through; min / max gate the native calendar by date part;
* - height + target-size parity classes are present on both halves.
*
* Under SSR both preferences resolve AUTO (no `window`), so the overlay follows
* the locale: de → "31.12.2026 14:05" (24h), en → "12/31/2026 02:05 PM".
* Under SSR both preferences resolve AUTO (no `window`), so each half follows
* the locale: de → "31.12.2026" + "14:05" (24h), en → "12/31/2026" + 12h.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -28,41 +31,35 @@ function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
}
describe("<DateTimeField>", () => {
it("keeps the local datetime value on a hidden native datetime-local input", () => {
it("keeps the combined local datetime value on a hidden input carrying name", () => {
const html = render(
<DateTimeField id="ts" name="ts" value="2026-12-31T14:05" />,
);
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*value="2026-12-31T14:05"/,
/<input[^>]*type="hidden"[^>]*value="2026-12-31T14:05"/,
);
expect(html).toContain('name="ts"');
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*class="[^"]*sr-only/,
);
});
it("paints the AUTO-locale display (de → dd.MM.yyyy + 24h)", () => {
it("paints the AUTO-locale display (de → dd.MM.yyyy + 24h time)", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />, "de");
expect(html).toContain('value="31.12.2026 14:05"');
expect(html).toContain('value="31.12.2026"');
expect(html).toContain('value="14:05"');
});
it("paints the AUTO-locale display (en → MM/dd/yyyy + 12h)", () => {
it("paints the AUTO-locale display (en → MM/dd/yyyy + 12h time)", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />, "en");
expect(html).toContain('value="12/31/2026"');
// en-US default hour cycle is AM/PM.
expect(html).toMatch(/value="12\/31\/2026 02:05\s?PM"/);
expect(html).toMatch(/value="02:05\s?PM"/);
});
it("renders the supplied placeholder when empty", () => {
it("threads the supplied placeholder onto the date half", () => {
const html = render(<DateTimeField value="" placeholder="Pick a moment" />);
expect(html).toContain('placeholder="Pick a moment"');
});
it("derives a format-shaped placeholder when none is supplied (en)", () => {
const html = render(<DateTimeField value="" />, "en");
expect(html).toContain('placeholder="MM/DD/YYYY --:--"');
});
it("threads disabled, min and max onto the native input", () => {
it("gates the native calendar by the min/max date part and threads disabled", () => {
const html = render(
<DateTimeField
value="2026-12-31T14:05"
@@ -71,23 +68,20 @@ describe("<DateTimeField>", () => {
max="2030-12-31T23:59"
/>,
);
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*min="2020-01-01T00:00"/,
);
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*max="2030-12-31T23:59"/,
);
expect(html).toMatch(/<input[^>]*type="datetime-local"[^>]*disabled/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*min="2020-01-01"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*max="2030-12-31"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*disabled/);
});
it("ships the WCAG target-size + height-parity classes", () => {
it("ships the WCAG target-size + height-parity classes on both halves", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />);
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
it("exposes a labelled calendar picker affordance", () => {
it("exposes both the calendar and the time picker affordances", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />);
expect(html).toContain('aria-label="Open date picker"');
expect(html).toContain('aria-label="Open time picker"');
});
});
@@ -0,0 +1,123 @@
/**
* `<TimeField>` contract (v1.25.4).
*
* The app-controlled time input replaces the native `<input type="time">` so
* the hour cycle follows the user's preference, never the browser UI language.
* The committed VALUE is always a 24-hour `"HH:mm"` string (drop-in for the
* native input). These tests pin the pure conversion/parse helpers plus the
* SSR-rendered display/affordance contract.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
TimeField,
parseHhmm,
parseTypedTime,
formatHhmm,
to12hHour,
from12hHour,
prefersTwelveHour,
} from "../time-field";
function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("parseHhmm", () => {
it("splits a well-formed 24h string", () => {
expect(parseHhmm("14:05")).toEqual({ hour: 14, minute: 5 });
expect(parseHhmm("00:00")).toEqual({ hour: 0, minute: 0 });
expect(parseHhmm("23:59")).toEqual({ hour: 23, minute: 59 });
});
it("rejects malformed / out-of-range input", () => {
expect(parseHhmm("")).toBeNull();
expect(parseHhmm("24:00")).toBeNull();
expect(parseHhmm("12:60")).toBeNull();
expect(parseHhmm("9:5")).toBeNull();
});
});
describe("parseTypedTime", () => {
it("accepts colon, dot and bare-digit forms", () => {
expect(parseTypedTime("14:30")).toBe("14:30");
expect(parseTypedTime("14.30")).toBe("14:30");
expect(parseTypedTime("1430")).toBe("14:30");
expect(parseTypedTime("930")).toBe("09:30");
expect(parseTypedTime("9")).toBe("09:00");
});
it("honours an AM/PM affix", () => {
expect(parseTypedTime("2:30 pm")).toBe("14:30");
expect(parseTypedTime("2:30pm")).toBe("14:30");
expect(parseTypedTime("12:00 am")).toBe("00:00");
expect(parseTypedTime("12:00 pm")).toBe("12:00");
expect(parseTypedTime("11:15 pm")).toBe("23:15");
});
it("rejects impossible times", () => {
expect(parseTypedTime("")).toBeNull();
expect(parseTypedTime("25:00")).toBeNull();
expect(parseTypedTime("14:70")).toBeNull();
expect(parseTypedTime("13:00 pm")).toBeNull(); // 13 can't be a 12h hour
});
});
describe("12h <-> 24h conversion", () => {
it("maps 24h to its 12h clock cell", () => {
expect(to12hHour(0)).toBe(12);
expect(to12hHour(12)).toBe(12);
expect(to12hHour(13)).toBe(1);
expect(to12hHour(23)).toBe(11);
});
it("maps a 12h cell + period back to 24h", () => {
expect(from12hHour(12, false)).toBe(0); // 12 AM
expect(from12hHour(12, true)).toBe(12); // 12 PM
expect(from12hHour(1, true)).toBe(13); // 1 PM
expect(from12hHour(11, true)).toBe(23); // 11 PM
});
});
describe("prefersTwelveHour", () => {
it("follows the explicit preference", () => {
expect(prefersTwelveHour("H12", "de")).toBe(true);
expect(prefersTwelveHour("H24", "en")).toBe(false);
});
it("falls to the locale under AUTO (en → 12h, others → 24h)", () => {
expect(prefersTwelveHour("AUTO", "en")).toBe(true);
expect(prefersTwelveHour("AUTO", "de")).toBe(false);
});
});
describe("formatHhmm", () => {
it("renders 24h under H24 regardless of locale", () => {
expect(formatHhmm("14:05", "H24", "en")).toMatch(/^14:05$/);
expect(formatHhmm("14:05", "H24", "de")).toMatch(/^14:05$/);
});
it("renders AM/PM under H12", () => {
expect(formatHhmm("14:05", "H12", "de")).toMatch(/02:05\s?PM/);
});
it("returns empty string for malformed input", () => {
expect(formatHhmm("", "H24", "de")).toBe("");
expect(formatHhmm("nope", "H24", "de")).toBe("");
});
});
describe("<TimeField> SSR", () => {
it("paints the value in 24h under the de AUTO locale and exposes the picker affordance", () => {
const html = render(<TimeField value="14:05" />, "de");
expect(html).toContain('value="14:05"');
expect(html).toContain('data-slot="time-field-trigger"');
});
it("keeps the 24h value on a hidden mirror carrying name", () => {
const html = render(<TimeField id="t" name="t" value="08:30" />, "de");
expect(html).toMatch(/<input[^>]*type="hidden"[^>]*value="08:30"/);
expect(html).toContain('name="t"');
});
it("ships the height-parity classes", () => {
const html = render(<TimeField value="08:30" />, "de");
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
});
+80 -177
View File
@@ -1,48 +1,30 @@
"use client";
import * as React from "react";
import { Calendar } from "lucide-react";
import { cn } from "@/lib/utils";
import {
useTranslations,
useDateFormatPreference,
useTimeFormatPreference,
} from "@/lib/i18n/context";
import { formatDate, resolveDateLocale } from "@/lib/date-format";
import { hourCycleOptions } from "@/lib/format-locale";
import { DateField } from "@/components/ui/date-field";
import { TimeField } from "@/components/ui/time-field";
/**
* Dependency-free datetime input that DISPLAYS the value in the user's
* date-order preference (date part) and hour-cycle preference (time part)
* while editing rides the browser's native datetime picker.
* Datetime input that composes a native-calendar `<DateField>` (date part)
* with the app-controlled `<TimeField>` (time part). The committed VALUE is a
* local `yyyy-MM-ddTHH:mm` string — identical to the native
* `<input type="datetime-local">` contract the original `<DateTimeField>`
* shipped — so this stays a drop-in for existing form wiring.
*
* The committed VALUE is always a local `yyyy-MM-ddTHH:mm` string — identical
* to the native `<input type="datetime-local">` contract `<DateTimeInput>`
* ships — so this is a drop-in for `<DateTimeInput>`. The value is a wall-clock
* local instant (no zone), so display parsing reads the literal parts rather
* than re-interpreting them through a timezone.
* Why the split: the native `datetime-local` picker renders its time spinner
* (and AM/PM toggle) by the BROWSER UI language, not the app's time-format
* preference — an English-language browser showed AM/PM even under H24. The
* date half keeps the familiar native calendar (no AM/PM there); the time half
* routes through `<TimeField>`, whose hour cycle always follows the preference.
*
* How it works mirrors `<DateField>`:
* - A visually-hidden native `<input type="datetime-local">` holds the real
* value and emits the change events the form listens to. Tapping the field
* (or its calendar button) calls `showPicker()`, falling back to `focus()`.
* - A read-only text overlay paints the formatted display string (date in
* the date-order preference, time in the hour-cycle preference) or the
* placeholder. Free-typed entry is not offered — the datetime parts are
* ambiguous enough that the native picker is the only edit path, same as
* the original `<DateTimeInput>` (which exposed no text fallback either).
*
* Height / a11y / target-size parity with `<DateTimeInput>`: the same
* `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5) and focus vocabulary.
* `min` / `max` are honoured two ways: their date portion gates the calendar,
* and the recombined value is clamped to `[min, max]` on every change. The
* `yyyy-MM-ddTHH:mm` shape sorts lexicographically = chronologically, so the
* clamp is a plain string compare.
*/
// Mirrors the `<DateInput>` / `<DateTimeInput>` height contract.
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
export interface DateTimeFieldProps {
id?: string;
name?: string;
@@ -89,179 +71,100 @@ export const DateTimeField = React.forwardRef<
"aria-invalid": ariaInvalid,
"aria-required": ariaRequired,
"data-testid": dataTestId,
...rest
},
forwardedRef,
) {
const { locale, t } = useTranslations();
const dateFormat = useDateFormatPreference();
const timeFormat = useTimeFormatPreference();
const seed = value ?? defaultValue ?? "";
const [internal, setInternal] = React.useState(seed);
const current = value ?? internal;
const innerRef = React.useRef<HTMLInputElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
// Split state so a half-filled field (date but no time yet) keeps both parts
// visible even while the combined value is still empty.
const [dateStr, setDateStr] = React.useState(() => splitDate(seed));
const [timeStr, setTimeStr] = React.useState(() => splitTime(seed));
const [internal, setInternal] = React.useState(defaultValue ?? "");
const local = value ?? internal;
// Re-sync the parts when a controlled value changes from the outside.
React.useEffect(() => {
if (value === undefined) return;
setDateStr(splitDate(value));
setTimeStr(splitTime(value));
}, [value]);
const display = formatLocalDateTime(local, dateFormat, timeFormat, locale);
function commit(next: string) {
if (value === undefined) setInternal(next);
onChange?.(next);
}
function openPicker() {
const el = innerRef.current;
if (!el || disabled) return;
try {
(el as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
} catch {
el.focus();
function commitParts(nextDate: string, nextTime: string) {
setDateStr(nextDate);
setTimeStr(nextTime);
const combined =
nextDate === ""
? ""
: clamp(`${nextDate}T${nextTime || "00:00"}`, min, max);
// A clamp can pull the time (or date) back; reflect that in the parts.
if (combined !== "") {
setDateStr(splitDate(combined));
setTimeStr(splitTime(combined));
}
if (value === undefined) setInternal(combined);
onChange?.(combined);
}
return (
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
className={cn("flex items-stretch gap-2", className)}
data-slot="date-time-field"
data-disabled={disabled ? "" : undefined}
>
{/* Visually-hidden native datetime input — owns the real value + events. */}
{/* Hidden mirror so the combined value participates in native submits. */}
<input
{...rest}
ref={setRefs}
id={id}
ref={forwardedRef}
type="hidden"
name={name}
type="datetime-local"
lang={locale}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
value={value !== undefined ? value : undefined}
defaultValue={value === undefined ? defaultValue : undefined}
value={current}
disabled={disabled}
required={required}
min={min}
max={max}
onChange={(e) => commit(e.target.value)}
/>
{/* Read-only display overlay. */}
<input
type="text"
readOnly
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 cursor-default bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={display}
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
/>
<DateField
id={id}
value={dateStr}
onChange={(d) => commitParts(d, timeStr)}
onBlur={onBlur}
disabled={disabled}
min={min ? splitDate(min) : undefined}
max={max ? splitDate(max) : undefined}
placeholder={placeholder}
className="min-w-0 flex-1"
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
aria-required={ariaRequired}
onClick={openPicker}
onFocus={openPicker}
onBlur={onBlur}
data-testid={dataTestId}
/>
<button
type="button"
tabIndex={-1}
<TimeField
value={timeStr}
onChange={(tm) => commitParts(dateStr, tm)}
onBlur={onBlur}
disabled={disabled}
aria-label={t("common.openDatePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
onClick={openPicker}
>
<Calendar className="h-4 w-4" aria-hidden="true" />
</button>
className="w-32 shrink-0"
aria-label={ariaLabel}
aria-required={ariaRequired}
/>
</div>
);
});
/**
* Format a local `yyyy-MM-ddTHH:mm` string into "<date> <time>" where the
* date part follows the date-order preference and the time part follows the
* hour-cycle preference. The value is a wall-clock instant (no zone), so the
* literal parts are read directly and rendered with `timeZone: "UTC"` to keep
* the displayed clock identical to what the user typed. Returns "" for an
* empty / malformed value so the field paints its placeholder.
*/
function formatLocalDateTime(
value: string,
dateFormat: ReturnType<typeof useDateFormatPreference>,
timeFormat: ReturnType<typeof useTimeFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): string {
const parts = parseLocalDateTime(value);
if (!parts) return "";
const datePart = formatDate(parts.date, dateFormat, locale);
if (datePart === "") return "";
// Anchor the wall-clock time in UTC so the rendered clock matches the typed
// parts exactly, regardless of the viewer's timezone.
const anchored = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute),
);
const timePart = anchored.toLocaleTimeString(
resolveDateLocale(dateFormat, locale),
{
timeZone: "UTC",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
},
);
return `${datePart} ${timePart}`;
/** First 10 chars of a `yyyy-MM-ddTHH:mm` string, when well-formed. */
function splitDate(value: string): string {
const m = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim());
return m ? m[1] : "";
}
/** Split a local `yyyy-MM-ddTHH:mm` string into its calendar parts. */
function parseLocalDateTime(value: string): {
date: string;
year: number;
month: number;
day: number;
hour: number;
minute: number;
} | null {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim());
if (!match) return null;
const [, y, mo, d, h, mi] = match;
const year = Number(y);
const month = Number(mo);
const day = Number(d);
const hour = Number(h);
const minute = Number(mi);
// Reject impossible component ranges so the field falls back to placeholder
// rather than rendering an "Invalid Date".
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour > 23 ||
minute > 59
) {
return null;
}
return { date: `${y}-${mo}-${d}`, year, month, day, hour, minute };
/** The `HH:mm` portion of a `yyyy-MM-ddTHH:mm` string, when present. */
function splitTime(value: string): string {
const m = /T(\d{2}:\d{2})/.exec(value.trim());
return m ? m[1] : "";
}
/** Placeholder that mirrors the active date order, e.g. "DD.MM.YYYY --:--". */
function formatPlaceholder(
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): string {
const date = formatDate("2026-12-31", dateFormat, locale)
.replace(/31/g, "DD")
.replace(/12/g, "MM")
.replace(/2026/g, "YYYY");
return `${date} --:--`;
/** Clamp a `yyyy-MM-ddTHH:mm` string to `[min, max]` (lexical = chronological). */
function clamp(value: string, min?: string, max?: string): string {
let out = value;
if (min && out < min) out = min;
if (max && out > max) out = max;
return out;
}
+511
View File
@@ -0,0 +1,511 @@
"use client";
import * as React from "react";
import { Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
/**
* App-controlled time input. The committed VALUE is always a 24-hour
* `"HH:mm"` string — identical to the native `<input type="time">` contract —
* so this is a drop-in for it and existing form wiring is unchanged.
*
* Why it exists: the native `<input type="time">` / `datetime-local` picker
* renders its clock (and the AM/PM toggle) according to the BROWSER UI
* language, NOT the page `lang` and NOT the app's time-format preference. A
* user on an English-language browser saw an AM/PM picker even with H24
* selected. This component owns the picker, so the hour cycle always follows
* the user's preference.
*
* How it works mirrors `<DateField>`:
* - A text overlay paints (and lets you type) the time in the user's hour-
* cycle preference (H24 → "14:30", H12 → "2:30 PM", AUTO → locale default).
* Free-typed entry is parsed tolerantly ("1430", "2:30pm", "14:30").
* - A clock button opens a dependency-free Popover with hour / minute (and,
* in 12-hour mode, AM/PM) columns. The columns ALWAYS reflect the
* preference — never the browser.
*
* Height / a11y / target-size parity with `<DateField>`: the same
* `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5) and field chrome.
*/
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
export interface TimeFieldProps {
id?: string;
name?: string;
/** 24-hour `"HH:mm"` (controlled). */
value?: string;
/** Initial 24-hour `"HH:mm"` (uncontrolled). */
defaultValue?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
className?: string;
"aria-label"?: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean | "true" | "false";
"aria-required"?: boolean | "true" | "false";
"data-testid"?: string;
}
export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
function TimeField(
{
id,
name,
value,
defaultValue,
onChange,
onBlur,
disabled,
required,
placeholder,
className,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
"aria-invalid": ariaInvalid,
"aria-required": ariaRequired,
"data-testid": dataTestId,
},
forwardedRef,
) {
const { locale, t } = useTranslations();
const timeFormat = useTimeFormatPreference();
const use12h = prefersTwelveHour(timeFormat, locale);
const innerRef = React.useRef<HTMLInputElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const [internal, setInternal] = React.useState(defaultValue ?? "");
const hhmm = value ?? internal;
// Mirror what the user is typing; cleared back to the formatted string
// whenever the committed value changes.
const [typed, setTyped] = React.useState<string | null>(null);
const [open, setOpen] = React.useState(false);
const display = formatHhmm(hhmm, timeFormat, locale);
const overlayValue = typed ?? display;
function commit(next: string) {
if (value === undefined) setInternal(next);
setTyped(null);
onChange?.(next);
}
function commitTyped() {
if (typed === null) return;
const trimmed = typed.trim();
if (trimmed === "") {
commit("");
return;
}
const parsed = parseTypedTime(trimmed);
if (parsed) commit(parsed);
else setTyped(null); // snap back to the committed value
}
const parts = parseHhmm(hhmm);
return (
<Popover open={open} onOpenChange={setOpen}>
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="time-field"
data-disabled={disabled ? "" : undefined}
>
{/* Hidden mirror so the value participates in native form submits. */}
<input
ref={setRefs}
type="hidden"
id={id}
name={name}
value={hhmm}
disabled={disabled}
required={required}
readOnly
/>
{/* Editable display overlay. */}
<input
type="text"
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? (use12h ? "--:-- --" : "--:--")}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
aria-required={ariaRequired}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTyped();
}
}}
onBlur={() => {
commitTyped();
onBlur?.();
}}
/>
<PopoverTrigger asChild>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openTimePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
data-slot="time-field-trigger"
>
<Clock className="h-4 w-4" aria-hidden="true" />
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="start"
className="w-auto p-0"
data-slot="time-field-popover"
>
<TimeColumns
use12h={use12h}
parts={parts}
onPick={(next) => commit(next)}
hoursLabel={t("common.hours")}
minutesLabel={t("common.minutes")}
periodLabel={t("common.period")}
amLabel={t("common.am")}
pmLabel={t("common.pm")}
/>
</PopoverContent>
</Popover>
);
},
);
// ────────────────────────────────────────────────────────────────────
// Popover columns
// ────────────────────────────────────────────────────────────────────
interface TimeColumnsProps {
use12h: boolean;
parts: { hour: number; minute: number } | null;
onPick: (hhmm: string) => void;
hoursLabel: string;
minutesLabel: string;
periodLabel: string;
amLabel: string;
pmLabel: string;
}
function TimeColumns({
use12h,
parts,
onPick,
hoursLabel,
minutesLabel,
periodLabel,
amLabel,
pmLabel,
}: TimeColumnsProps) {
const hour = parts?.hour ?? null;
const minute = parts?.minute ?? null;
// The selected period; default to AM so a fresh pick lands somewhere sane.
const isPm = hour !== null ? hour >= 12 : false;
const hourValues = use12h
? Array.from({ length: 12 }, (_, i) => i + 1) // 1..12
: Array.from({ length: 24 }, (_, i) => i); // 0..23
const minuteValues = Array.from({ length: 60 }, (_, i) => i);
// The hour cell currently highlighted, expressed in the column's own space.
const selectedHourCell =
hour === null ? null : use12h ? to12hHour(hour) : hour;
function pickHour(cell: number) {
const baseMinute = minute ?? 0;
const h24 = use12h ? from12hHour(cell, isPm) : cell;
onPick(`${pad(h24)}:${pad(baseMinute)}`);
}
function pickMinute(m: number) {
const baseHour = hour ?? (use12h ? 0 : 0);
onPick(`${pad(baseHour)}:${pad(m)}`);
}
function pickPeriod(pm: boolean) {
const cell = selectedHourCell ?? 12; // 12 = 12 AM / 12 PM anchor
const h24 = from12hHour(cell, pm);
onPick(`${pad(h24)}:${pad(minute ?? 0)}`);
}
return (
<div className="flex" data-slot="time-columns">
<Column
label={hoursLabel}
values={hourValues}
selected={selectedHourCell}
onPick={pickHour}
format={(v) => (use12h ? String(v) : pad(v))}
testid="time-field-hours"
/>
<Column
label={minutesLabel}
values={minuteValues}
selected={minute}
onPick={pickMinute}
format={(v) => pad(v)}
testid="time-field-minutes"
/>
{use12h && (
<div
className="flex flex-col border-s"
role="group"
aria-label={periodLabel}
data-slot="time-field-period"
>
<span className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium">
{periodLabel}
</span>
<div className="flex flex-col gap-0.5 p-1">
<PeriodButton
active={hour !== null && !isPm}
label={amLabel}
onClick={() => pickPeriod(false)}
/>
<PeriodButton
active={hour !== null && isPm}
label={pmLabel}
onClick={() => pickPeriod(true)}
/>
</div>
</div>
)}
</div>
);
}
interface ColumnProps {
label: string;
values: number[];
selected: number | null;
onPick: (value: number) => void;
format: (value: number) => string;
testid: string;
}
function Column({
label,
values,
selected,
onPick,
format,
testid,
}: ColumnProps) {
const listRef = React.useRef<HTMLDivElement | null>(null);
const activeRef = React.useRef<HTMLButtonElement | null>(null);
// Bring the selected cell into view when the popover mounts.
React.useEffect(() => {
activeRef.current?.scrollIntoView({ block: "center" });
}, []);
return (
<div
className="flex flex-col not-last:border-e"
role="group"
aria-label={label}
>
<span className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium">
{label}
</span>
<div
ref={listRef}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1"
data-slot={testid}
>
{values.map((v) => {
const isActive = selected === v;
return (
<button
key={v}
ref={isActive ? activeRef : undefined}
type="button"
aria-pressed={isActive}
data-active={isActive ? "true" : "false"}
onClick={() => onPick(v)}
className={cn(
"focus-visible:ring-ring w-12 rounded-md px-2 py-1.5 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
isActive
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted",
)}
>
{format(v)}
</button>
);
})}
</div>
</div>
);
}
function PeriodButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={active}
data-active={active ? "true" : "false"}
onClick={onClick}
className={cn(
"focus-visible:ring-ring w-12 rounded-md px-2 py-1.5 text-center text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted",
)}
>
{label}
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Pure helpers — exported for unit tests
// ────────────────────────────────────────────────────────────────────
/** AUTO follows the locale (en → 12h, every other shipped locale → 24h). */
export function prefersTwelveHour(
timeFormat: ReturnType<typeof useTimeFormatPreference>,
locale: string,
): boolean {
if (timeFormat === "H12") return true;
if (timeFormat === "H24") return false;
return locale === "en"; // AUTO
}
/** Split a 24-hour `"HH:mm"` string into parts, or null when malformed. */
export function parseHhmm(
value: string,
): { hour: number; minute: number } | null {
const m = TIME_RE.exec(value.trim());
if (!m) return null;
return { hour: Number(m[1]), minute: Number(m[2]) };
}
/**
* Format a 24-hour `"HH:mm"` string for display in the user's hour cycle.
* Returns "" for an empty / malformed value so the field paints its
* placeholder. Renders through `Intl` with the preference's hour cycle so the
* AM/PM affix (or its absence) matches every other clock in the app.
*/
export function formatHhmm(
value: string,
timeFormat: ReturnType<typeof useTimeFormatPreference>,
locale: string,
): string {
const parts = parseHhmm(value);
if (!parts) return "";
// Anchor on a fixed UTC date; only the wall-clock parts matter.
const date = new Date(Date.UTC(2000, 0, 1, parts.hour, parts.minute));
return date.toLocaleTimeString(locale, {
timeZone: "UTC",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
}
/**
* Tolerant parse of free-typed time into a 24-hour `"HH:mm"` string. Accepts
* "14:30", "1430", "2:30 pm", "2.30pm", "9" (→ 09:00). Returns null when the
* components don't form a real wall-clock time.
*/
export function parseTypedTime(input: string): string | null {
const raw = input.trim().toLowerCase();
if (raw === "") return null;
let pm: boolean | null = null;
let body = raw;
if (/\bp\.?m\.?$/.test(body) || body.endsWith("pm") || body.endsWith("p")) {
pm = true;
} else if (
/\ba\.?m\.?$/.test(body) ||
body.endsWith("am") ||
body.endsWith("a")
) {
pm = false;
}
body = body.replace(/[ap]\.?m?\.?$/i, "").trim();
const digits = body.replace(/[^\d]/g, "");
if (digits.length === 0) return null;
let hour: number;
let minute: number;
if (body.includes(":") || body.includes(".")) {
const [h, m] = body.split(/[.:]/);
hour = Number(h);
minute = m === undefined || m === "" ? 0 : Number(m);
} else if (digits.length <= 2) {
hour = Number(digits);
minute = 0;
} else {
// "1430" → 14:30, "930" → 9:30
hour = Number(digits.slice(0, digits.length - 2));
minute = Number(digits.slice(-2));
}
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
if (pm !== null) {
if (hour < 1 || hour > 12) return null;
hour = from12hHour(hour, pm);
}
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return `${pad(hour)}:${pad(minute)}`;
}
/** 24-hour hour → its 12-hour clock cell (0/12 → 12, 13 → 1, …). */
export function to12hHour(hour24: number): number {
const h = hour24 % 12;
return h === 0 ? 12 : h;
}
/** 12-hour clock cell + period → 24-hour hour. */
export function from12hHour(cell: number, pm: boolean): number {
const base = cell % 12; // 12 → 0
return pm ? base + 12 : base;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
+10 -2
View File
@@ -12,7 +12,7 @@
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys";
import { makeFormatters } from "./format-locale";
import { makeFormatters, type TimeFormatPreference } from "./format-locale";
import type { Locale } from "./i18n/config";
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
import {
@@ -130,6 +130,13 @@ export interface DoctorReportRenderOptions {
* Eastern-time rows even when generated in the browser.
*/
userTz?: string;
/**
* v1.25.4 — the user's hour-cycle preference. Threaded into the formatters
* so the footer "generated at" timestamp (and any other clock the report
* prints) honours H12 / H24 rather than falling to the locale default. When
* omitted it stays AUTO (locale default), matching the legacy contract.
*/
timeFormat?: TimeFormatPreference;
/**
* v1.7.0 — decrypted KVNR (German insurance number). Printed on the
* cover when present; the column is encrypted at rest, so the route
@@ -474,11 +481,12 @@ export function buildDoctorReportPdfDocument(
locale,
now = new Date(),
userTz,
timeFormat = "AUTO",
insuranceNumber = null,
includeCharts = true,
aiSummary = null,
} = options;
const formatters = makeFormatters(locale, userTz);
const formatters = makeFormatters(locale, userTz, timeFormat);
const num = (value: number, decimals = 1) =>
formatters.number(value, decimals);
const fmtDate = (iso: string) => formatters.date(iso);