feat(reporting): tag-filter + view-state core (TDD)

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 00:34:06 +01:00
parent 948a313c97
commit 77e5fcde20
4 changed files with 418 additions and 0 deletions

View file

@ -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<typeof tagFilterCondition>) =>
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/);
});
});

View file

@ -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 `);
}

View file

@ -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);
});
});

View file

@ -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<string, string | string[] | undefined>;
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<string>();
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;
}