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 "