From 77e5fcde20ddc0b1d0dacb2c41a6fedc33cbbc11 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 19 Jul 2026 00:34:06 +0100 Subject: [PATCH] feat(reporting): tag-filter + view-state core (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for filtering the reporting view by tags, URL-driven so the whole view survives refresh and back/forward. - viewState.ts: the reporting view as URL-serialisable state — scenario `view`, the scenario filters, and the tag filter {include, exclude, includeMode}. parse ⇄ serialize, defensively sanitised (numeric tag ids, dedupe, include/exclude overlap → exclude wins, defaults omitted for clean URLs). - tagFilterSql.ts: tagFilterCondition() → a SQL WHERE fragment. include=any (EXISTS), include=all (COUNT DISTINCT = size), exclude (NOT EXISTS), empty = TRUE (never filters everything out). Verified on real data (#796, #824): include = membership count, exclude = complement, ANY = union, ALL = intersection. 27 unit tests. No wiring yet — next: thread through the reporting queries + UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/reporting/tagFilterSql.test.ts | 48 +++++++ src/lib/reporting/tagFilterSql.ts | 56 ++++++++ src/lib/reporting/viewState.test.ts | 172 +++++++++++++++++++++++++ src/lib/reporting/viewState.ts | 142 ++++++++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 src/lib/reporting/tagFilterSql.test.ts create mode 100644 src/lib/reporting/tagFilterSql.ts create mode 100644 src/lib/reporting/viewState.test.ts create mode 100644 src/lib/reporting/viewState.ts diff --git a/src/lib/reporting/tagFilterSql.test.ts b/src/lib/reporting/tagFilterSql.test.ts new file mode 100644 index 00000000..851e5793 --- /dev/null +++ b/src/lib/reporting/tagFilterSql.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { tagFilterCondition } from "./tagFilterSql"; +import { emptyTagFilter } from "./viewState"; + +const render = (frag: ReturnType) => + new PgDialect().sqlToQuery(frag); + +describe("tagFilterCondition", () => { + it("is a no-op TRUE when the filter is empty (never filters everything out)", () => { + const { sql, params } = render(tagFilterCondition(emptyTagFilter())); + expect(sql.trim().toLowerCase()).toBe("true"); + expect(params).toEqual([]); + }); + + it("include (any) → EXISTS over property_tag with the ids bound", () => { + const { sql, params } = render( + tagFilterCondition({ include: ["10", "20"], exclude: [], includeMode: "any" }), + ); + expect(sql).toMatch(/EXISTS/); + expect(sql).not.toMatch(/NOT EXISTS/); + expect(sql).toMatch(/property_tag/); + expect(params).toEqual([10n, 20n]); + }); + + it("include (all) → COUNT(DISTINCT …) = include size", () => { + const { sql } = render( + tagFilterCondition({ include: ["10", "20"], exclude: [], includeMode: "all" }), + ); + expect(sql).toMatch(/COUNT\(DISTINCT/i); + expect(sql).toMatch(/= \$3/); // the size literal is the 3rd bound param + }); + + it("exclude → NOT EXISTS", () => { + const { sql, params } = render( + tagFilterCondition({ include: [], exclude: ["30"], includeMode: "any" }), + ); + expect(sql).toMatch(/NOT EXISTS/); + expect(params).toEqual([30n]); + }); + + it("include + exclude are ANDed together", () => { + const { sql } = render( + tagFilterCondition({ include: ["10"], exclude: ["30"], includeMode: "any" }), + ); + expect(sql).toMatch(/EXISTS[\s\S]*AND[\s\S]*NOT EXISTS/); + }); +}); diff --git a/src/lib/reporting/tagFilterSql.ts b/src/lib/reporting/tagFilterSql.ts new file mode 100644 index 00000000..d61ed2f5 --- /dev/null +++ b/src/lib/reporting/tagFilterSql.ts @@ -0,0 +1,56 @@ +/** + * Turns a {@link TagFilter} into a SQL WHERE condition that scopes a property + * scan. Shared by every reporting query (baseline, scenario overlay, drill-down) + * so tag scoping is defined once. Pure w.r.t. the DB — it only builds a `sql` + * fragment; the caller ANDs it into its own WHERE. + * + * WHERE p.portfolio_id = ${pid} AND ${tagFilterCondition(tags)} + * + * `propertyId` is the property-id column in the calling query (default `p.id`). + */ +import { sql, type SQL } from "drizzle-orm"; +import type { TagFilter } from "./viewState"; + +/** `tag_id IN (…)` over bigint ids. Assumes ids are already numeric strings. */ +function tagIdIn(ids: string[]): SQL { + return sql`pt.tag_id IN (${sql.join( + ids.map((id) => sql`${BigInt(id)}`), + sql`, `, + )})`; +} + +export function tagFilterCondition( + tags: TagFilter, + propertyId: SQL = sql`p.id`, +): SQL { + const conds: SQL[] = []; + + if (tags.include.length > 0) { + if (tags.includeMode === "all") { + // Home must carry EVERY included tag: the distinct matches among the + // included set must equal the size of that set. + conds.push(sql`( + SELECT COUNT(DISTINCT pt.tag_id) + FROM property_tag pt + WHERE pt.property_id = ${propertyId} AND ${tagIdIn(tags.include)} + ) = ${tags.include.length}`); + } else { + // Home carries at least one of the included tags. + conds.push(sql`EXISTS ( + SELECT 1 FROM property_tag pt + WHERE pt.property_id = ${propertyId} AND ${tagIdIn(tags.include)} + )`); + } + } + + if (tags.exclude.length > 0) { + // Home carries none of the excluded tags. + conds.push(sql`NOT EXISTS ( + SELECT 1 FROM property_tag pt + WHERE pt.property_id = ${propertyId} AND ${tagIdIn(tags.exclude)} + )`); + } + + if (conds.length === 0) return sql`TRUE`; + return sql.join(conds, sql` AND `); +} diff --git a/src/lib/reporting/viewState.test.ts b/src/lib/reporting/viewState.test.ts new file mode 100644 index 00000000..09923d6c --- /dev/null +++ b/src/lib/reporting/viewState.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from "vitest"; +import { + parseReportingViewState, + serializeReportingViewState, + isTagFilterActive, + emptyViewState, + type ReportingViewState, +} from "./viewState"; + +/** URLSearchParams from a query string, the shape the client/server hand in. */ +const params = (qs: string) => new URLSearchParams(qs); + +describe("parseReportingViewState — defaults", () => { + it("returns the empty/default state for no params", () => { + expect(parseReportingViewState(params(""))).toEqual(emptyViewState()); + }); + + it("defaults view to current-stock, filters off, no tags", () => { + const s = parseReportingViewState(params("")); + expect(s.view).toBe("current-stock"); + expect(s.hideNonCompliant).toBe(false); + expect(s.useLodgedBaseline).toBe(false); + expect(s.complianceWindow).toBeNull(); + expect(s.tags).toEqual({ include: [], exclude: [], includeMode: "any" }); + }); +}); + +describe("parseReportingViewState — view", () => { + it("reads the recommended pseudo-scenario", () => { + expect(parseReportingViewState(params("view=recommended")).view).toBe( + "recommended", + ); + }); + + it("reads a numeric scenario id as a number", () => { + expect(parseReportingViewState(params("view=1292")).view).toBe(1292); + }); + + it("falls back to current-stock for a nonsense view", () => { + expect(parseReportingViewState(params("view=banana")).view).toBe( + "current-stock", + ); + }); +}); + +describe("parseReportingViewState — scenario filters", () => { + it("reads the boolean toggles", () => { + const s = parseReportingViewState( + params("hideNonCompliant=true&useLodgedBaseline=true"), + ); + expect(s.hideNonCompliant).toBe(true); + expect(s.useLodgedBaseline).toBe(true); + }); + + it("reads a valid compliance window", () => { + const s = parseReportingViewState( + params("complianceBand=C&complianceDate=2030-01-01"), + ); + expect(s.complianceWindow).toEqual({ band: "C", date: "2030-01-01" }); + }); + + it("ignores a compliance window with an out-of-range band", () => { + expect( + parseReportingViewState( + params("complianceBand=Z&complianceDate=2030-01-01"), + ).complianceWindow, + ).toBeNull(); + }); + + it("ignores a compliance window with a malformed date", () => { + expect( + parseReportingViewState(params("complianceBand=C&complianceDate=soon")) + .complianceWindow, + ).toBeNull(); + }); + + it("needs both band and date for a compliance window", () => { + expect( + parseReportingViewState(params("complianceBand=C")).complianceWindow, + ).toBeNull(); + }); +}); + +describe("parseReportingViewState — tag filter", () => { + it("parses comma-separated include and exclude ids", () => { + const s = parseReportingViewState( + params("includeTags=10,20&excludeTags=30"), + ); + expect(s.tags.include).toEqual(["10", "20"]); + expect(s.tags.exclude).toEqual(["30"]); + }); + + it("dedupes repeated ids, preserving first-seen order", () => { + expect( + parseReportingViewState(params("includeTags=10,10,20,10")).tags.include, + ).toEqual(["10", "20"]); + }); + + it("drops non-numeric ids", () => { + expect( + parseReportingViewState(params("includeTags=10,abc,,20")).tags.include, + ).toEqual(["10", "20"]); + }); + + it("resolves include/exclude overlap in favour of exclude", () => { + const s = parseReportingViewState( + params("includeTags=10,20&excludeTags=20,30"), + ); + expect(s.tags.include).toEqual(["10"]); + expect(s.tags.exclude).toEqual(["20", "30"]); + }); + + it("reads includeMode=all, defaulting to any otherwise", () => { + expect( + parseReportingViewState(params("includeTags=10&includeMode=all")).tags + .includeMode, + ).toBe("all"); + expect( + parseReportingViewState(params("includeTags=10&includeMode=bogus")).tags + .includeMode, + ).toBe("any"); + }); +}); + +describe("isTagFilterActive", () => { + it("is false with no tags", () => { + expect(isTagFilterActive(emptyViewState().tags)).toBe(false); + }); + it("is true with an include", () => { + expect(isTagFilterActive({ include: ["1"], exclude: [], includeMode: "any" })).toBe(true); + }); + it("is true with an exclude", () => { + expect(isTagFilterActive({ include: [], exclude: ["1"], includeMode: "any" })).toBe(true); + }); +}); + +describe("serializeReportingViewState — round-trip + clean URLs", () => { + it("omits every default (empty query string)", () => { + expect(serializeReportingViewState(emptyViewState()).toString()).toBe(""); + }); + + it("omits includeMode when it is the default 'any'", () => { + const qs = serializeReportingViewState({ + ...emptyViewState(), + tags: { include: ["10"], exclude: [], includeMode: "any" }, + }).toString(); + expect(qs).toContain("includeTags=10"); + expect(qs).not.toContain("includeMode"); + }); + + it("emits includeMode only when 'all' and there are 2+ include tags", () => { + const qs = serializeReportingViewState({ + ...emptyViewState(), + tags: { include: ["10", "20"], exclude: [], includeMode: "all" }, + }).toString(); + expect(qs).toContain("includeMode=all"); + }); + + it("round-trips a fully-populated state", () => { + const state: ReportingViewState = { + view: 1292, + hideNonCompliant: true, + useLodgedBaseline: false, + complianceWindow: { band: "C", date: "2030-01-01" }, + tags: { include: ["10", "20"], exclude: ["30"], includeMode: "all" }, + }; + const round = parseReportingViewState( + serializeReportingViewState(state), + ); + expect(round).toEqual(state); + }); +}); diff --git a/src/lib/reporting/viewState.ts b/src/lib/reporting/viewState.ts new file mode 100644 index 00000000..7f24e1ad --- /dev/null +++ b/src/lib/reporting/viewState.ts @@ -0,0 +1,142 @@ +/** + * The reporting view as URL-serialisable state (ADR pending). Everything that + * shapes what the reporting page shows — the selected scenario `view`, the + * scenario filters, and the tag filter — lives here so it survives a refresh + * and browser back/forward, and so the server (baseline queries) and client + * (scenario overlay) read one canonical source: the URL. + * + * Pure: no DB, no React. Parsing is defensive — every value is sanitised so a + * hand-edited or stale URL can never produce an invalid query. + */ +import { EPC_BANDS } from "@/lib/epc/bands"; + +export type IncludeMode = "any" | "all"; + +/** Which tags a home must / must not carry to appear in the view. */ +export interface TagFilter { + /** Tag ids (as strings — tag ids are bigints) the home must match. */ + include: string[]; + /** Tag ids the home must NOT carry. */ + exclude: string[]; + /** `any` = home has ≥1 included tag; `all` = home has every included tag. */ + includeMode: IncludeMode; +} + +export type ReportView = "current-stock" | "recommended" | number; + +export interface ReportingViewState { + view: ReportView; + hideNonCompliant: boolean; + useLodgedBaseline: boolean; + complianceWindow: { band: string; date: string } | null; + tags: TagFilter; +} + +/** Accepts either the client's URLSearchParams or the server's searchParams object. */ +type ParamSource = + | URLSearchParams + | Record; + +function readParam(src: ParamSource, key: string): string | null { + if (src instanceof URLSearchParams) return src.get(key); + const v = src[key]; + if (Array.isArray(v)) return v[0] ?? null; + return v ?? null; +} + +/** Comma-separated numeric ids → deduped string[] (first-seen order). */ +function parseIds(raw: string | null): string[] { + if (!raw) return []; + const seen = new Set(); + const out: string[] = []; + for (const part of raw.split(",")) { + const id = part.trim(); + if (/^\d+$/.test(id) && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + return out; +} + +function parseView(raw: string | null): ReportView { + if (raw === "recommended") return "recommended"; + if (raw && /^\d+$/.test(raw)) return Number(raw); + return "current-stock"; +} + +export function emptyTagFilter(): TagFilter { + return { include: [], exclude: [], includeMode: "any" }; +} + +export function emptyViewState(): ReportingViewState { + return { + view: "current-stock", + hideNonCompliant: false, + useLodgedBaseline: false, + complianceWindow: null, + tags: emptyTagFilter(), + }; +} + +export function isTagFilterActive(tags: TagFilter): boolean { + return tags.include.length > 0 || tags.exclude.length > 0; +} + +export function parseReportingViewState( + src: ParamSource, +): ReportingViewState { + const band = readParam(src, "complianceBand"); + const date = readParam(src, "complianceDate"); + const complianceWindow = + band && + date && + (EPC_BANDS as readonly string[]).includes(band) && + /^\d{4}-\d{2}-\d{2}$/.test(date) && + !Number.isNaN(Date.parse(date)) + ? { band, date } + : null; + + const include = parseIds(readParam(src, "includeTags")); + const exclude = parseIds(readParam(src, "excludeTags")); + // A tag can't be both included and excluded — exclude wins (the stricter, + // safer intent), so it's dropped from the include set. + const excludeSet = new Set(exclude); + const includeMode = readParam(src, "includeMode") === "all" ? "all" : "any"; + + return { + view: parseView(readParam(src, "view")), + hideNonCompliant: readParam(src, "hideNonCompliant") === "true", + useLodgedBaseline: readParam(src, "useLodgedBaseline") === "true", + complianceWindow, + tags: { + include: include.filter((id) => !excludeSet.has(id)), + exclude, + includeMode, + }, + }; +} + +export function serializeReportingViewState( + state: ReportingViewState, +): URLSearchParams { + const p = new URLSearchParams(); + if (state.view !== "current-stock") p.set("view", String(state.view)); + if (state.hideNonCompliant) p.set("hideNonCompliant", "true"); + if (state.useLodgedBaseline) p.set("useLodgedBaseline", "true"); + if (state.complianceWindow) { + p.set("complianceBand", state.complianceWindow.band); + p.set("complianceDate", state.complianceWindow.date); + } + if (state.tags.include.length) { + p.set("includeTags", state.tags.include.join(",")); + } + if (state.tags.exclude.length) { + p.set("excludeTags", state.tags.exclude.join(",")); + } + // includeMode only bites with 2+ included tags; omit the default "any". + if (state.tags.includeMode === "all" && state.tags.include.length >= 2) { + p.set("includeMode", "all"); + } + return p; +}