mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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} --:--`;
|
||||
}
|
||||
Reference in new issue
Block a user