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.