feat(dates): restore a calendar picker behind DateInput

Dropping `<input type="date">` for the dd/mm/yyyy mask also dropped its
calendar, leaving typing as the only way to enter a date. ADR-0019 accepted
that cost but named the remedy: add a picker *behind* `DateInput`, writing the
same ISO value, rather than revert to the native control. That was felt
immediately in use, so this does it.

`DateInput` now renders a calendar button inside the field's right edge and a
Calendar in a Radix popover. Typing and picking are equal routes to the same
value — picking fills the text, typing moves the grid — so neither is
privileged and the two cannot disagree. The trigger is `type="button"` so it
cannot submit the form, and carries an aria-label, a bare icon having no
accessible name.

The calendar wrapper is written by hand rather than taken from the shadcn
registry: the published template targets react-day-picker v8/v9, whose
`classNames` keys differ from the `UI` enum v10 uses. It defaults to the enGB
locale, which carries `weekStartsOn: 1`, so the grid starts on Monday without
being told to.

The delicate part is the `Date` boundary. A calendar works in `Date`s, and
both obvious conversions are wrong: `new Date("2026-03-01")` is midnight UTC,
which is 29 February west of Greenwich, and `toISOString()` shifts the day back
the other way. So `isoToLocalDate` and `localDateToIso` build and read local
parts, and they are the only two functions in `src/utils/dates.ts` that touch
`Date` at all — everything else stays string-only. The round trip is tested
across a full month rather than at a sample day, because the failure only
appears on some days in some zones; the suite was also run under
TZ=America/Los_Angeles and TZ=Pacific/Auckland.

Dependencies: react-day-picker and date-fns, the latter already present
transitively and now direct at v4. `@tremor/react` bundles react-day-picker v8
nested and npm kept it isolated on date-fns v3, so nothing existing changed
version; no Tremor DatePicker is used anywhere in src, so there is no second
copy in the bundle. The alternative was a hand-rolled month grid with no
dependency, rejected because keyboard navigation and ARIA on a calendar are
easy to get subtly wrong and not worth owning.

ADR-0019's "the native calendar picker is gone" consequence was now false and
has been rewritten.

TESTS: tsc --noEmit and ESLint clean; vitest 582 passing (53 files), 24 of them
for the date helpers. NOT verified in a browser — this project has no jsdom or
testing-library, and `next build` was not run because it would statically
render pages against a DATABASE_URL still pointing at production. The new
Cypress case, which picks 17 March from the grid and expects 17/03/2026 in the
field, is unrun like the rest of that spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-22 15:39:11 +00:00
parent 1019e3ea3f
commit c96d964d24
9 changed files with 349 additions and 34 deletions

View file

@ -18,7 +18,8 @@
- **Never `<input type="date">`.** Its format follows the *browser's* locale,
so a US-configured browser shows `mm/dd/yyyy` and no attribute overrides it.
Use `DateInput` (`@/app/components/DateInput`) instead — it masks to
`dd/mm/yyyy` and its `value`/`onChange` stay `YYYY-MM-DD`.
`dd/mm/yyyy`, carries a calendar picker, and its `value`/`onChange` stay
`YYYY-MM-DD`.
- **`YYYY-MM-DD` is the only format that crosses a boundary** — form state,
request bodies, Drizzle `date` columns. The display format stops at the
input's edge. Parsing/formatting helpers are in `@/utils/dates`; for output,

View file

