feat(ara-projects): make work_order.priority a boolean and show it in the table (#419)

priority was one of the "enum values TBC" text columns. It turns out to be a
flag, not a scale — "look at this one first" — so there is no value set left
to agree, and text was modelling a decision nobody needs to make. Now
boolean NOT NULL DEFAULT false: a third "unknown" state has no meaning for a
flag, and the table either renders it or renders nothing.

Deliberately independent of overdue. Overdue is derived from the forecast
date and the stage ladder; priority is stored, because someone marked it. A
flagged work order may be well inside its dates and a late one nobody
flagged is not a priority, so they are separate columns and separate badges.

Read path: selected and grouped in the page query, carried through
RawWorkOrderRow -> WorkOrderRow, rendered as the wireframe's Priority column
between Docs and the row action. Shown only when true; a column of "No"
chips would give the exception the same weight as the rule.

Docs updated where they claimed otherwise: CONTEXT.md gains a Priority entry
and no longer lists the column as untouched, and schema.md no longer groups
it with the TBC-enum text columns.

MIGRATION NOT INCLUDED AND NOT APPLIED. This commit changes the Drizzle
schema only; work_order.priority is still `text` in the shared production
database, so the boolean the code expects is not there yet. Generating and
applying the migration is the repo owner's call. Two notes for whoever does:
the table holds 0 rows today (verified by a read-only SELECT), so the change
is lossless; and Postgres refuses ALTER COLUMN ... TYPE boolean on a text
column without a USING clause, which drizzle-kit will not emit.
This commit is contained in:
Daniel Roth 2026-07-23 14:55:29 +00:00
parent d75bdf0ac4
commit 0e69c080e1
8 changed files with 92 additions and 9 deletions

View file

@ -161,13 +161,17 @@ A category of works (Windows, Doors, Roofs, Electrical, Heating, …) — refere
_Avoid_: measure (Measure is taken by the Scenarios domain), trade, work type
**Stage**:
One step in a Project Workstream's ordered ladder (`project_workstream_stage`, positioned by `order`). Each Project Workstream owns its own ladder — different workstreams may follow different workflows. The v1 default ladder is Ordered / In progress / Completed / Charged / Closed, and the terminal stage is the one with the highest `order`. A Work order's stage FK (`work_order.project_workstream_stage_id`) is the **only lifecycle mechanism in v1**`work_order.status`, `work_order.priority` and `project_workstream.current_stage_id` exist in the schema but are untouched.
One step in a Project Workstream's ordered ladder (`project_workstream_stage`, positioned by `order`). Each Project Workstream owns its own ladder — different workstreams may follow different workflows. The v1 default ladder is Ordered / In progress / Completed / Charged / Closed, and the terminal stage is the one with the highest `order`. A Work order's stage FK (`work_order.project_workstream_stage_id`) is the **only lifecycle mechanism in v1**`work_order.status` and `project_workstream.current_stage_id` exist in the schema but are untouched. `work_order.priority` is now used, but it is not a lifecycle mechanism: see **Priority**.
_Avoid_: status (the unused text column), phase, step
**Work order**:
One unit of work issued to a contractor: a (Property, Project Workstream Stage, contractor organisation, unique reference, forecast end date) row in `work_order`. Its position in the workflow is its Stage FK (see **Stage**).
_Avoid_: job, ticket, task (a **Task** is the BulkUpload orchestration handle)
**Priority**:
A **flag on a Work order** (`work_order.priority`, boolean), meaning "look at this one first". It is a *stored* fact — someone marks it — and so is deliberately independent of **Overdue**, which is derived from the forecast end date and the Stage ladder: a flagged work order may be well inside its dates, and a late one nobody flagged is not a priority. Not a scale: there are no priority *levels* in v1, only flagged and not.
_Avoid_: urgency, severity, high/medium/low (there are no levels), escalation
**Evidence requirement** / **Evidence**:
An **Evidence requirement** (`project_workstream_evidence_requirement`) is configuration: a required `file_type` (the existing enum) per Project Workstream, optionally narrowed to a single Stage. **Evidence** is a submission: an `uploaded_files` row with `file_source = 'projects'`, linked to its Work order (`work_order_id`) and optionally to the requirement it satisfies. Evidence is **advisory-only in v1** — badges and counts, never a gate: it does not block stage transitions and there is no approval state.
_Avoid_: document (generic), attachment, proof

View file

@ -173,6 +173,13 @@ describe("buildWorkOrderPageQuery", () => {
expect(page().sql).toContain("LEFT JOIN project_workstream_contractor");
});
it("selects the priority flag and groups by it", () => {
const { sql } = page();
expect(sql).toContain("wo.priority");
// Selected alongside an aggregate, so it has to appear in the GROUP BY.
expect(sql).toMatch(/GROUP BY[^)]*wo\.priority/);
});
it("reads the stage and workstream names live, never from the work order", () => {
const { sql } = page();
expect(sql).toContain("pws.name");

View file

@ -188,6 +188,7 @@ interface WorkOrderQueryRow extends Record<string, unknown> {
stage_id: string;
stage_name: string;
forecast_end: string | null;
priority: boolean;
required_count: string | number;
satisfied_count: string | number;
}
@ -208,6 +209,9 @@ function toRawWorkOrderRow(row: WorkOrderQueryRow): RawWorkOrderRow {
// A `date` column arrives as `YYYY-MM-DD`; take the day part defensively so
// a driver that ever hands back a timestamp cannot leak a time into the UI.
forecastEnd: toCalendarDay(row.forecast_end),
// `work_order.priority` is a boolean column; `Boolean()` guards only against
// a driver handing back null for a row written before it was NOT NULL.
priority: Boolean(row.priority),
requiredEvidence: Number(row.required_count),
satisfiedEvidence: Number(row.satisfied_count),
};
@ -340,6 +344,7 @@ function workOrderScope(
wo.id,
wo.reference,
wo.forecast_end,
wo.priority,
wo.project_workstream_stage_id AS stage_id,
pws.name AS stage_name,
pw.id AS workstream_id,
@ -377,7 +382,7 @@ function workOrderScope(
AND a.requirement_id = uf.project_workstream_evidence_requirement_id
)
WHERE ${and(...conditions)}
GROUP BY wo.id, wo.reference, wo.forecast_end,
GROUP BY wo.id, wo.reference, wo.forecast_end, wo.priority,
wo.project_workstream_stage_id, pws.name, pw.id, w.name,
p.landlord_property_id, p.address, p.postcode,
pwc.organisation_id, org.name

View file

@ -35,6 +35,7 @@ function raw(overrides: Partial<RawWorkOrderRow> = {}): RawWorkOrderRow {
stageId: 102n,
stageName: "In progress",
forecastEnd: "2026-07-30",
priority: false,
requiredEvidence: 4,
satisfiedEvidence: 2,
...overrides,
@ -139,6 +140,31 @@ describe("toWorkOrderRow", () => {
expect(row.overdue).toBe(true);
});
it("carries the priority flag through as a stored fact, not a derivation", () => {
expect(toWorkOrderRow(LADDERS, raw({ priority: true }), ASOF).priority).toBe(
true,
);
expect(toWorkOrderRow(LADDERS, raw(), ASOF).priority).toBe(false);
});
it("keeps priority independent of overdue — a flag is not a deadline", () => {
// A priority work order comfortably inside its forecast is not overdue,
// and an overdue one nobody flagged is not a priority.
const flaggedOnTime = toWorkOrderRow(
LADDERS,
raw({ priority: true, forecastEnd: "2026-08-30" }),
ASOF,
);
expect(flaggedOnTime).toMatchObject({ priority: true, overdue: false });
const lateUnflagged = toWorkOrderRow(
LADDERS,
raw({ priority: false, forecastEnd: "2026-07-01" }),
ASOF,
);
expect(lateUnflagged).toMatchObject({ priority: false, overdue: true });
});
it("carries the live stage name through, so a rename propagates", () => {
const row = toWorkOrderRow(
LADDERS,

View file

@ -54,6 +54,12 @@ export interface RawWorkOrderRow {
stageName: string;
/** `work_order.forecast_end` as Drizzle hands back a `date`: `YYYY-MM-DD`. */
forecastEnd: string | null;
/**
* `work_order.priority` a flag, not a scale, and a stored fact rather than
* a derived one: someone marked this work order as needing attention first.
* Independent of `overdue`, which the ladder and the calendar decide.
*/
priority: boolean;
requiredEvidence: number;
satisfiedEvidence: number;
}
@ -73,6 +79,8 @@ export interface WorkOrderRow {
stageName: string;
/** `YYYY-MM-DD`, the only date format that crosses this boundary. The table formats it. */
forecastEnd: string | null;
/** Flagged as priority. A stored fact, not a derivation — see `RawWorkOrderRow`. */
priority: boolean;
/** Null when the row's stage was not loaded into the ladders — see derivations. */
progress: WorkOrderProgress | null;
overdue: boolean;
@ -115,6 +123,7 @@ export function toWorkOrderRow(
stageId: raw.stageId.toString(),
stageName: raw.stageName,
forecastEnd: raw.forecastEnd,
priority: raw.priority,
progress: progressOfWorkOrder(ladders, derivationInput),
overdue: isOverdue(ladders, derivationInput, asOf),
evidence,

View file

@ -7,8 +7,11 @@
// `project_workstream_contractor.organisation_id` are uuid FKs to it.
// - Property has a bigint PK (see property.ts), so every `property_id` FK is
// bigint, not the integer the doc assumed.
// - status / priority columns whose enum values are still TBC are modelled as
// `text` for now; convert to pgEnum once the value sets are agreed.
// - status columns whose enum values are still TBC are modelled as `text` for
// now; convert to pgEnum once the value sets are agreed.
// - `work_order.priority` started as one of those `text` columns and is now a
// boolean: it turned out to be a flag ("look at this one first"), not a
// scale, so there is no value set left to agree. See CONTEXT.md, "Priority".
import {
type AnyPgColumn,
bigint,
@ -181,7 +184,10 @@ export const workOrder = pgTable("work_order", {
// Enum values TBC — modelled as text until the value sets are agreed.
status: text("status").notNull(),
forecastEnd: date("forecast_end"),
priority: text("priority"),
// A flag, not a scale: a work order either is flagged as priority or is not.
// NOT NULL DEFAULT false because a third "unknown" state has no meaning for a
// flag — the table renders it or renders nothing.
priority: boolean("priority").notNull().default(false),
});
export type Project = InferModel<typeof project, "select">;

View file

@ -66,9 +66,13 @@ Project
> optional evidence-requirement link) carries this.
> - **Property has a `bigint` PK** (`bigserial`), not an integer one. Every
> `property_id` FK is `bigint`.
> - **`status` / `priority` columns are `text` for now**, not Postgres enums —
> their value sets are still TBC and an empty enum can't be created. Convert to
> `pgEnum` once the values are agreed.
> - **`status` columns are `text` for now**, not Postgres enums — their value
> sets are still TBC and an empty enum can't be created. Convert to `pgEnum`
> once the values are agreed.
> - **`work_order.priority` is a `boolean`**, not one of those `text` columns.
> It is a flag ("look at this one first"), not a scale, so no value set was
> ever needed. Changed under #419, when the admin work-orders table surfaced
> it as a column.
> - **No new `uploaded_by` column** on `uploaded_files`; the existing
> `uploaded_by` `bigint` FK → user is reused.
> - **No `document_type` table.** Evidence requirements reference the existing
@ -256,7 +260,7 @@ Represents work issued to a contractor.
| project_workstream_stage_id | bigint | No | FK → Project Workstream Stage |
| status | text | No | Values TBC — `text` for now, convert to Postgres enum once agreed |
| forecast_end | date | Yes | |
| priority | text | Yes | Values TBC — `text` for now, convert to Postgres enum once agreed |
| priority | boolean | No | Flag, not a scale — "look at this one first". Default `false` |
---

View file

@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { AlertTriangle } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { isoToLocalDate } from "@/utils/dates";
import type { WorkOrderRow } from "@/app/api/projects/[projectId]/work-orders/rows";
@ -141,6 +142,27 @@ export function createWorkOrderColumns(
cell: ({ row }) => <DocsBadge row={row.original} />,
},
{
id: "priority",
accessorKey: "priority",
header: () => <HeaderLabel>Priority</HeaderLabel>,
// A stored flag, unlike every other badge in this table: nothing derives
// it, someone set it. Rendered only when true — a column of "No" chips
// would give the exception the same weight as the rule.
cell: ({ row }) =>
row.original.priority ? (
<span
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
data-testid="work-order-priority"
>
<AlertTriangle className="h-3 w-3" aria-hidden />
Priority
</span>
) : (
<span className="sr-only">Not a priority</span>
),
},
{
id: "action",
header: () => <span className="sr-only">Action</span>,