Merge pull request #439 from Hestia-Homes/issue-409-wizard-projects-list

feat(ara-projects): setup wizard 1/5 — projects list, empty state, create-project modal
This commit is contained in:
Daniel Roth 2026-07-23 10:21:37 +01:00 committed by GitHub
commit 71b882a13b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2136 additions and 114 deletions

View file

@ -10,6 +10,29 @@
- **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`, carries a calendar picker, and its `value`/`onChange` stay
`YYYY-MM-DD`.
- **`YYYY-MM-DD` is the only format that crosses a boundary** — form state,
request bodies, Drizzle `date` columns. The display format stops at the
input's edge. Parsing/formatting helpers are in `@/utils/dates`; for output,
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

@ -0,0 +1,235 @@
/**
* Ara Projects projects list, empty state and create-project modal (#409)
*
* Two groups, because one of them writes:
*
* 1. The UI group stubs POST /api/projects with cy.intercept. It exercises
* the empty state, the modal, client-side validation and the navigation
* to workstream selection without touching the database, so it is safe to
* run anywhere.
*
* 2. The end-to-end group performs a real create and is the acceptance
* criterion's "create a project end-to-end". It INSERTs, so it is gated
* behind CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever be pointed at
* a non-production database. It also needs the #407 reference-data
* migration (0274) applied, or the project-type select will be empty and
* the page will not offer the CTA at all.
*
* Like the shell spec, this uses `cy.login` (cypress/support/commands.ts) to
* mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set.
*
* NOT YET RUN. The environment this was written in has DATABASE_URL pointing at
* production, so Cypress was deliberately not executed. Treat the selectors and
* assertions below as unverified until someone runs them against a scratch DB.
*/
const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES");
const INTERNAL_USER = {
email: "dev@domna.homes",
name: "Internal User",
onboarded: true,
sub: "cypress-internal",
};
describe("Ara Projects — create project (stubbed)", function () {
beforeEach(function () {
if (!JWT_SECRET) {
cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
this.skip();
}
cy.login(INTERNAL_USER);
});
it("offers the Create project CTA from the list", function () {
cy.visit("/projects");
// The CTA sits inside the welcome card when there are no projects, and in
// the page header once there are — either way there is exactly one.
cy.get("[data-testid=create-project-trigger]").should("be.visible").click();
cy.get("[data-testid=create-project-form]").should("be.visible");
cy.contains("Project details").should("be.visible");
});
it("shows the welcome empty state when the user has no visible projects", function () {
// Only meaningful on an instance with no projects visible to this user;
// where there are some, the list renders instead and this is skipped.
cy.visit("/projects");
cy.get("body").then(($body) => {
if ($body.find("[data-testid=projects-list-empty]").length === 0) {
cy.log("user has visible projects — empty state not applicable");
return;
}
cy.contains("Welcome to Ara Projects").should("be.visible");
cy.contains("How setup works").should("be.visible");
cy.get("[data-testid=create-project-trigger]").should("be.visible");
});
});
it("refuses to submit without a name and a project type", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
cy.get("[data-testid=create-project-submit]").click();
cy.contains("Enter a project name").should("be.visible");
cy.get("@createCall").should("not.have.been.called");
});
it("rejects an end date before the start date", function () {
cy.visit("/projects");
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("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(
"be.visible",
);
});
it("fills the field day-first from the calendar picker", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
// Type a date first so the grid opens on a known month rather than
// whatever month the test happens to run in.
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-start-date-picker]").click();
// The grid is en-GB, so the week starts on Monday.
cy.get("[role=dialog]").within(() => {
cy.contains("Mo").should("be.visible");
cy.contains("March 2026").should("be.visible");
cy.get("[role=gridcell] button").contains(/^17$/).click();
});
cy.get("[data-testid=create-project-start-date]").should(
"have.value",
"17/03/2026",
);
});
it("formats the date as it is typed and rejects a day that does not exist", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
// 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,
body: { id: "4242" },
}).as("createProject");
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
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");
});
it("surfaces the server's error message on a refusal", function () {
cy.intercept("POST", "/api/projects", {
statusCode: 403,
body: { error: "You cannot create a project for that organisation." },
}).as("createProject");
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Not mine");
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-submit]").click();
cy.wait("@createProject");
cy.get("[data-testid=create-project-error]")
.should("be.visible")
.and("contain", "cannot create a project for that organisation");
cy.location("pathname").should("eq", "/projects");
});
});
describe("Ara Projects — create project (end to end, writes)", function () {
beforeEach(function () {
if (!JWT_SECRET || !E2E_WRITES) {
cy.log(
"ARA_PROJECTS_E2E_WRITES not set — skipping the writing happy path",
);
this.skip();
}
cy.login(INTERNAL_USER);
});
it("creates a project and lands on workstream selection", function () {
// Unique per run so repeated runs do not collide on a name a human is
// eyeballing later. `project.name` has no unique constraint; this is for
// legibility, not correctness.
const name = `Cypress project ${Date.now()}`;
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type(name);
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-description]").type(
"Created by the Cypress happy path.",
);
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();
cy.get("[data-testid=create-project-submit]").click();
cy.location("pathname").should("match", /^\/projects\/\d+\/setup\/workstreams$/);
// The new project is now visible in the list, which is the visibility rule
// agreeing with the write that just happened.
cy.visit("/projects");
cy.get("[data-testid=projects-list]").should("exist");
cy.contains("[data-testid=projects-list-item]", name).should("be.visible");
});
});
/**
* Pick the first item in a shadcn/Radix `Select`. The options live in a portal
* outside the trigger, so this opens the trigger and then reaches for the
* listbox at the document root.
*/
function selectFirstOption(testId) {
cy.get(`[data-testid=${testId}]`).click();
cy.get("[role=option]").first().click();
}

View file

@ -76,7 +76,17 @@ describe("Ara Projects route shell", function () {
cy.get("[data-testid=projects-shell]").should("exist");
});
it("renders the per-project dashboard shell and its nav", function () {
// This assertion changed with #409. While the authz stub was in place,
// `canViewProject` could never return false, so /projects/<anything> rendered
// the dashboard shell for any signed-in user — which is what this test used
// to check. The real lib (#408) is now wired in, so a project the user has no
// standing on is a 404, and a project id that does not exist is the same 404
// (deliberately indistinguishable, so Project existence does not leak).
//
// Rendering the dashboard shell therefore now needs a real, visible project,
// which is covered by the create-project happy path in create-project.cy.js
// rather than by a hardcoded id here.
it("404s on a project the user has no standing on", function () {
cy.login({
email: "dev@domna.homes",
name: "Internal User",
@ -84,13 +94,21 @@ describe("Ara Projects route shell", function () {
sub: "cypress-internal",
});
cy.visit("/projects/1");
cy.request({ url: "/projects/999999999", failOnStatusCode: false })
.its("status")
.should("eq", 404);
});
cy.get("[data-testid=project-shell]").should("exist");
cy.get("[data-testid=project-dashboard-heading]").should("be.visible");
["dashboard", "workOrders", "import", "settings"].forEach((item) => {
cy.get(`[data-testid=projects-nav-${item}]`).should("be.visible");
it("404s on a malformed project id", function () {
cy.login({
email: "dev@domna.homes",
name: "Internal User",
onboarded: true,
sub: "cypress-internal",
});
cy.request({ url: "/projects/not-an-id", failOnStatusCode: false })
.its("status")
.should("eq", 404);
});
});

View file

@ -0,0 +1,102 @@
# 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, and supply our own
calendar to replace the native one it displaces.
## 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, with a calendar picker in a popover, and whose `value`/`onChange`
remain `YYYY-MM-DD`. The parsing and formatting are pure functions in
`src/utils/dates.ts` (`formatUkDate`, `parseUkDate`, `formatUkDateWhileTyping`,
`isoToLocalDate`, `localDateToIso`), unit-tested independently of React.
**`YYYY-MM-DD` remains the only format that crosses a boundary** — form state,
request bodies, Drizzle `date` columns. The display format stops at the input's
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 picker is replaced, not lost.** Dropping the native control also drops
its calendar, which was judged an acceptable cost — a picker is a
convenience, an ambiguous entry format is a correctness problem — but the
loss was immediately felt in use, so the calendar was rebuilt *behind*
`DateInput` as this ADR anticipated: a popover writing the same ISO value,
rather than a revert to the native control. It uses `react-day-picker` via
`shadcn_components/ui/calendar.tsx`, defaulting to the `enGB` locale, whose
`weekStartsOn: 1` puts Monday first. Typing and picking are equal routes to
the same value.
- **Two new dependencies**: `react-day-picker` and `date-fns` (the latter was
already present transitively; it is now direct). The alternative was a
hand-rolled month grid with no dependency, rejected because keyboard
navigation and ARIA on a calendar are easy to get subtly wrong and not worth
owning.
- **The `Date` boundary is confined to two functions.** A calendar works in
`Date`s, so `isoToLocalDate` / `localDateToIso` bridge to them — building and
reading *local* parts, never `new Date(iso)` or `toISOString()`, both of
which shift the day across midnight for a non-zero UTC offset. Everything
else in `src/utils/dates.ts` stays string-only. The round trip is tested
across a full month.
- **Caret handling is a masked input's usual compromise.** Typing at the end of
the field, which is the overwhelming case, behaves normally; editing in the
middle can move the caret to the end. Judged not worth a masking dependency.
- **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.

60
package-lock.json generated
View file

@ -46,6 +46,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
@ -61,6 +62,7 @@
"postcss": "^8.5.6",
"react": "18.3.1",
"react-confetti": "^6.4.0",
"react-day-picker": "^10.0.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",
@ -1250,6 +1252,12 @@
"ms": "^2.1.1"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -6036,12 +6044,36 @@
"node": ">=6"
}
},
"node_modules/@tremor/react/node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-day-picker": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
"integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -8528,9 +8560,9 @@
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
@ -13283,17 +13315,29 @@
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {

View file

@ -58,6 +58,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
@ -73,6 +74,7 @@
"postcss": "^8.5.6",
"react": "18.3.1",
"react-confetti": "^6.4.0",
"react-day-picker": "^10.0.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",

View file

@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { canManageProject } from "@/lib/projects/authz";
import {
createProjectSchema,
prospectiveProjectFacts,
} from "@/lib/projects/createProject";
import { loadAuthzUserByEmail } from "@/app/repositories/projects/authzRepository";
import {
insertProject,
organisationExists,
projectTypeExists,
} from "@/app/repositories/projects/projectsRepository";
/**
* POST /api/projects create an Ara Project (issue #409).
*
* The authorization question "may this user create a project for this
* organisation?" is answered by the *existing* guard: build the facts the
* project would have once created and ask `canManageProject`. That keeps
* creation on the same rule as every other management action instead of
* inventing a parallel one.
*
* The rule has a consequence worth naming: `domna_admin_access` gates the
* `internal` role, so a Domna user creating a project for an organisation they
* are not a member of must grant that access. Without it they would be
* creating a project they could not then see, and the guard refuses rather
* than producing an orphan.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const user = await loadAuthzUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const parsed = createProjectSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid body" },
{ status: 400 },
);
}
const draft = parsed.data;
if (!canManageProject(user, prospectiveProjectFacts(draft))) {
return NextResponse.json(
{
error:
"You cannot create a project for that organisation. Granting Domna admin access is required for an organisation you are not a member of.",
},
{ status: 403 },
);
}
// Checked after authorization so an unauthorized caller cannot use the
// difference between 400 and 403 to probe which organisations exist.
const projectTypeId = BigInt(draft.projectTypeId);
const [orgOk, typeOk] = await Promise.all([
organisationExists(draft.organisationId),
projectTypeExists(projectTypeId),
]);
if (!orgOk) {
return NextResponse.json({ error: "Unknown organisation" }, { status: 400 });
}
if (!typeOk) {
return NextResponse.json({ error: "Unknown project type" }, { status: 400 });
}
const id = await insertProject({ ...draft, projectTypeId });
// `id` is a bigint; JSON cannot carry one, so the client gets a string and
// uses it to build the next wizard step's path.
return NextResponse.json({ id: id.toString() }, { status: 201 });
}

View file

@ -0,0 +1,166 @@
"use client";
import * as React from "react";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
import { Calendar } from "@/app/shadcn_components/ui/calendar";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import {
formatUkDate,
formatUkDateWhileTyping,
isoToLocalDate,
localDateToIso,
parseUkDate,
} from "@/utils/dates";
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;
/** Cypress hook. The calendar trigger derives its own from this, suffixed. */
"data-testid"?: string;
}
/**
* 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.
*
* A date can be typed or picked. The calendar in the popover replaces the one
* lost with the native control, and writes through the same value, so neither
* route is privileged: picking fills the text, typing moves the grid.
*
* The value crossing this boundary is always `YYYY-MM-DD`, so form state,
* request bodies and `date` columns are unaffected only what the user sees
* 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 ?? "";
const [pickerOpen, setPickerOpen] = React.useState(false);
// The displayed text and the value it produced, held together so the pair
// can never disagree. Half-typed text ("03/0") has no ISO form, so the
// field cannot be driven by `value` alone.
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);
}
function handleSelect(date: Date | undefined) {
const next = date ? localDateToIso(date) : "";
setState({ text: formatUkDate(next), value: next });
onChange(next);
setPickerOpen(false);
}
// The date the grid should highlight and open on. A half-typed or
// impossible entry has none, in which case the picker opens on the current
// month rather than refusing to open.
const selected = isoToLocalDate(state.value) ?? undefined;
return (
<div className="relative">
<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}
className={cn("pr-10", props.className)}
/>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<button
type="button"
// Typing stays the primary interaction, so the trigger sits
// inside the field rather than beside it: the input keeps the
// full width of its grid column, and the layout is unchanged
// from the native control it replaced.
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={props.disabled}
// The field itself is labelled; this button is not, and a bare
// icon has no accessible name without one.
aria-label="Choose a date from the calendar"
data-testid={
props["data-testid"]
? `${props["data-testid"]}-picker`
: undefined
}
>
<CalendarDaysIcon className="h-4 w-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
defaultMonth={selected}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
);

View file

@ -1,6 +1,6 @@
import * as React from "react";
import { ProjectsNav, type ProjectsNavItem } from "../components/ProjectsNav";
import { requireProjectAccess } from "../authz";
import { requireProjectAccess } from "../guards";
function projectNav(projectId: string): ProjectsNavItem[] {
const base = `/projects/${projectId}`;

View file

@ -1,4 +1,4 @@
import { requireProjectAccess } from "../authz";
import { requireProjectAccess } from "../guards";
export const metadata = {
title: "Project dashboard | Ara",

View file

@ -1,65 +0,0 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
/**
* Temporary authorization seam for the Ara Projects route tree.
*
* TODO(#408): replace the bodies below with calls into
* `src/lib/projects/authz.ts` once that module lands. This file exists only so
* the route shell has a single, obvious place to wire authorization into
* deliberately *not* a second implementation of the rules.
*
* The visibility rule #408 will enforce:
* - internal users (`@domna.homes`) every Project
* - client-org members Projects where
* `project.organisation_id` is their org
* - contractor-org members Projects where their org is assigned
* to one of the Project's Workstreams
* (`project_workstream_contractor`)
*
* Note that resolving a user to an organisation does not exist yet either
* that resolution is part of #408, not of this shell.
*/
/**
* Guards a Projects route: requires a signed-in user and returns the session.
*
* Unauthenticated requests to `/projects/*` are already turned away by
* `src/middleware.ts`; this is the defence-in-depth check for the server
* component itself, matching the `bulk-upload/page.tsx` pattern.
*/
export async function requireProjectsSession(): Promise<Session> {
const session = await getServerSession(AuthOptions);
if (!session) redirect("/");
return session;
}
/**
* Whether `session`'s user may see the Project `projectId`.
*
* TODO(#408): delegate to the real visibility rule. Until then this only
* asserts a session exists the shell must not ship its own inlined
* permission logic, so it deliberately does not narrow further.
*/
export async function canViewProject(
session: Session,
projectId: string
): Promise<boolean> {
void projectId;
return Boolean(session?.user);
}
/**
* Guards a single-Project route. Throws the caller into a 404 rather than a
* 403 so that Project existence is not leaked to users outside its org.
*/
export async function requireProjectAccess(projectId: string): Promise<Session> {
const session = await requireProjectsSession();
if (!(await canViewProject(session, projectId))) {
// TODO(#408): notFound() once canViewProject can actually return false.
redirect("/projects");
}
return session;
}

