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;
+}