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 ? ( +
+ No tags yet.{" "} + + Create one + + . +
+ ) : ( + allTags.map((t) => { + const on = assignedIds.has(t.id); + return ( + + ); + }) + )} +
+
+ + + Manage tags + + {assign.isError && ( + {assign.error.message} + )} +
+
+
+ ); +} + +/** 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, + }; +}