feat(ara-projects): step through workstreams, and drop the per-stage dates

Ladders are no longer stacked. A project with eight workstreams has forty
stages, and one page of them turns configuring the last workstream into a
scroll — so one ladder is on screen at a time, the "Workstream overrides"
panel doubles as the picker, and the footer walks forwards through them
before it offers to leave. The stages *within* a ladder stay listed
together: they are dragged against each other, so they have to be seen
together.

`start_date` / `due_date` come out end-to-end — inputs, request schemas,
payload, insert and update — rather than being hidden in the UI. A stage is
a rung of a workflow, not a piece of scheduled work, so it has no date to
hold; delivery dates already belong to a work order. The columns stay on the
table and stay NULL until their keep-or-drop decision is made. This
supersedes one of #411's acceptance criteria, recorded in ADR-0021.

`updateStage` collapses to `renameStage` — the name is now the only field of
a stage a user owns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-23 15:00:14 +00:00
parent 235af64b43
commit 7b65eae7bd
14 changed files with 313 additions and 602 deletions

View file

@ -16,7 +16,7 @@ highest `order` (CONTEXT.md, **Stage**). `work_order.status`,
`work_order.priority` and `project_workstream.current_stage_id` are deliberately
untouched, deferred to #426.
Three things about that had to be decided before the screen could be written.
Four things about that had to be decided.
### 1. Where the default ladder gets seeded
@ -52,6 +52,14 @@ contradict: a stage row cannot be inserted without *some* value in a `NOT NULL`
column, so "seed the default ladder" and "never write `status`" cannot both be
taken literally.
### 4. The optional per-stage dates
`project_workstream_stage` carries nullable `start_date` and `due_date`
columns, and the issue asked for them to be exposed as optional inputs. On
review of the built screen they were rejected: a stage is a rung of a workflow,
not a piece of scheduled work, so there is no date it can meaningfully hold.
Delivery dates belong to a Work order (`work_order.forecast_end` already exists).
## Decision
**The stage routes are keyed on `workstream.id`**, the catalogue row — the same
@ -74,6 +82,12 @@ query, and not part of any API contract. It matches what
`seed-projects-dashboard.ts` already writes, so seeded and user-created rows
agree.
**`start_date` and `due_date` are not exposed and never written.** They are
absent from the request schemas, the payload and the UI, so a ladder stage
leaves them NULL for every row this screen creates. The columns stay on the
table for now; whether they are dropped is a schema question deferred to a
later issue, and nothing in #411 depends on the answer.
`project_workstream.current_stage_id` and `work_order.status` are genuinely
never written by #411 — the AC holds for both columns as stated. A stage that
`current_stage_id` points at cannot even be deleted from this screen.
@ -108,6 +122,12 @@ never written by #411 — the AC holds for both columns as stated. A stage that
yet**, because seeding is skipped for them. That is honest — the workstream
genuinely has no stages, and the readiness gate says so — and it keeps a
viewer from creating rows.
- **The dates decision supersedes an acceptance criterion of #411**, which
asked for the date inputs. It was taken after seeing them on the screen, and
the issue should be amended rather than the code quietly diverging from it.
- **A stage-level date has nowhere to come back to cheaply**: reinstating the
inputs means a schema keep-or-drop decision first. That is the intended
order — the columns are unused rather than half-used in the meantime.
- **`status` carries no meaning and must not acquire one by accident.** Anything
reading it today would be reading a placeholder. When #426 agrees the value
set, that issue owns both the enum conversion and the backfill of rows written

View file

