mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
20 files changed
+854
-246
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user