fix(reporting): handle string timestamp from raw db.execute in scenarios

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-08 10:54:22 +00:00
parent 97e24e0027
commit 9d8395f3ac
3 changed files with 103 additions and 22 deletions

View file

@ -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<number> {
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. */

View file

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

View file

@ -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<string>,
): 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",
};
}