From 4a841c20d4a82eaae5201e4de191ecfffcf8fcf4 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 15:23:18 +0000
Subject: [PATCH 01/16] docs(context): add Tag glossary entry (grill WIP)
Flat, portfolio-owned, many-to-many label for grouping Properties for bulk
actions. Captured during the tagging grill-with-docs session.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CONTEXT.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CONTEXT.md b/CONTEXT.md
index 60fc3694..628c0c25 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -126,6 +126,12 @@ _Avoid_: programme, scheme, deal group, batch (a **Batch** is a separate per-dea
An app-triggered, asynchronous assembly of a single ZIP of the documents held against a chosen set of Properties — picked as a row selection in the Documents tab, scoped by the tab's current Project/group — delivered as a time-limited link emailed to the requester (and, within the session, retrievable in-app). A selection that matches no documents produces no ZIP: it fails rather than emailing an empty archive. Properties are matched to their documents by **landlord property id**. See [ADR-0011](./docs/adr/0011-app-owned-task-marker-in-task-source.md).
_Avoid_: bulk export, document export, zip download
+### Tags
+
+**Tag**:
+A user-created, coloured label owned by a Portfolio, used to form an arbitrary **group** of its Properties for bulk actions (e.g. running Scenarios over the group, or excluding it from a Reporting view). Flat (no hierarchy) and many-to-many: a Property carries zero or more Tags, and a Tag labels zero or more Properties. Distinct from a **Project** (a HubSpot-sourced grouping, at most one per Property) and from a **Scenario** (a modelling question): a Tag is an app-owned, hand-curated selection with no external source and no modelling semantics of its own.
+_Avoid_: label, category, group (the group is the *effect* of a Tag, not a separate entity), segment, tag group
+
## Lifecycle
A **BulkUpload** moves through these statuses:
From af81827b14e75b6fe5942b8fef2437599c2abd4f Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 15:38:42 +0000
Subject: [PATCH 02/16] docs: ADR-0013 tagging system + CONTEXT (Bulk tag
assignment, Run filter tags)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Captures the grill-with-docs outcome: flat app-owned Tags, four assignment
paths (incl. in-app bulk upload, no FastAPI), tags as a Run-filter key extending
ADR-0008, hard-delete + cascade, reporting-by-tag deferred to the reporting
redesign. Design only — no implementation yet.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CONTEXT.md | 6 +-
...3-tags-are-app-owned-property-groupings.md | 101 ++++++++++++++++++
2 files changed, 106 insertions(+), 1 deletion(-)
create mode 100644 docs/adr/0013-tags-are-app-owned-property-groupings.md
diff --git a/CONTEXT.md b/CONTEXT.md
index 628c0c25..aa87bc9a 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -113,7 +113,7 @@ One user-initiated act of triggering modelling: a set of Scenarios crossed with
_Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records)
**Run filter**:
-The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend).
+The property-selecting constraints of a Modelling run: postcode, property type, built form, and **Tags** (each multi-select). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all; Tags are the Portfolio's own Tags (multi-select is **any-of**). Resolution follows the Landlord-override rule for type/built-form (an override fact beats an EPC-derived value) and a **direct membership lookup** for Tags — but in all cases the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend, which reads the same Tag membership the app writes). See [ADR-0013](./docs/adr/0013-tags-are-app-owned-property-groupings.md).
_Avoid_: property query, segment, search (the postcode-search journey is unrelated)
### Live projects
@@ -132,6 +132,10 @@ _Avoid_: bulk export, document export, zip download
A user-created, coloured label owned by a Portfolio, used to form an arbitrary **group** of its Properties for bulk actions (e.g. running Scenarios over the group, or excluding it from a Reporting view). Flat (no hierarchy) and many-to-many: a Property carries zero or more Tags, and a Tag labels zero or more Properties. Distinct from a **Project** (a HubSpot-sourced grouping, at most one per Property) and from a **Scenario** (a modelling question): a Tag is an app-owned, hand-curated selection with no external source and no modelling semantics of its own.
_Avoid_: label, category, group (the group is the *effect* of a Tag, not a separate entity), segment, tag group
+**Bulk tag assignment**:
+Assigning one Tag to many Properties at once from a small uploaded file (CSV/Excel) of property identifiers — either **landlord property id** or **UPRN**, whichever column the file carries. Parsed and matched entirely in the app (no FastAPI pipeline); the target Tag is chosen in the upload dialog, not named in the file. An identifier that matches no Property is reported back (never silently dropped); an already-tagged match is a no-op. The parallel filter-driven path assigns a Tag to **every Property matching the current property-table filter**.
+_Avoid_: tag import, bulk tagging (verb is fine; the noun is Bulk tag assignment)
+
## Lifecycle
A **BulkUpload** moves through these statuses:
diff --git a/docs/adr/0013-tags-are-app-owned-property-groupings.md b/docs/adr/0013-tags-are-app-owned-property-groupings.md
new file mode 100644
index 00000000..e139908a
--- /dev/null
+++ b/docs/adr/0013-tags-are-app-owned-property-groupings.md
@@ -0,0 +1,101 @@
+# 13. Tags are app-owned Property groupings, filtered in-app and passed to modelling as a filter key
+
+Date: 2026-07-13
+
+## Status
+
+Accepted
+
+Numbering note: 0010 (reporting redesign) and 0012 (portfolio-list descriptor
+resolution) are accepted on their own in-flight branches; 0013 avoids collision
+with both.
+
+## Context
+
+Users need to form **arbitrary, hand-curated groups** of a Portfolio's
+Properties to drive bulk actions — running Scenarios over a subset, and (later)
+including/excluding a group from Reporting. Nothing existing covers this:
+
+- **Projects** (ADR: HubSpot-sourced) are at most one per Property and owned by
+ an external system — not user-curated, not many-per-property.
+- **Run filters** (ADR-0008) select by postcode / property type / built form —
+ data-derived facts, not a free-form selection.
+
+ADR-0008 already anticipated this: it kept the distributor's filter payload a set
+of tiny keys resolved against the shared Postgres, **explicitly rejected sending
+resolved property-id lists** (~50k ids overflow the async entry point and move
+resolution into the app), and reserved that "tags become one more optional
+filter key."
+
+## Decision
+
+- **A Tag is a flat, app-owned, Portfolio-scoped label**, many-to-many with
+ Property (`portfolio_tag`, `property_tag`). No hierarchy, no external source,
+ no modelling semantics of its own — it exists only to name a group. See
+ CONTEXT.md (Tag).
+- **Assignment has four paths, all app-owned:** a per-Property inline editor; a
+ filter-driven "tag everything matching the current property-table filter";
+ a row-multiselect (whose "select all matching" delegates to the filter path);
+ and a **Bulk tag assignment** file upload.
+- **The bulk upload is parsed and matched entirely in the app — no FastAPI
+ pipeline.** The BulkUpload pipeline exists to match *arbitrary addresses* to
+ UPRNs and to *classify free text*; tag assignment matches an *existing
+ identifier* (landlord property id **or** UPRN, whichever column the file
+ carries) to *existing* Properties in one Portfolio. There is no address
+ matching and no classification, and the files are small — so the pipeline's
+ cost (S3 round-trip, async task, combiner) buys nothing. An unmatched
+ identifier is reported, never silently dropped; an ambiguous landlord id (they
+ are **not** unique per Portfolio) tags every match and says so.
+- **Tags reach modelling as a `tag_ids` filter key (extends ADR-0008), never as
+ a property-id list.** `RunFilters` gains `tag_ids`; it is **AND-combined**
+ with the other filter dimensions and **OR-within** (any-of), matching the
+ property-table filter. The app computes the in-app preview count by joining
+ `property_tag` (the reference implementation ADR-0008 requires); the
+ distributor resolves the same key by the same join against the shared DB it
+ already reads. This needs one backend change — the distributor learning
+ `tag_ids` — so the app side ships first and scenarios-on-tags goes live when
+ the backend lands.
+- **The property-table Tag filter is app-resolved** (unlike modelling, the app
+ owns the browse surface): a dynamic per-Portfolio option list, any-of, plus a
+ synthetic **Untagged** option (zero memberships).
+- **Delete is hard + cascade.** Removing a Tag drops its memberships behind a
+ confirm that shows the count. A Tag id baked into a past Run's stored filter
+ becomes dangling and renders as "(deleted tag)" in Run history — no archive
+ state to query around.
+- **Schema migrations ship in their own PR**, separate from the feature code
+ (project convention).
+
+## Alternatives considered
+
+- **Two-level Tag Group → Tag hierarchy.** More expressive (mutually-exclusive
+ groups), but every stated use case is served by flat Tags with far less schema
+ and UI; grouping can be added later without reshaping the join.
+- **Reuse Projects as the grouping.** No new tables, but Projects are
+ HubSpot-owned, one-per-Property, and not user-editable — the opposite of what a
+ Tag is.
+- **App resolves `tag_ids` → property-id list for the run.** No backend change,
+ but re-runs exactly what ADR-0008 rejected: an unbounded payload and the
+ resolution rule living in the app, where preview and run can drift.
+- **Route the bulk upload through the FastAPI BulkUpload pipeline.** Reuses the
+ existing async machinery, but pays for address-matching and classification the
+ task doesn't need, and couples a simple in-Portfolio identifier join to a
+ multi-stage S3 pipeline.
+- **Soft-delete / archive Tags.** Preserves history references exactly, but
+ burdens every Tag query with an `archived` predicate for a gain a
+ "(deleted tag)" label already covers.
+
+## Consequences
+
+- **One backend ask** must land for scenarios-on-tags to work end-to-end: the
+ distributor accepts and resolves a `tag_ids` filter key against `property_tag`,
+ mirroring the app's preview. Until then the run UI can show and preview a Tag
+ selection but the run won't scope by it.
+- **Reporting-by-tag is deferred to the reporting redesign** (PRD #370 /
+ ADR-0010): the substrate (`property_tag`) ships now; "include/exclude a tagged
+ group" becomes a requirement of that redesign rather than a retrofit onto the
+ scenario-aggregate reporting it replaces.
+- A deleted Tag leaves a dangling id in any Run-history record that referenced
+ it; history reads must tolerate it (render "(deleted tag)").
+- `landlord_property_id` is partial (~43% of Properties) and non-unique per
+ Portfolio, so bulk uploads that use it cover less than half the Portfolio and
+ can tag duplicates — UPRN is offered as the reliable alternative key.
From ed1dae4e99bf0986f3a37b252a2d86747bf583fb Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 15:50:09 +0000
Subject: [PATCH 03/16] feat(tags): pure Tag draft validation/normalisation
(TDD)
validateTagDraft: trims + requires a name, caps it at 30 chars, rejects a
case-insensitive duplicate against the portfolio's existing tag names (the
caller excludes the tag's own name on edit), and validates + lowercases a
freeform hex colour (#rgb or #rrggbb). The DB's case-insensitive unique index
remains the ultimate guard; this gives a friendly pre-check for the tag routes
and settings UI. 8 behaviours, built red-green. ADR-0013.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/lib/tags/model.test.ts | 62 ++++++++++++++++++++++++++++++++++++++
src/lib/tags/model.ts | 43 ++++++++++++++++++++++++++
2 files changed, 105 insertions(+)
create mode 100644 src/lib/tags/model.test.ts
create mode 100644 src/lib/tags/model.ts
diff --git a/src/lib/tags/model.test.ts b/src/lib/tags/model.test.ts
new file mode 100644
index 00000000..1b012fc0
--- /dev/null
+++ b/src/lib/tags/model.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { validateTagDraft } from "./model";
+
+describe("validateTagDraft", () => {
+ it("accepts a valid name + hex colour, trimming the name and lowercasing the hex", () => {
+ expect(validateTagDraft({ name: " Void ", colour: "#7C3AED" }, [])).toEqual({
+ ok: true,
+ tag: { name: "Void", colour: "#7c3aed" },
+ });
+ });
+
+ it("rejects a blank name (empty or whitespace-only)", () => {
+ expect(validateTagDraft({ name: " ", colour: "#7c3aed" }, [])).toEqual({
+ ok: false,
+ error: "Tag name is required",
+ });
+ });
+
+ it("rejects a name longer than 30 characters (after trimming)", () => {
+ const long = "a".repeat(31);
+ expect(validateTagDraft({ name: long, colour: "#7c3aed" }, [])).toEqual({
+ ok: false,
+ error: "Tag name must be 30 characters or fewer",
+ });
+ });
+
+ it("accepts a name of exactly 30 characters (boundary)", () => {
+ const name = "a".repeat(30);
+ expect(validateTagDraft({ name, colour: "#7c3aed" }, [])).toEqual({
+ ok: true,
+ tag: { name, colour: "#7c3aed" },
+ });
+ });
+
+ it("rejects a name that duplicates an existing one, case-insensitively", () => {
+ expect(
+ validateTagDraft({ name: " VOID ", colour: "#7c3aed" }, ["Void", "Phase 1"]),
+ ).toEqual({ ok: false, error: "A tag with this name already exists" });
+ });
+
+ it("accepts a name that clashes with no existing tag (edit excludes its own name)", () => {
+ // On edit the caller passes the OTHER tags' names, so keeping/recasing the
+ // tag's own name is allowed.
+ expect(
+ validateTagDraft({ name: "Void", colour: "#7c3aed" }, ["Phase 1"]),
+ ).toEqual({ ok: true, tag: { name: "Void", colour: "#7c3aed" } });
+ });
+
+ it("rejects a colour that is not a hex code", () => {
+ expect(validateTagDraft({ name: "Void", colour: "red" }, [])).toEqual({
+ ok: false,
+ error: "Colour must be a hex code like #7c3aed",
+ });
+ });
+
+ it("accepts a 3-digit hex shorthand, normalised to lowercase", () => {
+ expect(validateTagDraft({ name: "Void", colour: "#ABC" }, [])).toEqual({
+ ok: true,
+ tag: { name: "Void", colour: "#abc" },
+ });
+ });
+});
diff --git a/src/lib/tags/model.ts b/src/lib/tags/model.ts
new file mode 100644
index 00000000..49e8f39e
--- /dev/null
+++ b/src/lib/tags/model.ts
@@ -0,0 +1,43 @@
+/**
+ * Pure Tag domain logic — validation + normalisation for creating/editing a
+ * portfolio Tag (ADR-0013). Kept free of DB access so it unit-tests cleanly and
+ * is shared by the tag routes. The DB's case-insensitive unique index is the
+ * ultimate guard against duplicate names; this gives a friendly pre-check.
+ */
+
+export const MAX_TAG_NAME_LENGTH = 30;
+
+/** A hex colour: #rgb or #rrggbb (case-insensitive). */
+const HEX_COLOUR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
+
+export interface TagDraft {
+ name: string;
+ colour: string;
+}
+
+export type TagValidation =
+ | { ok: true; tag: TagDraft }
+ | { ok: false; error: string };
+
+export function validateTagDraft(
+ input: TagDraft,
+ existingNames: string[],
+): TagValidation {
+ const name = input.name.trim();
+ if (name.length === 0) return { ok: false, error: "Tag name is required" };
+ if (name.length > MAX_TAG_NAME_LENGTH) {
+ return {
+ ok: false,
+ error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or fewer`,
+ };
+ }
+ const lower = name.toLowerCase();
+ if (existingNames.some((existing) => existing.trim().toLowerCase() === lower)) {
+ return { ok: false, error: "A tag with this name already exists" };
+ }
+ const colour = input.colour.trim().toLowerCase();
+ if (!HEX_COLOUR.test(colour)) {
+ return { ok: false, error: "Colour must be a hex code like #7c3aed" };
+ }
+ return { ok: true, tag: { name, colour } };
+}
From 88c0c5b13db3118a8c8723bb1f54374db6d5b819 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 16:04:28 +0000
Subject: [PATCH 04/16] feat(tags): CRUD + assignment routes, bulk-file parser,
run-filter tag_ids
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Run filter gains an optional tag_ids key (ADR-0013): normaliseRunFilters
coerces/dedupes ids, buildRunRecord + parseRunConfig round-trip it, and
selectionSummary counts it. Pure, TDD (9 new cases).
- parseTagIdentifierRows (pure, TDD): a CSV/Excel 2D array → the identifier
column to match on (landlord_property_id or uprn, header-detected), trimmed,
deduped, capped at 10k.
- Routes (write-gated via shared getPortfolioRole/canWriteTags):
GET/POST /tags (list w/ counts, create), PATCH/DELETE /tags/[id] (edit,
delete+cascade), POST /tags/[id]/assignments (properties add/remove +
identifiers bulk-match with a summary). Names validated by validateTagDraft.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../tags/[tagId]/assignments/route.ts | 147 ++++++++++++++++++
.../[portfolioId]/tags/[tagId]/route.ts | 94 +++++++++++
.../api/portfolio/[portfolioId]/tags/route.ts | 121 ++++++++++++++
src/lib/modellingRuns/model.test.ts | 35 +++++
src/lib/modellingRuns/model.ts | 23 +++
src/lib/tags/bulkAssign.test.ts | 63 ++++++++
src/lib/tags/bulkAssign.ts | 61 ++++++++
src/lib/tags/server.ts | 36 +++++
8 files changed, 580 insertions(+)
create mode 100644 src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts
create mode 100644 src/app/api/portfolio/[portfolioId]/tags/[tagId]/route.ts
create mode 100644 src/app/api/portfolio/[portfolioId]/tags/route.ts
create mode 100644 src/lib/tags/bulkAssign.test.ts
create mode 100644 src/lib/tags/bulkAssign.ts
create mode 100644 src/lib/tags/server.ts
diff --git a/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts b/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts
new file mode 100644
index 00000000..92370a44
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts
@@ -0,0 +1,147 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import { portfolioTag, propertyTag } from "@/app/db/schema/tags";
+import { property } from "@/app/db/schema/property";
+import { and, eq, inArray } from "drizzle-orm";
+import { z } from "zod";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { canWriteTags, getPortfolioRole } from "@/lib/tags/server";
+import { MAX_BULK_TAG_ROWS } from "@/lib/tags/bulkAssign";
+
+type Params = { params: Promise<{ portfolioId: string; tagId: string }> };
+
+const bodySchema = z.discriminatedUnion("mode", [
+ z.object({
+ mode: z.literal("properties"),
+ action: z.enum(["add", "remove"]),
+ propertyIds: z.array(z.string()).min(1),
+ }),
+ z.object({
+ mode: z.literal("identifiers"),
+ key: z.enum(["landlord_property_id", "uprn"]),
+ identifiers: z.array(z.string()).min(1).max(MAX_BULK_TAG_ROWS),
+ }),
+]);
+
+/**
+ * POST — add/remove Property memberships for one Tag (ADR-0013). `properties`
+ * mode takes explicit ids (the inline popover + row multi-select); `identifiers`
+ * mode matches a Bulk tag assignment upload's landlord ids / UPRNs to Properties
+ * and returns a match summary. All writes are idempotent.
+ */
+export async function POST(req: NextRequest, props: Params) {
+ const session = await getServerSession(AuthOptions);
+ const { portfolioId, tagId } = await props.params;
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(tagId)) {
+ return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ }
+ const pId = BigInt(portfolioId);
+ const tId = BigInt(tagId);
+
+ const role = await getPortfolioRole(pId, session.user.email);
+ if (!canWriteTags(role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // The tag must belong to this portfolio (prevents cross-portfolio writes).
+ const tag = await db
+ .select({ id: portfolioTag.id })
+ .from(portfolioTag)
+ .where(and(eq(portfolioTag.id, tId), eq(portfolioTag.portfolioId, pId)))
+ .limit(1);
+ if (tag.length === 0) {
+ return NextResponse.json({ error: "Tag not found" }, { status: 404 });
+ }
+
+ let body: z.infer;
+ try {
+ body = bodySchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ if (body.mode === "properties") {
+ // Only ids that actually belong to this portfolio (guards spoofed ids).
+ const owned = await db
+ .select({ id: property.id })
+ .from(property)
+ .where(
+ and(
+ eq(property.portfolioId, pId),
+ inArray(
+ property.id,
+ body.propertyIds.map((id) => BigInt(id)),
+ ),
+ ),
+ );
+ const ids = owned.map((o) => o.id);
+ if (ids.length === 0) return NextResponse.json({ changed: 0 });
+
+ if (body.action === "add") {
+ const inserted = await db
+ .insert(propertyTag)
+ .values(ids.map((propertyId) => ({ propertyId, tagId: tId })))
+ .onConflictDoNothing()
+ .returning({ propertyId: propertyTag.propertyId });
+ return NextResponse.json({ changed: inserted.length });
+ }
+ const removed = await db
+ .delete(propertyTag)
+ .where(and(eq(propertyTag.tagId, tId), inArray(propertyTag.propertyId, ids)))
+ .returning({ propertyId: propertyTag.propertyId });
+ return NextResponse.json({ changed: removed.length });
+ }
+
+ // identifiers mode — Bulk tag assignment (add only) with a match summary.
+ const wanted = [...new Set(body.identifiers.map((s) => s.trim()).filter(Boolean))];
+ const matches = await db
+ .select({
+ id: property.id,
+ landlordPropertyId: property.landlordPropertyId,
+ uprn: property.uprn,
+ })
+ .from(property)
+ .where(
+ and(
+ eq(property.portfolioId, pId),
+ body.key === "uprn"
+ ? inArray(
+ property.uprn,
+ wanted
+ .filter((v) => /^\d+$/.test(v))
+ .map((v) => BigInt(v)),
+ )
+ : inArray(property.landlordPropertyId, wanted),
+ ),
+ );
+
+ const matchedValues = new Set(
+ matches.map((m) =>
+ body.key === "uprn" ? String(m.uprn) : String(m.landlordPropertyId),
+ ),
+ );
+ const unmatched = wanted.filter((v) => !matchedValues.has(v));
+ const matchedPropertyIds = matches.map((m) => m.id);
+
+ let taggedCount = 0;
+ if (matchedPropertyIds.length > 0) {
+ const inserted = await db
+ .insert(propertyTag)
+ .values(matchedPropertyIds.map((propertyId) => ({ propertyId, tagId: tId })))
+ .onConflictDoNothing()
+ .returning({ propertyId: propertyTag.propertyId });
+ taggedCount = inserted.length;
+ }
+
+ return NextResponse.json({
+ matchedPropertyCount: matchedPropertyIds.length,
+ taggedCount,
+ alreadyTaggedCount: matchedPropertyIds.length - taggedCount,
+ unmatchedCount: unmatched.length,
+ unmatchedIdentifiers: unmatched.slice(0, 100),
+ });
+}
diff --git a/src/app/api/portfolio/[portfolioId]/tags/[tagId]/route.ts b/src/app/api/portfolio/[portfolioId]/tags/[tagId]/route.ts
new file mode 100644
index 00000000..e0990f4d
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/tags/[tagId]/route.ts
@@ -0,0 +1,94 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import { portfolioTag } from "@/app/db/schema/tags";
+import { and, eq, ne } from "drizzle-orm";
+import { z } from "zod";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { validateTagDraft } from "@/lib/tags/model";
+import { canWriteTags, getPortfolioRole } from "@/lib/tags/server";
+
+const editSchema = z.object({ name: z.string(), colour: z.string() });
+
+type Params = { params: Promise<{ portfolioId: string; tagId: string }> };
+
+async function authWrite(portfolioId: string, tagId: string, email: string | undefined) {
+ if (!email) return { error: "Unauthorised", status: 401 as const };
+ if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(tagId)) {
+ return { error: "Invalid id", status: 400 as const };
+ }
+ const role = await getPortfolioRole(BigInt(portfolioId), email);
+ if (!canWriteTags(role)) return { error: "Forbidden", status: 403 as const };
+ return { ok: true as const };
+}
+
+/** PATCH — rename / recolour a tag (write-gated). */
+export async function PATCH(req: NextRequest, props: Params) {
+ const session = await getServerSession(AuthOptions);
+ const { portfolioId, tagId } = await props.params;
+ const auth = await authWrite(portfolioId, tagId, session?.user?.email);
+ if ("error" in auth) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+ const pId = BigInt(portfolioId);
+ const tId = BigInt(tagId);
+
+ let body: z.infer;
+ try {
+ body = editSchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ // Uniqueness is checked against the OTHER tags, so a tag may keep/recase its
+ // own name (ADR-0013).
+ const others = await db
+ .select({ name: portfolioTag.name })
+ .from(portfolioTag)
+ .where(and(eq(portfolioTag.portfolioId, pId), ne(portfolioTag.id, tId)));
+
+ const result = validateTagDraft(body, others.map((o) => o.name));
+ if (!result.ok) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+
+ const updated = await db
+ .update(portfolioTag)
+ .set({ name: result.tag.name, colour: result.tag.colour })
+ .where(and(eq(portfolioTag.id, tId), eq(portfolioTag.portfolioId, pId)))
+ .returning({ id: portfolioTag.id });
+
+ if (updated.length === 0) {
+ return NextResponse.json({ error: "Tag not found" }, { status: 404 });
+ }
+ return NextResponse.json({
+ id: tId.toString(),
+ name: result.tag.name,
+ colour: result.tag.colour,
+ });
+}
+
+/** DELETE — remove a tag; memberships cascade (ADR-0013). */
+export async function DELETE(_req: NextRequest, props: Params) {
+ const session = await getServerSession(AuthOptions);
+ const { portfolioId, tagId } = await props.params;
+ const auth = await authWrite(portfolioId, tagId, session?.user?.email);
+ if ("error" in auth) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status });
+ }
+
+ const deleted = await db
+ .delete(portfolioTag)
+ .where(
+ and(
+ eq(portfolioTag.id, BigInt(tagId)),
+ eq(portfolioTag.portfolioId, BigInt(portfolioId)),
+ ),
+ )
+ .returning({ id: portfolioTag.id });
+
+ if (deleted.length === 0) {
+ return NextResponse.json({ error: "Tag not found" }, { status: 404 });
+ }
+ return NextResponse.json({ success: true });
+}
diff --git a/src/app/api/portfolio/[portfolioId]/tags/route.ts b/src/app/api/portfolio/[portfolioId]/tags/route.ts
new file mode 100644
index 00000000..dad08134
--- /dev/null
+++ b/src/app/api/portfolio/[portfolioId]/tags/route.ts
@@ -0,0 +1,121 @@
+import { db } from "@/app/db/db";
+import { NextRequest, NextResponse } from "next/server";
+import { portfolioTag, propertyTag } from "@/app/db/schema/tags";
+import { eq, sql } from "drizzle-orm";
+import { z } from "zod";
+import { getServerSession } from "next-auth";
+import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
+import { validateTagDraft } from "@/lib/tags/model";
+import { canReadTags, canWriteTags, getPortfolioRole } from "@/lib/tags/server";
+
+const createSchema = z.object({
+ name: z.string(),
+ colour: z.string(),
+});
+
+/** GET — the portfolio's tags with their property counts (ADR-0013). */
+export async function GET(
+ _req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ const { portfolioId } = await props.params;
+ if (!/^\d+$/.test(portfolioId)) {
+ return NextResponse.json({ error: "Invalid portfolio" }, { status: 400 });
+ }
+ const pId = BigInt(portfolioId);
+
+ const role = await getPortfolioRole(pId, session.user.email);
+ if (!canReadTags(role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const rows = await db
+ .select({
+ id: portfolioTag.id,
+ name: portfolioTag.name,
+ colour: portfolioTag.colour,
+ propertyCount: sql`count(${propertyTag.propertyId})::int`,
+ })
+ .from(portfolioTag)
+ .leftJoin(propertyTag, eq(propertyTag.tagId, portfolioTag.id))
+ .where(eq(portfolioTag.portfolioId, pId))
+ .groupBy(portfolioTag.id)
+ .orderBy(portfolioTag.name);
+
+ return NextResponse.json(
+ rows.map((r) => ({
+ id: r.id.toString(),
+ name: r.name,
+ colour: r.colour,
+ propertyCount: r.propertyCount,
+ })),
+ );
+}
+
+/** POST — create a tag (write-gated). */
+export async function POST(
+ req: NextRequest,
+ props: { params: Promise<{ portfolioId: string }> },
+) {
+ const session = await getServerSession(AuthOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
+ }
+ const { portfolioId } = await props.params;
+ if (!/^\d+$/.test(portfolioId)) {
+ return NextResponse.json({ error: "Invalid portfolio" }, { status: 400 });
+ }
+ const pId = BigInt(portfolioId);
+
+ const role = await getPortfolioRole(pId, session.user.email);
+ if (!canWriteTags(role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ let body: z.infer;
+ try {
+ body = createSchema.parse(await req.json());
+ } catch {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+
+ const existing = await db
+ .select({ name: portfolioTag.name })
+ .from(portfolioTag)
+ .where(eq(portfolioTag.portfolioId, pId));
+
+ const result = validateTagDraft(body, existing.map((e) => e.name));
+ if (!result.ok) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+
+ try {
+ const [inserted] = await db
+ .insert(portfolioTag)
+ .values({
+ portfolioId: pId,
+ name: result.tag.name,
+ colour: result.tag.colour,
+ })
+ .returning({ id: portfolioTag.id });
+ return NextResponse.json(
+ {
+ id: inserted.id.toString(),
+ name: result.tag.name,
+ colour: result.tag.colour,
+ propertyCount: 0,
+ },
+ { status: 201 },
+ );
+ } catch {
+ // The case-insensitive unique index is the ultimate guard (racing create).
+ return NextResponse.json(
+ { error: "A tag with this name already exists" },
+ { status: 409 },
+ );
+ }
+}
diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts
index 5bd39f8c..b37789bc 100644
--- a/src/lib/modellingRuns/model.test.ts
+++ b/src/lib/modellingRuns/model.test.ts
@@ -46,6 +46,24 @@ describe("normaliseRunFilters", () => {
});
});
+describe("normaliseRunFilters — tags", () => {
+ it("normalises tag ids to a deduped, ascending number list", () => {
+ expect(normaliseRunFilters({ tagIds: [3, "1", 3, 2] })).toEqual({
+ ok: true,
+ filters: { tagIds: [1, 2, 3] },
+ });
+ });
+
+ it("rejects a non-numeric or non-positive tag id", () => {
+ expect(normaliseRunFilters({ tagIds: [1, "x"] }).ok).toBe(false);
+ expect(normaliseRunFilters({ tagIds: [0] }).ok).toBe(false);
+ });
+
+ it("treats an absent/empty tag selection as unconstrained (no key)", () => {
+ expect(normaliseRunFilters({ tagIds: [] })).toEqual({ ok: true, filters: {} });
+ });
+});
+
describe("buildRunRecord", () => {
it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => {
const record = buildRunRecord({
@@ -76,6 +94,18 @@ describe("buildRunRecord", () => {
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
});
});
+
+ it("carries tag ids into the task inputs, the dispatch payload, and back (ADR-0013)", () => {
+ const { task, dispatchPayload } = buildRunRecord({
+ portfolioId: "5",
+ scenarioIds: ["2"],
+ filters: { tagIds: [7, 3] },
+ previewedPropertyCount: 10,
+ triggeredBy: "u@x.io",
+ });
+ expect(dispatchPayload.filters).toEqual({ tag_ids: [7, 3] });
+ expect(parseRunConfig(task.inputs)?.filters.tagIds).toEqual([7, 3]);
+ });
});
describe("parseRunConfig", () => {
@@ -113,6 +143,11 @@ describe("selectionSummary", () => {
}),
).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown");
});
+
+ it("summarises a tag selection as a count (names aren't in the pure filters)", () => {
+ expect(selectionSummary({ tagIds: [7, 3] })).toBe("Tags: 2");
+ expect(selectionSummary({ tagIds: [7] })).toBe("Tags: 1");
+ });
});
describe("deriveRunStatus", () => {
diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts
index 179af4a7..103858f9 100644
--- a/src/lib/modellingRuns/model.ts
+++ b/src/lib/modellingRuns/model.ts
@@ -21,6 +21,8 @@ export interface RunFilters {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
+ /** Portfolio Tag ids (any-of); resolved against property_tag (ADR-0013). */
+ tagIds?: number[];
}
export type NormaliseFiltersResult =
@@ -34,6 +36,7 @@ export function normaliseRunFilters(input: {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
+ tagIds?: (number | string)[];
}): NormaliseFiltersResult {
const filters: RunFilters = {};
if (input.postcodes?.length) {
@@ -65,6 +68,20 @@ export function normaliseRunFilters(input: {
}
filters[key] = [...new Set(values)];
}
+ // Tags are dynamic (portfolio-specific), so there's no vocabulary to check
+ // against — just coerce to positive integer ids, dedupe and sort. Ids that
+ // don't belong to the portfolio simply match nothing at resolution time.
+ if (input.tagIds?.length) {
+ const ids = new Set();
+ for (const raw of input.tagIds) {
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n <= 0) {
+ return { ok: false, error: `Invalid tag '${raw}'` };
+ }
+ ids.add(n);
+ }
+ filters.tagIds = [...ids].sort((a, b) => a - b);
+ }
return { ok: true, filters };
}
@@ -91,6 +108,7 @@ export function parseRunConfig(inputs: string | null): RunConfigView | null {
if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes;
if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types;
if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms;
+ if (raw.filters?.tag_ids?.length) filters.tagIds = raw.filters.tag_ids;
return {
scenarioIds: raw.scenario_ids.map(String),
filters,
@@ -167,6 +185,9 @@ export function selectionSummary(filters: RunFilters): string {
if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`);
if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`);
if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`);
+ // Tag names aren't carried in the pure filters (only ids), so summarise as a
+ // count; the run-history UI resolves names separately when it needs them.
+ if (filters.tagIds?.length) bits.push(`Tags: ${filters.tagIds.length}`);
return bits.length ? bits.join(" · ") : "All properties";
}
@@ -186,6 +207,7 @@ export interface RunConfigInputs {
postcodes?: string[];
property_types?: string[];
built_forms?: string[];
+ tag_ids?: number[];
};
previewed_property_count: number;
triggered_by: string;
@@ -212,6 +234,7 @@ export function buildRunRecord(req: RunRequest): {
if (req.filters.postcodes) filters.postcodes = req.filters.postcodes;
if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes;
if (req.filters.builtForms) filters.built_forms = req.filters.builtForms;
+ if (req.filters.tagIds) filters.tag_ids = req.filters.tagIds;
const configInputs: RunConfigInputs = {
portfolio_id: Number(req.portfolioId),
scenario_ids: req.scenarioIds.map(Number),
diff --git a/src/lib/tags/bulkAssign.test.ts b/src/lib/tags/bulkAssign.test.ts
new file mode 100644
index 00000000..a93c1a1a
--- /dev/null
+++ b/src/lib/tags/bulkAssign.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { parseTagIdentifierRows, MAX_BULK_TAG_ROWS } from "./bulkAssign";
+
+describe("parseTagIdentifierRows", () => {
+ it("extracts landlord property ids from a landlord_property_id column", () => {
+ const rows = [["landlord_property_id"], ["ABC-001"], ["ABC-002"]];
+ expect(parseTagIdentifierRows(rows)).toEqual({
+ ok: true,
+ key: "landlord_property_id",
+ identifiers: ["ABC-001", "ABC-002"],
+ });
+ });
+
+ it("detects a UPRN column (header normalised case-insensitively)", () => {
+ const rows = [["UPRN"], [100023336956], [100023336957]];
+ expect(parseTagIdentifierRows(rows)).toEqual({
+ ok: true,
+ key: "uprn",
+ identifiers: ["100023336956", "100023336957"],
+ });
+ });
+
+ it("normalises a spaced/cased header and trims + dedupes + drops blank ids", () => {
+ const rows = [
+ ["Landlord Property ID"],
+ [" ABC-001 "],
+ ["ABC-001"],
+ [""],
+ ["ABC-002"],
+ ];
+ expect(parseTagIdentifierRows(rows)).toEqual({
+ ok: true,
+ key: "landlord_property_id",
+ identifiers: ["ABC-001", "ABC-002"],
+ });
+ });
+
+ it("errors when neither identifier column is present", () => {
+ const rows = [["address", "postcode"], ["12 Oak St", "SW1"]];
+ expect(parseTagIdentifierRows(rows)).toEqual({
+ ok: false,
+ error:
+ "The file needs a 'landlord_property_id' or 'uprn' column in the first row",
+ });
+ });
+
+ it("errors on an empty file (no data rows)", () => {
+ expect(parseTagIdentifierRows([["uprn"]])).toEqual({
+ ok: false,
+ error: "The file has no rows to assign",
+ });
+ });
+
+ it("errors when the file exceeds the row cap", () => {
+ const rows: (string | number)[][] = [["uprn"]];
+ for (let i = 0; i < MAX_BULK_TAG_ROWS + 1; i++) rows.push([`1000${i}`]);
+ const result = parseTagIdentifierRows(rows);
+ expect(result.ok).toBe(false);
+ expect(result.ok === false && result.error).toContain(
+ String(MAX_BULK_TAG_ROWS),
+ );
+ });
+});
diff --git a/src/lib/tags/bulkAssign.ts b/src/lib/tags/bulkAssign.ts
new file mode 100644
index 00000000..10cd1cb0
--- /dev/null
+++ b/src/lib/tags/bulkAssign.ts
@@ -0,0 +1,61 @@
+/**
+ * Pure parsing for Bulk tag assignment (ADR-0013). The client reads the uploaded
+ * CSV/Excel into a 2D array of rows (header row first, via SheetJS) and this
+ * turns it into the identifier column to match on — either `landlord_property_id`
+ * or `uprn`, whichever header the file carries. Kept DB-free so it unit-tests and
+ * runs in the browser before anything is sent to the server.
+ */
+
+/** Upper bound on rows per upload ("relatively small file", ADR-0013). */
+export const MAX_BULK_TAG_ROWS = 10_000;
+
+export type IdentifierKey = "landlord_property_id" | "uprn";
+
+export type ParsedIdentifiers =
+ | { ok: true; key: IdentifierKey; identifiers: string[] }
+ | { ok: false; error: string };
+
+type Cell = string | number | null | undefined;
+
+/** Normalise a header cell: trimmed, lowercased, spaces/hyphens → underscores. */
+function normaliseHeader(cell: Cell): string {
+ return String(cell ?? "")
+ .trim()
+ .toLowerCase()
+ .replace(/[\s-]+/g, "_");
+}
+
+export function parseTagIdentifierRows(rows: Cell[][]): ParsedIdentifiers {
+ const [header, ...dataRows] = rows;
+ const headers = (header ?? []).map(normaliseHeader);
+
+ const keyIndex = headers.findIndex(
+ (h) => h === "landlord_property_id" || h === "uprn",
+ );
+ if (keyIndex === -1) {
+ return {
+ ok: false,
+ error:
+ "The file needs a 'landlord_property_id' or 'uprn' column in the first row",
+ };
+ }
+ const key = headers[keyIndex] as IdentifierKey;
+
+ const seen = new Set();
+ for (const row of dataRows) {
+ const value = String(row[keyIndex] ?? "").trim();
+ if (value.length > 0) seen.add(value);
+ }
+
+ if (seen.size === 0) {
+ return { ok: false, error: "The file has no rows to assign" };
+ }
+ if (seen.size > MAX_BULK_TAG_ROWS) {
+ return {
+ ok: false,
+ error: `The file has more than ${MAX_BULK_TAG_ROWS} properties — split it into smaller files`,
+ };
+ }
+
+ return { ok: true, key, identifiers: [...seen] };
+}
diff --git a/src/lib/tags/server.ts b/src/lib/tags/server.ts
new file mode 100644
index 00000000..8be8cb77
--- /dev/null
+++ b/src/lib/tags/server.ts
@@ -0,0 +1,36 @@
+/**
+ * Server-side helpers shared by the tag routes: portfolio-role lookup and the
+ * write gate. Tags are portfolio-scoped; per ADR-0013 (Q11) any member with a
+ * write-capable role manages and assigns them, read members are view-only.
+ */
+import { db } from "@/app/db/db";
+import { portfolioUsers } from "@/app/db/schema/portfolio";
+import { user } from "@/app/db/schema/users";
+import { and, eq } from "drizzle-orm";
+
+/** Roles allowed to create/edit/delete/assign tags (read is view-only). */
+const WRITE_ROLES = new Set(["creator", "admin", "write"]);
+
+export async function getPortfolioRole(
+ portfolioId: bigint,
+ email: string,
+): Promise {
+ const rows = await db
+ .select({ role: portfolioUsers.role })
+ .from(portfolioUsers)
+ .innerJoin(user, eq(user.id, portfolioUsers.userId))
+ .where(
+ and(eq(portfolioUsers.portfolioId, portfolioId), eq(user.email, email)),
+ )
+ .limit(1);
+ return rows[0]?.role ?? null;
+}
+
+/** A member (any role) may read; only write-capable roles may mutate. */
+export function canReadTags(role: string | null): boolean {
+ return role != null;
+}
+
+export function canWriteTags(role: string | null): boolean {
+ return role != null && WRITE_ROLES.has(role);
+}
From c5d58dbc96df6ff7deed42127194bda9f56f2f10 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 16:23:49 +0000
Subject: [PATCH 05/16] feat(tags): table query + tag filter, filter-driven
bulk assign, preview join, settings UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend, end to end (ADR-0013):
- getProperties now returns each property's tags (name-ordered JSON); the
PropertyWithRelations type gains a `tags: PropertyTag[]` field.
- A "tags" property-table filter (EXISTS/NOT-EXISTS on property_tag, any-of +
an "Untagged" bucket) — PK-indexed by property_id, so no join added.
- getFilteredPropertyIds resolves the whole matching set for filter-driven Bulk
tag assignment; the assignments route's `filter` mode uses it.
- Run filter: previewModellingRun joins property_tag for tag_ids; the two
modelling routes accept tagIds. Distributor ask documented in
docs/backend-asks/tag-ids-run-filter.md.
UI:
- Settings → Tags: create (name + colour), edit, delete-with-count-confirm,
list with property counts (TanStack v4). Sidebar link added.
Typecheck clean; 431 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/backend-asks/tag-ids-run-filter.md | 73 +++++
.../modelling-runs/preview/route.ts | 1 +
.../[portfolioId]/modelling-runs/route.ts | 1 +
.../tags/[tagId]/assignments/route.ts | 96 ++++---
src/app/db/schema/property.ts | 9 +
.../[slug]/(portfolio)/settings/layout.tsx | 8 +-
.../(portfolio)/settings/tags/TagsCard.tsx | 261 ++++++++++++++++++
.../[slug]/(portfolio)/settings/tags/page.tsx | 12 +
src/app/portfolio/[slug]/utils.ts | 89 +++++-
src/app/utils/propertyFilters.ts | 3 +-
src/lib/modellingRuns/server.ts | 7 +
11 files changed, 526 insertions(+), 34 deletions(-)
create mode 100644 docs/backend-asks/tag-ids-run-filter.md
create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx
create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx
diff --git a/docs/backend-asks/tag-ids-run-filter.md b/docs/backend-asks/tag-ids-run-filter.md
new file mode 100644
index 00000000..3212d50c
--- /dev/null
+++ b/docs/backend-asks/tag-ids-run-filter.md
@@ -0,0 +1,73 @@
+# Backend ask — resolve a `tag_ids` Run-filter key
+
+**For:** the distributor (`POST /v1/modelling/trigger-run`)
+**From:** the portfolio tagging system (app), ADR-0013 (extends ADR-0008)
+**Status:** app side built; backend change outstanding
+
+## What's needed
+
+The modelling **Run filter** gains one optional key, `tag_ids` — a list of
+portfolio Tag ids. The distributor already receives
+`{ task_id, portfolio_id, scenario_ids, filters }` and resolves `filters`
+against the shared Postgres. It must now also resolve `tag_ids`.
+
+```jsonc
+// dispatch payload (unchanged except the new optional filters key)
+{
+ "portfolio_id": 814,
+ "scenario_ids": [12, 34],
+ "filters": {
+ "postcodes": ["B93 8SU"], // existing
+ "property_types": ["House"], // existing
+ "built_forms": ["Detached"], // existing
+ "tag_ids": [7, 3] // NEW — optional
+ }
+}
+```
+
+## Resolution rule
+
+Tags are a **direct membership** lookup, not the override→EPC precedence used for
+type/built-form. A property matches `tag_ids` when it has a row in
+`property_tag` for any of the ids (**any-of**). Combine with the other filter
+dimensions the same way they combine today: **AND across dimensions, OR within**.
+
+```sql
+-- add to the property-matching WHERE, only when tag_ids is present:
+p.id IN (
+ SELECT pt.property_id FROM property_tag pt
+ WHERE pt.tag_id IN (:tag_ids)
+)
+```
+
+Tables (already migrated — see PR #387 / migration `0271`):
+
+- `portfolio_tag(id, portfolio_id, name, colour, …)`
+- `property_tag(property_id, tag_id)` — PK `(property_id, tag_id)`, index
+ `ix_property_tag_tag` on `tag_id` for exactly this reverse lookup.
+
+Ids that don't belong to the portfolio simply match nothing (no validation
+needed). An absent/empty `tag_ids` means unconstrained, like the other keys.
+
+## Reference implementation (must stay in lock-step)
+
+The app's in-app **preview count** is the reference implementation ADR-0008
+requires — the distributor's resolution must match it. See
+`previewModellingRun` in `src/lib/modellingRuns/server.ts`: the tag clause is
+
+```ts
+conditions.push(
+ sql`resolved.id IN (SELECT pt.property_id FROM property_tag pt
+ WHERE pt.tag_id IN (${inList(args.filters.tagIds)}))`,
+);
+```
+
+Divergence between the app preview and the distributor is defined as a bug
+(ADR-0008).
+
+## Until this lands
+
+The run UI can show and preview a Tag selection (the app resolves the preview),
+but a triggered run won't scope by tags until the distributor resolves
+`tag_ids`. No contract-shape change is needed on either side beyond reading the
+new key.
diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts
index 4817d91c..fd5af586 100644
--- a/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/preview/route.ts
@@ -12,6 +12,7 @@ const bodySchema = z.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
+ tagIds: z.array(z.union([z.string(), z.number()])).optional(),
})
.default({}),
});
diff --git a/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts b/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts
index b62f8078..46f70890 100644
--- a/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts
@@ -12,6 +12,7 @@ const filtersSchema = z.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
+ tagIds: z.array(z.union([z.string(), z.number()])).optional(),
});
const bodySchema = z.object({
diff --git a/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts b/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts
index 92370a44..64db0dba 100644
--- a/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts
+++ b/src/app/api/portfolio/[portfolioId]/tags/[tagId]/assignments/route.ts
@@ -8,15 +8,27 @@ import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { canWriteTags, getPortfolioRole } from "@/lib/tags/server";
import { MAX_BULK_TAG_ROWS } from "@/lib/tags/bulkAssign";
+import { getFilteredPropertyIds } from "@/app/portfolio/[slug]/utils";
+import type { FilterGroups } from "@/app/utils/propertyFilters";
type Params = { params: Promise<{ portfolioId: string; tagId: string }> };
+/** Batch size for membership writes — keeps a whole-portfolio op under the bind-param cap. */
+const CHUNK = 5000;
+
const bodySchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("properties"),
action: z.enum(["add", "remove"]),
propertyIds: z.array(z.string()).min(1),
}),
+ z.object({
+ mode: z.literal("filter"),
+ action: z.enum(["add", "remove"]),
+ // The property-table filter groups; resolved to ids server-side so the whole
+ // matching set is tagged, not just the visible page (ADR-0013).
+ filterGroups: z.array(z.unknown()),
+ }),
z.object({
mode: z.literal("identifiers"),
key: z.enum(["landlord_property_id", "uprn"]),
@@ -64,39 +76,60 @@ export async function POST(req: NextRequest, props: Params) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
- if (body.mode === "properties") {
- // Only ids that actually belong to this portfolio (guards spoofed ids).
- const owned = await db
- .select({ id: property.id })
- .from(property)
- .where(
- and(
- eq(property.portfolioId, pId),
- inArray(
- property.id,
- body.propertyIds.map((id) => BigInt(id)),
+ if (body.mode === "properties" || body.mode === "filter") {
+ let ids: bigint[];
+ if (body.mode === "properties") {
+ // Only ids that actually belong to this portfolio (guards spoofed ids).
+ const owned = await db
+ .select({ id: property.id })
+ .from(property)
+ .where(
+ and(
+ eq(property.portfolioId, pId),
+ inArray(
+ property.id,
+ body.propertyIds.map((id) => BigInt(id)),
+ ),
),
- ),
- );
- const ids = owned.map((o) => o.id);
+ );
+ ids = owned.map((o) => o.id);
+ } else {
+ // Resolve the current property-table filter to its whole matching set.
+ ids = await getFilteredPropertyIds(portfolioId, body.filterGroups as FilterGroups);
+ }
if (ids.length === 0) return NextResponse.json({ changed: 0 });
- if (body.action === "add") {
- const inserted = await db
- .insert(propertyTag)
- .values(ids.map((propertyId) => ({ propertyId, tagId: tId })))
- .onConflictDoNothing()
- .returning({ propertyId: propertyTag.propertyId });
- return NextResponse.json({ changed: inserted.length });
+ // Chunk so a whole-portfolio selection stays under Postgres' bind-param cap.
+ let changed = 0;
+ for (let i = 0; i < ids.length; i += CHUNK) {
+ const batch = ids.slice(i, i + CHUNK);
+ if (body.action === "add") {
+ const inserted = await db
+ .insert(propertyTag)
+ .values(batch.map((propertyId) => ({ propertyId, tagId: tId })))
+ .onConflictDoNothing()
+ .returning({ propertyId: propertyTag.propertyId });
+ changed += inserted.length;
+ } else {
+ const removed = await db
+ .delete(propertyTag)
+ .where(
+ and(eq(propertyTag.tagId, tId), inArray(propertyTag.propertyId, batch)),
+ )
+ .returning({ propertyId: propertyTag.propertyId });
+ changed += removed.length;
+ }
}
- const removed = await db
- .delete(propertyTag)
- .where(and(eq(propertyTag.tagId, tId), inArray(propertyTag.propertyId, ids)))
- .returning({ propertyId: propertyTag.propertyId });
- return NextResponse.json({ changed: removed.length });
+ return NextResponse.json({ changed });
}
// identifiers mode — Bulk tag assignment (add only) with a match summary.
+ if (body.mode !== "identifiers") {
+ return NextResponse.json({ error: "Invalid body" }, { status: 400 });
+ }
+ // Capture the narrowed discriminant in a const — TS widens `let body` back to
+ // the full union inside the closures below (e.g. matches.map).
+ const key = body.key;
const wanted = [...new Set(body.identifiers.map((s) => s.trim()).filter(Boolean))];
const matches = await db
.select({
@@ -108,7 +141,7 @@ export async function POST(req: NextRequest, props: Params) {
.where(
and(
eq(property.portfolioId, pId),
- body.key === "uprn"
+ key === "uprn"
? inArray(
property.uprn,
wanted
@@ -121,20 +154,21 @@ export async function POST(req: NextRequest, props: Params) {
const matchedValues = new Set(
matches.map((m) =>
- body.key === "uprn" ? String(m.uprn) : String(m.landlordPropertyId),
+ key === "uprn" ? String(m.uprn) : String(m.landlordPropertyId),
),
);
const unmatched = wanted.filter((v) => !matchedValues.has(v));
const matchedPropertyIds = matches.map((m) => m.id);
let taggedCount = 0;
- if (matchedPropertyIds.length > 0) {
+ for (let i = 0; i < matchedPropertyIds.length; i += CHUNK) {
+ const batch = matchedPropertyIds.slice(i, i + CHUNK);
const inserted = await db
.insert(propertyTag)
- .values(matchedPropertyIds.map((propertyId) => ({ propertyId, tagId: tId })))
+ .values(batch.map((propertyId) => ({ propertyId, tagId: tId })))
.onConflictDoNothing()
.returning({ propertyId: propertyTag.propertyId });
- taggedCount = inserted.length;
+ taggedCount += inserted.length;
}
return NextResponse.json({
diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts
index c81ff31e..d05097c8 100644
--- a/src/app/db/schema/property.ts
+++ b/src/app/db/schema/property.ts
@@ -416,6 +416,15 @@ export interface PropertyWithRelations extends Record {
co2Emissions: number | null;
mainfuel: string | null;
lexiscore: number | null;
+ // Portfolio Tags on this property (ADR-0013), name-ordered; [] when untagged.
+ tags: PropertyTag[];
+}
+
+/** A Tag as it appears on a property row (id serialised to a string). */
+export interface PropertyTag {
+ id: string;
+ name: string;
+ colour: string;
}
export type NonIntrusiveSurveyNotes = InferModel<
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx
index db1a38bf..11eafddd 100644
--- a/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/layout.tsx
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { SettingsSidebarLink } from "./SettingsSidebarLink";
-import { Settings2, Users, Building2, ScrollText } from "lucide-react";
+import { Settings2, Users, Building2, ScrollText, Tag } from "lucide-react";
export default async function SettingsLayout({
children,
@@ -33,6 +33,12 @@ export default async function SettingsLayout({
>
User Access
+ }
+ >
+ Tags
+
{isDomnaUser && (
{
+ const res = await fetch(`/api/portfolio/${portfolioId}/tags`);
+ if (!res.ok) throw new Error("Failed to load tags");
+ return res.json();
+}
+
+export default function TagsCard({ portfolioId }: { portfolioId: string }) {
+ const queryClient = useQueryClient();
+ const queryKey = ["portfolio-tags", portfolioId];
+
+ const { data: tags = [], isLoading } = useQuery({
+ queryKey,
+ queryFn: () => fetchTags(portfolioId),
+ refetchOnWindowFocus: false,
+ });
+
+ const [name, setName] = useState("");
+ const [colour, setColour] = useState(DEFAULT_COLOUR);
+ const [formError, setFormError] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+
+ const invalidate = () => queryClient.invalidateQueries({ queryKey });
+
+ const createTag = useMutation({
+ mutationFn: async (input: { name: string; colour: string }) => {
+ const res = await fetch(`/api/portfolio/${portfolioId}/tags`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+ const body = await res.json();
+ if (!res.ok) throw new Error(body.error ?? "Failed to create tag");
+ return body;
+ },
+ onSuccess: () => {
+ setName("");
+ setColour(DEFAULT_COLOUR);
+ setFormError(null);
+ invalidate();
+ },
+ onError: (e: Error) => setFormError(e.message),
+ });
+
+ return (
+
+
Tags
+
+ Group properties to run scenarios on a subset or filter your reporting.
+ Assign tags from the property table.
+
+
+ {/* Create */}
+
+ {formError &&
{formError}
}
+
+ {/* List */}
+ {isLoading ? (
+
Loading…
+ ) : tags.length === 0 ? (
+
No tags yet.
+ ) : (
+
+ {tags.map((tag) =>
+ editingId === tag.id ? (
+ {
+ setEditingId(null);
+ invalidate();
+ }}
+ onCancel={() => setEditingId(null)}
+ />
+ ) : (
+ setEditingId(tag.id)}
+ onDeleted={invalidate}
+ />
+ ),
+ )}
+
+ )}
+
+ );
+}
+
+function TagChip({ colour, name }: { colour: string; name: string }) {
+ return (
+
+
+ {name}
+
+ );
+}
+
+function TagRow({
+ portfolioId,
+ tag,
+ onEdit,
+ onDeleted,
+}: {
+ portfolioId: string;
+ tag: Tag;
+ onEdit: () => void;
+ onDeleted: () => void;
+}) {
+ const del = useMutation({
+ mutationFn: async () => {
+ const res = await fetch(`/api/portfolio/${portfolioId}/tags/${tag.id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) throw new Error("Failed to delete tag");
+ },
+ onSuccess: onDeleted,
+ });
+
+ return (
+
+
+
+ {tag.propertyCount} propert{tag.propertyCount === 1 ? "y" : "ies"}
+
+
+
+
+
+
+ );
+}
+
+function EditRow({
+ portfolioId,
+ tag,
+ onDone,
+ onCancel,
+}: {
+ portfolioId: string;
+ tag: Tag;
+ onDone: () => void;
+ onCancel: () => void;
+}) {
+ const [name, setName] = useState(tag.name);
+ const [colour, setColour] = useState(tag.colour);
+ const [error, setError] = useState(null);
+
+ const save = useMutation({
+ mutationFn: async () => {
+ const res = await fetch(`/api/portfolio/${portfolioId}/tags/${tag.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({ name, colour }),
+ });
+ const body = await res.json();
+ if (!res.ok) throw new Error(body.error ?? "Failed to save");
+ return body;
+ },
+ onSuccess: onDone,
+ onError: (e: Error) => setError(e.message),
+ });
+
+ return (
+
+ setColour(e.target.value)}
+ className="h-8 w-9 rounded-md border border-gray-300 bg-white p-0.5 cursor-pointer"
+ aria-label="Tag colour"
+ />
+ setName(e.target.value)}
+ className="flex-1"
+ />
+ {error && {error}}
+
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx
new file mode 100644
index 00000000..d2042156
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx
@@ -0,0 +1,12 @@
+import TagsCard from "./TagsCard";
+
+export default async function TagsSettingsPage(props: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await props.params;
+ return (
+
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts
index 0eb1b88e..c6f5f326 100644
--- a/src/app/portfolio/[slug]/utils.ts
+++ b/src/app/portfolio/[slug]/utils.ts
@@ -656,6 +656,38 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul
if (filter.operator === "num_equals") return sql`${carbon} = ${n}`;
return null;
}
+
+ case "tags": {
+ // Value is a JSON array of selected tag ids, optionally with the synthetic
+ // "__untagged__" bucket. Any-of within tags (ADR-0013). EXISTS on
+ // property_tag is PK-indexed by property_id — no join to add to the count.
+ if (filter.operator !== "enum_one_of") return null;
+ let selected: string[];
+ try {
+ selected = JSON.parse(filter.value);
+ } catch {
+ return null;
+ }
+ if (selected.length === 0) return null;
+
+ const untagged = selected.includes("__untagged__");
+ const ids = selected.filter((s) => /^\d+$/.test(s));
+ const parts: ReturnType[] = [];
+ if (ids.length > 0) {
+ const placeholders = ids.map((id) => sql`${id}::bigint`);
+ parts.push(
+ sql`EXISTS (SELECT 1 FROM property_tag pt WHERE pt.property_id = p.id AND pt.tag_id IN (${sql.join(placeholders, sql`, `)}))`,
+ );
+ }
+ if (untagged) {
+ parts.push(
+ sql`NOT EXISTS (SELECT 1 FROM property_tag pt WHERE pt.property_id = p.id)`,
+ );
+ }
+ if (parts.length === 0) return null;
+ if (parts.length === 1) return parts[0];
+ return sql`(${sql.join(parts, sql` OR `)})`;
+ }
}
return null;
}
@@ -748,6 +780,49 @@ export async function getPropertiesCount(
return parseInt(result.rows[0]?.count ?? "0", 10);
}
+/**
+ * The ids of every Property matching a filter selection — used by filter-driven
+ * Bulk tag assignment ("tag all N matching the current filter", ADR-0013). Same
+ * resolution + conditional joins as getPropertiesCount, but returns ids across
+ * the whole matching set, not a display page. Mirrors the table's `uprn IS NOT
+ * NULL` exclusion so the count the user saw and the set that gets tagged agree.
+ */
+export async function getFilteredPropertyIds(
+ portfolioId: string,
+ filterGroups: FilterGroups = [],
+): Promise {
+ const combinedWhere = buildWhereClause(filterGroups);
+ const fields = filterFieldsInUse(filterGroups);
+ const needsEpcJoins = [...fields].some((f) => EPC_JOIN_FILTER_FIELDS.has(f));
+ const needsPlanJoin = [...fields].some((f) => PLAN_JOIN_FILTER_FIELDS.has(f));
+
+ const epcJoins = needsEpcJoins
+ ? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id
+ ${newApproachJoins}`
+ : sql``;
+ const planJoin = needsPlanJoin
+ ? sql`LEFT JOIN LATERAL (
+ SELECT id, post_sap_points FROM plan
+ WHERE property_id = p.id
+ AND portfolio_id = p.portfolio_id
+ AND is_default = true
+ ORDER BY created_at DESC
+ LIMIT 1
+ ) pl ON true`
+ : sql``;
+
+ const result = await db.execute<{ id: string }>(sql`
+ SELECT DISTINCT p.id AS id
+ FROM property p
+ ${epcJoins}
+ ${planJoin}
+ WHERE p.portfolio_id = ${portfolioId}
+ AND p.uprn IS NOT NULL
+ ${combinedWhere}
+ `);
+ return result.rows.map((r) => BigInt(r.id));
+}
+
export async function getProperties(
portfolioId: string,
limit: number = 1000,
@@ -798,7 +873,19 @@ export async function getProperties(
${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea",
${carbonSql(sql`epc`)} AS "co2Emissions",
${mainfuelSql(sql`epc`)} AS mainfuel,
- p.lexiscore AS lexiscore
+ p.lexiscore AS lexiscore,
+ -- Portfolio Tags on this property, name-ordered, as JSON (ADR-0013). A
+ -- correlated subquery keeps the main query at one row per property (a JOIN
+ -- would multiply rows and break the LATERAL aggregates above).
+ COALESCE((
+ SELECT json_agg(
+ json_build_object('id', t.id::text, 'name', t.name, 'colour', t.colour)
+ ORDER BY t.name
+ )
+ FROM property_tag pt
+ JOIN portfolio_tag t ON t.id = pt.tag_id
+ WHERE pt.property_id = p.id
+ ), '[]'::json) AS tags
FROM property p
-- LATERAL one-row lookups keep this query at "N properties × index probe"
-- instead of hash-joining the multi-million-row plan/recommendation tables.
diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts
index 1f85675e..d85609ef 100644
--- a/src/app/utils/propertyFilters.ts
+++ b/src/app/utils/propertyFilters.ts
@@ -13,7 +13,8 @@ export type FilterField =
| "provenance"
| "floorArea"
| "co2Emissions"
- | "mainfuel";
+ | "mainfuel"
+ | "tags";
export type FilterOperator =
| "contains"
diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts
index 63f03c31..990915a7 100644
--- a/src/lib/modellingRuns/server.ts
+++ b/src/lib/modellingRuns/server.ts
@@ -141,6 +141,13 @@ export async function previewModellingRun(args: {
if (args.filters.builtForms) {
conditions.push(sql`resolved.bform IN (${inList(args.filters.builtForms)})`);
}
+ if (args.filters.tagIds) {
+ // Tags resolve by direct membership (any-of), not the override→EPC rule —
+ // ADR-0013. The distributor mirrors this join against the shared DB.
+ conditions.push(
+ sql`resolved.id IN (SELECT pt.property_id FROM property_tag pt WHERE pt.tag_id IN (${inList(args.filters.tagIds)}))`,
+ );
+ }
const result = await db.execute<{
matched: number;
From b9d7bb4bbad71af78de957de11750173eb64d82b Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 16:52:48 +0000
Subject: [PATCH 06/16] feat(tags): property-table Tags column + inline assign
popover
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Chips with "+N" overflow in a default-visible, toggle-able column; the
cell doubles as an assign popover that toggles membership via
POST …/assignments {mode:"properties"}, optimistic across every cached
property-table page and reconciled on settle. Freeform-hex chips get an
auto-contrast foreground (new pure colour util, TDD). Shared usePortfolioTags
hook keeps settings/table/filter in sync.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/PropertyTable.tsx | 3 +-
.../portfolio/[slug]/components/TagChip.tsx | 50 +++++
.../portfolio/[slug]/components/TagsCell.tsx | 208 ++++++++++++++++++
.../components/propertyTableColumns.tsx | 11 +
.../portfolio/[slug]/components/useTags.ts | 45 ++++
src/lib/tags/colour.test.ts | 45 ++++
src/lib/tags/colour.ts | 73 ++++++
7 files changed, 434 insertions(+), 1 deletion(-)
create mode 100644 src/app/portfolio/[slug]/components/TagChip.tsx
create mode 100644 src/app/portfolio/[slug]/components/TagsCell.tsx
create mode 100644 src/app/portfolio/[slug]/components/useTags.ts
create mode 100644 src/lib/tags/colour.test.ts
create mode 100644 src/lib/tags/colour.ts
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index b5a87197..b822fbf5 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -322,7 +322,8 @@ export default function PropertyTable({
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
- init[id] = false;
+ // Tags ship visible by default (the headline of ADR-0013); the rest hidden.
+ init[id] = id === "tags";
});
return init;
},
diff --git a/src/app/portfolio/[slug]/components/TagChip.tsx b/src/app/portfolio/[slug]/components/TagChip.tsx
new file mode 100644
index 00000000..1ef68862
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/TagChip.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { X } from "lucide-react";
+import { tagChipStyle } from "@/lib/tags/colour";
+
+/**
+ * A Tag chip: solid fill in the Tag's freeform hex, with auto-contrast text so
+ * any colour stays legible (ADR-0013). Shared across the property table, the
+ * tags filter, and the run UI so every surface reads as one system.
+ */
+export function TagChip({
+ name,
+ colour,
+ size = "sm",
+ onRemove,
+ title,
+}: {
+ name: string;
+ colour: string;
+ size?: "xs" | "sm";
+ onRemove?: () => void;
+ title?: string;
+}) {
+ const sizing =
+ size === "xs"
+ ? "px-1.5 py-0.5 text-[10px]"
+ : "px-2 py-0.5 text-[11px]";
+ return (
+
+ {name}
+ {onRemove && (
+
+ )}
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/components/TagsCell.tsx b/src/app/portfolio/[slug]/components/TagsCell.tsx
new file mode 100644
index 00000000..5582426c
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/TagsCell.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Check, Plus, Tag as TagIcon } from "lucide-react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/app/shadcn_components/ui/popover";
+import { PropertyWithRelations, PropertyTag } from "@/app/db/schema/property";
+import { tagChipStyle } from "@/lib/tags/colour";
+import { TagChip } from "./TagChip";
+import {
+ Tag,
+ usePortfolioTags,
+ portfolioTagsKey,
+} from "./useTags";
+import type { PropertiesResponse } from "./useProperties";
+
+/** How many chips to show inline before collapsing the rest into "+N". */
+const MAX_VISIBLE_CHIPS = 2;
+
+type AssignVars = { tag: Tag; action: "add" | "remove" };
+
+/**
+ * The property table's Tags cell (ADR-0013): shows the property's Tags as chips
+ * with a "+N" overflow, and an assign popover that toggles membership against
+ * POST …/assignments {mode:"properties"}. Writes are optimistic across every
+ * cached property-table query for this portfolio, reconciled on settle.
+ */
+export function TagsCell({ property }: { property: PropertyWithRelations }) {
+ const portfolioId = String(property.portfolioId);
+ const propertyId = String(property.id);
+ const tags = property.tags ?? [];
+
+ const [open, setOpen] = useState(false);
+ const queryClient = useQueryClient();
+ const { data: allTags = [], isLoading, isError } = usePortfolioTags(portfolioId);
+
+ const assignedIds = new Set(tags.map((t) => t.id));
+
+ const assign = useMutation({
+ mutationFn: async ({ tag, action }) => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ mode: "properties",
+ action,
+ propertyIds: [propertyId],
+ }),
+ },
+ );
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error ?? "Couldn't update tags");
+ }
+ return res.json();
+ },
+ // Optimistically add/remove the chip on every cached property-table page.
+ onMutate: async ({ tag, action }) => {
+ await queryClient.cancelQueries({ queryKey: ["properties", portfolioId] });
+ const snapshot = queryClient.getQueriesData({
+ queryKey: ["properties", portfolioId],
+ });
+ const chip: PropertyTag = { id: tag.id, name: tag.name, colour: tag.colour };
+ queryClient.setQueriesData(
+ { queryKey: ["properties", portfolioId] },
+ (prev) =>
+ prev
+ ? {
+ ...prev,
+ data: prev.data.map((row) =>
+ String(row.id) === propertyId
+ ? { ...row, tags: nextTags(row.tags ?? [], chip, action) }
+ : row,
+ ),
+ }
+ : prev,
+ );
+ return { snapshot };
+ },
+ onError: (_e, _vars, context) => {
+ context?.snapshot.forEach(([key, data]) =>
+ queryClient.setQueryData(key, data),
+ );
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] });
+ queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
+ },
+ });
+
+ const visible = tags.slice(0, MAX_VISIBLE_CHIPS);
+ const overflow = tags.length - visible.length;
+
+ return (
+
+
+
+
+
+
+
Assign tags
+
+ {property.address ?? "This property"}
+
+
+
+ {isLoading ? (
+
Loading tags…
+ ) : isError ? (
+
Couldn't load tags.
+ ) : allTags.length === 0 ? (
+
+ ) : (
+ allTags.map((t) => {
+ const on = assignedIds.has(t.id);
+ return (
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
+
+/** Add or remove a chip from a property's tag list, keeping it name-ordered. */
+function nextTags(
+ current: PropertyTag[],
+ chip: PropertyTag,
+ action: "add" | "remove",
+): PropertyTag[] {
+ if (action === "remove") return current.filter((t) => t.id !== chip.id);
+ if (current.some((t) => t.id === chip.id)) return current;
+ return [...current, chip].sort((a, b) => a.name.localeCompare(b.name));
+}
diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
index 9d2955d5..4f60a1ae 100644
--- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
+++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
@@ -23,6 +23,7 @@ import {
MAINFUEL_OPTIONS,
} from "@/app/utils/propertyFilters";
import { CurrentEpcTooltip } from "./CurrentEpcTooltip";
+import { TagsCell } from "./TagsCell";
/* -----------------------------------------------------------------------
Helpers
@@ -166,6 +167,7 @@ export function DataTableFilterHeader({
Column metadata
------------------------------------------------------------------------ */
export const OPTIONAL_COLUMN_IDS = [
+ "tags",
"propertyType",
"builtForm",
"tenure",
@@ -178,6 +180,7 @@ export const OPTIONAL_COLUMN_IDS = [
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
const OPTIONAL_COLUMN_LABELS: Record = {
+ tags: "Tags",
propertyType: "Property Type",
builtForm: "Built Form",
tenure: "Tenure",
@@ -235,6 +238,14 @@ const coreColumns: ColumnDef[] = [
),
},
+ {
+ id: "tags",
+ // Toggle-able (in OPTIONAL_COLUMN_IDS) but default-visible — see PropertyTable.
+ // Not sortable/filterable here; the tags filter lives in PropertyFilters.
+ enableSorting: false,
+ header: () => Tags
,
+ cell: ({ row }) => ,
+ },
{
accessorKey: "currentEpc",
header: () => (
diff --git a/src/app/portfolio/[slug]/components/useTags.ts b/src/app/portfolio/[slug]/components/useTags.ts
new file mode 100644
index 00000000..582f48c9
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/useTags.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+
+/** A portfolio Tag as returned by GET /api/portfolio/[id]/tags (ADR-0013). */
+export interface Tag {
+ id: string;
+ name: string;
+ colour: string;
+ propertyCount: number;
+}
+
+/** Shared query key so every tag surface (settings, table, filter) stays in sync. */
+export function portfolioTagsKey(portfolioId: string) {
+ return ["portfolio-tags", portfolioId] as const;
+}
+
+export async function fetchTags(portfolioId: string): Promise {
+ const res = await fetch(`/api/portfolio/${portfolioId}/tags`);
+ if (!res.ok) throw new Error("Failed to load tags");
+ return res.json();
+}
+
+/**
+ * The portfolio's Tags, deduped across every component that reads them (chips,
+ * assign popover, filter, run UI) via a shared query key. TanStack v4 — reads
+ * with `isLoading`.
+ */
+export function usePortfolioTags(portfolioId: string) {
+ return useQuery({
+ queryKey: portfolioTagsKey(portfolioId),
+ queryFn: () => fetchTags(portfolioId),
+ refetchOnWindowFocus: false,
+ staleTime: 60_000,
+ });
+}
+
+/** Invalidate the tags list + the property table so freshly-assigned chips show. */
+export function useInvalidateTagSurfaces(portfolioId: string) {
+ const queryClient = useQueryClient();
+ return () => {
+ queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
+ queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] });
+ };
+}
diff --git a/src/lib/tags/colour.test.ts b/src/lib/tags/colour.test.ts
new file mode 100644
index 00000000..6dae9d09
--- /dev/null
+++ b/src/lib/tags/colour.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { readableTextColour, tagChipStyle } from "./colour";
+
+describe("readableTextColour", () => {
+ it("returns dark text on a light background", () => {
+ expect(readableTextColour("#ffffff")).toBe("#1f2937");
+ expect(readableTextColour("#fde047")).toBe("#1f2937"); // bright yellow
+ });
+
+ it("returns white text on a dark background", () => {
+ expect(readableTextColour("#000000")).toBe("#ffffff");
+ expect(readableTextColour("#7c3aed")).toBe("#ffffff"); // violet
+ });
+
+ it("accepts 3-digit hex", () => {
+ expect(readableTextColour("#fff")).toBe("#1f2937");
+ expect(readableTextColour("#000")).toBe("#ffffff");
+ });
+
+ it("is case-insensitive and tolerates surrounding whitespace", () => {
+ expect(readableTextColour(" #7C3AED ")).toBe("#ffffff");
+ });
+
+ it("falls back to dark text for an unparseable colour", () => {
+ expect(readableTextColour("not-a-colour")).toBe("#1f2937");
+ });
+});
+
+describe("tagChipStyle", () => {
+ it("builds a solid chip with an auto-contrast foreground", () => {
+ expect(tagChipStyle("#7c3aed")).toEqual({
+ backgroundColor: "#7c3aed",
+ color: "#ffffff",
+ borderColor: "#7c3aed",
+ });
+ });
+
+ it("normalises the hex it echoes back (trim + lowercase)", () => {
+ expect(tagChipStyle(" #FDE047 ")).toEqual({
+ backgroundColor: "#fde047",
+ color: "#1f2937",
+ borderColor: "#fde047",
+ });
+ });
+});
diff --git a/src/lib/tags/colour.ts b/src/lib/tags/colour.ts
new file mode 100644
index 00000000..775f451a
--- /dev/null
+++ b/src/lib/tags/colour.ts
@@ -0,0 +1,73 @@
+/**
+ * Pure colour helpers for rendering Tag chips (ADR-0013). A Tag's colour is a
+ * freeform hex, so the foreground text must be chosen per-colour to stay legible
+ * on both pale and vivid backgrounds. Kept DB-free + side-effect-free so it unit
+ * tests cleanly and is shared by every chip surface (table, filter, run UI).
+ */
+
+/** Slate-800 — the dark foreground used on light chip backgrounds. */
+const DARK_TEXT = "#1f2937";
+const LIGHT_TEXT = "#ffffff";
+
+/** Parse `#rgb` / `#rrggbb` (any case, surrounding space) → [r,g,b] 0-255, or null. */
+function parseHex(input: string): [number, number, number] | null {
+ const hex = input.trim().toLowerCase().replace(/^#/, "");
+ const full =
+ hex.length === 3
+ ? hex
+ .split("")
+ .map((c) => c + c)
+ .join("")
+ : hex;
+ if (!/^[0-9a-f]{6}$/.test(full)) return null;
+ return [
+ parseInt(full.slice(0, 2), 16),
+ parseInt(full.slice(2, 4), 16),
+ parseInt(full.slice(4, 6), 16),
+ ];
+}
+
+/** Normalise a hex to lowercase `#rrggbb`, or null if unparseable. */
+export function normaliseHex(input: string): string | null {
+ const rgb = parseHex(input);
+ if (!rgb) return null;
+ return "#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
+}
+
+/**
+ * Relative luminance (WCAG) of a hex colour, 0 (black) → 1 (white). Unparseable
+ * colours are treated as light so the caller falls back to dark text.
+ */
+export function luminance(input: string): number {
+ const rgb = parseHex(input);
+ if (!rgb) return 1;
+ const [r, g, b] = rgb.map((c) => {
+ const s = c / 255;
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+/**
+ * The text colour to lay over a `background` chip: dark on light colours, white
+ * on dark ones. 0.5 is a practical threshold for readable both-ways contrast.
+ */
+export function readableTextColour(background: string): string {
+ return luminance(background) > 0.5 ? DARK_TEXT : LIGHT_TEXT;
+}
+
+export interface ChipStyle {
+ backgroundColor: string;
+ color: string;
+ borderColor: string;
+}
+
+/** Inline style for a solid Tag chip filled with its colour + auto-contrast text. */
+export function tagChipStyle(colour: string): ChipStyle {
+ const bg = normaliseHex(colour) ?? colour.trim().toLowerCase();
+ return {
+ backgroundColor: bg,
+ color: readableTextColour(bg),
+ borderColor: bg,
+ };
+}
From 5abf4a3a106f7c4207e3110ab85d10ddb2cc9d03 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 16:58:19 +0000
Subject: [PATCH 07/16] feat(tags): dynamic Tags filter in the property-table
filter panel
Adds a "Tags" field to PropertyFilters with per-portfolio options fetched
via GET /tags plus a synthetic "Untagged" bucket. Emits enum_one_of with
value = JSON of tag ids (+ "__untagged__"); the server SQL already resolves
this (any-of, ADR-0013). Condition chips label by tag name and render
"(deleted tag)" for dangling ids.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/PropertyFilters.tsx | 181 +++++++++++++++++-
.../[slug]/components/PropertyTable.tsx | 1 +
2 files changed, 178 insertions(+), 4 deletions(-)
diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx
index 7add0920..979c5c77 100644
--- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx
@@ -3,6 +3,8 @@
import React, { useState, useRef, useEffect } from "react";
import { X, Plus, ChevronDown, Check } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
+import { tagChipStyle } from "@/lib/tags/colour";
+import { Tag, usePortfolioTags } from "./useTags";
import {
FilterGroups,
FilterGroup,
@@ -38,8 +40,12 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "floorArea", label: "Floor Area (m²)" },
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
{ value: "mainfuel", label: "Main Fuel" },
+ { value: "tags", label: "Tags" },
];
+/** The synthetic "no tags" bucket the server SQL recognises (ADR-0013). */
+const UNTAGGED_VALUE = "__untagged__";
+
const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "epc_less_than", label: "is worse than" },
{ value: "equals", label: "equals" },
@@ -95,10 +101,14 @@ function isNumericField(field: FilterField) {
return field === "floorArea" || field === "co2Emissions";
}
+function isTagField(field: FilterField) {
+ return field === "tags";
+}
+
function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] {
if (isEpcField(field)) return EPC_OPERATOR_OPTIONS;
if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS;
- if (isEnumField(field)) return ENUM_OPERATOR_OPTIONS;
+ if (isEnumField(field) || isTagField(field)) return ENUM_OPERATOR_OPTIONS;
if (isNumericField(field)) return NUM_OPERATOR_OPTIONS;
return [];
}
@@ -107,9 +117,22 @@ function defaultOperatorForField(field: FilterField): FilterOperator {
return operatorsForField(field)[0]?.value ?? "equals";
}
-function conditionLabel(condition: PropertyFilter): string {
+function conditionLabel(condition: PropertyFilter, tags: Tag[] = []): string {
const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field;
+ if (isTagField(condition.field)) {
+ try {
+ const values: string[] = JSON.parse(condition.value);
+ const byId = new Map(tags.map((t) => [t.id, t.name]));
+ const names = values.map((v) =>
+ v === UNTAGGED_VALUE ? "Untagged" : (byId.get(v) ?? "(deleted tag)"),
+ );
+ return `${fieldLabel} is one of: ${names.join(", ")}`;
+ } catch {
+ return `${fieldLabel} is one of: ${condition.value}`;
+ }
+ }
+
if (isEpcField(condition.field)) {
const opLabel = EPC_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
const value =
@@ -381,6 +404,133 @@ function EnumMultiDropdown({
);
}
+/* -----------------------------------------------------------------------
+ Tag Multi-Select Dropdown (dynamic per-portfolio, id-valued + Untagged)
+------------------------------------------------------------------------ */
+function TagMultiDropdown({
+ tags,
+ selected,
+ onChange,
+}: {
+ tags: Tag[];
+ selected: string[]; // tag ids and/or UNTAGGED_VALUE
+ onChange: (values: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [dropdownStyle, setDropdownStyle] = useState({});
+ const ref = useRef(null);
+ const buttonRef = useRef(null);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ function handleOutside(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ }
+ function handleScroll(e: Event) {
+ if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return;
+ setOpen(false);
+ }
+ if (open) {
+ document.addEventListener("mousedown", handleOutside);
+ window.addEventListener("scroll", handleScroll, true);
+ }
+ return () => {
+ document.removeEventListener("mousedown", handleOutside);
+ window.removeEventListener("scroll", handleScroll, true);
+ };
+ }, [open]);
+
+ function openDropdown() {
+ if (buttonRef.current) {
+ const rect = buttonRef.current.getBoundingClientRect();
+ setDropdownStyle({
+ position: "fixed",
+ top: rect.bottom + 4,
+ left: rect.left,
+ width: Math.max(rect.width, 220),
+ zIndex: 9999,
+ maxHeight: 280,
+ overflowY: "auto",
+ });
+ }
+ setOpen((o) => !o);
+ }
+
+ function toggle(value: string) {
+ onChange(
+ selected.includes(value)
+ ? selected.filter((v) => v !== value)
+ : [...selected, value],
+ );
+ }
+
+ const byId = new Map(tags.map((t) => [t.id, t]));
+ const summary = selected
+ .map((v) => (v === UNTAGGED_VALUE ? "Untagged" : byId.get(v)?.name))
+ .filter(Boolean)
+ .join(", ");
+
+ const options: { value: string; label: string; colour?: string }[] = [
+ ...tags.map((t) => ({ value: t.id, label: t.name, colour: t.colour })),
+ { value: UNTAGGED_VALUE, label: "Untagged" },
+ ];
+
+ return (
+
+
+
+ {open && (
+
+ {tags.length === 0 && (
+
+ No tags yet — filter by Untagged, or create tags in settings.
+
+ )}
+ {options.map((opt) => {
+ const isSelected = selected.includes(opt.value);
+ const untagged = opt.value === UNTAGGED_VALUE;
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
/* -----------------------------------------------------------------------
Number Filter Input
------------------------------------------------------------------------ */
@@ -407,17 +557,19 @@ function NumberFilterInput({
------------------------------------------------------------------------ */
interface AddFilterFormProps {
targetGroupId: string | null; // null = new group
+ tags: Tag[];
onConfirm: (groupId: string | null, condition: PropertyFilter) => void;
onCancel: () => void;
}
-function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProps) {
+function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFormProps) {
const [field, setField] = useState("currentEpc");
const [operator, setOperator] = useState("epc_less_than");
const [epcSelected, setEpcSelected] = useState([]);
const [dateValue, setDateValue] = useState("");
const [preset, setPreset] = useState("expired");
const [enumSelected, setEnumSelected] = useState([]);
+ const [tagSelected, setTagSelected] = useState([]);
const [numValue, setNumValue] = useState("");
function handleFieldChange(newField: FilterField) {
@@ -426,6 +578,7 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
setEpcSelected([]);
setDateValue("");
setEnumSelected([]);
+ setTagSelected([]);
setNumValue("");
}
@@ -436,6 +589,10 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
if (field === "epcExpiryDate") {
return operator === "date_preset" ? preset : dateValue;
}
+ if (isTagField(field)) {
+ // Value = JSON of tag ids (+ optional Untagged bucket); server resolves it.
+ return tagSelected.length > 0 ? JSON.stringify(tagSelected) : "";
+ }
if (isEnumField(field)) {
return enumSelected.length > 0 ? JSON.stringify(enumSelected) : "";
}
@@ -537,6 +694,14 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
/>
)}
+ {isTagField(field) && (
+
+ )}
+
{isNumericField(field) && (
)}
@@ -569,15 +734,17 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
------------------------------------------------------------------------ */
function ConditionRow({
condition,
+ tags,
onRemove,
}: {
condition: PropertyFilter;
+ tags: Tag[];
onRemove: () => void;
}) {
return (
- {conditionLabel(condition)}
+ {conditionLabel(condition, tags)}
@@ -701,6 +872,7 @@ export default function PropertyFilters({
{isAndTarget ? (
setAddMode(null)}
/>
@@ -723,6 +895,7 @@ export default function PropertyFilters({
{addMode === "or" ? (
setAddMode(null)}
/>
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index b822fbf5..c2ccb19a 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -776,6 +776,7 @@ export default function PropertyTable({
Curate Selection
From f98e36ad87573b68bea2d93a096379a3bcb5767d Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 17:14:30 +0000
Subject: [PATCH 08/16] =?UTF-8?q?feat(tags):=20bulk=20tag=20assignment=20?=
=?UTF-8?q?=E2=80=94=20selection,=20filter-driven,=20and=20file=20upload?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds row multi-select to the property table (new checkbox column + stable
id-keyed selection surviving pagination) with a bulk bar that tags the
selection ({mode:"properties"}) or every property matching the active filter
({mode:"filter"}, whole set resolved server-side). Plus a Bulk tag assignment
upload modal: client-side SheetJS → parseTagIdentifierRows → pick a target tag
→ {mode:"identifiers"} → match summary (matched / already-tagged / unmatched).
Selection is filter-scoped and clears on filter change.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.mint.cjs | 3 +
.../[slug]/components/BulkTagUploadModal.tsx | 287 ++++++++++++++++++
.../[slug]/components/PropertyTable.tsx | 24 ++
.../[slug]/components/TagBulkBar.tsx | 206 +++++++++++++
.../portfolio/[slug]/components/dataTable.tsx | 15 +
.../components/propertyTableColumns.tsx | 26 ++
6 files changed, 561 insertions(+)
create mode 100644 .mint.cjs
create mode 100644 src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx
create mode 100644 src/app/portfolio/[slug]/components/TagBulkBar.tsx
diff --git a/.mint.cjs b/.mint.cjs
new file mode 100644
index 00000000..138979f5
--- /dev/null
+++ b/.mint.cjs
@@ -0,0 +1,3 @@
+const fs=require("fs");const {encode}=require("next-auth/jwt");
+const env=Object.fromEntries(fs.readFileSync(".env.local","utf8").split("\n").filter(l=>l.includes("=")).map(l=>{const i=l.indexOf("=");return[l.slice(0,i).trim(),l.slice(i+1).trim().replace(/^["']|["']$/g,"")]}));
+encode({token:{email:"khalim@domna.homes",name:"K",onboarded:true,sub:"verify"},secret:env.NEXTAUTH_SECRET}).then(t=>console.log(t));
diff --git a/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx
new file mode 100644
index 00000000..992f8e14
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import * as XLSX from "xlsx";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/app/shadcn_components/ui/dialog";
+import { Button } from "@/app/shadcn_components/ui/button";
+import { parseTagIdentifierRows, IdentifierKey } from "@/lib/tags/bulkAssign";
+import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
+import { TagChip } from "./TagChip";
+
+interface Parsed {
+ key: IdentifierKey;
+ identifiers: string[];
+}
+
+interface AssignSummary {
+ matchedPropertyCount: number;
+ taggedCount: number;
+ alreadyTaggedCount: number;
+ unmatchedCount: number;
+ unmatchedIdentifiers: string[];
+}
+
+const KEY_LABEL: Record = {
+ landlord_property_id: "Property Ref (landlord id)",
+ uprn: "UPRN",
+};
+
+/**
+ * Bulk tag assignment upload (ADR-0013): reads a small CSV/Excel of identifiers
+ * client-side, parses it with the shared pure parser, lets the user pick a
+ * target Tag, then POSTs {mode:"identifiers"} and renders the match summary.
+ * No FastAPI pipeline — the file matches existing identifiers to Properties.
+ */
+export function BulkTagUploadModal({
+ portfolioId,
+ open,
+ onOpenChange,
+}: {
+ portfolioId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { data: tags = [] } = usePortfolioTags(portfolioId);
+ const invalidate = useInvalidateTagSurfaces(portfolioId);
+
+ const [fileName, setFileName] = useState(null);
+ const [parsed, setParsed] = useState(null);
+ const [parseError, setParseError] = useState(null);
+ const [targetTag, setTargetTag] = useState(null);
+ const [summary, setSummary] = useState(null);
+
+ function reset() {
+ setFileName(null);
+ setParsed(null);
+ setParseError(null);
+ setTargetTag(null);
+ setSummary(null);
+ assign.reset();
+ }
+
+ async function onFile(file: File) {
+ setParseError(null);
+ setParsed(null);
+ setSummary(null);
+ setFileName(file.name);
+ try {
+ const buf = await file.arrayBuffer();
+ const wb = XLSX.read(buf, { type: "array" });
+ const sheet = wb.Sheets[wb.SheetNames[0]];
+ const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as (
+ | string
+ | number
+ | null
+ )[][];
+ const result = parseTagIdentifierRows(rows);
+ if (!result.ok) {
+ setParseError(result.error);
+ return;
+ }
+ setParsed({ key: result.key, identifiers: result.identifiers });
+ } catch {
+ setParseError("Couldn't read that file — is it a valid CSV or Excel?");
+ }
+ }
+
+ const assign = useMutation({
+ mutationFn: async () => {
+ if (!parsed || !targetTag) throw new Error("Pick a file and a tag first");
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/tags/${targetTag.id}/assignments`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ mode: "identifiers",
+ key: parsed.key,
+ identifiers: parsed.identifiers,
+ }),
+ },
+ );
+ const body = await res.json();
+ if (!res.ok) throw new Error(body.error ?? "Couldn't assign the tag");
+ return body;
+ },
+ onSuccess: (data) => {
+ setSummary(data);
+ invalidate();
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index c2ccb19a..27ef002d 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -35,8 +35,10 @@ import {
VisibilityState,
Updater,
PaginationState,
+ RowSelectionState,
} from "@tanstack/react-table";
import { Tooltip } from "./Tooltip";
+import { TagBulkBar } from "./TagBulkBar";
import {
DropdownMenu,
@@ -317,6 +319,10 @@ export default function PropertyTable({
const [openFilter, setOpenFilter] = useState(null);
const [filterGroups, setFilterGroups] = useState([]);
+ // Row selection for bulk tagging — keyed by property id (see DataTable getRowId).
+ const [rowSelection, setRowSelection] = useState({});
+ const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]);
+
// Column visibility — lifted up from DataTable
const [columnVisibility, setColumnVisibility] = useState(
() => {
@@ -447,6 +453,8 @@ export default function PropertyTable({
prevFilterKeyRef.current = filterKey;
if (pagination.pageIndex !== 0)
setPagination((p) => ({ ...p, pageIndex: 0 }));
+ // The selection is filter-scoped — a changed filter invalidates it.
+ if (selectedIds.length > 0) setRowSelection({});
}
const [isFetchingMore, setIsFetchingMore] = useState(false);
@@ -742,6 +750,16 @@ export default function PropertyTable({
/>
+ {/* Bulk tag actions (selection-driven, filter-driven, file upload) */}
+ setRowSelection({})}
+ hasActiveFilters={hasActiveFilters}
+ filterGroups={allFilterGroups}
+ filteredTotal={filteredTotal}
+ />
+
{/* Display-limit notice */}
{isAtDisplayLimit && hasMore && (
@@ -822,6 +840,12 @@ export default function PropertyTable({
onPaginationChange={
setPagination as (updater: Updater
) => void
}
+ rowSelection={rowSelection}
+ onRowSelectionChange={
+ setRowSelection as (
+ updater: Updater,
+ ) => void
+ }
/>
)}
diff --git a/src/app/portfolio/[slug]/components/TagBulkBar.tsx b/src/app/portfolio/[slug]/components/TagBulkBar.tsx
new file mode 100644
index 00000000..5cbaa9c3
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/TagBulkBar.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { ChevronDownIcon, DocumentArrowUpIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/app/shadcn_components/ui/dropdown-menu";
+import { FilterGroups } from "@/app/utils/propertyFilters";
+import { tagChipStyle } from "@/lib/tags/colour";
+import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
+import { BulkTagUploadModal } from "./BulkTagUploadModal";
+
+type Target =
+ | { mode: "properties"; propertyIds: string[] }
+ | { mode: "filter"; filterGroups: FilterGroups };
+
+/**
+ * Bulk tag actions for the property table (ADR-0013): tag the current row
+ * selection ({mode:"properties"}), tag every property matching the active
+ * filter ({mode:"filter"} — the whole set, resolved server-side, not just the
+ * loaded page), and the file-upload entry point. Renders quietly when idle.
+ */
+export function TagBulkBar({
+ portfolioId,
+ selectedIds,
+ onClearSelection,
+ hasActiveFilters,
+ filterGroups,
+ filteredTotal,
+}: {
+ portfolioId: string;
+ selectedIds: string[];
+ onClearSelection: () => void;
+ hasActiveFilters: boolean;
+ filterGroups: FilterGroups;
+ filteredTotal: number;
+}) {
+ const { data: tags = [] } = usePortfolioTags(portfolioId);
+ const invalidate = useInvalidateTagSurfaces(portfolioId);
+ const [uploadOpen, setUploadOpen] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const assign = useMutation<{ changed: number }, Error, { tag: Tag; target: Target }>({
+ mutationFn: async ({ tag, target }) => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...target, action: "add" }),
+ },
+ );
+ const body = await res.json();
+ if (!res.ok) throw new Error(body.error ?? "Couldn't apply the tag");
+ return body;
+ },
+ onSuccess: (data, { tag, target }) => {
+ setResult(
+ `Tagged ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"} with “${tag.name}”.`,
+ );
+ invalidate();
+ if (target.mode === "properties") onClearSelection();
+ },
+ onError: (e) => setResult(e.message),
+ });
+
+ const hasSelection = selectedIds.length > 0;
+
+ return (
+
+ {hasSelection && (
+
+
+ {selectedIds.length.toLocaleString()} selected
+
+
+ assign.mutate({ tag, target: { mode: "properties", propertyIds: selectedIds } })
+ }
+ />
+
+
+ )}
+
+ {hasActiveFilters && (
+
+ assign.mutate({ tag, target: { mode: "filter", filterGroups } })
+ }
+ variant="solid"
+ />
+ )}
+
+
+
+ {result && (
+
+ {result}
+
+
+ )}
+
+
+
+ );
+}
+
+function TagMenu({
+ label,
+ tags,
+ portfolioId,
+ disabled,
+ onPick,
+ variant = "outline",
+}: {
+ label: string;
+ tags: Tag[];
+ portfolioId: string;
+ disabled?: boolean;
+ onPick: (tag: Tag) => void;
+ variant?: "outline" | "solid";
+}) {
+ return (
+
+
+
+
+
+ Apply tag
+
+ {tags.length === 0 ? (
+
+ ) : (
+ tags.map((t) => (
+ onPick(t)}
+ >
+
+ {t.name}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx
index 18acbb79..75e654ef 100644
--- a/src/app/portfolio/[slug]/components/dataTable.tsx
+++ b/src/app/portfolio/[slug]/components/dataTable.tsx
@@ -6,6 +6,7 @@ import {
SortingState,
PaginationState,
VisibilityState,
+ RowSelectionState,
Updater,
flexRender,
getCoreRowModel,
@@ -50,6 +51,9 @@ interface DataTableProps {
// Controlled pagination — when omitted the table manages its own pagination state
pagination?: PaginationState;
onPaginationChange?: (updater: Updater) => void;
+ // Controlled row selection — when omitted, selection is disabled/uncontrolled
+ rowSelection?: RowSelectionState;
+ onRowSelectionChange?: (updater: Updater) => void;
}
export default function DataTable>({
@@ -60,6 +64,8 @@ export default function DataTable>({
onColumnVisibilityChange,
pagination: controlledPagination,
onPaginationChange: controlledOnPaginationChange,
+ rowSelection,
+ onRowSelectionChange,
}: DataTableProps) {
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
@@ -70,22 +76,30 @@ export default function DataTable>({
const pagination = isControlled ? controlledPagination : internalPagination;
const onPaginationChange = isControlled ? controlledOnPaginationChange! : setInternalPagination;
+ const selectionEnabled = rowSelection !== undefined;
+
const table = useReactTable({
data,
columns,
+ // Map selection/row identity to the property's stable id, so a selection
+ // survives pagination and lazy-loaded pages (not row-index keyed).
+ getRowId: (row) => String(row.id),
+
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
autoResetPageIndex: false,
+ enableRowSelection: selectionEnabled,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange,
onColumnVisibilityChange,
+ onRowSelectionChange,
globalFilterFn: fuzzyFilter,
@@ -95,6 +109,7 @@ export default function DataTable>({
globalFilter,
pagination,
columnVisibility,
+ rowSelection: rowSelection ?? {},
},
meta: {
diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
index 4f60a1ae..6630b0c8 100644
--- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
+++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
@@ -24,6 +24,7 @@ import {
} from "@/app/utils/propertyFilters";
import { CurrentEpcTooltip } from "./CurrentEpcTooltip";
import { TagsCell } from "./TagsCell";
+import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
/* -----------------------------------------------------------------------
Helpers
@@ -196,6 +197,31 @@ export { OPTIONAL_COLUMN_LABELS };
Core columns
------------------------------------------------------------------------ */
const coreColumns: ColumnDef[] = [
+ {
+ id: "select",
+ enableSorting: false,
+ enableHiding: false,
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!v)}
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!v)}
+ />
+ ),
+ },
{
accessorKey: "address",
enableGlobalFilter: true,
From 9749e8525b208855388701db55a3814635104873 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 19:00:12 +0000
Subject: [PATCH 09/16] feat(tags): tags multi-select in the Run modelling
filter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a Tags FilterColumn (per-portfolio options from GET /tags, colour dots,
counts) feeding filters.tagIds; folds it into filterCount/clearFilters and
removes the "Tags are coming" copy. The in-app preview already counts by tag
membership, so the selection previews exactly. The run button is guarded while
tags are selected — the distributor can't resolve tag_ids yet (backend ask,
ADR-0013), so previewing is honest but running stays disabled until it lands.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../modelling/run/RunModellingClient.tsx | 119 +++++++++++++-----
1 file changed, 88 insertions(+), 31 deletions(-)
diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
index 3d4808d4..fe3bc0fc 100644
--- a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
+++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
@@ -15,6 +15,8 @@ import {
MAX_FILTER_POSTCODES,
selectionSummary,
} from "@/lib/modellingRuns/model";
+import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags";
+import { tagChipStyle } from "@/lib/tags/colour";
export interface ScenarioOption {
id: string;
@@ -77,11 +79,13 @@ function FilterColumn({
options,
selected,
onToggle,
+ emptyHint,
}: {
title: string;
- options: { value: string; count?: number }[];
+ options: { value: string; label?: string; count?: number; colour?: string }[];
selected: Set;
onToggle: (value: string) => void;
+ emptyHint?: React.ReactNode;
}) {
return (
@@ -94,28 +98,38 @@ function FilterColumn({
);
@@ -135,6 +149,7 @@ export default function RunModellingClient({
const [selPcs, setSelPcs] = useState>(new Set());
const [selTypes, setSelTypes] = useState>(new Set());
const [selForms, setSelForms] = useState>(new Set());
+ const [selTags, setSelTags] = useState>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null);
@@ -142,9 +157,12 @@ export default function RunModellingClient({
...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}),
...(selTypes.size ? { propertyTypes: [...selTypes] } : {}),
...(selForms.size ? { builtForms: [...selForms] } : {}),
+ ...(selTags.size ? { tagIds: [...selTags].map(Number) } : {}),
};
const scenarioIds = [...selScenarios].sort();
+ const { data: tags = [] } = usePortfolioTags(portfolioId);
+
const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({
queryKey: ["portfolio-postcodes", portfolioId],
staleTime: 5 * 60 * 1000,
@@ -219,15 +237,21 @@ export default function RunModellingClient({
const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0);
const scenarioById = new Map(scenarios.map((s) => [s.id, s]));
const startRun = () => {
+ if (tagsBlockRun) return;
if (matched != null && isLargeRun(matched)) setConfirmOpen(true);
else run.mutate();
};
- const filterCount = selPcs.size + selTypes.size + selForms.size;
+ const filterCount = selPcs.size + selTypes.size + selForms.size + selTags.size;
const clearFilters = () => {
setSelPcs(new Set());
setSelTypes(new Set());
setSelForms(new Set());
+ setSelTags(new Set());
};
+ // The distributor can't resolve tag_ids yet (backend ask pending, ADR-0013):
+ // preview counts tags in-app, but an actual run would model the unscoped set.
+ // Guard the run until the backend lands — remove this once it does.
+ const tagsBlockRun = selTags.size > 0;
return (
@@ -376,7 +400,7 @@ export default function RunModellingClient({
takes priority over its EPC; “Unknown” picks out homes where we have
neither.
-
+
{postcodesQuery.isError ? (
@@ -422,10 +446,26 @@ export default function RunModellingClient({
selected={selForms}
onToggle={(v) => toggle(selForms, setSelForms, v)}
/>
+ ({
+ value: t.id,
+ label: t.name,
+ colour: t.colour,
+ count: t.propertyCount,
+ }))}
+ selected={selTags}
+ onToggle={(v) => toggle(selTags, setSelTags, v)}
+ emptyHint={
+ <>
+ No tags yet — create them from the property table to run a
+ scenario over a hand-picked group.
+ >
+ }
+ />
- Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming —
- you'll be able to save a selection and reuse it.
+ Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run.
@@ -560,17 +600,34 @@ export default function RunModellingClient({
+ {tagsBlockRun && (
+
+
+ You can preview a tag selection now, but running scenarios over
+ tags needs a modelling-engine update that's on the way. The
+ count above is exact — the run button unlocks once it lands.
+
+
+ )}
{(remodel.length > 0 || run.isError) && (
{remodel.map((p) => (
From 1d12fa1bafaf6818e668becf08b9f8edf4d723e0 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 19:09:27 +0000
Subject: [PATCH 10/16] refactor(tags): one calm, AA-safe chip vocabulary
across every surface
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Impeccable pass. Replaces the solid full-saturation chips (a heavy-accent
inconsistency vs the settings card's soft chips) with a single quiet vocabulary
per PRODUCT.md: a soft tint of the Tag's freeform hex, a colour dot, and dark
hue-preserving ink whose contrast on the tint is guaranteed ≥4.5:1 for any
colour (new tagChipTokens, TDD — yellow and every hue verified). Settings,
table, filter, bulk bar, upload, and run UI now share one TagChip. Also drops a
stray dev scratch file and gitignores the pattern.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.gitignore | 6 +
.mint.cjs | 3 -
.../(portfolio)/settings/tags/TagsCard.tsx | 13 +--
.../[slug]/components/PropertyFilters.tsx | 4 +-
.../[slug]/components/TagBulkBar.tsx | 4 +-
.../portfolio/[slug]/components/TagChip.tsx | 27 +++--
.../portfolio/[slug]/components/TagsCell.tsx | 4 +-
.../modelling/run/RunModellingClient.tsx | 4 +-
src/lib/tags/colour.test.ts | 105 +++++++++++------
src/lib/tags/colour.ts | 108 ++++++++++++------
10 files changed, 178 insertions(+), 100 deletions(-)
delete mode 100644 .mint.cjs
diff --git a/.gitignore b/.gitignore
index 04f701b5..237fa406 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,9 @@ docs/wip/**
# Personal Claude Code settings (per-developer, not shared)
.claude/settings.local.json
+
+# transient local scratch (dev session helpers)
+.mint.cjs
+.chip-preview.*
+.shot.cjs
+scratch-*.cjs
diff --git a/.mint.cjs b/.mint.cjs
deleted file mode 100644
index 138979f5..00000000
--- a/.mint.cjs
+++ /dev/null
@@ -1,3 +0,0 @@
-const fs=require("fs");const {encode}=require("next-auth/jwt");
-const env=Object.fromEntries(fs.readFileSync(".env.local","utf8").split("\n").filter(l=>l.includes("=")).map(l=>{const i=l.indexOf("=");return[l.slice(0,i).trim(),l.slice(i+1).trim().replace(/^["']|["']$/g,"")]}));
-encode({token:{email:"khalim@domna.homes",name:"K",onboarded:true,sub:"verify"},secret:env.NEXTAUTH_SECRET}).then(t=>console.log(t));
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx
index 47419323..b9c0d4a4 100644
--- a/src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Pencil, Trash2, Check, X, Plus } from "lucide-react";
import { MAX_TAG_NAME_LENGTH } from "@/lib/tags/model";
+import { TagChip } from "@/app/portfolio/[slug]/components/TagChip";
interface Tag {
id: string;
@@ -135,18 +136,6 @@ export default function TagsCard({ portfolioId }: { portfolioId: string }) {
);
}
-function TagChip({ colour, name }: { colour: string; name: string }) {
- return (
-
-
- {name}
-
- );
-}
-
function TagRow({
portfolioId,
tag,
diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx
index 979c5c77..b6551978 100644
--- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx
@@ -3,7 +3,7 @@
import React, { useState, useRef, useEffect } from "react";
import { X, Plus, ChevronDown, Check } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
-import { tagChipStyle } from "@/lib/tags/colour";
+import { tagDotColour } from "@/lib/tags/colour";
import { Tag, usePortfolioTags } from "./useTags";
import {
FilterGroups,
@@ -512,7 +512,7 @@ function TagMultiDropdown({
>
{opt.label}
{t.name}
diff --git a/src/app/portfolio/[slug]/components/TagChip.tsx b/src/app/portfolio/[slug]/components/TagChip.tsx
index 1ef68862..f2bb8f9c 100644
--- a/src/app/portfolio/[slug]/components/TagChip.tsx
+++ b/src/app/portfolio/[slug]/components/TagChip.tsx
@@ -1,12 +1,14 @@
"use client";
import { X } from "lucide-react";
-import { tagChipStyle } from "@/lib/tags/colour";
+import { tagChipTokens } from "@/lib/tags/colour";
/**
- * A Tag chip: solid fill in the Tag's freeform hex, with auto-contrast text so
- * any colour stays legible (ADR-0013). Shared across the property table, the
- * tags filter, and the run UI so every surface reads as one system.
+ * A Tag chip in the app's one calm, AA-safe chip vocabulary (ADR-0013,
+ * PRODUCT.md): a soft tint of the Tag's freeform hex, a colour dot, and dark
+ * hue-preserving ink that stays legible for any colour. Shared across the
+ * property table, tags filter, bulk bar, upload, settings, and run UI so every
+ * surface reads as one system.
*/
export function TagChip({
name,
@@ -21,16 +23,23 @@ export function TagChip({
onRemove?: () => void;
title?: string;
}) {
+ const tokens = tagChipTokens(colour);
const sizing =
size === "xs"
- ? "px-1.5 py-0.5 text-[10px]"
- : "px-2 py-0.5 text-[11px]";
+ ? "gap-1 px-1.5 py-0.5 text-[10px]"
+ : "gap-1.5 px-2 py-0.5 text-[11px]";
+ const dot = size === "xs" ? "h-1.5 w-1.5" : "h-2 w-2";
return (
+
{name}
{onRemove && (
- {/* Bulk tag actions (selection-driven, filter-driven, file upload) */}
-
setTagMode(null)}
+ />
+ )}
+
+ setRowSelection({})}
- hasActiveFilters={hasActiveFilters}
- filterGroups={allFilterGroups}
- filteredTotal={filteredTotal}
+ open={tagUploadOpen}
+ onOpenChange={setTagUploadOpen}
/>
{/* Display-limit notice */}
diff --git a/src/app/portfolio/[slug]/components/TagActionBar.tsx b/src/app/portfolio/[slug]/components/TagActionBar.tsx
new file mode 100644
index 00000000..a5c80771
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/TagActionBar.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/app/shadcn_components/ui/dropdown-menu";
+import { FilterGroups } from "@/app/utils/propertyFilters";
+import { tagDotColour } from "@/lib/tags/colour";
+import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
+
+export type TagMode = "assign" | "remove";
+
+/**
+ * The contextual strip shown while assigning or removing a tag in bulk
+ * (ADR-0013). Its target follows the live table state: the row selection if any,
+ * else every property matching the active filter, else the whole portfolio —
+ * so ticking rows or changing filters re-scopes it in place. Picking a tag adds
+ * (assign) or deletes (remove) memberships server-side and reports the count.
+ */
+export function TagActionBar({
+ portfolioId,
+ mode,
+ selectedIds,
+ hasActiveFilters,
+ filterGroups,
+ filteredTotal,
+ totalCount,
+ onClose,
+}: {
+ portfolioId: string;
+ mode: TagMode;
+ selectedIds: string[];
+ hasActiveFilters: boolean;
+ filterGroups: FilterGroups;
+ filteredTotal: number;
+ totalCount: number;
+ onClose: () => void;
+}) {
+ const { data: tags = [] } = usePortfolioTags(portfolioId);
+ const invalidate = useInvalidateTagSurfaces(portfolioId);
+ const [result, setResult] = useState(null);
+
+ const action = mode === "assign" ? "add" : "remove";
+ const verb = mode === "assign" ? "Assign" : "Remove";
+ const prep = mode === "assign" ? "to" : "from";
+
+ // Target precedence: explicit selection → active filter set → whole portfolio.
+ const target =
+ selectedIds.length > 0
+ ? {
+ body: { mode: "properties" as const, propertyIds: selectedIds },
+ label: `${selectedIds.length.toLocaleString()} selected propert${selectedIds.length === 1 ? "y" : "ies"}`,
+ }
+ : hasActiveFilters
+ ? {
+ body: { mode: "filter" as const, filterGroups },
+ label: `all ${filteredTotal.toLocaleString()} matching your filters`,
+ }
+ : {
+ body: { mode: "filter" as const, filterGroups: [] as FilterGroups },
+ label: `all ${totalCount.toLocaleString()} properties`,
+ };
+
+ const apply = useMutation<{ changed: number }, Error, Tag>({
+ mutationFn: async (tag) => {
+ const res = await fetch(
+ `/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...target.body, action }),
+ },
+ );
+ const body = await res.json();
+ if (!res.ok) throw new Error(body.error ?? "Couldn't update tags");
+ return body;
+ },
+ onSuccess: (data, tag) => {
+ setResult(
+ `${mode === "assign" ? "Added" : "Removed"} “${tag.name}” ${prep} ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"}.`,
+ );
+ invalidate();
+ },
+ onError: (e) => setResult(e.message),
+ });
+
+ return (
+
+
{verb} a tag
+
+ {prep} {target.label}
+ — tick rows or apply filters to change this
+
+
+
+
+
+ {apply.isLoading ? "Applying…" : "Choose a tag"}
+
+
+
+
+
+ {verb} which tag?
+
+
+ {tags.length === 0 ? (
+
+ ) : (
+ tags.map((t) => (
+ apply.mutate(t)}
+ >
+
+ {t.name}
+
+ ))
+ )}
+
+
+
+ {result && (
+
+ {result}
+
+ )}
+
+
+
+ Done
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/components/TagBulkBar.tsx b/src/app/portfolio/[slug]/components/TagBulkBar.tsx
deleted file mode 100644
index 5315f9ee..00000000
--- a/src/app/portfolio/[slug]/components/TagBulkBar.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useMutation } from "@tanstack/react-query";
-import { ChevronDownIcon, DocumentArrowUpIcon, XMarkIcon } from "@heroicons/react/24/outline";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/app/shadcn_components/ui/dropdown-menu";
-import { FilterGroups } from "@/app/utils/propertyFilters";
-import { tagDotColour } from "@/lib/tags/colour";
-import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
-import { BulkTagUploadModal } from "./BulkTagUploadModal";
-
-type Target =
- | { mode: "properties"; propertyIds: string[] }
- | { mode: "filter"; filterGroups: FilterGroups };
-
-/**
- * Bulk tag actions for the property table (ADR-0013): tag the current row
- * selection ({mode:"properties"}), tag every property matching the active
- * filter ({mode:"filter"} — the whole set, resolved server-side, not just the
- * loaded page), and the file-upload entry point. Renders quietly when idle.
- */
-export function TagBulkBar({
- portfolioId,
- selectedIds,
- onClearSelection,
- hasActiveFilters,
- filterGroups,
- filteredTotal,
-}: {
- portfolioId: string;
- selectedIds: string[];
- onClearSelection: () => void;
- hasActiveFilters: boolean;
- filterGroups: FilterGroups;
- filteredTotal: number;
-}) {
- const { data: tags = [] } = usePortfolioTags(portfolioId);
- const invalidate = useInvalidateTagSurfaces(portfolioId);
- const [uploadOpen, setUploadOpen] = useState(false);
- const [result, setResult] = useState(null);
-
- const assign = useMutation<{ changed: number }, Error, { tag: Tag; target: Target }>({
- mutationFn: async ({ tag, target }) => {
- const res = await fetch(
- `/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...target, action: "add" }),
- },
- );
- const body = await res.json();
- if (!res.ok) throw new Error(body.error ?? "Couldn't apply the tag");
- return body;
- },
- onSuccess: (data, { tag, target }) => {
- setResult(
- `Tagged ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"} with “${tag.name}”.`,
- );
- invalidate();
- if (target.mode === "properties") onClearSelection();
- },
- onError: (e) => setResult(e.message),
- });
-
- const hasSelection = selectedIds.length > 0;
-
- return (
-
- {hasSelection && (
-
-
- {selectedIds.length.toLocaleString()} selected
-
-
- assign.mutate({ tag, target: { mode: "properties", propertyIds: selectedIds } })
- }
- />
-
-
- Clear
-
-
- )}
-
- {hasActiveFilters && (
-
- assign.mutate({ tag, target: { mode: "filter", filterGroups } })
- }
- variant="solid"
- />
- )}
-
- setUploadOpen(true)}
- className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-primary transition hover:bg-slate-50"
- >
-
- Assign tags from file
-
-
- {result && (
-
- {result}
- setResult(null)}
- className="rounded hover:text-emerald-950"
- aria-label="Dismiss"
- >
-
-
-
- )}
-
-
-
- );
-}
-
-function TagMenu({
- label,
- tags,
- portfolioId,
- disabled,
- onPick,
- variant = "outline",
-}: {
- label: string;
- tags: Tag[];
- portfolioId: string;
- disabled?: boolean;
- onPick: (tag: Tag) => void;
- variant?: "outline" | "solid";
-}) {
- return (
-
-
-
- {label}
-
-
-
-
- Apply tag
-
- {tags.length === 0 ? (
-
- ) : (
- tags.map((t) => (
- onPick(t)}
- >
-
- {t.name}
-
- ))
- )}
-
-
- );
-}
diff --git a/src/app/portfolio/[slug]/components/TagsMenu.tsx b/src/app/portfolio/[slug]/components/TagsMenu.tsx
new file mode 100644
index 00000000..ad1f02a5
--- /dev/null
+++ b/src/app/portfolio/[slug]/components/TagsMenu.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/app/shadcn_components/ui/dropdown-menu";
+import {
+ ChevronDownIcon,
+ DocumentArrowUpIcon,
+ PlusIcon,
+ MinusIcon,
+ TagIcon,
+} from "@heroicons/react/24/outline";
+
+/**
+ * The single Tags entry point in the property-table toolbar (ADR-0013): a
+ * dropdown offering the three bulk-tag paths. Upload opens the file modal;
+ * Assign / Remove enter a contextual mode (TagActionBar) that operates on the
+ * row selection or the active filter. Consolidates what was an isolated button.
+ */
+export function TagsMenu({
+ onUpload,
+ onAssign,
+ onRemove,
+}: {
+ onUpload: () => void;
+ onAssign: () => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+
+ Tags
+
+
+
+
+ }
+ title="Upload tags from file"
+ desc="Match a spreadsheet of IDs to a tag"
+ onSelect={onUpload}
+ />
+ }
+ title="Assign a tag"
+ desc="Apply a tag to selected rows or your filtered results"
+ onSelect={onAssign}
+ />
+ }
+ title="Remove a tag"
+ desc="Take a tag off selected rows or your filtered results"
+ onSelect={onRemove}
+ />
+
+
+ );
+}
+
+function Item({
+ icon,
+ title,
+ desc,
+ onSelect,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ desc: string;
+ onSelect: () => void;
+}) {
+ return (
+
+ {icon}
+
+ {title}
+ {desc}
+
+
+ );
+}
From 46421279fba005d3ffa43a9edc9cc5aaf7a59bf3 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 21:50:27 +0000
Subject: [PATCH 14/16] =?UTF-8?q?feat(portfolio):=20address=20impeccable?=
=?UTF-8?q?=20critique=20=E2=80=94=20no-plans=20state,=20contrast,=20focus?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design pass on the portfolio table (critique: 27/40; P0 was the unmodelled state).
- onboard: a mixed-safe "not modelled yet" nudge above the table — shown whenever
any loaded property lacks a plan (so a part-modelled portfolio still gets it),
wired to Run modelling. Turns a wall of empty cells into a next step without
hiding real data for the rows that ARE modelled.
- harden: one empty-cell vocabulary (EmptyCell) replacing the four divergent
renderings (blank div, slate-300 "—", italic "No cost", "Unknown"); AA-legible
and aria-labelled. Empty EPC bubble is now a labelled placeholder ring, not an
invisible div. Export failures surface an inline error instead of dying silently.
- a11y: the quick-filter clear is no longer a nested inside a
— pill is a container with sibling open/clear buttons.
- distill: exactly one navy CTA at a time — Add properties leads an empty
portfolio, Run modelling leads a populated one; the other demotes to neutral.
- colorize: black tokens in PropertyFilters + the Edit-Columns checkboxes now use
the brand navy/brandmidblue scale (was raw black, drifting off the toolbar).
- cleanup: removed the unreachable delete-property modal (no DELETE endpoint,
onDeleteProperty never called). Real property deletion is its own slice.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...folio-slug-components-propertytable-tsx.md | 78 +++++++
.../[slug]/components/PropertyFilters.tsx | 22 +-
.../[slug]/components/PropertyTable.tsx | 212 +++++++++---------
.../components/propertyTableColumns.tsx | 51 ++++-
4 files changed, 236 insertions(+), 127 deletions(-)
create mode 100644 .impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md
diff --git a/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md b/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md
new file mode 100644
index 00000000..73736a11
--- /dev/null
+++ b/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md
@@ -0,0 +1,78 @@
+---
+target: portfolio page (property table + toolbar + no-plans state)
+total_score: 27
+p0_count: 1
+p1_count: 1
+timestamp: 2026-07-13T21-29-24Z
+slug: rc-app-portfolio-slug-components-propertytable-tsx
+---
+Method: dual-agent (A: ad10a436 design-review · B: a517f2c1 detector+evidence)
+
+## Design Health Score
+
+| # | Heuristic | Score | Key Issue |
+|---|-----------|-------|-----------|
+| 1 | Visibility of System Status | 3 | Good results count + spinner overlay, but first-load is bare "Loading properties…" (no skeleton) and a no-plans portfolio gives zero signal that modelling is the missing step. |
+| 2 | Match System / Real World | 3 | "Plan Cost / No cost", "Expected EPC" read well; but blank EPC bubbles + "—" Ref communicate nothing about *why* they're empty. |
+| 3 | User Control and Freedom | 3 | Clear/Clear-all on filters + selection; no undo on optimistic tag assign, no cancel-export. |
+| 4 | Consistency and Standards | 2 | Two colour systems: toolbar uses brandblue/primary; filter sidebar + Edit-Columns checkboxes use raw black/bg-black/accent-black. |
+| 5 | Error Prevention | 3 | Delete has a preview modal, export gated >6000 — but the delete modal has Cancel and no wired Confirm (dead-end). |
+| 6 | Recognition Rather Than Recall | 3 | Columns labelled, Current EPC has a "?" tooltip; but Current/Lodged/Expected are three identical blank bubbles with no legend when empty. |
+| 7 | Flexibility and Efficiency | 3 | Column visibility, quick filters, filter builder, export — strong; undercut by a 7-row page size on a desk tool. |
+| 8 | Aesthetic and Minimalist Design | 2 | The no-plans grid is a wall of "—", blank bubbles, italic "No cost"; 6 top-level toolbar actions + two dark CTAs + shouty micro-labels. |
+| 9 | Error Recovery | 3 | load-more + export fail silently; tag-assign error only shows inline in the popover footer. |
+| 10 | Help and Documentation | 2 | One tooltip (Current EPC); no inline guidance connecting an unmodelled portfolio to "Run modelling". |
+| **Total** | | **27/40** | **Acceptable — solid plumbing, dragged down by the empty state + consistency** |
+
+## Anti-Patterns Verdict
+
+**LLM assessment:** Not egregious slop — the table is competently built and restrained — but three tells betray a generated-template lineage: tiny uppercase black micro-labels everywhere (`RESULTS:` font-black uppercase, `CURATE SELECTION` at 9px), `rounded-xl` + `shadow-sm` cards the brief names as an anti-reference, and em-dash-as-content filling 5–6 of 9 columns. Bones are institutional; finish is generic-admin.
+
+**Deterministic scan:** detect.mjs exit 0 — **zero findings**. No gradient text, decorative gradients, side-stripe borders, glassmorphism, over-rounded radii (only rounded-lg/xl/full), heavy shadows (only shadow-sm), or arbitrary z-index. The mechanical slop tells are genuinely absent — the problems here are IA/hierarchy/contrast that a static scanner can't see, which is exactly where the design review earns its keep.
+
+**Visual overlays:** none — browser automation unavailable (arm64 container can't run the bundled x86 Chromium under qemu). Fallback: static grep evidence corroborated the review (8 placeholder "—" cells in propertyTableColumns.tsx: 256/336/442/451/459/470/483/494; text-slate-300 at 384/442/451/494/524; 6 toolbar controls at 602/617/658/686/693/742; no dedicated no-plans empty-state component).
+
+## Overall Impression
+
+The interaction plumbing is genuinely good (optimistic tag writes, filter-scoped selection, provenance signalling). What lets it down is a single unaddressed truth: **the default state of every brand-new portfolio — properties added, nothing modelled — is the one state with zero design investment.** A first-time housing-association user lands on 185 rows where Ref is "—", three EPC columns are blank, Expiry is "—", and every Plan Cost is a faint italic "No cost". The read isn't "fresh start", it's "this tool is broken / my import failed" — the worst possible first impression for a product whose promise is *trustworthy numbers*. The single biggest opportunity is to design the unmodelled state as a first-class surface, not a fallthrough.
+
+## What's Working
+
+1. **The Current-EPC "?" tooltip** — exactly right for the institutional register: explains a load-bearing figure inline without cluttering the header. A pattern to extend, not remove.
+2. **The Predicted pill + provenance model** (amber "Predicted", ADR-0002) — genuine trust-signalling that tells analysts which EPCs to trust less; precisely the "trustworthy numbers" value the brand claims.
+3. **Optimistic tag writes across every cached page + filter-scoped selection surviving pagination** — Linear/Stripe-class plumbing, invisible when it works.
+
+## Priority Issues
+
+**[P0] The no-plans / unmodelled state has no design at all.** `EmptyPropertyState` only fires when there are literally zero rows; a portfolio with 185 unmodelled properties falls straight through to the full table with 5–6 empty columns. This is the *default* state of every new portfolio and the exact case flagged as "terrible". **Fix:** detect "has properties, zero plans/EPC" and render a purpose-built view — collapse/hide the three EPC + Expiry + Plan Cost columns, show Address/Ref/Tags at full density, and surface one reassuring banner ("185 properties added — run modelling to generate EPC ratings and retrofit costs →") wired to the existing Run-modelling route. *Command: `onboard`*
+
+**[P1] Empty cells fail contrast and read as breakage.** `text-slate-300 italic` "No cost" and slate-300 em-dashes / "Unknown" are below WCAG AA on white and make populated data indistinguishable from missing data at a glance. Four different "empty" renderings coexist (blank `w-5 h-5` div, "—", italic "No cost", "Unknown"). **Fix:** raise empty text to slate-400/500 and pick ONE absence vocabulary. *Command: `harden`*
+
+**[P2] Six top-level toolbar actions with two competing dark CTAs.** Filters, Edit Columns, Export, Tags▾, Add properties▾, Run modelling in one undifferentiated row; Add properties and Run modelling are both navy `bg-primary` — no primary focus, and the brief says navy belongs "in small doses". **Fix:** one dark CTA at a time (Add properties when empty → Run modelling once populated), fold Export/Edit Columns/Tags into a quieter secondary group. *Command: `distill`*
+
+**[P3] Colour-system drift between toolbar and filter sidebar.** Toolbar uses brandblue/primary; `PropertyFilters.tsx` and the Edit-Columns checkboxes use raw black/bg-black/accent-black — two systems on one page. **Fix:** replace every black token with the brand navy/brandblue scale. *Command: `colorize`*
+
+**[P3] No first-load skeleton; silent failures.** First load is a lone "Loading properties…" string; load-more and export swallow errors (`// silently ignore`). For a data-trust product a silent export failure is a credibility risk. **Fix:** skeleton rows + a toast/inline error on export and load-more failure. *Command: `polish`*
+
+## Persona Red Flags
+
+**Alex (power user):** `pageSize: 7` on a large desk screen means paging 27× through 185 homes; lazy load-more only triggers on reaching the last page — no jump-to-end or density control. Wants 50–100 rows + persistent EPC/cost sort.
+
+**Sam (accessibility):** slate-300 "No cost"/"Unknown" fail AA outright; the quick-filter clear control is a `` nested *inside* a `` (invalid interactive nesting, unpredictable for SR/keyboard); the empty EpcLetterBubble is an unlabelled `` with no "no data" text.
+
+**Riley (edge/empty-state stress):** the 185-no-plans grid is the headline failure — renders as a wall of placeholders with no explanation. Compounded by the delete modal's Cancel-with-no-Confirm dead-end (and `deletePreview` never populated, so the impact list is always empty), and load-more failing silently leaving a partial list.
+
+## Minor Observations
+
+- Give the empty EpcLetterBubble an aria-hidden "—" so the column isn't three invisible gaps.
+- `RESULTS:` in font-black uppercase is louder than the numbers it introduces — invert the weight.
+- Export button sits in `bg-slate-100` while neighbours are `bg-white` — reads as disabled when it's active.
+- The floating chat launcher (third-party) overlaps the right-edge Actions/⋯ column and can occlude the row menu.
+- Delete modal `DialogDescription` promises "Review the impact below" but `deletePreview` is never fetched in this file — always empty (engineering bug).
+- Heavy useEffect/useMemo usage contradicts the repo CLAUDE.md React guidance (for the engineering review, not design).
+
+## Questions to Consider
+
+1. If the default state of every new portfolio is "properties added, nothing modelled", why is that the state with zero design investment — shouldn't the unmodelled view be the primary design target?
+2. Three EPC columns collapse to three identical blank bubbles with no data — should Lodged/Expected be progressive-disclosure behind the modelled state?
+3. Two navy buttons say "both of these are the most important thing" — which ONE action is the product betting a first-time user should take, and does the layout make that bet visible?
diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx
index f4a9dc35..f8df4b12 100644
--- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx
@@ -248,7 +248,7 @@ function EpcDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
- className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
+ className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
{selected.length === 0 ? (
@@ -287,7 +287,7 @@ function EpcDropdown({
{multi ? (
{isSelected && }
@@ -372,7 +372,7 @@ function EnumMultiDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
- className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
+ className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
{selectedLabels.length === 0 ? (
@@ -398,7 +398,7 @@ function EnumMultiDropdown({
{opt.label}
{isSelected && }
@@ -489,7 +489,7 @@ function TagMultiDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
- className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
+ className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
{selected.length === 0 ? (
@@ -525,7 +525,7 @@ function TagMultiDropdown({
{opt.label}
{isSelected && }
@@ -552,7 +552,7 @@ function NumberFilterInput({
return (
onChange(e.target.value)}
@@ -627,7 +627,7 @@ function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFo
}
const selectClass =
- "w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10";
+ "w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20";
const enumOptions = isEnumField(field) ? ENUM_FIELD_OPTIONS[field] : [];
@@ -688,7 +688,7 @@ function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFo
{field === "epcExpiryDate" && operator !== "date_preset" && (
setDateValue(e.target.value)}
/>
@@ -721,7 +721,7 @@ function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFo
type="button"
onClick={handleConfirm}
disabled={!canConfirm()}
- className="flex-1 h-8 rounded-md bg-black text-xs font-medium text-white hover:bg-black/90 transition disabled:opacity-40 disabled:cursor-not-allowed"
+ className="flex-1 h-8 rounded-md bg-primary text-xs font-medium text-white hover:opacity-90 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
@@ -939,7 +939,7 @@ export default function PropertyFilters({
Apply
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index 8c8b86c9..a64be71d 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -47,21 +47,20 @@ import {
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/app/shadcn_components/ui/dialog";
-import { Button } from "@/app/shadcn_components/ui/button";
/* ----------------------------------------
Export helpers
----------------------------------------- */
const EXPORT_LIMIT = 6000;
+// Exactly one navy CTA is primary at a time (impeccable distill): Add properties
+// leads an empty portfolio, Run modelling leads a populated one; the other demotes
+// to the neutral toolbar-button style so the toolbar has a single focal action.
+const PRIMARY_CTA =
+ "flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition";
+const SECONDARY_CTA =
+ "flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-primary text-xs font-semibold hover:bg-slate-50 transition";
+
function resolveEnumLabel(
options: EnumOption[],
dbValue: string | null | undefined,
@@ -237,43 +236,44 @@ function QuickFilterDropdown({
return (
-
- {label}
- {isActive ? (
- <>
+
+ {label}
+ {isActive ? (
: {committedValue}
- {
- e.stopPropagation();
- onClear();
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.stopPropagation();
- onClear();
- }
- }}
- className="ml-0.5 rounded-full hover:opacity-75"
- >
-
-
- >
- ) : (
-
+ ) : (
+
+ )}
+
+ {isActive && (
+ {
+ e.stopPropagation();
+ onClear();
+ }}
+ aria-label={`Clear ${label} filter`}
+ className="ml-1 rounded-full hover:opacity-75"
+ >
+
+
)}
-
+
{isOpen && (
@@ -422,6 +422,7 @@ export default function PropertyTable({
filterGroups: [],
});
const totalCount = allResponse?.total ?? 0;
+ const [modelNudgeDismissed, setModelNudgeDismissed] = useState(false);
const DISPLAY_LIMIT = 250;
const LOAD_MORE_SIZE = 100;
@@ -448,6 +449,11 @@ export default function PropertyTable({
[queryData, extraRows],
);
+ // Properties with no default plan yet — the "not modelled" set. Counted from the
+ // loaded rows (cheap; avoids a hook per project convention): exact for a
+ // fully-loaded portfolio, a floor when more rows lazy-load beyond view.
+ const unmodelledInView = tableData.filter((p) => p.planId == null).length;
+
// Controlled pagination (lifted from DataTable so we can detect the last page)
const [pagination, setPagination] = useState
({
pageIndex: 0,
@@ -519,9 +525,11 @@ export default function PropertyTable({
// Export fetches its own batch (up to EXPORT_LIMIT) for the current filters,
// independent of the on-screen page — the display fetch is deliberately small.
const [isExporting, setIsExporting] = useState(false);
+ const [exportError, setExportError] = useState(null);
const handleExport = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
+ setExportError(null);
try {
const res = await fetch("/api/properties", {
method: "POST",
@@ -533,24 +541,17 @@ export default function PropertyTable({
offset: 0,
}),
});
- if (!res.ok) return;
+ if (!res.ok) throw new Error();
const json: { data: PropertyWithRelations[] } = await res.json();
exportToCsv(json.data);
+ } catch {
+ // Don't fail silently — a data-trust product must say when an export dies.
+ setExportError("Export failed — please try again.");
} finally {
setIsExporting(false);
}
}, [isExporting, portfolioId, allFilterGroups]);
- /* ----------------------------------------
- Delete preview state
- ----------------------------------------- */
- const [deletePropertyId, setDeletePropertyId] = useState(null);
- const [deletePreview, setDeletePreview] = useState<
- { table: string; count: number }[] | null
- >(null);
- const [previewLoading] = useState(false);
- const [previewError] = useState(null);
-
return (
{/* Action bar */}
@@ -586,6 +587,18 @@ export default function PropertyTable({
)}
+ {exportError && (
+
+ {exportError}
+ setExportError(null)}
+ aria-label="Dismiss"
+ className="text-red-400 hover:text-red-700"
+ >
+
+
+
+ )}
{hasActiveFilters && (
{OPTIONAL_COLUMN_LABELS[colId]}
@@ -689,10 +702,10 @@ export default function PropertyTable({
onRemove={() => setTagMode("remove")}
/>
- {/* Add properties */}
+ {/* Add properties — primary CTA only while the portfolio is empty */}
-
+ 0 ? SECONDARY_CTA : PRIMARY_CTA}>
Add properties
@@ -738,10 +751,10 @@ export default function PropertyTable({
portfolioId={portfolioId}
/>
- {/* Run modelling */}
+ {/* Run modelling — primary CTA once there are properties to model */}
router.push(`/portfolio/${portfolioId}/modelling/run`)}
- className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition"
+ className={totalCount > 0 ? PRIMARY_CTA : SECONDARY_CTA}
>
Run modelling
@@ -821,6 +834,49 @@ export default function PropertyTable({
onOpenChange={setTagUploadOpen}
/>
+ {/* Not-modelled nudge — turns a wall of empty cells into a next step.
+ Mixed-safe: shown whenever any loaded property lacks a plan, so a
+ part-modelled portfolio still gets the prompt for the rest. */}
+ {!isLoading &&
+ totalCount > 0 &&
+ unmodelledInView > 0 &&
+ !modelNudgeDismissed && (
+
+
+
+
+
+ {unmodelledInView === totalCount
+ ? "None of these properties have been modelled yet"
+ : `${unmodelledInView.toLocaleString()} ${unmodelledInView === 1 ? "property hasn't" : "properties haven't"} been modelled yet`}
+
+
+ Run modelling to generate Expected EPC ratings and retrofit
+ costs for them.
+
+
+
+
+
+ router.push(`/portfolio/${portfolioId}/modelling/run`)
+ }
+ className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90"
+ >
+
+ Run modelling
+
+
setModelNudgeDismissed(true)}
+ aria-label="Dismiss"
+ className="rounded-md p-1.5 text-slate-400 transition hover:text-brandblue"
+ >
+
+
+
+
+ )}
+
{/* Display-limit notice */}
{isAtDisplayLimit && hasMore && (
@@ -890,7 +946,6 @@ export default function PropertyTable({
setDeletePropertyId(id)}
columnVisibility={columnVisibility}
onColumnVisibilityChange={
setColumnVisibility as (
@@ -911,57 +966,6 @@ export default function PropertyTable({
)}
-
- {/* Delete preview modal */}
-
);
}
diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
index 3142be4a..3307ca92 100644
--- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
+++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx
@@ -53,8 +53,29 @@ function Pill({
/* -----------------------------------------------------------------------
EPC letter bubble
------------------------------------------------------------------------ */
+/**
+ * The one treatment for an absent value across the table (ADR-0013 impeccable
+ * pass): a single quiet, AA-legible em-dash with an accessible label — replacing
+ * the four different empty renderings (blank div, slate-300 "—", italic
+ * "No cost", "Unknown") the critique flagged.
+ */
+const EmptyCell = ({ label = "No data" }: { label?: string }) => (
+
+ —
+
+);
+
const EpcLetterBubble = ({ letter }: { letter: string }) => {
- if (!letter) return ;
+ if (!letter)
+ return (
+
+ –
+
+ );
return (
[] = [
header: () =>
Property Ref
,
cell: ({ row }) => (
- {row.original.landlordPropertyId ?? "—"}
+ {row.original.landlordPropertyId ?? }
),
},
@@ -333,7 +354,11 @@ const coreColumns: ColumnDef
[] = [
const expired = row.original.epcIsExpired;
if (!dateStr) {
- return —
;
+ return (
+
+
+
+ );
}
const lodgementDate = new Date(dateStr);
@@ -375,13 +400,13 @@ const coreColumns: ColumnDef[] = [
}
return (
-
+
{cost ? (
£{formatNumber(cost)}
) : (
- No cost
+
)}
);
@@ -439,7 +464,7 @@ const optionalColumns: ColumnDef
[] = [
header: () => Property Type
,
cell: ({ row }) => {
const val = row.original.propertyType;
- return val ? {val} : —;
+ return val ? {val} : ;
},
},
{
@@ -448,7 +473,7 @@ const optionalColumns: ColumnDef[] = [
header: () => Built Form
,
cell: ({ row }) => {
const val = row.original.builtForm;
- return val ? {val} : —;
+ return val ? {val} : ;
},
},
{
@@ -456,7 +481,9 @@ const optionalColumns: ColumnDef[] = [
accessorKey: "yearBuilt",
header: () => Construction Age
,
cell: ({ row }) => (
- {row.original.yearBuilt ?? "—"}
+
+ {row.original.yearBuilt ?? }
+
),
},
{
@@ -467,7 +494,7 @@ const optionalColumns: ColumnDef[] = [
const val = row.original.totalFloorArea;
return (
- {val != null ? `${val.toFixed(1)} m²` : "—"}
+ {val != null ? `${val.toFixed(1)} m²` : }
);
},
@@ -480,7 +507,7 @@ const optionalColumns: ColumnDef[] = [
const val = row.original.co2Emissions;
return (
- {val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
+ {val != null ? `${val.toFixed(1)} kg/m²/yr` : }
);
},
@@ -491,7 +518,7 @@ const optionalColumns: ColumnDef[] = [
header: () => Main Fuel
,
cell: ({ row }) => {
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
- return label ? {label} : —;
+ return label ? {label} : ;
},
},
{
@@ -521,7 +548,7 @@ function DescriptorCell({ value }: { value: string | null }) {
From af171f50d77131019a918831a904ef7f64107296 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 22:10:02 +0000
Subject: [PATCH 15/16] feat(tags): only show row-select checkboxes while
bulk-tagging
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The selection checkbox column was persistently visible. Now it appears only
when a bulk assign/remove mode is active (launched from the Tags menu) via a
select-less column set, and the selection clears on entering and leaving the
mode. The default table has no checkbox column; per-property tagging still uses
the inline Tags-cell popover, and the bulk bar's target still follows selection
→ filter → whole portfolio while the mode is open.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/PropertyTable.tsx | 22 ++++++++++++++-----
.../components/propertyTableColumns.tsx | 6 +++++
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index a64be71d..1e7b5580 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -20,7 +20,10 @@ import { useRouter } from "next/navigation";
import { HomeIcon } from "@heroicons/react/24/outline";
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
import { sapToEpc } from "@/app/utils";
-import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
+import {
+ columns,
+ columnsWithoutSelect,
+} from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters";
import {
@@ -698,8 +701,14 @@ export default function PropertyTable({
{/* Tags — bulk assign / remove / upload (ADR-0013) */}
setTagUploadOpen(true)}
- onAssign={() => setTagMode("assign")}
- onRemove={() => setTagMode("remove")}
+ onAssign={() => {
+ setRowSelection({});
+ setTagMode("assign");
+ }}
+ onRemove={() => {
+ setRowSelection({});
+ setTagMode("remove");
+ }}
/>
{/* Add properties — primary CTA only while the portfolio is empty */}
@@ -824,7 +833,10 @@ export default function PropertyTable({
filterGroups={allFilterGroups}
filteredTotal={filteredTotal}
totalCount={totalCount}
- onClose={() => setTagMode(null)}
+ onClose={() => {
+ setTagMode(null);
+ setRowSelection({});
+ }}
/>
)}
@@ -945,7 +957,7 @@ export default function PropertyTable({
) : (
[] = [
...coreColumns,
...optionalColumns,
];
+
+// The same columns without the leading selection checkbox — row-select is only
+// shown while bulk-tagging (ADR-0013 impeccable pass), so the default table has
+// no persistent checkbox column. Precomputed (stable identity, no useMemo).
+export const columnsWithoutSelect: ColumnDef[] =
+ columns.filter((c) => c.id !== "select");
From 971767a62a5a1067286ac0a5a7551c259e6b3043 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 13 Jul 2026 22:30:58 +0000
Subject: [PATCH 16/16] fix(portfolio): don't show Run modelling twice in the
no-plans state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The not-modelled banner and the toolbar both rendered a navy "Run modelling"
CTA whenever a portfolio had properties but no plans — two identical buttons.
The banner owns the action in that state, so the toolbar's Run modelling is now
hidden while the banner is up (it returns once everything is modelled or the
banner is dismissed). One primary CTA at a time, as intended.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/PropertyTable.tsx | 34 ++++++++++++-------
1 file changed, 22 insertions(+), 12 deletions(-)
diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx
index 1e7b5580..6cbf399c 100644
--- a/src/app/portfolio/[slug]/components/PropertyTable.tsx
+++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx
@@ -456,6 +456,13 @@ export default function PropertyTable({
// loaded rows (cheap; avoids a hook per project convention): exact for a
// fully-loaded portfolio, a floor when more rows lazy-load beyond view.
const unmodelledInView = tableData.filter((p) => p.planId == null).length;
+ // The not-modelled banner owns the Run-modelling CTA in this state, so the
+ // toolbar must NOT also show one — otherwise two identical navy buttons appear.
+ const showModelNudge =
+ !isLoading &&
+ totalCount > 0 &&
+ unmodelledInView > 0 &&
+ !modelNudgeDismissed;
// Controlled pagination (lifted from DataTable so we can detect the last page)
const [pagination, setPagination] = useState({
@@ -760,14 +767,20 @@ export default function PropertyTable({
portfolioId={portfolioId}
/>
- {/* Run modelling — primary CTA once there are properties to model */}
- router.push(`/portfolio/${portfolioId}/modelling/run`)}
- className={totalCount > 0 ? PRIMARY_CTA : SECONDARY_CTA}
- >
-
- Run modelling
-
+ {/* Run modelling — primary CTA once there are properties to model.
+ Hidden while the not-modelled banner is up, since the banner already
+ carries this action (no duplicate navy CTA). */}
+ {!showModelNudge && (
+
+ router.push(`/portfolio/${portfolioId}/modelling/run`)
+ }
+ className={totalCount > 0 ? PRIMARY_CTA : SECONDARY_CTA}
+ >
+
+ Run modelling
+
+ )}
@@ -849,10 +862,7 @@ export default function PropertyTable({
{/* Not-modelled nudge — turns a wall of empty cells into a next step.
Mixed-safe: shown whenever any loaded property lacks a plan, so a
part-modelled portfolio still gets the prompt for the rest. */}
- {!isLoading &&
- totalCount > 0 &&
- unmodelledInView > 0 &&
- !modelNudgeDismissed && (
+ {showModelNudge && (