@ -92,6 +92,28 @@ describe("Ara Projects — create project (stubbed)", function () {
);
});
it("fills the field day-first from the calendar picker", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
// Type a date first so the grid opens on a known month rather than
// whatever month the test happens to run in.
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-start-date-picker]").click();
// The grid is en-GB, so the week starts on Monday.
cy.get("[role=dialog]").within(() => {
cy.contains("Mo").should("be.visible");
cy.contains("March 2026").should("be.visible");
cy.get("[role=gridcell] button").contains(/^17$/).click();
});
cy.get("[data-testid=create-project-start-date]").should(
"have.value",
"17/03/2026",
);
});
it("formats the date as it is typed and rejects a day that does not exist", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();

View file

@ -40,7 +40,8 @@ The options were:
"Start date (dd/mm/yyyy)". Honest, but a lie on a US-locale browser, where
the box then contradicts its own label.
3. **Replace the native control with a masked text input** that reads and
writes `dd/mm/yyyy`, keeping `YYYY-MM-DD` as the value.
writes `dd/mm/yyyy`, keeping `YYYY-MM-DD` as the value, and supply our own
calendar to replace the native one it displaces.
## Decision
@ -50,9 +51,10 @@ every user, regardless of their browser's locale.
**Date form fields use `DateInput` (`src/app/components/DateInput.tsx`), not
`<input type="date">`.** It is a text input that masks to `dd/mm/yyyy` as the
user types, and whose `value`/`onChange` remain `YYYY-MM-DD`. The parsing and
formatting are pure functions in `src/utils/dates.ts` (`formatUkDate`,
`parseUkDate`, `formatUkDateWhileTyping`), unit-tested independently of React.
user types, with a calendar picker in a popover, and whose `value`/`onChange`
remain `YYYY-MM-DD`. The parsing and formatting are pure functions in
`src/utils/dates.ts` (`formatUkDate`, `parseUkDate`, `formatUkDateWhileTyping`,
`isoToLocalDate`, `localDateToIso`), unit-tested independently of React.
**`YYYY-MM-DD` remains the only format that crosses a boundary** — form state,
request bodies, Drizzle `date` columns. The display format stops at the input's
@ -65,11 +67,26 @@ what rendering already did.
## Consequences
- **The native calendar picker is gone** from fields that use `DateInput`. This
is the real cost, and it is accepted: a picker is a convenience, whereas an
ambiguous entry format is a correctness problem. If a picker is wanted later,
it should be added *behind* `DateInput` — a popover calendar writing the same
ISO value — not by reverting to the native control.
- **The picker is replaced, not lost.** Dropping the native control also drops
its calendar, which was judged an acceptable cost — a picker is a
convenience, an ambiguous entry format is a correctness problem — but the
loss was immediately felt in use, so the calendar was rebuilt *behind*
`DateInput` as this ADR anticipated: a popover writing the same ISO value,
rather than a revert to the native control. It uses `react-day-picker` via
`shadcn_components/ui/calendar.tsx`, defaulting to the `enGB` locale, whose
`weekStartsOn: 1` puts Monday first. Typing and picking are equal routes to
the same value.
- **Two new dependencies**: `react-day-picker` and `date-fns` (the latter was
already present transitively; it is now direct). The alternative was a
hand-rolled month grid with no dependency, rejected because keyboard
navigation and ARIA on a calendar are easy to get subtly wrong and not worth
owning.
- **The `Date` boundary is confined to two functions.** A calendar works in
`Date`s, so `isoToLocalDate` / `localDateToIso` bridge to them — building and
reading *local* parts, never `new Date(iso)` or `toISOString()`, both of
which shift the day across midnight for a non-zero UTC offset. Everything
else in `src/utils/dates.ts` stays string-only. The round trip is tested
across a full month.
- **Caret handling is a masked input's usual compromise.** Typing at the end of
the field, which is the overwhelming case, behaves normally; editing in the
middle can move the caret to the end. Judged not worth a masking dependency.

60
package-lock.json generated
View file

@ -46,6 +46,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
@ -61,6 +62,7 @@
"postcss": "^8.5.6",
"react": "18.3.1",
"react-confetti": "^6.4.0",
"react-day-picker": "^10.0.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",
@ -1250,6 +1252,12 @@
"ms": "^2.1.1"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -6036,12 +6044,36 @@
"node": ">=6"
}
},
"node_modules/@tremor/react/node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-day-picker": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
"integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -8528,9 +8560,9 @@
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
@ -13283,17 +13315,29 @@
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {

View file

@ -58,6 +58,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
@ -73,6 +74,7 @@
"postcss": "^8.5.6",
"react": "18.3.1",
"react-confetti": "^6.4.0",
"react-day-picker": "^10.0.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",

View file

@ -1,10 +1,20 @@
"use client";
import * as React from "react";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
import { Calendar } from "@/app/shadcn_components/ui/calendar";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import {
formatUkDate,
formatUkDateWhileTyping,
isoToLocalDate,
localDateToIso,
parseUkDate,
} from "@/utils/dates";
@ -25,6 +35,8 @@ export interface DateInputProps
* it see the note in the component body.
*/
onChange: (value: string) => void;
/** Cypress hook. The calendar trigger derives its own from this, suffixed. */
"data-testid"?: string;
}
/**
@ -33,8 +45,11 @@ export interface DateInputProps
* Use this rather than `<input type="date">` anywhere a user enters a date.
* The native control's display format follows the *browser's* locale, not the
* document's so a US-configured browser shows `mm/dd/yyyy` on a UK product,
* with no attribute available to override it. The cost is losing the native
* calendar picker; ADR-0019 records why that trade is the right way round.
* with no attribute available to override it.
*
* A date can be typed or picked. The calendar in the popover replaces the one
* lost with the native control, and writes through the same value, so neither
* route is privileged: picking fills the text, typing moves the grid.
*
* The value crossing this boundary is always `YYYY-MM-DD`, so form state,
* request bodies and `date` columns are unaffected only what the user sees
@ -47,6 +62,8 @@ export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
// text mid-keystroke, because typing reports `""` for incomplete input.
const current = value ?? "";
const [pickerOpen, setPickerOpen] = React.useState(false);
// The displayed text and the value it produced, held together so the pair
// can never disagree. Half-typed text ("03/0") has no ISO form, so the
// field cannot be driven by `value` alone.
@ -83,20 +100,67 @@ export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
onChange(next);
}
function handleSelect(date: Date | undefined) {
const next = date ? localDateToIso(date) : "";
setState({ text: formatUkDate(next), value: next });
onChange(next);
setPickerOpen(false);
}
// The date the grid should highlight and open on. A half-typed or
// impossible entry has none, in which case the picker opens on the current
// month rather than refusing to open.
const selected = isoToLocalDate(state.value) ?? undefined;
return (
<Input
{...props}
ref={ref}
// `text`, not `date` — see the component's docs.
type="text"
// Phones get the number pad; the mask supplies the separators.
inputMode="numeric"
autoComplete="off"
placeholder="dd/mm/yyyy"
maxLength={10}
value={state.text}
onChange={handleChange}
/>
<div className="relative">
<Input
{...props}
ref={ref}
// `text`, not `date` — see the component's docs.
type="text"
// Phones get the number pad; the mask supplies the separators.
inputMode="numeric"
autoComplete="off"
placeholder="dd/mm/yyyy"
maxLength={10}
value={state.text}
onChange={handleChange}
className={cn("pr-10", props.className)}
/>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<button
type="button"
// Typing stays the primary interaction, so the trigger sits
// inside the field rather than beside it: the input keeps the
// full width of its grid column, and the layout is unchanged
// from the native control it replaced.
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={props.disabled}
// The field itself is labelled; this button is not, and a bare
// icon has no accessible name without one.
aria-label="Choose a date from the calendar"
data-testid={
props["data-testid"]
? `${props["data-testid"]}-picker`
: undefined
}
>
<CalendarDaysIcon className="h-4 w-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
defaultMonth={selected}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
);

View file

@ -0,0 +1,82 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { DayPicker, type DayPickerProps } from "react-day-picker";
import { enGB } from "react-day-picker/locale";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/app/shadcn_components/ui/button";
export type CalendarProps = DayPickerProps;
/**
* The shadcn calendar, on react-day-picker v10.
*
* Written by hand rather than pulled from the shadcn registry: the published
* template targets v8/v9, whose `classNames` keys differ from the `UI` enum
* this version uses. The keys below are v10's.
*
* Defaults to `enGB`, which is the reason this wrapper exists rather than
* `DayPicker` being used directly the locale carries `weekStartsOn: 1`, so
* the grid starts on Monday and the weekday headers read MoSu, matching how
* the rest of the app formats dates.
*/
function Calendar({
className,
classNames,
showOutsideDays = true,
locale = enGB,
...props
}: CalendarProps) {
return (
<DayPicker
locale={locale}
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-4",
month: "flex flex-col gap-4",
month_caption: "flex justify-center pt-1 relative items-center h-7",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1 absolute right-1 top-1 z-10",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
),
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative focus-within:relative focus-within:z-20",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
selected:
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:focus]:bg-primary",
today: "[&>button]:bg-accent [&>button]:text-accent-foreground",
outside: "[&>button]:text-muted-foreground [&>button]:opacity-50",
disabled: "[&>button]:text-muted-foreground [&>button]:opacity-50",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation, className: chevronClassName, ...rest }) => {
const Icon =
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
return <Icon className={cn("h-4 w-4", chevronClassName)} {...rest} />;
},
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View file

