From 0816b3b0c0a37c6c320001827965559de6811782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:14:19 +0200 Subject: [PATCH 1/2] feat(date): migrate remaining date inputs to DateField Swap the four DateInput callsites (filter bar, export range, schedule history, onboarding baseline) to DateField so the user's date-order preference paints every date input. Adapt the onChange from a native event to the direct ISO value DateField emits, and add an optional autoComplete prop so the onboarding date-of-birth field keeps its bday autofill hint. --- .../scheduling/schedule-history-timeline.tsx | 14 +++++++------- src/components/onboarding/baseline-form.tsx | 6 +++--- src/components/settings/export-section.tsx | 10 +++++----- src/components/ui/date-field.tsx | 4 ++++ src/components/ui/filter-bar.tsx | 10 +++++----- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 9be638d5f..779a405d7 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -59,7 +59,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; @@ -545,22 +545,22 @@ function EraDialog({ - setFromDate(e.target.value)} - data-slot="zeitplan-history-from" + onChange={setFromDate} + data-testid="zeitplan-history-from" />
- setUntilDate(e.target.value)} - data-slot="zeitplan-history-until" + onChange={setUntilDate} + data-testid="zeitplan-history-until" />
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 2ca6f2d47..979f2dd33 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -242,10 +242,10 @@ export function BaselineForm() { - patch("dateOfBirth", e.target.value)} + onChange={(value) => patch("dateOfBirth", value)} max={new Date().toISOString().slice(0, 10)} autoComplete="bday" /> diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 8b84ce5f6..7078532a6 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -36,7 +36,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -181,10 +181,10 @@ function DateRangeFields({ - setSince(e.target.value)} + onChange={setSince} max={until || undefined} /> @@ -192,10 +192,10 @@ function DateRangeFields({ - setUntil(e.target.value)} + onChange={setUntil} min={since || undefined} /> diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx index 049fe5884..04255a45f 100644 --- a/src/components/ui/date-field.tsx +++ b/src/components/ui/date-field.tsx @@ -50,6 +50,8 @@ export interface DateFieldProps { onBlur?: () => void; disabled?: boolean; required?: boolean; + /** Autofill hint forwarded to the native date input (e.g. "bday"). */ + autoComplete?: string; /** ISO `yyyy-MM-dd`. */ min?: string; /** ISO `yyyy-MM-dd`. */ @@ -73,6 +75,7 @@ export const DateField = React.forwardRef( onBlur, disabled, required, + autoComplete, min, max, placeholder, @@ -174,6 +177,7 @@ export const DateField = React.forwardRef( defaultValue={value === undefined ? defaultValue : undefined} disabled={disabled} required={required} + autoComplete={autoComplete} min={min} max={max} onChange={handleNativeChange} diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index b27de321e..032c0c563 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { ListFilter, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -266,12 +266,12 @@ export function FilterBarDateRange({ > {t("dataList.dateFrom")} - onFromChange(e.target.value)} + onChange={onFromChange} />
@@ -281,12 +281,12 @@ export function FilterBarDateRange({ > {t("dataList.dateTo")} - onToChange(e.target.value)} + onChange={onToChange} />
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 2/2] 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 "