diff --git a/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md b/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md index 6e3783d3..1a24e594 100644 --- a/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md +++ b/docs/adr/0021-stage-ladder-seeding-and-the-status-column.md @@ -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 diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.test.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.test.ts index 3584bae5..65320637 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.test.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.test.ts @@ -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.', }); diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.ts index 67208cbd..033eef27 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/[stageId]/route.ts @@ -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 }, ); } diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.test.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.test.ts index 67a21d44..521a1d99 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.test.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.test.ts @@ -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({ diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.ts index 2c1295d4..81fa09d0 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder.ts @@ -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 }; diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries.ts index bd5e21dd..6d9dc893 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries.ts @@ -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`count(distinct ${workOrder.id})::int`, evidenceRequirements: sql`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`, isCurrentStage: sql`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 { 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 { + name: string, +): Promise { 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" }; }); } diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.test.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.test.ts index 42ccab4a..6c495f44 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.test.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.test.ts @@ -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 () => { diff --git a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.ts b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.ts index f5f1d94b..d152439c 100644 --- a/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.ts +++ b/src/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/route.ts @@ -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") { diff --git a/src/app/projects/[projectId]/setup/stages/LadderOverridesPanel.tsx b/src/app/projects/[projectId]/setup/stages/LadderOverridesPanel.tsx deleted file mode 100644 index 2d7c9403..00000000 --- a/src/app/projects/[projectId]/setup/stages/LadderOverridesPanel.tsx +++ /dev/null @@ -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 ( -
-
- -

- Workstream overrides -

- - {custom.length === 0 - ? `all ${ladders.length === 1 ? "" : `${ladders.length} `}on the standard ladder` - : `${custom.length} of ${ladders.length} customised`} - -
- - - -

- The standard ladder is {DEFAULT_LADDER.join(" → ")}. -

-
- ); -} diff --git a/src/app/projects/[projectId]/setup/stages/StageLadderEditor.tsx b/src/app/projects/[projectId]/setup/stages/StageLadderEditor.tsx index 4f72a10d..254c3336 100644 --- a/src/app/projects/[projectId]/setup/stages/StageLadderEditor.tsx +++ b/src/app/projects/[projectId]/setup/stages/StageLadderEditor.tsx @@ -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}`} /> - - +
+ {openIndex === 0 ? ( + + ) : ( + + )}
+ 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"} - {mode === "hub" ? ( + + {!onLast ? ( + + ) : mode === "hub" ? ( + + ); + })} + + +

+ The standard ladder is {DEFAULT_LADDER.join(" → ")}. +

+ + ); +} diff --git a/src/app/projects/[projectId]/setup/stages/dateEntry.ts b/src/app/projects/[projectId]/setup/stages/dateEntry.ts deleted file mode 100644 index 0ae573f9..00000000 --- a/src/app/projects/[projectId]/setup/stages/dateEntry.ts +++ /dev/null @@ -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).";