@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { formatUkDate, formatUkDateWhileTyping, parseUkDate } from "./dates";
import {
formatUkDate,
formatUkDateWhileTyping,
isoToLocalDate,
localDateToIso,
parseUkDate,
} from "./dates";
describe("formatUkDate", () => {
it("renders an ISO date day-first", () => {
@ -66,6 +72,49 @@ describe("parseUkDate", () => {
});
});
describe("isoToLocalDate / localDateToIso", () => {
it("builds local midnight, not UTC midnight", () => {
const date = isoToLocalDate("2026-03-01")!;
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(2); // zero-based: March
expect(date.getDate()).toBe(1);
expect(date.getHours()).toBe(0);
});
it("returns null for anything that is not a real ISO date", () => {
expect(isoToLocalDate("")).toBeNull();
expect(isoToLocalDate(null)).toBeNull();
expect(isoToLocalDate(undefined)).toBeNull();
expect(isoToLocalDate("01/03/2026")).toBeNull();
expect(isoToLocalDate("2026-02-31")).toBeNull();
});
it("reads the local parts back", () => {
expect(localDateToIso(new Date(2026, 2, 1))).toBe("2026-03-01");
});
it("pads single-digit months and days", () => {
expect(localDateToIso(new Date(2026, 0, 5))).toBe("2026-01-05");
});
it("round-trips every day of a month regardless of the host timezone", () => {
// The bug this guards is a date shifting by one day through a UTC
// conversion, which only shows up on some days in some zones — so check
// them all rather than a single sample.
for (let day = 1; day <= 31; day++) {
const iso = `2026-03-${String(day).padStart(2, "0")}`;
expect(localDateToIso(isoToLocalDate(iso)!)).toBe(iso);
}
});
it("survives a date whose UTC form is the previous day", () => {
// 1 March at local midnight is 28 February in UTC for any negative
// offset. toISOString() would report the 28th; this must not.
const date = new Date(2026, 2, 1);
expect(localDateToIso(date)).toBe("2026-03-01");
});
});
describe("formatUkDateWhileTyping", () => {
const typed = (raw: string) => formatUkDateWhileTyping(raw, { deleting: false });
const deleted = (raw: string) => formatUkDateWhileTyping(raw, { deleting: true });

View file

@ -78,6 +78,40 @@ export function parseUkDate(text: string): string | null {
return `${year}-${month}-${day}`;
}
/**
* `YYYY-MM-DD` a `Date` at **local** midnight, for the calendar picker.
*
* This is the one place a `Date` is unavoidable a calendar component works
* in `Date`s so it is also the one place the timezone trap has to be
* handled. `new Date("2026-03-01")` parses as midnight *UTC*, which west of
* Greenwich is the evening of 29 February, and a calendar comparing it against
* local days would highlight the wrong one. Passing the parts separately
* builds local midnight instead, which is what the grid compares against.
*
* Returns `null` for anything that is not a real ISO date.
*/
export function isoToLocalDate(iso: string | null | undefined): Date | null {
const match = iso?.match(ISO_DATE);
if (!match) return null;
const [, year, month, day] = match;
if (!isRealDate(Number(year), Number(month), Number(day))) return null;
return new Date(Number(year), Number(month) - 1, Number(day));
}
/**
* A `Date` `YYYY-MM-DD`, reading the **local** parts.
*
* The inverse of {@link isoToLocalDate}, and it must not go via `toISOString`,
* which converts to UTC and would shift the date across midnight for any
* non-zero offset the same bug in the opposite direction.
*/
export function localDateToIso(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Reformat a half-typed `dd/mm/yyyy` field after every keystroke: keep the
* digits, drop everything else, and regroup.