diff --git a/CLAUDE.md b/CLAUDE.md
index 3a0e1179..c87b3c04 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,7 +18,8 @@
- **Never ``.** 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,
diff --git a/cypress/e2e/projects/create-project.cy.js b/cypress/e2e/projects/create-project.cy.js
index e397f760..25e38639 100644
--- a/cypress/e2e/projects/create-project.cy.js
+++ b/cypress/e2e/projects/create-project.cy.js
@@ -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();
diff --git a/docs/adr/0019-uk-date-format-in-form-inputs.md b/docs/adr/0019-uk-date-format-in-form-inputs.md
index 932389a6..13b3a2aa 100644
--- a/docs/adr/0019-uk-date-format-in-form-inputs.md
+++ b/docs/adr/0019-uk-date-format-in-form-inputs.md
@@ -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
``.** 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.
diff --git a/package-lock.json b/package-lock.json
index 7bc83da5..12c9f9b8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index 92fda474..5cee2c0b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/components/DateInput.tsx b/src/app/components/DateInput.tsx
index 84c2ce90..20530110 100644
--- a/src/app/components/DateInput.tsx
+++ b/src/app/components/DateInput.tsx
@@ -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 `` 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(
// 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(
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 (
-
+
+
+
+
+
+
+
+
+
+
+
);
}
);
diff --git a/src/app/shadcn_components/ui/calendar.tsx b/src/app/shadcn_components/ui/calendar.tsx
new file mode 100644
index 00000000..2f7691d5
--- /dev/null
+++ b/src/app/shadcn_components/ui/calendar.tsx
@@ -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 Mo–Su, matching how
+ * the rest of the app formats dates.
+ */
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ locale = enGB,
+ ...props
+}: CalendarProps) {
+ return (
+ 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 ;
+ },
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = "Calendar";
+
+export { Calendar };
diff --git a/src/utils/dates.test.ts b/src/utils/dates.test.ts
index 4f07bbdc..976247bb 100644
--- a/src/utils/dates.test.ts
+++ b/src/utils/dates.test.ts
@@ -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 });
diff --git a/src/utils/dates.ts b/src/utils/dates.ts
index 424a089e..4c4ed01c 100644
--- a/src/utils/dates.ts
+++ b/src/utils/dates.ts
@@ -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.