feat(dates): enter and display dates as dd/mm/yyyy, site-wide

The create-project form's date fields were `<input type="date">`, whose
display format follows the *browser's* locale rather than the document's. On a
US-configured browser they rendered mm/dd/yyyy inside an app that renders
en-GB everywhere else (~40 call sites). That is not cosmetic: 03/09/2026 is a
valid date under both readings, so a user cannot tell which one the box took
and no validation can catch it. The failure mode is silently wrong data, not
an error message. No attribute, `lang` value or CSS overrides the native
control, so the control had to go.

`DateInput` replaces it: a text field that masks to dd/mm/yyyy as you type,
whose value and onChange remain YYYY-MM-DD. The display format therefore stops
at the input's edge — the schema, the request body and the Drizzle `date`
column are untouched, and dates keep sorting lexicographically with no
timezone in play. The cost is the native calendar picker, which ADR-0019
argues is the right way round: a picker is a convenience, an ambiguous entry
format is a correctness problem. If one is wanted later it belongs behind
`DateInput`, not in a revert to the native control.

The parsing lives in `src/utils/dates.ts`, pure and Date-free — `new
Date("2026-03-01")` is midnight UTC, which is the previous day in a
negative-offset zone, so this compares and builds strings instead. Impossible
days are rejected explicitly because `Date` would roll 31/02 forward into
March rather than refuse it.

Two behaviours worth knowing, both covered by tests. A *finished* but
impossible date is handed to the schema as typed rather than reported as
empty: a field visibly holding 31/02/2026 while an optional-date schema reads
it as unfilled would tell the user nothing. Unfinished input does report
empty, so no error fires mid-keystroke. And the mask only re-adds a separator
while characters are being added — doing it during a backspace would make the
slash undeletable.

`DateInput` adjusts state during render rather than in a `useEffect` (the
documented alternative) to resync when the form sets the field from outside.

Per CLAUDE.md's utils split, dates.ts is generic and lives in the new
`src/utils/`; `src/app/utils.ts` stays for company-specific helpers (EPC
bands, SAP points) and `src/lib/utils.ts` is vendored Tremor/shadcn.

CLAUDE.md records the rule and the "never `<input type=\"date\">`"
prohibition so the next agent finds it before writing the wrong thing.

Five other date inputs remain wrong under this ADR — PibiSection,
PibiDatesEditor and PropertyFilters — left alone as unrelated to this branch
and recorded in ADR-0019 as convert-when-next-touched.

TESTS: tsc --noEmit and ESLint clean; vitest 576 passing (53 files), 18 of
them new for the date helpers. Cypress was NOT run — this environment's
DATABASE_URL still points at production. The spec's four date entries were
converted to day-first, an assertion added that 01/03/2026 crosses the wire as
2026-03-01, and a new case covers the mask and the impossible-date refusal;
all three are unverified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-22 10:50:09 +00:00
parent a7a1f8ee98
commit 1019e3ea3f
7 changed files with 444 additions and 8 deletions

View file

@ -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 `<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`.
- **`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.

View file

@ -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();

View file

@ -0,0 +1,85 @@
# 19. Dates are entered and displayed as dd/mm/yyyy, not with `<input type="date">`
Date: 2026-07-22
## Status
Accepted
Numbering note: 00150017 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
`<input type="date">`, 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
`<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.
**`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 `<input type="date">` 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.

View file

@ -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<HTMLInputElement>,
"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 `<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.
*
* 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<HTMLInputElement, DateInputProps>(
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<HTMLInputElement>) {
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 (
<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}
/>
);
}
);

View file

@ -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({
<FormItem>
<FormLabel>Start date</FormLabel>
<FormControl>
<Input
<DateInput
{...field}
type="date"
data-testid="create-project-start-date"
/>
</FormControl>
@ -261,9 +261,8 @@ export function CreateProjectDialog({
<FormItem>
<FormLabel>Estimated end date</FormLabel>
<FormControl>
<Input
<DateInput
{...field}
type="date"
data-testid="create-project-end-date"
/>
</FormControl>

106
src/utils/dates.test.ts Normal file
View file

@ -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("");
});
});

100
src/utils/dates.ts Normal file
View file

@ -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 `<input type="date">`.
*/
/** `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;
}