View file

@ -0,0 +1,332 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { PlusIcon } from "@heroicons/react/24/outline";
import {
createProjectSchema,
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/app/shadcn_components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/app/shadcn_components/ui/form";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
/**
* The HTTP call, kept outside the component so the component itself contains
* no `fetch` TanStack Query owns the request lifecycle.
*
* Route handlers in this codebase answer errors as `{ error: string }`, so a
* failure is surfaced with the server's own message rather than a generic one.
*/
async function createProject(
values: CreateProjectFormValues,
): Promise<{ id: string }> {
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error ?? "Could not create the project");
}
return body;
}
export interface CreateProjectDialogProps {
organisations: SelectOption[];
projectTypes: SelectOption[];
/** Rendered as the trigger's label — "Create project" everywhere so far. */
label?: string;
}
/**
* Create-project modal step 1 of the setup wizard.
*
* On success it navigates to workstream selection (#410) rather than back to
* the list: creating a project is the start of setup, not the end of it.
*/
export function CreateProjectDialog({
organisations,
projectTypes,
label = "Create project",
}: CreateProjectDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const form = useForm<CreateProjectFormValues>({
resolver: zodResolver(createProjectSchema),
defaultValues: {
name: "",
// A client user typically belongs to exactly one organisation, so the
// select is pre-filled and effectively fixed. Internal users, offered
// every organisation, start with no choice made.
organisationId: organisations.length === 1 ? organisations[0].id : "",
projectTypeId: "",
description: "",
startDate: "",
endDate: "",
domnaAdminAccess: false,
},
});
const mutation = useMutation(createProject, {
onSuccess: ({ id }) => router.push(`/projects/${id}/setup/workstreams`),
});
// Deliberately not reset on close: a user who dismissed the dialog by
// accident gets their typing back. A completed creation navigates away, so
// stale values are never shown against a project that already exists.
function onOpenChange(next: boolean) {
if (!next && mutation.isLoading) return;
setOpen(next);
if (next) mutation.reset();
}
return (
<>
<Button
onClick={() => onOpenChange(true)}
data-testid="create-project-trigger"
>
<PlusIcon className="h-4 w-4 mr-2" />
{label}
</Button>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Project details</DialogTitle>
<DialogDescription>
Step 1 of 5 name the project and choose who it belongs to. You
will pick its workstreams next.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
className="space-y-4"
data-testid="create-project-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project name</FormLabel>
<FormControl>
<Input
{...field}
autoFocus
placeholder="e.g. Riverside retrofit 2026"
data-testid="create-project-name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organisationId"
render={({ field }) => (
<FormItem>
<FormLabel>Client</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={organisations.length <= 1}
>
<FormControl>
<SelectTrigger data-testid="create-project-organisation">
<SelectValue placeholder="Select client..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{organisations.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
{organisations.length === 1 && (
<FormDescription>
Projects you create belong to your organisation.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Project type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger data-testid="create-project-type">
<SelectValue placeholder="Select type..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{projectTypes.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<textarea
{...field}
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="What is this programme of works for?"
data-testid="create-project-description"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start date</FormLabel>
<FormControl>
<DateInput
{...field}
data-testid="create-project-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Estimated end date</FormLabel>
<FormControl>
<DateInput
{...field}
data-testid="create-project-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="domnaAdminAccess"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-testid="create-project-domna-admin-access"
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Grant Domna admin access</FormLabel>
<FormDescription>
Domna admins can help configure this project, support
imports, and view project data. This can be revoked at
any time.
</FormDescription>
</div>
</FormItem>
)}
/>
{mutation.isError && (
<p
role="alert"
className="text-sm text-red-600"
data-testid="create-project-error"
>
{(mutation.error as Error).message}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={mutation.isLoading}
data-testid="create-project-submit"
>
{mutation.isLoading ? "Creating…" : "Create and continue"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,88 @@
/**
* Route guards for the Ara Projects tree.
*
* This is glue, not policy. It resolves the three things a server component
* cannot get from a pure function the NextAuth session, the user's
* organisation memberships, and the project's facts hands them to the real
* guards in `@/lib/projects/authz`, and turns a refusal into a Next.js
* navigation. It contains **no permission rules of its own**; every decision
* below is a call into that module.
*
* Layering: `@/lib/projects/authz` decides (pure, no db),
* `@/app/repositories/projects/authzRepository` loads (Drizzle), this file
* navigates (redirect / notFound). The dependency only ever points that way.
*/
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import type { Session } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import {
loadAuthzUserByEmail,
loadProjectAuthzFacts,
} from "@/app/repositories/projects/authzRepository";
import {
canViewProject,
type AuthzUser,
type ProjectAuthzFacts,
} from "@/lib/projects/authz";
/**
* Requires a signed-in user and returns the session.
*
* Unauthenticated requests to `/projects/*` are already turned away by
* `src/middleware.ts`; this is the defence-in-depth check for the server
* component itself, matching the `bulk-upload/page.tsx` pattern. The redirect
* target matches the middleware's (`/`, the sign-in page).
*/
export async function requireProjectsSession(): Promise<Session> {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) redirect("/");
return session;
}
/**
* Requires a signed-in user and resolves them to an `AuthzUser` the shape
* every guard in `@/lib/projects/authz` takes.
*
* A session whose email has no `user` row is treated as no session at all: it
* cannot be authorized against anything, so it goes back to sign-in rather
* than falling through to an empty-membership user that silently sees nothing.
*/
export async function requireProjectsUser(): Promise<AuthzUser> {
const session = await requireProjectsSession();
const user = await loadAuthzUserByEmail(session.user.email!);
if (!user) redirect("/");
return user;
}
/**
* Parses a `[projectId]` path segment into the bigint PK it addresses.
*
* Returns null for anything that is not a bare non-negative integer, so a junk
* segment becomes a 404 rather than a Drizzle error.
*/
export function parseProjectId(projectId: string): bigint | null {
return /^\d+$/.test(projectId) ? BigInt(projectId) : null;
}
/**
* Guards a single-Project route, returning the facts the page will need.
*
* A project the user may not see is a 404, not a 403 Project existence must
* not leak to users outside its organisation, so "no such project" and "not
* yours" are deliberately indistinguishable.
*/
export async function requireProjectAccess(projectId: string): Promise<{
user: AuthzUser;
project: ProjectAuthzFacts;
}> {
const user = await requireProjectsUser();
const id = parseProjectId(projectId);
if (id === null) notFound();
const project = await loadProjectAuthzFacts(id);
if (!project || !canViewProject(user, project)) notFound();
return { user, project };
}

View file

@ -1,6 +1,6 @@
import * as React from "react";
import { ProjectsNav, type ProjectsNavItem } from "./components/ProjectsNav";
import { requireProjectsSession } from "./authz";
import { requireProjectsSession } from "./guards";
const TOP_LEVEL_NAV: ProjectsNavItem[] = [
{ label: "Projects", href: "/projects", icon: "projects", exact: true },

View file

@ -1,49 +1,194 @@
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { requireProjectsSession } from "./authz";
import Link from "next/link";
import { FolderOpenIcon } from "@heroicons/react/24/outline";
import { requireProjectsUser } from "./guards";
import { CreateProjectDialog } from "./components/CreateProjectDialog";
import {
listOrganisationOptions,
listProjectTypes,
listVisibleProjects,
} from "@/app/repositories/projects/projectsRepository";
import type { ProjectListItem } from "@/lib/projects/createProject";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/app/shadcn_components/ui/card";
export const metadata = {
title: "Projects | Ara",
};
/** The wizard's shape, shown on the empty state so setup is not a surprise. */
const SETUP_STEPS = [
{
title: "Project details",
body: "Name the project, choose the client organisation and the type of works.",
},
{
title: "Workstreams and stages",
body: "Pick the categories of work and the stage ladder each one follows.",
},
{
title: "Documents and permissions",
body: "Say what evidence each workstream needs, and who may upload it.",
},
{
title: "Import and validate work orders",
body: "Bring in the work orders and check them before they go live.",
},
];
function formatDateRange(project: ProjectListItem): string | null {
const format = (value: string) =>
new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${value}T00:00:00Z`));
if (project.startDate && project.endDate) {
return `${format(project.startDate)}${format(project.endDate)}`;
}
if (project.startDate) return `From ${format(project.startDate)}`;
if (project.endDate) return `Until ${format(project.endDate)}`;
return null;
}
/**
* Projects list.
* Projects list the entry point to Ara Projects (issue #409).
*
* Stub for now the real list (and its visibility rule: internal users see
* everything, client-org members see their org's Projects, contractor-org
* members see Projects their org is assigned to a Workstream on) arrives with
* the list issue on top of the authorization work in #408.
* A Server Component: the visibility rule is enforced before anything reaches
* the browser, so a project the user may not see is never serialised to it.
* Only the create-project modal is client-side, and it is handed its select
* options as props rather than fetching them the server already knows which
* organisations this user may choose.
*/
export default async function ProjectsListPage() {
await requireProjectsSession();
const user = await requireProjectsUser();
const [projects, organisations, projectTypes] = await Promise.all([
listVisibleProjects(user),
listOrganisationOptions(user),
listProjectTypes(),
]);
// A user with no organisation to create *for*, or an instance whose
// reference data has not been seeded, cannot complete the form — so the CTA
// is not offered. (The route handler refuses these cases regardless; this
// only avoids presenting a dead end.)
const canCreate = organisations.length > 0 && projectTypes.length > 0;
return (
<div className="max-w-4xl mx-auto px-6 py-10">
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
Projects
</h1>
<p className="text-sm text-gray-500 mt-1">
Works programmes owned by your organisation.
</p>
<div className="mb-8 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
Projects
</h1>
<p className="text-sm text-gray-500 mt-1">
Works programmes owned by your organisation.
</p>
</div>
{projects.length > 0 && canCreate && (
<CreateProjectDialog
organisations={organisations}
projectTypes={projectTypes}
/>
)}
</div>
<div
className="flex flex-col items-center justify-center py-24 border-2 border-dashed border-gray-200 rounded-2xl text-center"
data-testid="projects-list-empty"
>
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<Squares2X2Icon className="h-7 w-7 text-gray-400" />
{projects.length === 0 ? (
<div data-testid="projects-list-empty">
<Card className="text-center py-12">
<CardHeader>
<div className="mx-auto w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center mb-2">
<FolderOpenIcon className="h-7 w-7 text-gray-400" />
</div>
<CardTitle className="text-2xl">Welcome to Ara Projects</CardTitle>
<CardDescription className="max-w-md mx-auto">
You don&apos;t have any active projects yet. Start by creating a
project to organise your workstreams, track progress and manage
contractors.
</CardDescription>
</CardHeader>
{canCreate && (
<CardContent className="flex justify-center">
<CreateProjectDialog
organisations={organisations}
projectTypes={projectTypes}
/>
</CardContent>
)}
</Card>
<div className="mt-10">
<h2 className="text-sm font-semibold text-gray-700 mb-4">
How setup works
</h2>
<ol className="grid gap-3 sm:grid-cols-2">
{SETUP_STEPS.map((step, index) => (
<li
key={step.title}
className="flex gap-3 rounded-xl border border-gray-200 p-4"
>
<span className="shrink-0 w-6 h-6 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold flex items-center justify-center">
{index + 1}
</span>
<div>
<p className="text-sm font-semibold text-gray-800">
{step.title}
</p>
<p className="text-sm text-gray-500 mt-0.5">{step.body}</p>
</div>
</li>
))}
</ol>
</div>
</div>
<p className="text-base font-semibold text-gray-700 mb-1">
No projects to show yet
</p>
<p className="text-sm text-gray-400">
The projects list is not built yet.
</p>
</div>
) : (
<ul className="space-y-3" data-testid="projects-list">
{projects.map((project) => {
const dates = formatDateRange(project);
return (
<li key={project.id}>
<Link
href={`/projects/${project.id}`}
className="block rounded-xl border border-gray-200 p-5 hover:border-gray-300 hover:bg-gray-50 transition-colors"
data-testid="projects-list-item"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-base font-semibold text-gray-900">
{project.name}
</h2>
<span className="shrink-0 text-xs font-medium text-gray-500 bg-gray-100 rounded-full px-2.5 py-1">
{project.projectTypeName}
</span>
</div>
{project.organisationName && (
<p className="text-sm text-gray-500 mt-1">
{project.organisationName}
</p>
)}
{project.description && (
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
{project.description}
</p>
)}
{dates && (
<p className="text-xs text-gray-400 mt-3">{dates}</p>
)}
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}

View file

@ -67,6 +67,28 @@ export async function loadAuthzUser(userId: bigint): Promise<AuthzUser | null> {
return { id: found.id, email: found.email, organisationIds };
}
/**
* Load a user plus their organisation memberships, keyed by email.
*
* Server components and route handlers hold a NextAuth session, and the only
* identifier that session reliably carries is the email: `session.user.dbId`
* is populated by the `jwt` callback at initial sign-in only, so sessions
* issued before that field existed have no id at all. Email is unique on
* `user`, so this is the same row `loadAuthzUser` would return.
*/
export async function loadAuthzUserByEmail(
email: string,
): Promise<AuthzUser | null> {
const [found] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, email))
.limit(1);
if (!found) return null;
return { ...found, organisationIds: await getUserOrganisations(found.id) };
}
/**
* Load one project's permission-relevant facts, including every organisation
* assigned as contractor to any of its workstreams. Returns null when the

View file

@ -0,0 +1,192 @@
/**
* Ara Projects persistence for the projects list and the create-project flow
* (issue #409).
*
* Same boundary as `./authzRepository`: Drizzle lives here, decisions live in
* `@/lib/projects/*`. Nothing in this file decides who may see what it loads
* facts and hands them to `canViewProject`.
*/
import { db } from "@/app/db/db";
import { asc, desc, eq, inArray } from "drizzle-orm";
import { organisation } from "@/app/db/schema/organisation";
import {
project,
projectType,
projectWorkstream,
projectWorkstreamContractor,
} from "@/app/db/schema/projects/projects";
import {
canViewProject,
isInternalEmail,
type AuthzUser,
type ProjectAuthzFacts,
} from "@/lib/projects/authz";
import type { ProjectListItem, SelectOption } from "@/lib/projects/createProject";
/**
* Every project the user may see, newest first.
*
* The visibility rule is **not** restated in SQL. Doing so would mean writing
* the `domna_admin_access OR owning-org OR contractor-org` disjunction a second
* time, in a second language, free to drift from `resolveProjectRole`. Instead
* this loads each project's facts and asks `canViewProject` so the rule has
* exactly one definition, and the acceptance criteria (client sees its own,
* contractor sees what it is assigned to, internal sees all subject to
* `domna_admin_access`) are satisfied by construction.
*
* The cost is that the filtering happens in the app rather than the database,
* over two round trips and no N+1. That is the right trade at v1 scale, where
* a project is a works programme and they number in the tens. If the table
* grows by orders of magnitude, the fix is a **superset** prefilter in SQL
* narrowing the rows loaded while still letting `canViewProject` make the
* final call not a reimplementation of the rule in a WHERE clause.
*/
export async function listVisibleProjects(
user: AuthzUser,
): Promise<ProjectListItem[]> {
const [rows, contractorRows] = await Promise.all([
db
.select({
id: project.id,
name: project.name,
description: project.description,
startDate: project.startDate,
endDate: project.endDate,
organisationId: project.organisationId,
organisationName: organisation.name,
domnaAdminAccess: project.domnaAdminAccess,
projectTypeName: projectType.name,
})
.from(project)
.innerJoin(projectType, eq(project.projectTypeId, projectType.id))
.leftJoin(organisation, eq(project.organisationId, organisation.id))
.orderBy(desc(project.id)),
db
.selectDistinct({
projectId: projectWorkstream.projectId,
organisationId: projectWorkstreamContractor.organisationId,
})
.from(projectWorkstreamContractor)
.innerJoin(
projectWorkstream,
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
),
]);
const contractorsByProject = new Map<string, string[]>();
for (const row of contractorRows) {
const key = row.projectId.toString();
const existing = contractorsByProject.get(key);
if (existing) existing.push(row.organisationId);
else contractorsByProject.set(key, [row.organisationId]);
}
return rows
.filter((row) => {
const facts: ProjectAuthzFacts = {
id: row.id,
organisationId: row.organisationId,
domnaAdminAccess: row.domnaAdminAccess,
contractorOrganisationIds:
contractorsByProject.get(row.id.toString()) ?? [],
};
return canViewProject(user, facts);
})
.map((row) => ({
id: row.id.toString(),
name: row.name,
projectTypeName: row.projectTypeName,
organisationName: row.organisationName,
description: row.description,
startDate: row.startDate,
endDate: row.endDate,
}));
}
/** The seeded project types, for the modal's Project type select. */
export async function listProjectTypes(): Promise<SelectOption[]> {
const rows = await db
.select({ id: projectType.id, name: projectType.name })
.from(projectType)
.orderBy(asc(projectType.name));
return rows.map((r) => ({ id: r.id.toString(), name: r.name }));
}
/**
* The organisations the user may create a project *for*.
*
* Internal users pick any organisation; everyone else is confined to the ones
* they belong to which for a typical client user is exactly one, so the
* select is effectively fixed. This only shapes the choices offered: the
* server still re-checks the submitted organisation against
* `canManageProject`, because a hidden option is not a permission.
*
* `organisation.name` is nullable in the schema; unnamed rows are dropped
* rather than rendered as a blank option nobody can identify.
*/
export async function listOrganisationOptions(
user: AuthzUser,
): Promise<SelectOption[]> {
if (!isInternalEmail(user.email) && user.organisationIds.length === 0) {
return [];
}
const rows = await db
.select({ id: organisation.id, name: organisation.name })
.from(organisation)
.where(
isInternalEmail(user.email)
? undefined
: inArray(organisation.id, user.organisationIds),
)
.orderBy(asc(organisation.name));
return rows.flatMap((r) => (r.name ? [{ id: r.id, name: r.name }] : []));
}
/** Whether a project type id names a seeded row. */
export async function projectTypeExists(id: bigint): Promise<boolean> {
const [found] = await db
.select({ id: projectType.id })
.from(projectType)
.where(eq(projectType.id, id))
.limit(1);
return Boolean(found);
}
/** Whether an organisation id names a real organisation. */
export async function organisationExists(id: string): Promise<boolean> {
const [found] = await db
.select({ id: organisation.id })
.from(organisation)
.where(eq(organisation.id, id))
.limit(1);
return Boolean(found);
}
/** Insert the project row and return its new id. */
export async function insertProject(values: {
name: string;
organisationId: string;
projectTypeId: bigint;
description?: string;
startDate?: string;
endDate?: string;
domnaAdminAccess: boolean;
}): Promise<bigint> {
const [created] = await db
.insert(project)
.values({
name: values.name,
organisationId: values.organisationId,
projectTypeId: values.projectTypeId,
description: values.description ?? null,
startDate: values.startDate ?? null,
endDate: values.endDate ?? null,
domnaAdminAccess: values.domnaAdminAccess,
})
.returning({ id: project.id });
return created.id;
}

View file

@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { DayPicker, type DayPickerProps } from "react-day-picker";
import { enGB } from "react-day-picker/locale";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/app/shadcn_components/ui/button";
export type CalendarProps = DayPickerProps;
/**
* The shadcn calendar, on react-day-picker v10.
*
* Written by hand rather than pulled from the shadcn registry: the published
* template targets v8/v9, whose `classNames` keys differ from the `UI` enum
* this version uses. The keys below are v10's.
*
* Defaults to `enGB`, which is the reason this wrapper exists rather than
* `DayPicker` being used directly the locale carries `weekStartsOn: 1`, so
* the grid starts on Monday and the weekday headers read MoSu, matching how
* the rest of the app formats dates.
*
* `fixedWeeks` is a default rather than a stylistic choice. A month needs
* either five or six week rows depending on the weekday it starts on August
* 2026 needs six, July and September five and a grid that changes height
* mid-navigation makes the popover holding it jump: Radix re-runs collision
* detection on resize, so one extra row is enough to flip the calendar from
* below the field to above it and back again as the user pages through months.
* Six rows always means a constant height, so there is nothing to react to.
*/
function Calendar({
className,
classNames,
showOutsideDays = true,
fixedWeeks = true,
locale = enGB,
...props
}: CalendarProps) {
return (
<DayPicker
locale={locale}
showOutsideDays={showOutsideDays}
fixedWeeks={fixedWeeks}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-4",
month: "flex flex-col gap-4",
month_caption: "flex justify-center pt-1 relative items-center h-7",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1 absolute right-1 top-1 z-10",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
),
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative focus-within:relative focus-within:z-20",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
selected:
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:focus]:bg-primary",
today: "[&>button]:bg-accent [&>button]:text-accent-foreground",
outside: "[&>button]:text-muted-foreground [&>button]:opacity-50",
disabled: "[&>button]:text-muted-foreground [&>button]:opacity-50",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation, className: chevronClassName, ...rest }) => {
const Icon =
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
return <Icon className={cn("h-4 w-4", chevronClassName)} {...rest} />;
},
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View file

@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
createProjectSchema,
prospectiveProjectFacts,
} from "./createProject";
import { canManageProject, type AuthzUser } from "./authz";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const OTHER_ORG = "22222222-2222-2222-2222-222222222222";
function validDraft(overrides: Record<string, unknown> = {}) {
return {
name: "Riverside retrofit 2026",
organisationId: CLIENT_ORG,
projectTypeId: "1",
description: "",
startDate: "",
endDate: "",
domnaAdminAccess: false,
...overrides,
};
}
function makeUser(overrides: Partial<AuthzUser> = {}): AuthzUser {
return {
id: 1n,
email: "someone@landlord.example",
organisationIds: [],
...overrides,
};
}
describe("createProjectSchema", () => {
it("accepts a minimal draft", () => {
const parsed = createProjectSchema.parse(validDraft());
expect(parsed.name).toBe("Riverside retrofit 2026");
expect(parsed.organisationId).toBe(CLIENT_ORG);
expect(parsed.domnaAdminAccess).toBe(false);
});
it("normalises the untouched optional fields to undefined", () => {
// An untouched <input> submits "", which must not be written to the column
// as an empty string masquerading as a description or a date.
const parsed = createProjectSchema.parse(validDraft());
expect(parsed.description).toBeUndefined();
expect(parsed.startDate).toBeUndefined();
expect(parsed.endDate).toBeUndefined();
});
it("trims and keeps a real description", () => {
const parsed = createProjectSchema.parse(
validDraft({ description: " Phase one " }),
);
expect(parsed.description).toBe("Phase one");
});
it("rejects a blank or whitespace-only name", () => {
expect(createProjectSchema.safeParse(validDraft({ name: "" })).success).toBe(
false,
);
expect(
createProjectSchema.safeParse(validDraft({ name: " " })).success,
).toBe(false);
});
it("rejects an organisation id that is not a uuid", () => {
expect(
createProjectSchema.safeParse(validDraft({ organisationId: "acme" })).success,
).toBe(false);
});
it("rejects a non-numeric project type id", () => {
expect(
createProjectSchema.safeParse(validDraft({ projectTypeId: "retrofit" }))
.success,
).toBe(false);
});
it("rejects a malformed date", () => {
expect(
createProjectSchema.safeParse(validDraft({ startDate: "01/02/2026" }))
.success,
).toBe(false);
});
it("accepts an end date on or after the start date", () => {
for (const endDate of ["2026-03-01", "2026-09-30"]) {
const parsed = createProjectSchema.safeParse(
validDraft({ startDate: "2026-03-01", endDate }),
);
expect(parsed.success).toBe(true);
}
});
it("rejects an end date before the start date, against the end field", () => {
const parsed = createProjectSchema.safeParse(
validDraft({ startDate: "2026-09-30", endDate: "2026-03-01" }),
);
expect(parsed.success).toBe(false);
if (!parsed.success) {
expect(parsed.error.issues[0].path).toEqual(["endDate"]);
}
});
it("allows one date without the other", () => {
expect(
createProjectSchema.safeParse(validDraft({ startDate: "2026-03-01" }))
.success,
).toBe(true);
expect(
createProjectSchema.safeParse(validDraft({ endDate: "2026-03-01" })).success,
).toBe(true);
});
});
describe("prospectiveProjectFacts", () => {
it("lets a member of the owning organisation create the project", () => {
const user = makeUser({ organisationIds: [CLIENT_ORG] });
const draft = createProjectSchema.parse(validDraft());
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(true);
});
it("refuses a user with no standing on the chosen organisation", () => {
const user = makeUser({ organisationIds: [OTHER_ORG] });
const draft = createProjectSchema.parse(validDraft());
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(false);
});
it("lets an internal user create for any organisation when access is granted", () => {
const user = makeUser({ email: "dev@domna.homes" });
const draft = createProjectSchema.parse(
validDraft({ domnaAdminAccess: true }),
);
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(true);
});
it("refuses an internal non-member who withholds Domna admin access", () => {
// `domna_admin_access` gates the internal role, so this draft would create
// a project its own author could not then see. Refusing is the rule
// working, not a gap in it.
const user = makeUser({ email: "dev@domna.homes" });
const draft = createProjectSchema.parse(
validDraft({ domnaAdminAccess: false }),
);
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(false);
});
it("still lets an internal user who is a genuine member create without the toggle", () => {
const user = makeUser({
email: "dev@domna.homes",
organisationIds: [CLIENT_ORG],
});
const draft = createProjectSchema.parse(
validDraft({ domnaAdminAccess: false }),
);
expect(canManageProject(user, prospectiveProjectFacts(draft))).toBe(true);
});
it("grants nobody a contractor role on a project that has no workstreams yet", () => {
const draft = createProjectSchema.parse(validDraft());
expect(prospectiveProjectFacts(draft).contractorOrganisationIds).toEqual([]);
});
});

View file

@ -0,0 +1,94 @@
/**
* The create-project draft: what the form collects, what the route handler
* accepts, and how it is turned into facts the authz guards can judge.
*
* Pure and db-free, like `./authz` the schema is imported by both the client
* form (as a `zodResolver`) and `POST /api/projects` (as the request-body
* parser), so the two cannot drift into disagreeing about what a valid draft
* is. The client validates for the user's benefit; the server validates
* because the client cannot be trusted.
*
* Everything crossing the wire is a string: `project_type.id` is a bigint that
* JSON cannot carry, and `project.start_date` / `end_date` are Drizzle `date`
* columns, which round-trip as `YYYY-MM-DD`.
*/
import { z } from "zod";
import type { ProjectAuthzFacts } from "./authz";
/** `YYYY-MM-DD`, the wire and column format for the two date fields. */
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/**
* An optional free-text or date field. Empty strings are what an untouched
* `<input>` submits, so they are normalised to `undefined` rather than being
* written to the column as `""`.
*/
const optionalText = (schema: z.ZodString) =>
z
.union([schema, z.literal("")])
.optional()
.transform((value) => (value ? value : undefined));
export const createProjectSchema = z
.object({
name: z.string().trim().min(1, "Enter a project name").max(255),
organisationId: z.string().uuid("Select a client organisation"),
projectTypeId: z
.string()
.regex(/^\d+$/, "Select a project type"),
description: optionalText(z.string().trim().max(2000)),
startDate: optionalText(z.string().regex(ISO_DATE, "Enter a valid date")),
endDate: optionalText(z.string().regex(ISO_DATE, "Enter a valid date")),
domnaAdminAccess: z.boolean(),
})
// Lexicographic comparison is exact for zero-padded ISO dates, so this needs
// no Date parsing (and no timezone to get wrong).
.refine(
(draft) => !draft.startDate || !draft.endDate || draft.endDate >= draft.startDate,
{ message: "The end date cannot be before the start date", path: ["endDate"] },
);
/** The validated draft — the output type, after empty strings are dropped. */
export type CreateProjectDraft = z.output<typeof createProjectSchema>;
/** The form's working type — the input type, before the transforms run. */
export type CreateProjectFormValues = z.input<typeof createProjectSchema>;
/**
* The `ProjectAuthzFacts` a draft *would* have once created, so the same
* guards that police an existing project can police its creation there is no
* separate "can create" rule to keep in step.
*
* `id` is a placeholder: the row has no key until it is inserted, and no guard
* in `./authz` reads it. `contractorOrganisationIds` is genuinely empty a
* brand-new project has no workstreams, so nobody holds a contractor role on
* it and only the client (or an internal user the draft grants access to) can
* pass `canManageProject`.
*/
export function prospectiveProjectFacts(
draft: Pick<CreateProjectDraft, "organisationId" | "domnaAdminAccess">,
): ProjectAuthzFacts {
return {
id: 0n,
organisationId: draft.organisationId,
domnaAdminAccess: draft.domnaAdminAccess,
contractorOrganisationIds: [],
};
}
/** A project as the list page renders it. Ids are strings for the same reason. */
export interface ProjectListItem {
id: string;
name: string;
projectTypeName: string;
organisationName: string | null;
description: string | null;
startDate: string | null;
endDate: string | null;
}
/** An option in the modal's Client / Project type selects. */
export interface SelectOption {
id: string;
name: string;
}

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

@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import {
formatUkDate,
formatUkDateWhileTyping,
isoToLocalDate,
localDateToIso,
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("isoToLocalDate / localDateToIso", () => {
it("builds local midnight, not UTC midnight", () => {
const date = isoToLocalDate("2026-03-01")!;
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(2); // zero-based: March
expect(date.getDate()).toBe(1);
expect(date.getHours()).toBe(0);
});
it("returns null for anything that is not a real ISO date", () => {
expect(isoToLocalDate("")).toBeNull();
expect(isoToLocalDate(null)).toBeNull();
expect(isoToLocalDate(undefined)).toBeNull();
expect(isoToLocalDate("01/03/2026")).toBeNull();
expect(isoToLocalDate("2026-02-31")).toBeNull();
});
it("reads the local parts back", () => {
expect(localDateToIso(new Date(2026, 2, 1))).toBe("2026-03-01");
});
it("pads single-digit months and days", () => {
expect(localDateToIso(new Date(2026, 0, 5))).toBe("2026-01-05");
});
it("round-trips every day of a month regardless of the host timezone", () => {
// The bug this guards is a date shifting by one day through a UTC
// conversion, which only shows up on some days in some zones — so check
// them all rather than a single sample.
for (let day = 1; day <= 31; day++) {
const iso = `2026-03-${String(day).padStart(2, "0")}`;
expect(localDateToIso(isoToLocalDate(iso)!)).toBe(iso);
}
});
it("survives a date whose UTC form is the previous day", () => {
// 1 March at local midnight is 28 February in UTC for any negative
// offset. toISOString() would report the 28th; this must not.
const date = new Date(2026, 2, 1);
expect(localDateToIso(date)).toBe("2026-03-01");
});
});
describe("formatUkDateWhileTyping", () => {
const typed = (raw: string) => formatUkDateWhileTyping(raw, { deleting: false });
const deleted = (raw: string) => formatUkDateWhileTyping(raw, { deleting: true });
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("");
});
});

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

@ -0,0 +1,134 @@
/**
* 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}`;
}
/**
* `YYYY-MM-DD` a `Date` at **local** midnight, for the calendar picker.
*
* This is the one place a `Date` is unavoidable a calendar component works
* in `Date`s so it is also the one place the timezone trap has to be
* handled. `new Date("2026-03-01")` parses as midnight *UTC*, which west of
* Greenwich is the evening of 29 February, and a calendar comparing it against
* local days would highlight the wrong one. Passing the parts separately
* builds local midnight instead, which is what the grid compares against.
*
* Returns `null` for anything that is not a real ISO date.
*/
export function isoToLocalDate(iso: string | null | undefined): Date | null {
const match = iso?.match(ISO_DATE);
if (!match) return null;
const [, year, month, day] = match;
if (!isRealDate(Number(year), Number(month), Number(day))) return null;
return new Date(Number(year), Number(month) - 1, Number(day));
}
/**
* A `Date` `YYYY-MM-DD`, reading the **local** parts.
*
* The inverse of {@link isoToLocalDate}, and it must not go via `toISOString`,
* which converts to UTC and would shift the date across midnight for any
* non-zero offset the same bug in the opposite direction.
*/
export function localDateToIso(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Reformat a half-typed `dd/mm/yyyy` field after every keystroke: keep the
* digits, drop everything else, and regroup.
*
* 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;
}