mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
a7a1f8ee98
commit
1019e3ea3f
7 changed files with 444 additions and 8 deletions
22
CLAUDE.md
22
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 `<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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
85
docs/adr/0019-uk-date-format-in-form-inputs.md
Normal file
85
docs/adr/0019-uk-date-format-in-form-inputs.md
Normal 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: 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
|
||||
`<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.
|
||||
102
src/app/components/DateInput.tsx
Normal file
102
src/app/components/DateInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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
106
src/utils/dates.test.ts
Normal 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
100
src/utils/dates.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue