From 01b24c4d733b4e32b142e6aa32e9ceaff14ba579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:17:55 +0200 Subject: [PATCH] feat(date): add DateTimeField and migrate datetime inputs Add a dependency-free DateTimeField that paints the date part in the user's date-order preference and the time part in the hour-cycle preference over a hidden native datetime-local input, preserving the local yyyy-MM-ddTHH:mm value contract. Migrate the seven DateTimeInput callsites (measurement add/edit, mood add/edit, lab add/edit, medication intake quick-add) so the chosen format is honoured there too, and cover the field's value contract and per-preference rendering with a component test. --- .../dashboard/medication-intake-quick-add.tsx | 6 +- src/components/labs/lab-form.tsx | 6 +- src/components/labs/lab-history-list.tsx | 6 +- .../measurements/measurement-form.tsx | 6 +- .../measurements/measurement-list.tsx | 6 +- src/components/mood/mood-form.tsx | 6 +- src/components/mood/mood-list.tsx | 6 +- .../ui/__tests__/date-time-field.test.tsx | 93 ++++++ src/components/ui/date-time-field.tsx | 280 ++++++++++++++++++ 9 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 src/components/ui/__tests__/date-time-field.test.tsx create mode 100644 src/components/ui/date-time-field.tsx diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 25d7f1c4a..b3d0cd6fb 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -5,7 +5,7 @@ import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Label } from "@/components/ui/label"; import { Select, @@ -454,11 +454,11 @@ export function MedicationIntakeQuickAdd({ - setTakenAt(e.target.value)} + onChange={setTakenAt} required aria-required="true" aria-invalid={!!error || undefined} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index c9f2a1e3f..273a26208 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -310,10 +310,10 @@ export function LabForm({
- setTakenAt(e.target.value)} + onChange={setTakenAt} max={defaultTakenAtValue()} required /> diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 599a0321f..98b072074 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -7,7 +7,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -285,10 +285,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- setEditTakenAt(e.target.value)} + onChange={setEditTakenAt} required />
diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index bce390b67..ab6cfba5d 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Label } from "@/components/ui/label"; import { Select, @@ -468,10 +468,10 @@ export function MeasurementForm({
- setMeasuredAt(e.target.value)} + onChange={setMeasuredAt} // v1.17 W1b — mirror the server-side plausibility bound: the // picker cannot select a future instant. Matches the // `validateEntryInstant` refine on `measuredAt`. diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 49e368be3..b270a1fd4 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -66,7 +66,7 @@ import { MEASUREMENT_NOTES_MAX_LENGTH, measurementSourceEnum, } from "@/lib/validations/measurement"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Checkbox } from "@/components/ui/checkbox"; import { SortableHead, @@ -1339,10 +1339,10 @@ export function MeasurementList({ - setEditMeasuredAt(e.target.value)} + onChange={setEditMeasuredAt} // v1.17 W1b — match the server bound: no future instant. max={toDateTimeLocalValue(new Date().toISOString())} required diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 30bd2f698..959e47ea5 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -22,7 +22,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Label } from "@/components/ui/label"; import { ListChecks, @@ -511,10 +511,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
- setMoodLoggedAt(e.target.value)} + onChange={setMoodLoggedAt} // v1.17 W1b — match the server bound: no future instant. max={getDefaultMoodLoggedAtValue()} required diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 3669f4825..772f902f6 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -29,7 +29,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeField } from "@/components/ui/date-time-field"; import { Label } from "@/components/ui/label"; import { Table, @@ -844,10 +844,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
- setEditMoodLoggedAt(e.target.value)} + onChange={setEditMoodLoggedAt} // v1.17 W1b — match the server bound: no future instant. max={toDateTimeLocalValue(new Date().toISOString())} required diff --git a/src/components/ui/__tests__/date-time-field.test.tsx b/src/components/ui/__tests__/date-time-field.test.tsx new file mode 100644 index 000000000..7ab4018b8 --- /dev/null +++ b/src/components/ui/__tests__/date-time-field.test.tsx @@ -0,0 +1,93 @@ +/** + * `` contract (v1.21.0). + * + * Like ``, 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 ``; + * - 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. + * + * 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". + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { DateTimeField } from "../date-time-field"; + +function render(node: React.ReactNode, locale: "de" | "en" = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("keeps the local datetime value on a hidden native datetime-local input", () => { + const html = render( + , + ); + expect(html).toMatch( + /]*type="datetime-local"[^>]*value="2026-12-31T14:05"/, + ); + expect(html).toContain('name="ts"'); + expect(html).toMatch( + /]*type="datetime-local"[^>]*class="[^"]*sr-only/, + ); + }); + + it("paints the AUTO-locale display (de → dd.MM.yyyy + 24h)", () => { + const html = render(, "de"); + expect(html).toContain('value="31.12.2026 14:05"'); + }); + + it("paints the AUTO-locale display (en → MM/dd/yyyy + 12h)", () => { + const html = render(, "en"); + // en-US default hour cycle is AM/PM. + expect(html).toMatch(/value="12\/31\/2026 02:05\s?PM"/); + }); + + it("renders the supplied placeholder when empty", () => { + const html = render(); + expect(html).toContain('placeholder="Pick a moment"'); + }); + + it("derives a format-shaped placeholder when none is supplied (en)", () => { + const html = render(, "en"); + expect(html).toContain('placeholder="MM/DD/YYYY --:--"'); + }); + + it("threads disabled, min and max onto the native input", () => { + const html = render( + , + ); + expect(html).toMatch( + /]*type="datetime-local"[^>]*min="2020-01-01T00:00"/, + ); + expect(html).toMatch( + /]*type="datetime-local"[^>]*max="2030-12-31T23:59"/, + ); + expect(html).toMatch(/]*type="datetime-local"[^>]*disabled/); + }); + + it("ships the WCAG target-size + height-parity classes", () => { + const html = render(); + expect(html).toContain("min-h-11"); + expect(html).toContain("sm:h-10"); + }); + + it("exposes a labelled calendar picker affordance", () => { + const html = render(); + expect(html).toContain('aria-label="Open date picker"'); + }); +}); diff --git a/src/components/ui/date-time-field.tsx b/src/components/ui/date-time-field.tsx new file mode 100644 index 000000000..09ab7fc22 --- /dev/null +++ b/src/components/ui/date-time-field.tsx @@ -0,0 +1,280 @@ +"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"; + +/** + * 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. + * + * The committed VALUE is always a local `yyyy-MM-ddTHH:mm` string — identical + * to the native `` contract `` + * ships — so this is a drop-in for ``. 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. + * + * How it works mirrors ``: + * - A visually-hidden native `` 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 `` (which exposed no text fallback either). + * + * Height / a11y / target-size parity with ``: the same + * `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5) and focus vocabulary. + */ + +// Mirrors the `` / `` 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; + /** Local `yyyy-MM-ddTHH:mm` (controlled). */ + value?: string; + /** Initial local `yyyy-MM-ddTHH:mm` (uncontrolled). */ + defaultValue?: string; + onChange?: (value: string) => void; + onBlur?: () => void; + disabled?: boolean; + required?: boolean; + /** Local `yyyy-MM-ddTHH:mm`. */ + min?: string; + /** Local `yyyy-MM-ddTHH:mm`. */ + max?: string; + 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 DateTimeField = React.forwardRef< + HTMLInputElement, + DateTimeFieldProps +>(function DateTimeField( + { + id, + name, + value, + defaultValue, + onChange, + onBlur, + disabled, + required, + min, + max, + placeholder, + className, + "aria-label": ariaLabel, + "aria-describedby": ariaDescribedBy, + "aria-invalid": ariaInvalid, + "aria-required": ariaRequired, + "data-testid": dataTestId, + ...rest + }, + forwardedRef, +) { + const { locale, t } = useTranslations(); + const dateFormat = useDateFormatPreference(); + const timeFormat = useTimeFormatPreference(); + + const innerRef = React.useRef(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 local = value ?? internal; + + 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(); + } + } + + return ( +
+ {/* Visually-hidden native datetime input — owns the real value + events. */} + commit(e.target.value)} + /> + {/* Read-only display overlay. */} + + +
+ ); +}); + +/** + * Format a local `yyyy-MM-ddTHH:mm` string into "