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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:17:55 +02:00
1 parent 0816b3b0c0
commit 01b24c4d73
9 files changed
+394 -21

No files matched your search

@@ -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({
<Label htmlFor="medication-intake-taken-at">
{t("dashboard.medicationIntakeQuickAdd.timeLabel")}
</Label>
<DateTimeInput
<DateTimeField
id="medication-intake-taken-at"
data-testid="medication-intake-quick-add-taken-at"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
onChange={setTakenAt}
required
aria-required="true"
aria-invalid={!!error || undefined}
+3 -3
View File
@@ -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({
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
<DateTimeField
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
onChange={setTakenAt}
max={defaultTakenAtValue()}
required
/>
+3 -3
View File
@@ -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[] }) {
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
<DateTimeField
id="lab-edit-takenAt"
value={editTakenAt}
onChange={(e) => setEditTakenAt(e.target.value)}
onChange={setEditTakenAt}
required
/>
</div>
@@ -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({
<div className="space-y-2">
<Label htmlFor="measuredAt">{t("measurements.timestamp")}</Label>
<DateTimeInput
<DateTimeField
id="measuredAt"
value={measuredAt}
onChange={(e) => 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`.
@@ -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({
<Label htmlFor="edit-measuredAt">
{t("measurements.timestamp")}
</Label>
<DateTimeInput
<DateTimeField
id="edit-measuredAt"
value={editMeasuredAt}
onChange={(e) => setEditMeasuredAt(e.target.value)}
onChange={setEditMeasuredAt}
// v1.17 W1b — match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
+3 -3
View File
@@ -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) {
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
<DateTimeField
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
onChange={setMoodLoggedAt}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
+3 -3
View File
@@ -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 = {}) {
<div className="space-y-2">
<Label htmlFor="edit-mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
<DateTimeField
id="edit-mood-logged-at"
value={editMoodLoggedAt}
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
onChange={setEditMoodLoggedAt}
// v1.17 W1b — match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
@@ -0,0 +1,93 @@
/**
* `<DateTimeField>` contract (v1.21.0).
*
* 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.
*
* 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DateTimeField>", () => {
it("keeps the local datetime value on a hidden native datetime-local input", () => {
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"/,
);
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)", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />, "de");
expect(html).toContain('value="31.12.2026 14:05"');
});
it("paints the AUTO-locale display (en → MM/dd/yyyy + 12h)", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />, "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(<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", () => {
const html = render(
<DateTimeField
value="2026-12-31T14:05"
disabled
min="2020-01-01T00:00"
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/);
});
it("ships the WCAG target-size + height-parity classes", () => {
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", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />);
expect(html).toContain('aria-label="Open date picker"');
});
});
+280
View File
@@ -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 `<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.
*
* 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.
*/
// 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;
/** 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<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 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 (
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="date-time-field"
data-disabled={disabled ? "" : undefined}
>
{/* Visually-hidden native datetime input — owns the real value + events. */}
<input
{...rest}
ref={setRefs}
id={id}
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}
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)}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
aria-required={ariaRequired}
onClick={openPicker}
onFocus={openPicker}
onBlur={onBlur}
/>
<button
type="button"
tabIndex={-1}
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>
</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}`;
}
/** Mirror of `format-locale.ts` `hourCycleOptions`, kept local to the field. */
function hourCycleOptions(
timeFormat: ReturnType<typeof useTimeFormatPreference>,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
case "H12":
return { hour12: true };
case "H24":
return { hourCycle: "h23" };
default:
return {};
}
}
/** 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 };
}
/** 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} --:--`;
}