From 9d8395f3acc55d8f034d229cc2fcfc34f10e326a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 8 Jul 2026 10:54:22 +0000 Subject: [PATCH] fix(reporting): handle string timestamp from raw db.execute in scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getReportingScenarios uses raw db.execute, which returns created_at as a string (Drizzle .select() returns a Date). Calling .toISOString() on it threw 'created_at.toISOString is not a function' and 500'd /portfolio/[id]/reporting. Extracted the row→ReportingScenario mapping into a pure, tested module (reportingScenarios.ts, toReportingScenario) that normalises created_at whether it arrives as a string or a Date, and covers the modelled-status and bigint-id / null-name cases. databaseFunctions re-exports the type. TDD: 4 tests; full suite 446 green, tsc clean. Co-Authored-By: Claude Fable 5 --- .../reporting/databaseFunctions.ts | 30 ++++-------- .../reporting/reportingScenarios.test.ts | 48 +++++++++++++++++++ .../reporting/reportingScenarios.ts | 47 ++++++++++++++++++ 3 files changed, 103 insertions(+), 22 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.test.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 1cdc293d..f4155a09 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -24,6 +24,12 @@ import type { BaselineMetrics, TotalMetrics, } from "./types"; +import { + toReportingScenario, + type ReportingScenario, +} from "./reportingScenarios"; + +export type { ReportingScenario }; export async function getPortfolioCounts(portfolioId: number): Promise { const result = await db.execute<{ total: number }>(sql` @@ -239,17 +245,6 @@ export async function loadBaselineMetrics( }; } -export interface ReportingScenario { - id: number; - name: string; - goal: string; - goalValue: string | null; - budget: number | null; - housingType: string; - createdAt: string; - status: "modelled" | "awaiting_modelling"; -} - /** * Scenarios for the reporting combobox: config plus derived modelled state * (ADR-0003). Deliberately lighter than listScenariosWithStatus — it derives @@ -271,7 +266,7 @@ export async function getReportingScenarios( goal_value: string | null; budget: number | null; housing_type: string; - created_at: Date; + created_at: string | Date; }>(sql` SELECT id, name, goal, goal_value, budget, housing_type, created_at FROM scenario @@ -287,16 +282,7 @@ export async function getReportingScenarios( const modelled = new Set(modelledRes.rows.map((r) => String(r.scenario_id))); - return configRes.rows.map((s) => ({ - id: Number(s.id), - name: s.name ?? `Scenario ${s.id}`, - goal: s.goal, - goalValue: s.goal_value, - budget: s.budget, - housingType: s.housing_type, - createdAt: s.created_at.toISOString(), - status: modelled.has(String(s.id)) ? "modelled" : "awaiting_modelling", - })); + return configRes.rows.map((s) => toReportingScenario(s, modelled)); } /** The portfolio's own goal — drives the current-stock goal callout. */ diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.test.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.test.ts new file mode 100644 index 00000000..f7cbf6f8 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { toReportingScenario } from "./reportingScenarios"; + +describe("toReportingScenario", () => { + const baseRow = { + id: "12", + name: "EPC C by 2030", + goal: "Increasing EPC", + goal_value: "C", + budget: 15000, + housing_type: "Social", + created_at: "2026-07-04T09:10:00.000Z", + }; + + it("normalises a string timestamp from raw db.execute to an ISO string", () => { + // Regression: raw db.execute returns created_at as a string, so calling + // .toISOString() on it threw. The mapper must accept a string. + const s = toReportingScenario(baseRow, new Set(["12"])); + expect(s.createdAt).toBe("2026-07-04T09:10:00.000Z"); + }); + + it("also accepts a Date timestamp", () => { + const s = toReportingScenario( + { ...baseRow, created_at: new Date("2026-07-04T09:10:00.000Z") }, + new Set(), + ); + expect(s.createdAt).toBe("2026-07-04T09:10:00.000Z"); + }); + + it("marks a scenario modelled only when its id is in the modelled set", () => { + expect(toReportingScenario(baseRow, new Set(["12"])).status).toBe( + "modelled", + ); + expect(toReportingScenario(baseRow, new Set(["99"])).status).toBe( + "awaiting_modelling", + ); + }); + + it("falls back to a placeholder name and coerces a bigint id", () => { + const s = toReportingScenario( + { ...baseRow, id: 12n as unknown as bigint, name: null }, + new Set(["12"]), + ); + expect(s.id).toBe(12); + expect(s.name).toBe("Scenario 12"); + expect(s.status).toBe("modelled"); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.ts new file mode 100644 index 00000000..8e8f1d39 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/reportingScenarios.ts @@ -0,0 +1,47 @@ +/** + * Pure mapping for the reporting scenario combobox. Lives apart from the db + * function so the row-shaping (including the string|Date timestamp quirk of + * raw db.execute) is unit-tested without a database. + */ + +export interface ReportingScenario { + id: number; + name: string; + goal: string; + goalValue: string | null; + budget: number | null; + housingType: string; + createdAt: string; + status: "modelled" | "awaiting_modelling"; +} + +export interface RawScenarioRow { + id: string | number | bigint; + name: string | null; + goal: string; + goal_value: string | null; + budget: number | null; + housing_type: string; + created_at: string | Date; +} + +/** Raw db.execute returns created_at as a string; Drizzle .select() as a Date. */ +function toIso(value: string | Date): string { + return value instanceof Date ? value.toISOString() : value; +} + +export function toReportingScenario( + row: RawScenarioRow, + modelledIds: Set, +): ReportingScenario { + return { + id: Number(row.id), + name: row.name ?? `Scenario ${row.id}`, + goal: row.goal, + goalValue: row.goal_value, + budget: row.budget, + housingType: row.housing_type, + createdAt: toIso(row.created_at), + status: modelledIds.has(String(row.id)) ? "modelled" : "awaiting_modelling", + }; +}