@ -1,5 +1,6 @@
/**
* PATCH / DELETE `.../workstreams/[workstreamId]/stages/[stageId]` (#411).
* PATCH (rename) / DELETE `.../workstreams/[workstreamId]/stages/[stageId]`
* (issue #411).
*
* Database mocked at the module boundary (`../queries` and the authz
* repository); no connection is opened. The blocked-delete path the one the
@ -14,14 +15,14 @@ const {
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockFindLadder,
mockUpdateStage,
mockRenameStage,
mockDeleteStage,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockFindLadder: vi.fn(),
mockUpdateStage: vi.fn(),
mockRenameStage: vi.fn(),
mockDeleteStage: vi.fn(),
}));
@ -33,7 +34,7 @@ vi.mock("@/app/repositories/projects/authzRepository", () => ({
}));
vi.mock("../queries", () => ({
findLadder: mockFindLadder,
updateStage: mockUpdateStage,
renameStage: mockRenameStage,
deleteStage: mockDeleteStage,
}));
@ -54,20 +55,11 @@ const LADDER = {
workstreamName: "Windows",
isDefault: false,
stages: [
{
id: "100",
name: "Ordered",
order: 0,
startDate: "2026-09-03",
dueDate: "2026-12-01",
dependents: NO_DEPENDENTS,
},
{ id: "100", name: "Ordered", order: 0, dependents: NO_DEPENDENTS },
{
id: "101",
name: "Closed",
order: 1,
startDate: null,
dueDate: null,
dependents: { ...NO_DEPENDENTS, workOrders: 4 },
},
],
@ -115,7 +107,7 @@ function patchRequest(body: unknown, stageId = "100") {
beforeEach(() => {
vi.clearAllMocks();
mockFindLadder.mockResolvedValue(LADDER);
mockUpdateStage.mockResolvedValue({ kind: "updated" });
mockRenameStage.mockResolvedValue({ kind: "renamed" });
mockDeleteStage.mockResolvedValue({ kind: "deleted" });
});
@ -135,92 +127,53 @@ describe("PATCH", () => {
signedInAs([CONTRACTOR_ORG]);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(403);
expect(mockUpdateStage).not.toHaveBeenCalled();
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("renames a stage", async () => {
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ stageId: "100", updated: true });
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { name: "On site" });
expect(await res.json()).toEqual({ stageId: "100", renamed: true });
expect(mockRenameStage).toHaveBeenCalledWith(9n, 100n, "On site");
});
it("sets the optional dates", async () => {
const res = await PATCH(
patchRequest({ startDate: "2026-09-03", dueDate: "2026-12-01" }),
params("101"),
);
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 101n, {
startDate: "2026-09-03",
dueDate: "2026-12-01",
});
});
it("clears a date with null", async () => {
const res = await PATCH(patchRequest({ dueDate: null }), params());
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { dueDate: null });
});
it("treats an empty date string as a clear", async () => {
const res = await PATCH(patchRequest({ startDate: "" }), params());
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { startDate: null });
});
it("rejects dd/mm/yyyy on the wire", async () => {
const res = await PATCH(patchRequest({ dueDate: "01/12/2026" }), params());
expect(res.status).toBe(400);
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("checks a new start date against the stored due date", async () => {
// Stage 100 is due 2026-12-01; moving its start past that is rejected even
// though the request carries only one end of the range.
const res = await PATCH(patchRequest({ startDate: "2027-01-01" }), params());
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("before the start date");
expect(mockUpdateStage).not.toHaveBeenCalled();
});
it("allows a start date that clears the stored due date", async () => {
const res = await PATCH(patchRequest({ startDate: "2026-11-30" }), params());
expect(res.status).toBe(200);
});
it("400s an empty patch", async () => {
it("400s a patch with no name", async () => {
const res = await PATCH(patchRequest({}), params());
expect(res.status).toBe(400);
expect(mockUpdateStage).not.toHaveBeenCalled();
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("ignores any attempt to set status or order", async () => {
// Neither column is this issue's to write (ADR-0021): unknown keys are
// dropped by the schema, so they never reach the update.
it("ignores any attempt to set status, order or the dates", async () => {
// None of those columns is this issue's to write (ADR-0021): unknown keys
// are dropped by the schema, so they never reach the rename.
const res = await PATCH(
patchRequest({ name: "On site", status: "complete", order: 0 }),
patchRequest({
name: "On site",
status: "complete",
order: 0,
startDate: "2026-09-03",
}),
params(),
);
expect(res.status).toBe(200);
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { name: "On site" });
expect(mockRenameStage).toHaveBeenCalledWith(9n, 100n, "On site");
});
it("404s a stage that is not in this workstream's ladder", async () => {
const res = await PATCH(patchRequest({ name: "On site" }, "999"), params("999"));
expect(res.status).toBe(404);
expect(mockUpdateStage).not.toHaveBeenCalled();
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("404s a workstream of another project", async () => {
mockFindLadder.mockResolvedValue(null);
const res = await PATCH(patchRequest({ name: "On site" }), params());
expect(res.status).toBe(404);
expect(mockUpdateStage).not.toHaveBeenCalled();
expect(mockRenameStage).not.toHaveBeenCalled();
});
it("passes a rejected name back as a 400 with its reason", async () => {
mockUpdateStage.mockResolvedValue({
mockRenameStage.mockResolvedValue({
kind: "rejected",
reason: 'This workstream already has a "Closed" stage.',
});

View file

@ -1,7 +1,7 @@
/**
* `.../workstreams/[workstreamId]/stages/[stageId]` (issue #411).
*
* PATCH rename a stage and/or set its optional start / due dates.
* PATCH rename a stage.
* DELETE remove a stage, subject to the ladder guardrails.
*
* The delete guardrails are the server's decision, always: the counts a
@ -17,8 +17,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "../../../authorize";
import { checkStageDates } from "../ladder";
import { deleteStage, findLadder, updateStage } from "../queries";
import { deleteStage, findLadder, renameStage } from "../queries";
type Params = {
params: Promise<{
@ -30,33 +29,19 @@ type Params = {
};
/**
* `YYYY-MM-DD` on the wire, always (ADR-0019). `null` clears the date, an
* absent key leaves it untouched, and `""` what a half-cleared field sends
* means the same as `null`.
* The name is the only field of a stage a user owns: `order` moves through the
* collection route's reorder, and `status` / `start_date` / `due_date` are not
* this issue's to write (ADR-0021).
*/
const optionalIsoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Dates must be YYYY-MM-DD")
.or(z.literal(""))
.nullable()
.transform((value) => (value ? value : null));
const patchSchema = z
.object({
name: z.string().optional(),
startDate: optionalIsoDate.optional(),
dueDate: optionalIsoDate.optional(),
})
.refine(
(patch) => Object.keys(patch).length > 0,
"Nothing to update",
);
const patchSchema = z.object({
name: z.string(),
});
function parseId(raw: string): bigint | null {
return /^\d+$/.test(raw) ? BigInt(raw) : null;
}
/** PATCH — rename and/or re-date one stage. */
/** PATCH — rename one stage. */
export async function PATCH(req: NextRequest, props: Params) {
const { projectId, workstreamId: workstreamIdParam, stageId } = await props.params;
const auth = await authorizeWorkstreamRequest(projectId, "manage");
@ -77,9 +62,7 @@ export async function PATCH(req: NextRequest, props: Params) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
// Scopes the stage to the project in the path, and gives the current dates —
// a patch that sets only one end of the range still has to be checked
// against the other end as stored.
// Scopes the stage to the project in the path.
const ladder = await findLadder(auth.projectId, workstreamId);
if (!ladder) {
return NextResponse.json(
@ -87,24 +70,15 @@ export async function PATCH(req: NextRequest, props: Params) {
{ status: 404 },
);
}
const current = ladder.stages.find((stage) => stage.id === stageId);
if (!current) {
if (!ladder.stages.some((stage) => stage.id === stageId)) {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
}
const dates = checkStageDates(
body.startDate === undefined ? current.startDate : body.startDate,
body.dueDate === undefined ? current.dueDate : body.dueDate,
);
if (!dates.ok) {
return NextResponse.json({ error: dates.error }, { status: 400 });
}
try {
const result = await updateStage(
const result = await renameStage(
BigInt(ladder.projectWorkstreamId),
id,
body,
body.name,
);
if (result.kind === "not-found") {
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
@ -112,11 +86,11 @@ export async function PATCH(req: NextRequest, props: Params) {
if (result.kind === "rejected") {
return NextResponse.json({ error: result.reason }, { status: 400 });
}
return NextResponse.json({ stageId, updated: true });
return NextResponse.json({ stageId, renamed: true });
} catch (err) {
console.error("PATCH .../stages/[stageId] failed:", err);
return NextResponse.json(
{ error: "Couldn't update the stage" },
{ error: "Couldn't rename the stage" },
{ status: 500 },
);
}

View file

@ -7,7 +7,6 @@ import {
MIN_STAGES,
NO_STAGE_DEPENDENTS,
checkReorder,
checkStageDates,
checkStageName,
contiguousOrders,
decideDeleteStage,
@ -157,30 +156,6 @@ describe("checkStageName", () => {
});
});
describe("checkStageDates", () => {
it("accepts a stage with no dates", () => {
expect(checkStageDates(null, null)).toEqual({ ok: true });
});
it("accepts either date on its own", () => {
expect(checkStageDates("2026-09-03", null).ok).toBe(true);
expect(checkStageDates(null, "2026-09-03").ok).toBe(true);
});
it("accepts a due date on or after the start date", () => {
expect(checkStageDates("2026-09-03", "2026-09-03").ok).toBe(true);
expect(checkStageDates("2026-09-03", "2026-12-01").ok).toBe(true);
});
it("rejects a due date before the start date", () => {
// 03/09/2026 is 3 September and 09/03/2026 would be 9 March — the ISO
// strings compare correctly whatever a browser's locale renders.
const check = checkStageDates("2026-09-03", "2026-03-09");
expect(check.ok).toBe(false);
expect(!check.ok && check.error).toContain("before the start date");
});
});
describe("checkReorder", () => {
it("accepts a permutation of the ladder", () => {
expect(checkReorder(["1", "2", "3"], ["3", "1", "2"])).toEqual({

View file

@ -168,26 +168,6 @@ export function checkStageName(
return { ok: true, name };
}
export type StageDatesCheck = { ok: true } | { ok: false; error: string };
/**
* Validate the optional per-stage dates.
*
* Both are `YYYY-MM-DD` the only format that crosses a boundary (ADR-0019);
* what the user reads and types is `dd/mm/yyyy`, and `DateInput` converts at
* the field's edge. ISO dates sort lexicographically, so the ordering check is
* a string comparison with no `Date` and therefore no timezone to get wrong.
*/
export function checkStageDates(
startDate: string | null,
dueDate: string | null,
): StageDatesCheck {
if (startDate && dueDate && dueDate < startDate) {
return { ok: false, error: "The due date can't be before the start date." };
}
return { ok: true };
}
export type ReorderCheck =
| { ok: true; ids: string[] }
| { ok: false; error: string };

View file

@ -5,12 +5,16 @@
* the two always agree on shape the page paints first, the handlers serve
* every refetch after it. Same split as `../../queries.ts` for #410.
*
* Two columns are never written from this module, by decision (ADR-0021):
* Several columns are never written from this module, by decision (ADR-0021):
* - `project_workstream.current_stage_id` deferred to #426; v1 derives a
* workstream's position from its Work orders' stage FKs.
* - `work_order.status` the stage FK is the only progress mechanism.
* - `project_workstream_stage.start_date` / `due_date` dates describe a
* work order's delivery, not a rung of a workflow, so a ladder stage has
* no date to hold. The columns stay nullable and stay NULL; whether they
* survive is a schema question for later.
*
* `project_workstream_stage.status` is a third case and the awkward one: it is
* `project_workstream_stage.status` is the awkward case: it is
* `NOT NULL` with no database default, so a row cannot be inserted without a
* value. Inserts therefore write the placeholder constant below, once, and no
* update path ever touches the column again. See ADR-0021.
@ -51,9 +55,6 @@ export interface LadderStage {
name: string;
/** Contiguous from 0 within its ladder. */
order: number;
/** `YYYY-MM-DD`, or null. Optional per issue #411. */
startDate: string | null;
dueDate: string | null;
/** What points at this stage — drives the delete guardrails' explanations. */
dependents: StageDependents;
}
@ -76,8 +77,6 @@ type StageRow = {
projectWorkstreamId: bigint;
name: string;
order: number;
startDate: string | null;
dueDate: string | null;
workOrders: number;
evidenceRequirements: number;
isCurrentStage: boolean;
@ -101,8 +100,6 @@ async function loadStageRows(
projectWorkstreamId: projectWorkstreamStage.projectWorkstreamId,
name: projectWorkstreamStage.name,
order: projectWorkstreamStage.order,
startDate: projectWorkstreamStage.startDate,
dueDate: projectWorkstreamStage.dueDate,
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
isCurrentStage: sql<boolean>`coalesce(bool_or(${projectWorkstream.currentStageId} = ${projectWorkstreamStage.id}), false)`,
@ -133,8 +130,6 @@ function toLadderStage(row: StageRow): LadderStage {
id: row.id.toString(),
name: row.name,
order: row.order,
startDate: row.startDate,
dueDate: row.dueDate,
dependents: {
workOrders: row.workOrders,
evidenceRequirements: row.evidenceRequirements,
@ -352,7 +347,7 @@ export type AddStageResult =
*/
export async function addStage(
projectWorkstreamId: bigint,
stage: { name: string; startDate: string | null; dueDate: string | null },
stage: { name: string },
): Promise<AddStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
@ -369,8 +364,6 @@ export async function addStage(
projectWorkstreamId,
name: check.name,
order: existing.length,
startDate: stage.startDate,
dueDate: stage.dueDate,
status: PLACEHOLDER_STAGE_STATUS,
})
.returning({ id: projectWorkstreamStage.id });
@ -386,58 +379,41 @@ export async function addStage(
});
}
export type UpdateStageResult =
| { kind: "updated" }
export type RenameStageResult =
| { kind: "renamed" }
| { kind: "not-found" }
| { kind: "rejected"; reason: string };
/**
* Rename a stage and/or set its optional dates.
* Rename a stage.
*
* Only the fields present in `patch` are written a rename never clears a
* date, and a date change never renames. `status`, `status_description` and
* `order` are not writable here: order changes go through `reorderStages`, and
* the status columns are not this issue's to write (ADR-0021).
* The name is the only field of a stage a user owns: `order` changes go
* through `reorderStages`, and `status`, `status_description`, `start_date`
* and `due_date` are not this issue's to write (ADR-0021). The uniqueness
* check runs against names re-read under the lock, excluding this stage so a
* stage may always keep its own name.
*/
export async function updateStage(
export async function renameStage(
projectWorkstreamId: bigint,
stageId: bigint,
patch: {
name?: string;
startDate?: string | null;
dueDate?: string | null;
},
): Promise<UpdateStageResult> {
name: string,
): Promise<RenameStageResult> {
return db.transaction(async (tx) => {
const existing = await lockedStages(tx, projectWorkstreamId);
const target = existing.find((s) => s.id === stageId);
if (!target) return { kind: "not-found" };
if (!existing.some((s) => s.id === stageId)) return { kind: "not-found" };
const values: {
name?: string;
startDate?: string | null;
dueDate?: string | null;
} = {};
const check = checkStageName(
name,
existing.filter((s) => s.id !== stageId).map((s) => s.name),
);
if (!check.ok) return { kind: "rejected", reason: check.error };
if (patch.name !== undefined) {
const check = checkStageName(
patch.name,
existing.filter((s) => s.id !== stageId).map((s) => s.name),
);
if (!check.ok) return { kind: "rejected", reason: check.error };
values.name = check.name;
}
if (patch.startDate !== undefined) values.startDate = patch.startDate;
if (patch.dueDate !== undefined) values.dueDate = patch.dueDate;
await tx
.update(projectWorkstreamStage)
.set({ name: check.name })
.where(eq(projectWorkstreamStage.id, stageId));
if (Object.keys(values).length > 0) {
await tx
.update(projectWorkstreamStage)
.set(values)
.where(eq(projectWorkstreamStage.id, stageId));
}
return { kind: "updated" };
return { kind: "renamed" };
});
}

View file

@ -58,22 +58,8 @@ const LADDER = {
workstreamName: "Windows",
isDefault: true,
stages: [
{
id: "100",
name: "Ordered",
order: 0,
startDate: null,
dueDate: null,
dependents: NO_DEPENDENTS,
},
{
id: "101",
name: "Closed",
order: 1,
startDate: "2026-09-03",
dueDate: null,
dependents: NO_DEPENDENTS,
},
{ id: "100", name: "Ordered", order: 0, dependents: NO_DEPENDENTS },
{ id: "101", name: "Closed", order: 1, dependents: NO_DEPENDENTS },
],
};
@ -183,14 +169,10 @@ describe("POST", () => {
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ stageId: "102" });
expect(mockAddStage).toHaveBeenCalledWith(9n, {
name: "Snagging",
startDate: null,
dueDate: null,
});
expect(mockAddStage).toHaveBeenCalledWith(9n, { name: "Snagging" });
});
it("accepts the optional dates as YYYY-MM-DD", async () => {
it("ignores dates on the way in — a stage has none (ADR-0021)", async () => {
const res = await POST(
jsonRequest("POST", {
name: "Snagging",
@ -200,48 +182,7 @@ describe("POST", () => {
params(),
);
expect(res.status).toBe(201);
expect(mockAddStage).toHaveBeenCalledWith(9n, {
name: "Snagging",
startDate: "2026-09-03",
dueDate: "2026-12-01",
});
});
it("rejects a date that is not YYYY-MM-DD", async () => {
// dd/mm/yyyy is what the user types; DateInput converts before it is sent.
const res = await POST(
jsonRequest("POST", { name: "Snagging", startDate: "03/09/2026" }),
params(),
);
expect(res.status).toBe(400);
expect(mockAddStage).not.toHaveBeenCalled();
});
it("treats an empty date string as no date", async () => {
const res = await POST(
jsonRequest("POST", { name: "Snagging", startDate: "", dueDate: "" }),
params(),
);
expect(res.status).toBe(201);
expect(mockAddStage).toHaveBeenCalledWith(9n, {
name: "Snagging",
startDate: null,
dueDate: null,
});
});
it("rejects a due date before the start date", async () => {
const res = await POST(
jsonRequest("POST", {
name: "Snagging",
startDate: "2026-09-03",
dueDate: "2026-03-09",
}),
params(),
);
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("before the start date");
expect(mockAddStage).not.toHaveBeenCalled();
expect(mockAddStage).toHaveBeenCalledWith(9n, { name: "Snagging" });
});
it("400s a body with no name", async () => {

View file

@ -6,7 +6,7 @@
* POST add a stage to the end of the ladder.
* PATCH reorder the whole ladder (`{ stageIds }`, every id exactly once).
*
* Rename, dates and delete are keyed on a single stage and live on
* Rename and delete are keyed on a single stage and live on
* `./[stageId]/route.ts`.
*
* Keyed on `workstream.id` the catalogue row because these routes are
@ -23,31 +23,21 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { authorizeWorkstreamRequest } from "../../authorize";
import { checkStageDates } from "./ladder";
import { addStage, findLadder, reorderStages } from "./queries";
type Params = {
params: Promise<{ projectId: string; workstreamId: string }>;
};
/** `YYYY-MM-DD` — see ADR-0019: the display format stops at the input's edge. */
const isoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Dates must be YYYY-MM-DD");
/**
* An optional date field. The client sends `null` to clear one and omits the
* key to leave it alone, so `""` is normalised to `null` rather than stored.
* A stage is a name and a position, and the position is decided by where it is
* added so a name is the whole body. The `start_date` / `due_date` columns
* exist on the table but describe a work order's delivery rather than a rung
* of a workflow; they are deliberately left NULL (ADR-0021), so there is
* nothing here to set them with.
*/
const optionalIsoDate = isoDate
.or(z.literal(""))
.nullish()
.transform((value) => (value ? value : null));
const addSchema = z.object({
name: z.string(),
startDate: optionalIsoDate,
dueDate: optionalIsoDate,
});
const reorderSchema = z.object({
@ -104,11 +94,6 @@ export async function POST(req: NextRequest, props: Params) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const dates = checkStageDates(body.startDate, body.dueDate);
if (!dates.ok) {
return NextResponse.json({ error: dates.error }, { status: 400 });
}
// Existence and ownership before the write, and the translation from the
// catalogue id in the path to the `project_workstream.id` the write takes.
const ladder = await findLadder(auth.projectId, workstreamId);
@ -122,8 +107,6 @@ export async function POST(req: NextRequest, props: Params) {
try {
const result = await addStage(BigInt(ladder.projectWorkstreamId), {
name: body.name,
startDate: body.startDate,
dueDate: body.dueDate,
});
if (result.kind === "rejected") {

View file

@ -1,79 +0,0 @@
"use client";
import { Layers } from "lucide-react";
import { DEFAULT_LADDER } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
/**
* The "Workstream overrides" panel from the wireframe (issue #411).
*
* Answers one question at a glance: which workstreams still follow the
* standard ladder, and which have been changed. That matters because most
* projects leave most workstreams alone the exceptions are what a reviewer
* is looking for, and hunting for them through five expanded editors is how
* they get missed.
*
* `isDefault` is decided server-side by `isDefaultLadder`, so this panel and
* the API agree on what "default" means rather than each having a copy.
*/
export function LadderOverridesPanel({
ladders,
}: {
ladders: WorkstreamLadder[];
}) {
const custom = ladders.filter((l) => !l.isDefault);
return (
<section
className="rounded-xl border border-gray-200 bg-gray-50/60 p-4"
data-testid="ladder-overrides"
>
<div className="mb-3 flex items-center gap-2">
<Layers className="h-4 w-4 text-gray-400" />
<h2 className="text-sm font-semibold text-gray-900">
Workstream overrides
</h2>
<span className="text-xs text-gray-500">
{custom.length === 0
? `all ${ladders.length === 1 ? "" : `${ladders.length} `}on the standard ladder`
: `${custom.length} of ${ladders.length} customised`}
</span>
</div>
<ul className="flex flex-wrap gap-2">
{ladders.map((ladder) => (
<li key={ladder.workstreamId}>
<a
href={`#ladder-${ladder.workstreamId}`}
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs transition hover:border-gray-300"
data-testid={`ladder-override-${ladder.workstreamId}`}
data-default={ladder.isDefault}
>
<span className="font-semibold text-gray-800">
{ladder.workstreamName}
</span>
<span
className={[
"rounded-full px-1.5 py-0.5 text-[11px] font-medium",
ladder.isDefault
? "bg-gray-100 text-gray-600"
: "bg-indigo-50 text-indigo-700",
].join(" ")}
>
{ladder.isDefault ? "Default" : "Custom"}
</span>
<span className="text-gray-400">
{ladder.stages.length}{" "}
{ladder.stages.length === 1 ? "stage" : "stages"}
</span>
</a>
</li>
))}
</ul>
<p className="mt-3 text-xs text-gray-500">
The standard ladder is {DEFAULT_LADDER.join(" → ")}.
</p>
</section>
);
}

View file

@ -20,9 +20,7 @@ import {
import { Loader2, Plus, RotateCcw } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { DateInput } from "@/app/components/DateInput";
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
import { IMPOSSIBLE_DATE_MESSAGE, isIsoDate } from "./dateEntry";
import { stageLaddersQueryKey } from "./queryKey";
import {
contiguousOrders,
@ -74,8 +72,8 @@ function applyOrder(stages: LadderStage[], ids: string[]): LadderStage[] {
type Notice = { tone: "error" | "info"; text: string };
/** A blank add-a-stage form. */
const EMPTY_DRAFT = { name: "", startDate: "", dueDate: "" };
/** A blank add-a-stage form. A stage is a name and nothing else. */
const EMPTY_DRAFT = { name: "" };
export function StageLadderEditor({
projectId,
@ -128,11 +126,7 @@ export function StageLadderEditor({
send(base, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: draft.name,
startDate: draft.startDate || null,
dueDate: draft.dueDate || null,
}),
body: JSON.stringify({ name: draft.name }),
}),
onSuccess: (result) => {
if (!result.ok) {
@ -147,18 +141,12 @@ export function StageLadderEditor({
onError: (err: Error) => setNotice({ tone: "error", text: err.message }),
});
const update = useMutation({
mutationFn: async ({
stage,
patch,
}: {
stage: LadderStage;
patch: { name?: string; startDate?: string | null; dueDate?: string | null };
}) =>
const rename = useMutation({
mutationFn: async ({ stage, name }: { stage: LadderStage; name: string }) =>
send(`${base}/${stage.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
body: JSON.stringify({ name }),
}),
onSuccess: (result) => {
if (!result.ok) {
@ -166,7 +154,7 @@ export function StageLadderEditor({
} else {
setNotice(null);
}
// Even a refusal refetches: the row must snap back to what was stored,
// Even a refusal refetches: the row must snap back to the stored name,
// not sit showing an edit the database rejected.
onChanged();
},
@ -234,28 +222,9 @@ export function StageLadderEditor({
},
});
const busy = add.isLoading || update.isLoading || remove.isLoading;
const busy = add.isLoading || rename.isLoading || remove.isLoading;
const stageIds = ladder.stages.map((s) => s.id);
/**
* Add the drafted stage, unless a date is finished but impossible.
*
* `DateInput` hands "31/02/2026" through as typed rather than reporting it
* empty, so the check happens here and the user is told which field is wrong
* the server's "Dates must be YYYY-MM-DD" is true but unhelpful to someone
* looking at a `dd/mm/yyyy` field.
*/
function submitAdd() {
const bad = ([draft.startDate, draft.dueDate] as const).some(
(value) => value !== "" && !isIsoDate(value),
);
if (bad) {
setNotice({ tone: "error", text: IMPOSSIBLE_DATE_MESSAGE });
return;
}
add.mutate();
}
/** Commit a drop, unless the row was put back where it came from. */
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
@ -342,8 +311,7 @@ export function StageLadderEditor({
stageCount={ladder.stages.length}
canManage={canManage}
busy={busy}
onRename={(name) => update.mutate({ stage, patch: { name } })}
onDates={(patch) => update.mutate({ stage, patch })}
onRename={(name) => rename.mutate({ stage, name })}
onDelete={() => setPendingDelete(stage)}
/>
))}
@ -373,40 +341,14 @@ export function StageLadderEditor({
}
onKeyDown={(e) => {
if (e.key === "Enter" && draft.name.trim() && !busy) {
submitAdd();
add.mutate();
}
}}
data-testid={`add-stage-name-${ladder.workstreamId}`}
/>
</label>
<label className="flex w-[150px] flex-col gap-1">
<span className="text-xs font-medium text-gray-500">
Start date
</span>
<DateInput
value={draft.startDate}
disabled={busy}
onChange={(value) =>
setDraft((d) => ({ ...d, startDate: value }))
}
data-testid={`add-stage-start-${ladder.workstreamId}`}
/>
</label>
<label className="flex w-[150px] flex-col gap-1">
<span className="text-xs font-medium text-gray-500">
Due date
</span>
<DateInput
value={draft.dueDate}
disabled={busy}
onChange={(value) =>
setDraft((d) => ({ ...d, dueDate: value }))
}
data-testid={`add-stage-due-${ladder.workstreamId}`}
/>
</label>
<Button
onClick={submitAdd}
onClick={() => add.mutate()}
disabled={busy || draft.name.trim().length === 0}
data-testid={`add-stage-submit-${ladder.workstreamId}`}
>

View file

@ -5,26 +5,11 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Flag, GripVertical, Lock, Trash2 } from "lucide-react";
import { Input } from "@/app/shadcn_components/ui/input";
import { DateInput } from "@/app/components/DateInput";
import type { LadderStage } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
import { IMPOSSIBLE_DATE_MESSAGE, isIsoDate } from "./dateEntry";
/** The two optional per-stage dates, as they are named on the wire. */
type DateField = "startDate" | "dueDate";
type DateDraft = Partial<Record<DateField, string>>;
type DateErrors = Partial<Record<DateField, boolean>>;
/**
* One stage in the ladder editor (issue #411).
*
* ## Dates
*
* `start_date` and `due_date` are optional per-stage fields, entered through
* `DateInput` never `<input type="date">`, whose display format follows the
* *browser's* locale and would render 03/09/2026 as 3 March on a US-configured
* machine (ADR-0019). What crosses the boundary here is `YYYY-MM-DD`; the
* `dd/mm/yyyy` a person reads and types stops at the field's edge.
*
* ## Reordering
*
* The row is dragged by its grip, not by its body the body is full of text
@ -35,12 +20,9 @@ type DateErrors = Partial<Record<DateField, boolean>>;
*
* ## Saving
*
* The name saves on blur and the dates on change, because a date is complete
* the moment it parses there is no half-typed state worth persisting, and a
* calendar pick may never blur the field. The row keeps a draft so typing is
* not fought by refetches, and re-syncs from the server value whenever it
* changes: adjusting state during render rather than in an effect, the same
* pattern `DateInput` itself uses.
* The name saves on blur. The row keeps a draft so typing is not fought by
* refetches, and re-syncs from the server value whenever it changes
* adjusting state during render rather than in an effect.
*/
export function StageRow({
stage,
@ -49,7 +31,6 @@ export function StageRow({
canManage,
busy,
onRename,
onDates,
onDelete,
}: {
stage: LadderStage;
@ -59,7 +40,6 @@ export function StageRow({
/** Another mutation on this ladder is in flight. */
busy: boolean;
onRename: (name: string) => void;
onDates: (patch: { startDate?: string | null; dueDate?: string | null }) => void;
onDelete: () => void;
}) {
const {
@ -70,10 +50,10 @@ export function StageRow({
transition,
isDragging,
} = useSortable({ id: stage.id, disabled: !canManage || busy });
// The stored name, tracked so the draft can be rebuilt whenever it moves —
// a rename landed, or another tab edited it. Keyed on the name alone: a date
// save refetches this row too, and must not throw away a rename being typed.
// `DateInput` keeps its own text, so the dates need no draft here.
// The stored name, tracked so the draft can be rebuilt whenever it moves — a
// rename landed, or another tab edited it. A reorder refetches the row too,
// and must not throw away a rename being typed, so this is keyed on the name
// rather than on the row as a whole.
const [synced, setSynced] = useState(stage.name);
const [name, setName] = useState(stage.name);
@ -82,35 +62,6 @@ export function StageRow({
setName(stage.name);
}
// What each date field last reported, and whether it is currently holding a
// complete-but-impossible entry. Both are per-field and short-lived: a save
// or a refetch clears them.
const [typed, setTyped] = useState<DateDraft>({});
const [dateErrors, setDateErrors] = useState<DateErrors>({});
/**
* Save a date the moment it becomes real. A clear is *not* saved here: an
* empty value is reported for every keystroke of an incomplete entry too, so
* committing one would wipe the stored date as soon as the field was touched.
* `blurDate` handles the genuine clear.
*/
function changeDate(field: DateField, value: string) {
setTyped((t) => ({ ...t, [field]: value }));
setDateErrors((e) => ({ ...e, [field]: false }));
if (isIsoDate(value) && value !== stage[field]) onDates({ [field]: value });
}
/** Commit an emptied field, and flag an entry that is not a real date. */
function blurDate(field: DateField) {
const value = typed[field];
if (value === undefined) return;
if (value === "") {
if (stage[field] !== null) onDates({ [field]: null });
return;
}
if (!isIsoDate(value)) setDateErrors((e) => ({ ...e, [field]: true }));
}
const isTerminal = index === stageCount - 1;
const { workOrders, evidenceRequirements, isCurrentStage } = stage.dependents;
// Signposting only — the server decides, and says so in its 409.
@ -203,46 +154,6 @@ export function StageRow({
</span>
</div>
<label className="flex w-[140px] flex-col gap-0.5">
<span className="text-[11px] font-medium text-gray-400">Start</span>
<DateInput
className="h-9"
value={stage.startDate}
disabled={!canManage || busy}
onChange={(value) => changeDate("startDate", value)}
onBlur={() => blurDate("startDate")}
data-testid={`stage-start-${stage.id}`}
/>
{dateErrors.startDate && (
<span
className="text-[11px] text-red-600"
data-testid={`stage-start-error-${stage.id}`}
>
{IMPOSSIBLE_DATE_MESSAGE}
</span>
)}
</label>
<label className="flex w-[140px] flex-col gap-0.5">
<span className="text-[11px] font-medium text-gray-400">Due</span>
<DateInput
className="h-9"
value={stage.dueDate}
disabled={!canManage || busy}
onChange={(value) => changeDate("dueDate", value)}
onBlur={() => blurDate("dueDate")}
data-testid={`stage-due-${stage.id}`}
/>
{dateErrors.dueDate && (
<span
className="text-[11px] text-red-600"
data-testid={`stage-due-error-${stage.id}`}
>
{IMPOSSIBLE_DATE_MESSAGE}
</span>
)}
</label>
{canManage && (
<div className="flex items-center gap-1">
<button

View file

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -7,7 +8,7 @@ import { ArrowLeft, ArrowRight, Info } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
import { StageLadderEditor } from "./StageLadderEditor";
import { LadderOverridesPanel } from "./LadderOverridesPanel";
import { WorkstreamStepper } from "./WorkstreamStepper";
import { stageLaddersQueryKey } from "./queryKey";
/**
@ -18,18 +19,29 @@ import { stageLaddersQueryKey } from "./queryKey";
* from the setup hub (#444). The only difference between the two is where the
* footer sends you.
*
* ## One workstream at a time
*
* A project with eight workstreams has forty stages, and stacking every ladder
* down one page turns configuring the last one into a scroll. So the ladders
* are stepped through instead: one on screen, the rest a click away in the
* stepper above it, and the footer walking forwards through them before it
* offers to leave. The stages *within* a ladder stay listed together they
* are dragged against each other, so they have to be seen together.
*
* Which workstream is open is local state, not a URL parameter: it is a
* position within a step, not a step of its own, and a half-configured ladder
* is not something to deep-link back into.
*
* ## One query, many ladders
*
* The ladders are held in a single cache entry fetched from the per-workstream
* GET endpoints in parallel, rather than one query per editor. The overrides
* panel and the editors then read the same object, so a rename cannot show as
* "custom" in one place and "default" in the other. Each editor owns its own
* mutations and its own error line a failure belongs to the workstream it
* happened in, not to the page.
* Every ladder is held in a single cache entry, fetched from the per-workstream
* GET endpoints in parallel. The stepper and the open editor then read the same
* object, so a rename cannot show as "custom" in one place and "default" in the
* other, and switching workstreams costs nothing the data is already there.
*
* The set of workstreams itself is fixed for the visit: it is chosen on the
* previous step (#410), and adding one there is a navigation, which re-runs
* the server component and re-seeds.
* previous step (#410), and adding one there is a navigation, which re-runs the
* server component and re-seeds.
*/
/** Which way the user arrived, and therefore where the footer leads. */
@ -57,11 +69,18 @@ export function StagesSetup({
projectId: string;
mode: SetupEntryMode;
canManage: boolean;
/** Never empty — the page renders its own empty state instead. */
initialLadders: WorkstreamLadder[];
}) {
const router = useRouter();
const queryClient = useQueryClient();
// Held by id rather than by index, so a refetch that reorders or drops a
// workstream cannot silently move the user to a different one.
const [openWorkstreamId, setOpenWorkstreamId] = useState(
initialLadders[0].workstreamId,
);
const workstreamIds = initialLadders.map((l) => l.workstreamId);
const { data: ladders } = useQuery({
@ -76,8 +95,21 @@ export function StagesSetup({
queryKey: stageLaddersQueryKey(projectId),
});
// Falls back to the first ladder if the open one has gone, which keeps the
// screen rendering something rather than blanking.
const openIndex = Math.max(
0,
ladders.findIndex((l) => l.workstreamId === openWorkstreamId),
);
const open = ladders[openIndex];
const onLast = openIndex === ladders.length - 1;
const withoutStages = ladders.filter((l) => l.stages.length === 0);
function step(by: 1 | -1) {
const next = ladders[openIndex + by];
if (next) setOpenWorkstreamId(next.workstreamId);
}
return (
<div className="flex flex-1 flex-col">
{!canManage && (
@ -91,37 +123,57 @@ export function StagesSetup({
</p>
)}
<LadderOverridesPanel ladders={ladders} />
<WorkstreamStepper
ladders={ladders}
activeWorkstreamId={open.workstreamId}
onSelect={setOpenWorkstreamId}
/>
<div className="mt-6 space-y-6">
{ladders.map((ladder) => (
<StageLadderEditor
key={ladder.workstreamId}
projectId={projectId}
ladder={ladder}
canManage={canManage}
onChanged={refresh}
/>
))}
<div className="mt-6">
<StageLadderEditor
// Keyed so switching workstreams gives the editor a clean slate — a
// half-typed new stage name belongs to the ladder it was typed in.
key={open.workstreamId}
projectId={projectId}
ladder={open}
canManage={canManage}
onChanged={refresh}
/>
</div>
<div className="mt-10 flex items-center justify-between border-t border-gray-200 pt-6">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
<div className="mt-8 flex items-center justify-between border-t border-gray-200 pt-6">
{openIndex === 0 ? (
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
) : (
<Button
variant="outline"
onClick={() => step(-1)}
data-testid="stages-previous-workstream"
>
<ArrowLeft className="mr-1.5 h-4 w-4" />
{ladders[openIndex - 1].workstreamName}
</Button>
)}
<div className="flex items-center gap-4">
<span className="text-xs text-gray-500" data-testid="stages-summary">
Workstream {openIndex + 1} of {ladders.length}
{withoutStages.length > 0
? `${withoutStages.length} ${
withoutStages.length === 1 ? "workstream has" : "workstreams have"
} no stages yet`
: `${ladders.length} ${
ladders.length === 1 ? "ladder" : "ladders"
} configured${canManage ? " · changes save as you go" : ""}`}
? ` · ${withoutStages.length} still ${
withoutStages.length === 1 ? "has" : "have"
} no stages`
: canManage && " · changes save as you go"}
</span>
{mode === "hub" ? (
{!onLast ? (
<Button onClick={() => step(1)} data-testid="stages-next-workstream">
{ladders[openIndex + 1].workstreamName}
<ArrowRight className="ml-1.5 h-4 w-4" />
</Button>
) : mode === "hub" ? (
<Button asChild>
<Link
href={`/projects/${projectId}/settings`}

View file

@ -0,0 +1,110 @@
"use client";
import { AlertTriangle, Layers } from "lucide-react";
import { DEFAULT_LADDER } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder";
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
/**
* The workstream picker above the ladder editor (issue #411).
*
* Doing double duty, deliberately. It is the wireframe's "Workstream
* overrides" panel which workstreams still follow the standard ladder and
* which have been changed, at a glance *and* it is how you get from one
* workstream to the next, because only one ladder is on screen at a time.
* Merging them keeps the overview and the navigation from disagreeing about
* where you are.
*
* `isDefault` is decided server-side by `isDefaultLadder`, so this panel and
* the API agree on what "default" means rather than each keeping a copy.
*/
export function WorkstreamStepper({
ladders,
activeWorkstreamId,
onSelect,
}: {
ladders: WorkstreamLadder[];
activeWorkstreamId: string;
onSelect: (workstreamId: string) => void;
}) {
const custom = ladders.filter((l) => !l.isDefault);
return (
<nav
className="rounded-xl border border-gray-200 bg-gray-50/60 p-4"
aria-label="Workstreams"
data-testid="ladder-overrides"
>
<div className="mb-3 flex items-center gap-2">
<Layers className="h-4 w-4 text-gray-400" />
<h2 className="text-sm font-semibold text-gray-900">
Workstream overrides
</h2>
<span className="text-xs text-gray-500">
{custom.length === 0
? "all on the standard ladder"
: `${custom.length} of ${ladders.length} customised`}
</span>
</div>
<ul className="flex flex-wrap gap-2">
{ladders.map((ladder) => {
const active = ladder.workstreamId === activeWorkstreamId;
return (
<li key={ladder.workstreamId}>
<button
type="button"
onClick={() => onSelect(ladder.workstreamId)}
aria-current={active ? "step" : undefined}
className={[
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition",
active
? "border-indigo-600 bg-white shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300",
].join(" ")}
data-testid={`ladder-override-${ladder.workstreamId}`}
data-default={ladder.isDefault}
data-active={active}
>
<span
className={
active
? "font-semibold text-indigo-700"
: "font-semibold text-gray-800"
}
>
{ladder.workstreamName}
</span>
{ladder.stages.length === 0 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
<AlertTriangle className="h-3 w-3" />
No stages
</span>
) : (
<>
<span
className={[
"rounded-full px-1.5 py-0.5 text-[11px] font-medium",
ladder.isDefault
? "bg-gray-100 text-gray-600"
: "bg-indigo-50 text-indigo-700",
].join(" ")}
>
{ladder.isDefault ? "Default" : "Custom"}
</span>
<span className="text-gray-400">
{ladder.stages.length}
</span>
</>
)}
</button>
</li>
);
})}
</ul>
<p className="mt-3 text-xs text-gray-500">
The standard ladder is {DEFAULT_LADDER.join(" → ")}.
</p>
</nav>
);
}

View file

@ -1,27 +0,0 @@
/**
* How the stage editor decides a typed date is worth saving (issue #411).
*
* `DateInput` reports three different things through one `onChange`, and each
* needs a different response from a field that saves as you type:
*
* - `"2026-09-03"` a real date. Save it.
* - `""` *either* an empty field or one mid-keystroke ("03/0"). Saving on
* sight would clear a stored date the moment the user touched it, so a
* clear is only committed when the field is left empty (on blur).
* - `"31/02/2026"` finished, but not a date that exists. `DateInput` hands
* these through as typed precisely so they are not mistaken for "empty";
* they are shown back to the user rather than sent.
*
* See `@/app/components/DateInput` and ADR-0019.
*/
/** `YYYY-MM-DD` — the only date format that crosses a boundary. */
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** Whether a `DateInput` value is a stored-shape date. */
export function isIsoDate(value: string): boolean {
return ISO_DATE.test(value);
}
/** Shown under a field holding a complete entry that is not a real date. */
export const IMPOSSIBLE_DATE_MESSAGE = "That isn't a real date (dd/mm/yyyy).";