diff --git a/CLAUDE.md b/CLAUDE.md index 5fd0594b..3a0e1179 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,28 @@ - **Avoid `useEffect` and `useMemo`.** Derive values inline, prefer Server Components + Route Handlers, prefer event handlers. If a hook is genuinely the only option, flag it and ask before using it. - **Use TanStack Query (`@tanstack/react-query`), not raw `fetch`, for client-side HTTP.** Reads use `useQuery`; writes use `useMutation`. This project is on **v4** — note that `refetchInterval`'s callback signature is `(data, query)`, not v5's `(query)`. +## Dates + +- **`dd/mm/yyyy` everywhere a person reads or types a date.** This is a UK + product. `03/09/2026` is 3 September; rendering it as 3 March is silently + wrong data, not a cosmetic bug. +- **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`. +- **`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, + pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`. +- See [ADR-0019](./docs/adr/0019-uk-date-format-in-form-inputs.md). + +## Utilities + +- **`src/utils/`** is for generic helpers — no domain knowledge (e.g. + `dates.ts`). **`src/app/utils.ts`** is for company-specific ones (EPC bands, + SAP points, portfolio ratings). `src/lib/utils.ts` is vendored Tremor/shadcn + class helpers — don't add to it. + ## Next.js 15 route handlers - `params` is a `Promise` — type as `{ params: Promise<{ ... }> }` and `await params` before destructuring. diff --git a/cypress/e2e/projects/create-project.cy.js b/cypress/e2e/projects/create-project.cy.js index c868632e..e397f760 100644 --- a/cypress/e2e/projects/create-project.cy.js +++ b/cypress/e2e/projects/create-project.cy.js @@ -83,8 +83,8 @@ describe("Ara Projects — create project (stubbed)", function () { cy.get("[data-testid=create-project-trigger]").click(); cy.get("[data-testid=create-project-name]").type("Backwards dates"); - cy.get("[data-testid=create-project-start-date]").type("2026-09-30"); - cy.get("[data-testid=create-project-end-date]").type("2026-03-01"); + cy.get("[data-testid=create-project-start-date]").type("30/09/2026"); + cy.get("[data-testid=create-project-end-date]").type("01/03/2026"); cy.get("[data-testid=create-project-submit]").click(); cy.contains("The end date cannot be before the start date").should( @@ -92,6 +92,24 @@ describe("Ara Projects — create project (stubbed)", function () { ); }); + 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(); + + // The separators are supplied by the field, so eight digits are enough. + cy.get("[data-testid=create-project-start-date]") + .type("31022026") + .should("have.value", "31/02/2026"); + + cy.get("[data-testid=create-project-name]").type("Impossible date"); + cy.intercept("POST", "/api/projects", cy.spy().as("createCall")); + cy.get("[data-testid=create-project-submit]").click(); + + // A finished-but-impossible date must not read as an empty optional field. + cy.contains("Enter a valid date").should("be.visible"); + cy.get("@createCall").should("not.have.been.called"); + }); + it("navigates to workstream selection on success", function () { cy.intercept("POST", "/api/projects", { statusCode: 201, @@ -104,12 +122,16 @@ describe("Ara Projects — create project (stubbed)", function () { cy.get("[data-testid=create-project-name]").type("Riverside retrofit 2026"); selectFirstOption("create-project-organisation"); selectFirstOption("create-project-type"); + cy.get("[data-testid=create-project-start-date]").type("01/03/2026"); cy.get("[data-testid=create-project-domna-admin-access]").click(); cy.get("[data-testid=create-project-submit]").click(); cy.wait("@createProject").then(({ request }) => { expect(request.body.name).to.eq("Riverside retrofit 2026"); expect(request.body.domnaAdminAccess).to.eq(true); + // Typed day-first, sent ISO: the display format stops at the input, and + // 01/03 is 1 March rather than 3 January. + expect(request.body.startDate).to.eq("2026-03-01"); }); cy.location("pathname").should("eq", "/projects/4242/setup/workstreams"); @@ -162,8 +184,8 @@ describe("Ara Projects — create project (end to end, writes)", function () { cy.get("[data-testid=create-project-description]").type( "Created by the Cypress happy path.", ); - cy.get("[data-testid=create-project-start-date]").type("2026-03-01"); - cy.get("[data-testid=create-project-end-date]").type("2026-09-30"); + cy.get("[data-testid=create-project-start-date]").type("01/03/2026"); + cy.get("[data-testid=create-project-end-date]").type("30/09/2026"); // Required for an internal user creating on behalf of an organisation they // are not a member of — without it the guard refuses, by design. cy.get("[data-testid=create-project-domna-admin-access]").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 new file mode 100644 index 00000000..932389a6 --- /dev/null +++ b/docs/adr/0019-uk-date-format-in-form-inputs.md @@ -0,0 +1,85 @@ +# 19. Dates are entered and displayed as dd/mm/yyyy, not with `` + +Date: 2026-07-22 + +## Status + +Accepted + +Numbering note: 0015–0017 are in flight on the reporting-redesign branch +(PR #416), alongside 0010; 0019 follows ADR-0018 and avoids collision with all +of them. + +## Context + +This is a UK product. Everything we *display* already knows that — some forty +call sites pass `"en-GB"` to `toLocaleDateString` / `Intl.DateTimeFormat`, so a +rendered date reads `3 September 2026` or `03/09/2026`. + +Form **inputs** did not follow. Every date field in the app was +``, and the native control's display format is chosen by the +**browser's** locale, not the document's. A developer or user whose Chrome is +configured `en-US` sees `mm/dd/yyyy` in the same app that renders `dd/mm/yyyy` +everywhere else. + +That inconsistency is not cosmetic. `03/09/2026` is a valid date under both +readings — 3 September to a UK reader, 9 March to a US one. A user entering a +project's start date has no way to tell which one the box took, and no +validation can catch it, because both are dates. The failure mode is silently +wrong data written months into a works programme, not an error message. + +The `lang` attribute does not fix this: Chromium and Firefox both take the date +input's format from browser/OS locale settings and ignore document language. +There is no attribute, CSS property or polyfill that makes the native control +render day-first on demand. + +The options were: + +1. **Leave it.** Accept that the entry format varies per viewer. +2. **Use the native control but display the format in the label** — + "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. + +## Decision + +**`dd/mm/yyyy` is the date format for the whole app, in inputs as well as +output.** A date shown to or typed by a person is day-first, everywhere, for +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. + +**`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 +edge, so schemas, route handlers and the database are untouched by this +decision, and dates keep sorting lexicographically with no timezone in play. + +**Output keeps using `"en-GB"`** with `toLocaleDateString` / `Intl`. This ADR +does not introduce a second way to render a date; it makes entry agree with +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. +- **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. +- **A finished-but-impossible date is passed to the schema as typed** rather + than reported as empty, so `31/02/2026` produces "Enter a valid date" + instead of silently reading as an unfilled optional field. Unfinished input + reports empty, so no error appears mid-keystroke. +- **Migration is incremental.** `DateInput` is used by the create-project modal + (#409). Five other `` fields remain, in + `PibiSection.tsx`, `PibiDatesEditor.tsx` and `PropertyFilters.tsx`; they are + wrong under this ADR and should be converted when next touched. +- **New date fields have one right answer**, recorded in CLAUDE.md so it is + found before the wrong one is written. diff --git a/src/app/components/DateInput.tsx b/src/app/components/DateInput.tsx new file mode 100644 index 00000000..84c2ce90 --- /dev/null +++ b/src/app/components/DateInput.tsx @@ -0,0 +1,102 @@ +"use client"; + +import * as React from "react"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { + formatUkDate, + formatUkDateWhileTyping, + parseUkDate, +} from "@/utils/dates"; + +export interface DateInputProps + extends Omit< + React.InputHTMLAttributes, + "value" | "onChange" | "type" + > { + /** + * The date as `YYYY-MM-DD`. Empty, `null` and `undefined` all mean "no date" + * — an optional field's form value is `undefined` before it is touched. + */ + value: string | null | undefined; + /** + * Called with `YYYY-MM-DD` once a real date has been typed, and `""` while + * the field is still incomplete. A *complete* entry that is not a real date + * (`31/02/2026`) is passed through as typed, so the caller's schema rejects + * it — see the note in the component body. + */ + onChange: (value: string) => void; +} + +/** + * A date field that reads and writes `dd/mm/yyyy`. + * + * 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. + * + * 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 + * changes. + */ +export const DateInput = React.forwardRef( + function DateInput({ value, onChange, ...props }, ref) { + // Normalised first: an untouched optional field is `undefined` and a + // cleared one is `""`. Treating them as different values would reset the + // text mid-keystroke, because typing reports `""` for incomplete input. + const current = value ?? ""; + + // 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. + const [state, setState] = React.useState(() => ({ + text: formatUkDate(current), + value: current, + })); + + // Adjusting state during render, not in an effect: when the form resets or + // sets this field from outside, the incoming value is authoritative and + // the displayed text is rebuilt from it. React re-runs the render with the + // new state before touching the DOM, so this costs no extra paint. + if (current !== state.value) { + setState({ text: formatUkDate(current), value: current }); + } + + function handleChange(event: React.ChangeEvent) { + const raw = event.target.value; + const text = formatUkDateWhileTyping(raw, { + deleting: raw.length < state.text.length, + }); + + // Three outcomes, and the middle one is the reason this is not just + // `parseUkDate(text) ?? ""`. A field holding "31/02/2026" that reported + // itself empty would pass an optional-date schema silently, and the user + // would be told nothing about a date they can see in the box. So a + // finished-but-impossible date is handed over as typed, where the + // schema's `YYYY-MM-DD` check rejects it. Unfinished input is genuinely + // "no date yet" and reports empty, so no error appears mid-keystroke. + const finished = text.replace(/\D/g, "").length === 8; + const next = parseUkDate(text) ?? (finished ? text : ""); + + setState({ text, value: next }); + onChange(next); + } + + return ( + + ); + } +); diff --git a/src/app/projects/components/CreateProjectDialog.tsx b/src/app/projects/components/CreateProjectDialog.tsx index 505713a8..34e8e532 100644 --- a/src/app/projects/components/CreateProjectDialog.tsx +++ b/src/app/projects/components/CreateProjectDialog.tsx @@ -11,6 +11,7 @@ import { type CreateProjectFormValues, type SelectOption, } from "@/lib/projects/createProject"; +import { DateInput } from "@/app/components/DateInput"; import { Button } from "@/app/shadcn_components/ui/button"; import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; import { @@ -244,9 +245,8 @@ export function CreateProjectDialog({ Start date - @@ -261,9 +261,8 @@ export function CreateProjectDialog({ Estimated end date - diff --git a/src/utils/dates.test.ts b/src/utils/dates.test.ts new file mode 100644 index 00000000..4f07bbdc --- /dev/null +++ b/src/utils/dates.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { formatUkDate, formatUkDateWhileTyping, parseUkDate } from "./dates"; + +describe("formatUkDate", () => { + it("renders an ISO date day-first", () => { + expect(formatUkDate("2026-09-03")).toBe("03/09/2026"); + }); + + it("keeps the leading zeros", () => { + expect(formatUkDate("2026-01-05")).toBe("05/01/2026"); + }); + + it("returns empty for null, undefined and the empty string", () => { + expect(formatUkDate(null)).toBe(""); + expect(formatUkDate(undefined)).toBe(""); + expect(formatUkDate("")).toBe(""); + }); + + it("returns empty for a date that is not ISO", () => { + expect(formatUkDate("03/09/2026")).toBe(""); + expect(formatUkDate("2026-9-3")).toBe(""); + }); + + it("returns empty for a day that does not exist", () => { + expect(formatUkDate("2026-02-31")).toBe(""); + }); +}); + +describe("parseUkDate", () => { + it("reads a UK date as day-first", () => { + expect(parseUkDate("03/09/2026")).toBe("2026-09-03"); + }); + + it("ignores surrounding whitespace", () => { + expect(parseUkDate(" 03/09/2026 ")).toBe("2026-09-03"); + }); + + it("returns null for partial input", () => { + expect(parseUkDate("")).toBeNull(); + expect(parseUkDate("03")).toBeNull(); + expect(parseUkDate("03/09")).toBeNull(); + expect(parseUkDate("03/09/20")).toBeNull(); + }); + + it("rejects an American date rather than silently transposing it", () => { + // 09/30/2026 is 30 September to a US reader; as dd/mm it has month 30. + expect(parseUkDate("09/30/2026")).toBeNull(); + }); + + it("rejects days that do not exist", () => { + expect(parseUkDate("31/02/2026")).toBeNull(); + expect(parseUkDate("31/04/2026")).toBeNull(); + expect(parseUkDate("00/01/2026")).toBeNull(); + expect(parseUkDate("01/13/2026")).toBeNull(); + }); + + it("accepts 29 February only in a leap year", () => { + expect(parseUkDate("29/02/2024")).toBe("2024-02-29"); + expect(parseUkDate("29/02/2026")).toBeNull(); + expect(parseUkDate("29/02/2000")).toBe("2000-02-29"); + expect(parseUkDate("29/02/1900")).toBeNull(); + }); + + it("round-trips with formatUkDate", () => { + expect(formatUkDate(parseUkDate("03/09/2026"))).toBe("03/09/2026"); + }); +}); + +describe("formatUkDateWhileTyping", () => { + const typed = (raw: string) => formatUkDateWhileTyping(raw, { deleting: false }); + const deleted = (raw: string) => formatUkDateWhileTyping(raw, { deleting: true }); + + it("adds each separator as soon as its group is complete", () => { + expect(typed("0")).toBe("0"); + expect(typed("03")).toBe("03/"); + expect(typed("03/0")).toBe("03/0"); + expect(typed("03/09")).toBe("03/09/"); + expect(typed("03/09/2026")).toBe("03/09/2026"); + }); + + it("accepts digits typed without any separators", () => { + expect(typed("03092026")).toBe("03/09/2026"); + }); + + it("discards anything that is not a digit", () => { + expect(typed("3rd Sept")).toBe("3"); + expect(typed("03-09-2026")).toBe("03/09/2026"); + }); + + it("stops at eight digits", () => { + expect(typed("030920261234")).toBe("03/09/2026"); + }); + + it("lets a separator be deleted instead of re-adding it", () => { + // Backspacing over "03/" must land on "03", not bounce back to "03/". + expect(deleted("03")).toBe("03"); + expect(deleted("03/09")).toBe("03/09"); + }); + + it("keeps deleting all the way to empty", () => { + expect(deleted("03/0")).toBe("03/0"); + expect(deleted("03/")).toBe("03"); + expect(deleted("0")).toBe("0"); + expect(deleted("")).toBe(""); + }); +}); diff --git a/src/utils/dates.ts b/src/utils/dates.ts new file mode 100644 index 00000000..424a089e --- /dev/null +++ b/src/utils/dates.ts @@ -0,0 +1,100 @@ +/** + * UK date entry and display. + * + * Generic: this file knows about calendars and string formats, and nothing + * about our domain. Domain-specific helpers live in `@/app/utils`. + * + * Two formats, and they never mix: + * + * - **`dd/mm/yyyy` is what a person sees and types.** This is a UK product; + * `03/09/2026` means 3 September, and a form that renders it as 3 March is + * not a cosmetic bug — the user cannot tell they have entered the wrong + * date, so it is silently wrong data. + * - **`YYYY-MM-DD` is what everything else uses** — form state, request + * bodies, and Drizzle `date` columns. It sorts lexicographically, carries no + * timezone, and is the only format that crosses a boundary. + * + * The parsing side is deliberately `Date`-free, so there is no timezone to get + * wrong: `new Date("2026-03-01")` is midnight UTC, which in a negative-offset + * zone is the previous day. We compare and build strings. + * + * See ADR-0019 for why date form inputs are not ``. + */ + +/** `YYYY-MM-DD` — the wire and column format. */ +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** `dd/mm/yyyy` — the format a person reads and types. */ +const UK_DATE = /^(\d{2})\/(\d{2})\/(\d{4})$/; + +/** Days in each month, index 0 = January. February is corrected below. */ +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/** Proleptic Gregorian, which is what the `date` column stores. */ +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +/** + * Whether the parts name a day that exists — `31/02/2026` and `31/04/2026` do + * not. Checked explicitly because `Date` would roll them forward into March + * and May rather than reject them. + */ +function isRealDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) return false; + const limit = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1]; + return day <= limit; +} + +/** + * `YYYY-MM-DD` → `dd/mm/yyyy`, for showing a stored date to a person. + * + * Anything that is not a real ISO date — including `null`, `undefined` and the + * empty string an untouched field holds — becomes `""`, so a caller can render + * the result directly without a null check. + */ +export function formatUkDate(iso: string | null | undefined): string { + const match = iso?.match(ISO_DATE); + if (!match) return ""; + const [, year, month, day] = match; + if (!isRealDate(Number(year), Number(month), Number(day))) return ""; + return `${day}/${month}/${year}`; +} + +/** + * `dd/mm/yyyy` → `YYYY-MM-DD`, for turning what a person typed into the format + * everything else uses. + * + * Returns `null` for anything that is not a complete, real UK date — partial + * input (`01/0`), a transposed pair that looks American (`09/30/2026`, whose + * month is 30), and days that do not exist (`31/02/2026`). Callers treat + * `null` as "not a date yet", not as "empty". + */ +export function parseUkDate(text: string): string | null { + const match = text.trim().match(UK_DATE); + if (!match) return null; + const [, day, month, year] = match; + if (!isRealDate(Number(year), Number(month), Number(day))) return null; + return `${year}-${month}-${day}`; +} + +/** + * Reformat a half-typed `dd/mm/yyyy` field after every keystroke: keep the + * digits, drop everything else, and regroup. + * + * The separator is added as soon as a group is complete so the user does not + * have to type it — but only while they are *adding* characters. Re-adding it + * during a backspace would make the slash undeletable: the keystroke that + * removed it would immediately put it back, and the caret would never get past + * it. + */ +export function formatUkDateWhileTyping( + raw: string, + { deleting }: { deleting: boolean } +): string { + const digits = raw.replace(/\D/g, "").slice(0, 8); + const groups = [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 8)]; + const text = groups.filter((group) => group.length > 0).join("/"); + const completesAGroup = digits.length === 2 || digits.length === 4; + return !deleting && completesAGroup ? `${text}/` : text; +}