mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
948a313c97
commit
77e5fcde20
4 changed files with 418 additions and 0 deletions
48
src/lib/reporting/tagFilterSql.test.ts
Normal file
48
src/lib/reporting/tagFilterSql.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
56
src/lib/reporting/tagFilterSql.ts
Normal file
56
src/lib/reporting/tagFilterSql.ts
Normal 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 `);
|
||||
}
|
||||
172
src/lib/reporting/viewState.test.ts
Normal file
172
src/lib/reporting/viewState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
142
src/lib/reporting/viewState.ts
Normal file
142
src/lib/reporting/viewState.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue