diff --git a/.agents/prd-live-tracking-properties-tab.md b/.agents/prd-live-tracking-properties-tab.md new file mode 100644 index 00000000..91dd8426 --- /dev/null +++ b/.agents/prd-live-tracking-properties-tab.md @@ -0,0 +1,157 @@ +# PRD: Live Tracking — Properties Tab Operational Workflow + +## Problem Statement + +Coordinators recommend measures in HubSpot, which sync to `hubspot_deal_data.proposedMeasures` and surface in the live tracking properties tab. The current MVP captures client approvals against those measures, but the broader operational workflow has gaps: + +- Clients sometimes instruct additional measures the coordinator did not recommend. There is no way to capture this in the platform; instructed measures are tracked outside the system or pushed informally back to coordinators. +- After approval, Pre-Installation Building Inspections (PIBIs) are ordered against a subset of approved measures. There is no UI to record which measures are going for PIBI, when they were ordered, or when they completed. +- Some properties require a Domna technical building survey before measures are finalised. The boolean flag `domna_survey_required` is too coarse for this — the survey type can vary and the corresponding date has nowhere to be set in-app. +- Properties get halted mid-flow for varied reasons. There is no place to record halt date and reason. +- After tech survey, the internal team finalises a list of technical-approved measures. This list lives in `hubspot_deal_data.technicalApprovedMeasuresForInstall` and is not surfaced in the UI. +- The HubSpot pull pipeline now sends multi-value measure fields semicolon-separated, but the current parser still splits on commas — so post-pipeline data renders as a single concatenated string. + +The result is that key operational steps are managed outside the platform, the platform shows stale data, and clients cannot self-serve the parts of the lifecycle that belong to them. + +## Solution + +Extend the properties tab to be the operational source of truth across the deal lifecycle: survey → propose → approve → instruct → PIBI → tech survey → tech-approved → halt. Where the platform captures user-originated state (instructed measures, PIBI selections, dates, halt reason, domna survey metadata), persist it locally and push it to HubSpot. Where HubSpot is the source of truth (proposed measures, technical-approved measures, survey type), display it read-only. + +Introduce a normalized `user_defined_deal_measures` table for measures created in this platform, keyed by `(deal, measure_name, source)` with a polymorphic `source` enum. Reuse the existing detail drawer as the rich editing surface, widened from ~512px to ~1024px and reorganised into stage-ordered sections. The main `MeasuresTable` becomes a scannable summary; row click opens the drawer. + +Do not gate the workflow by stage. Soft-warn at out-of-order actions. Role/capability gating still applies. + +## User Stories + +1. As a coordinator, I want HubSpot's semicolon-separated `proposed_measures` field to render correctly in the properties tab, so that approvers see the measures I recommended. +2. As an approver, I want to see survey type, survey date, proposed measures, approvals, instructed measures, PIBI status, domna survey status, halted state, and technical-approved measures in one drawer per property, so that I can understand the full state of a deal without leaving the page. +3. As an approver, I want to instruct a measure that wasn't proposed by the coordinator, picking from a known catalogue of measure types, so that the coordination team is told to add it to the deal. +4. As an approver, I want my instructed measures to push back to HubSpot under a dedicated `instructed_measures` deal property, so that the CRM reflects the decision and downstream consumers see it. +5. As an approver, I want an instructed measure to be auto-approved on my behalf, so that I don't have to perform a redundant approval click on something I just instructed. +6. As an approver, I want instructing a measure on a deal with no proposed measures and no existing approvals to also populate `proposed_measures` in HubSpot with that same measure, so that the deal has a coherent measures starting point. +7. As an approver, I want to mark which approved measures are going for PIBI, so that we capture intent before ordering PIBIs externally. +8. As an approver, I want PIBI selections to push to HubSpot under `measures_for_pibi_ordered`, so that the CRM is consistent. +9. As a write-or-above user, I want to set `pibi_order_date` and `pibi_completed_date` against a deal, so that the actual external dates are recorded and visible to all stakeholders. +10. As an approver, I want to mark a property as halted with a date and a free-text reason, so that the halted state is visible to coordinators and stakeholders. +11. As an approver, I want to clear the halted date to resume a property, so that the deal returns to the active workflow. +12. As an approver, I want to specify the domna survey type and date when a domna survey is required, so that the platform can replace the legacy boolean flag with structured data. +13. As any user with read access, I want to see read-only `survey_type` and `technical_approved_measures_for_install` displayed in the drawer, so that I can verify what the internal team finalised without opening HubSpot. +14. As an approver, I want a soft warning when I attempt a workflow action out of typical order (for example, instructing measures after tech-approved is set), so that I know I'm overriding the normal flow but I'm not blocked from doing so. +15. As a developer, I want a single canonical list of measure names in TypeScript, so that UI dropdowns, validation, and document-requirements lookup all share one source. +16. As a developer, I want a generic HubSpot measures-field sync helper, so that adding new multi-value measure properties does not require copy-pasting another bespoke sync function. +17. As a developer, I want instructed-measure writes to be transactional across the local table, the approvals table, and the HubSpot push, so that we don't end up with half-written state on partial failure. +18. As a tester, I want unit tests for sync helpers and write services, so that regressions in HubSpot integration logic are caught without standing up the full app. +19. As a tester, I want Cypress e2e coverage of the instruct/PIBI/halt/domna happy paths, so that the user-facing flows are validated against a running app. +20. As a release engineer, I want the change to land on a single feature branch with a Vercel preview deploy, so that we can run UAT before merging to production. + +## Implementation Decisions + +### Data model + +- Existing measure text fields on `hubspot_deal_data` (`proposedMeasures`, `actualMeasuresInstalled`, `approvedPackage`, `technicalApprovedMeasuresForInstall`) remain HubSpot-mirrored text. They are read-only in the platform. +- New table `user_defined_deal_measures` stores measures created in this platform. Columns: `id`, `hubspot_deal_id`, `measure_name`, `source` (enum: `instructed`, `pibi_ordered`, extensible), `created_by_user_id`, `created_at`, `pushed_at` (nullable, set on successful HubSpot push), `confirmed_in_hubspot_at` (nullable, set when HubSpot pull confirms the value round-tripped), `notes` (nullable text). +- Read strategy: the local table is canonical for sources it owns. HubSpot text fields act as a mirror, not a fallback read source. Once the upstream HubSpot pull pipeline sends instructed/PIBI fields back, a follow-up will set `confirmed_in_hubspot_at` for round-tripped rows. That follow-up is out of scope for this PRD. +- `domna_survey_required` boolean column is dropped. `domna_survey_type` text (nullable) is added. No automated backfill — historical data will be back-populated separately by the data team. +- Multi-value measure fields are semicolon-separated in HubSpot output. The shared parser is updated from `split(",")` to `split(";")`. + +### Measure name catalogue + +- Lift the implicit measure list (currently the keys of `MEASURE_DOC_REQUIREMENTS` plus the trailing comment) into an exported `MEASURE_NAMES` const tuple with derived `MeasureName` type. `MEASURE_DOC_REQUIREMENTS` is refactored to consume the catalogue rather than own it. +- `user_defined_deal_measures.measure_name` is stored as `text`. Validation is enforced at the write boundary via zod against `MEASURE_NAMES`. No PG enum (HubSpot is the upstream source of measure names; migration churn is undesirable). + +### HubSpot sync layer + +- Extend `dealSync.ts` with one generic helper: `syncMeasuresFieldToHubSpot(dealId, propName, measureNames)`. It dispatches to the correct HubSpot deal property based on `propName` (`instructed_measures`, `measures_for_pibi_ordered`, future). Retains the existing direct-API + 3x retry pattern. +- The three existing bespoke sync functions can stay as thin wrappers over the generic helper or be left as-is for the existing approval/removal/contractor flows. The new helper is the canonical entry point for the new fields. +- Sync is synchronous within the API request lifecycle, matching existing convention. On success, `pushed_at` is set on local rows. On failure, `pushed_at` stays null and the request returns the error. +- An outbox/worker pattern is explicitly out of scope. + +### Instruct-measure service + +- Single transactional service entry point. Inputs: `dealId`, `measureName`, `actingUserId`. The service: + 1. Inserts a `user_defined_deal_measures` row with `source = 'instructed'`. + 2. Inserts a `deal_measure_approvals` row with `is_approved = true`, `acted_by = actingUserId`, plus the corresponding append-only event in `deal_measure_approval_events`. + 3. Reads `hubspot_deal_data.proposedMeasures` and counts approvals for this deal. If `proposedMeasures` is empty AND no approval rows exist for this deal, also pushes the new measure name as the `proposed_measures` HubSpot property. + 4. Pushes the updated instructed-measures list to HubSpot via the generic sync helper. +- All steps are committed in a single DB transaction; HubSpot push happens after commit, with `pushed_at` updated on success. +- This bypasses the existing pending-changes batch UI used for ordinary approval toggles. Instructing is an immediate, non-toggle commit. + +### PIBI-selection service + +- Mirrors the instruct-measure service, but writes `user_defined_deal_measures` rows with `source = 'pibi_ordered'` and pushes to the HubSpot `measures_for_pibi_ordered` property. No auto-approval row is created — PIBI selection is downstream of approval. +- The PIBI selector lists all measures associated with the deal (proposed + instructed). Approved measures are pre-ticked. The user can select non-approved measures; no hard block. + +### Deal-property update service + +- Single PATCH endpoint covering field-level updates on `hubspot_deal_data`: `pibi_order_date`, `pibi_completed_date`, `property_halted_date`, `property_halted_reason`, `domna_survey_type`, `domna_survey_date`. Validates per-field and pushes the corresponding HubSpot property updates. Halted-resume is modelled as setting `property_halted_date` to null. Halted reason is free text. Halt history is not modelled. + +### API routes + +- `POST /api/portfolio/[portfolioId]/instructed-measures` — body `{ dealId, measureName }`, approver-only. +- `POST /api/portfolio/[portfolioId]/pibi-measures` — body `{ dealId, measureNames[] }`, approver-only. +- `PATCH /api/portfolio/[portfolioId]/deal-properties` — body `{ dealId, fields: { ... } }`. Per-field permission checks: PIBI dates require `write+`; halted, domna fields require approver capability. + +### Permissions + +| Action | Required | +|---|---| +| Instruct measure | approver capability | +| PIBI tick | approver capability | +| Set PIBI dates | role `write` or above | +| Set property halted (date + reason) | approver capability | +| Set domna survey type/date | approver capability | +| View any of the above | role `read` or above | + +### UI + +- `PropertyDetailDrawer` width: drop `max-w-lg`. Set to `w-[60vw] max-w-5xl` (~1024px). +- Drawer content reorganised into sections, stage-ordered: Survey → Measures (proposed + instructed + approve toggle + add-instructed) → PIBI (selector + dates) → Domna Survey (type + date) → Halted (date + reason) → Technical Approved (read-only). +- `MeasuresTable` simplified to a scannable summary: measure name, source badge, approved indicator, PIBI indicator, technical-approved indicator. Row click opens the drawer scrolled to the Measures section. +- `survey_type` and `technical_approved_measures_for_install` rendered read-only in their respective sections. +- Soft warnings: out-of-order actions display a non-blocking inline warning. Implementation reuses the existing stage-detection logic in `computeLiveTrackerData()`. + +## Testing Decisions + +A good test exercises external behaviour, not implementation detail. Tests assert the contract of a module — what it returns, what it writes, what it pushes — not how it's wired internally. Mocks are restricted to system boundaries (HubSpot API, DB driver where unavoidable). + +### Vitest (added) + +The repo currently has no unit test infrastructure for app code. Vitest is added as part of this work. Unit-tested modules: + +- **Measure name catalogue** — type/runtime invariants, doc-requirements lookup falls through to base docs for unlisted names. +- **HubSpot measures-field sync helper** — given a list of measure names, formats the semicolon-joined value and calls the HubSpot client with the correct `propName`. Retry behaviour on `ECONNRESET`. Failure leaves `pushed_at` unset. +- **Instruct-measure service** — happy path writes user-defined row + approval row + event row + pushes HubSpot instructed-measures. Auto-populates `proposed_measures` when proposed is empty and no approvals exist; does not when either is non-empty. Transaction rolls back on DB failure; HubSpot push failure does not roll back the DB commit but leaves `pushed_at` null. +- **PIBI-selection service** — writes rows, pushes correct HubSpot property, does not create approval rows. +- **Deal-property update service** — per-field validation, correct HubSpot property mapped per field, halted-resume clears the date. + +The semicolon parser change is exercised through the tests of any module that consumes parsed measure lists. + +### Cypress (existing) + +Happy-path e2e coverage: + +- Approver instructs a measure; the drawer reflects it; HubSpot mock receives the push; an approval row is visible in the approval log. +- Approver toggles PIBI selections; the drawer state reflects the selection; HubSpot mock receives the PIBI push. +- A write user sets PIBI order and completion dates; the drawer reflects them. +- Approver halts a property with a reason, then resumes it. +- Approver sets domna survey type and date. + +There is no prior art for unit tests in the app codebase; Vitest config and the first test files in this PRD establish the pattern. Existing Cypress specs in `cypress/e2e/` are prior art for the e2e additions. + +## Out of Scope + +- Reverse-confirm pipeline that sets `confirmed_in_hubspot_at` after HubSpot pull — depends on changes in the upstream HubSpot-pull repo. Tracked separately. +- Backfill of historical data from HubSpot text fields into `user_defined_deal_measures` — done separately by the data team after the upstream pipeline lands. +- "Other observations" / additional measure observations from coordinators — out of MVP. Will be revisited if it surfaces as a user need. +- Editable `technical_approved_measures_for_install` from this platform — phase 1 read-only only. +- Halt history (multiple halt/resume cycles preserved) — only current halt state is modelled. +- PG enum for measure names or domna survey type — TS-side validation only for now. +- Outbox / background worker pattern for HubSpot sync — premature given current scale. +- Hard workflow gating — explicitly avoided. Soft warnings only. + +## Further Notes + +- Delivery: single feature branch, four internal slices (data plumbing → instructed measures → PIBI → halt+domna+migration). Vercel preview deploy used for UAT before merge to `main`. +- The design preserves the existing approval batch UI for ordinary proposed-measure approvals; only instructed-measure writes bypass it. +- The HubSpot deal property `instructed_measures` does not yet exist in HubSpot. Coordinating its creation with the CRM admin is a prerequisite for the push path to succeed end-to-end. Until it exists, push calls will error and `pushed_at` will remain null; the local rows are still authoritative for in-app reads. +- The semicolon parser change is potentially user-visible the moment it lands — verify the upstream pipeline is producing semicolons before merging, or the parser change will break the existing read path. If timing is uncertain, make the parser tolerant (`split(/[,;]/)`) as a safety net and tighten later. diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 00000000..85770a38 --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,49 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by dropping + filler, articles, and pleasantries while keeping full technical accuracy. + Use when user says "caveman mode", "talk like caveman", "use caveman", + "less tokens", "be brief", or invokes /caveman. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Abbreviate common terms (DB/auth/config/req/res/fn/impl). Strip conjunctions. Use arrows for causality (X -> Y). One word when one word enough. + +Technical terms stay exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +### Examples + +**"Why React component re-render?"** + +> Inline obj prop -> new ref -> re-render. `useMemo`. + +**"Explain database connection pooling."** + +> Pool = reuse DB conn. Skip handshake -> fast under load. + +## Auto-Clarity Exception + +Drop caveman temporarily for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. + +Example -- destructive op: + +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> +> ```sql +> DROP TABLE users; +> ``` +> +> Caveman resume. Verify backup exist first. diff --git a/.agents/skills/diagnose/SKILL.md b/.agents/skills/diagnose/SKILL.md new file mode 100644 index 00000000..ed55bda2 --- /dev/null +++ b/.agents/skills/diagnose/SKILL.md @@ -0,0 +1,117 @@ +--- +name: diagnose +description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +--- + +# Diagnose + +A discipline for hard bugs. Skip phases only when explicitly justified. + +When exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching. + +## Phase 1 — Build a feedback loop + +**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you. + +Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** + +### Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +Build the right feedback loop, and the bug is 90% fixed. + +### Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +### Non-deterministic bugs + +The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +### When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. + +Do not proceed to Phase 2 until you have a loop you believe in. + +## Phase 2 — Reproduce + +Run the loop. Watch the bug appear. + +Confirm: + +- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. +- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. + +Do not proceed until you reproduce the bug. + +## Phase 3 — Hypothesise + +Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea. + +Each hypothesis must be **falsifiable**: state the prediction it makes. + +> Format: "If is the cause, then will make the bug disappear / will make it worse." + +If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it. + +**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK. + +## Phase 4 — Instrument + +Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.** + +Tool preference: + +1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs. +2. **Targeted logs** at the boundaries that distinguish hypotheses. +3. Never "log everything and grep". + +**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die. + +**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second. + +## Phase 5 — Fix + regression test + +Write the regression test **before the fix** — but only if there is a **correct seam** for it. + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. + +**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. + +If a correct seam exists: + +1. Turn the minimised repro into a failing test at that seam. +2. Watch it fail. +3. Apply the fix. +4. Watch it pass. +5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. + +## Phase 6 — Cleanup + post-mortem + +Required before declaring done: + +- [ ] Original repro no longer reproduces (re-run the Phase 1 loop) +- [ ] Regression test passes (or absence of seam is documented) +- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix) +- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location) +- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns + +**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started. diff --git a/.agents/skills/diagnose/scripts/hitl-loop.template.sh b/.agents/skills/diagnose/scripts/hitl-loop.template.sh new file mode 100644 index 00000000..40afc465 --- /dev/null +++ b/.agents/skills/diagnose/scripts/hitl-loop.template.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Human-in-the-loop reproduction loop. +# Copy this file, edit the steps below, and run it. +# The agent runs the script; the user follows prompts in their terminal. +# +# Usage: +# bash hitl-loop.template.sh +# +# Two helpers: +# step "" → show instruction, wait for Enter +# capture VAR "" → show question, read response into VAR +# +# At the end, captured values are printed as KEY=VALUE for the agent to parse. + +set -euo pipefail + +step() { + printf '\n>>> %s\n' "$1" + read -r -p " [Enter when done] " _ +} + +capture() { + local var="$1" question="$2" answer + printf '\n>>> %s\n' "$question" + read -r -p " > " answer + printf -v "$var" '%s' "$answer" +} + +# --- edit below --------------------------------------------------------- + +step "Open the app at http://localhost:3000 and sign in." + +capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)" + +capture ERROR_MSG "Paste the error message (or 'none'):" + +# --- edit above --------------------------------------------------------- + +printf '\n--- Captured ---\n' +printf 'ERRORED=%s\n' "$ERRORED" +printf 'ERROR_MSG=%s\n' "$ERROR_MSG" diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 00000000..bd04394c --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -0,0 +1,10 @@ +--- +name: grill-me +description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +--- + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +If a question can be answered by exploring the codebase, explore the codebase instead. diff --git a/.agents/skills/grill-with-docs/ADR-FORMAT.md b/.agents/skills/grill-with-docs/ADR-FORMAT.md new file mode 100644 index 00000000..da7e78ec --- /dev/null +++ b/.agents/skills/grill-with-docs/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily — only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited +- **Considered Options** — only when the rejected alternatives are worth remembering +- **Consequences** — only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months. diff --git a/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md b/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md new file mode 100644 index 00000000..ddfa247c --- /dev/null +++ b/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md @@ -0,0 +1,77 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A concise description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account + +## Relationships + +- An **Order** produces one or more **Invoices** +- An **Invoice** belongs to exactly one **Customer** + +## Example dialogue + +> **Dev:** "When a **Customer** places an **Order**, do we create the **Invoice** immediately?" +> **Domain expert:** "No — an **Invoice** is only generated once a **Fulfillment** is confirmed." + +## Flagged ambiguities + +- "account" was used to mean both **Customer** and **User** — resolved: these are distinct concepts. +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid. +- **Flag conflicts explicitly.** If a term is used ambiguously, call it out in "Flagged ambiguities" with a clear resolution. +- **Keep definitions tight.** One sentence max. Define what it IS, not what it does. +- **Show relationships.** Use bold term names and express cardinality where obvious. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. +- **Write an example dialogue.** A conversation between a dev and a domain expert that demonstrates how the terms interact naturally and clarifies boundaries between related concepts. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 00000000..6dad6ad7 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -0,0 +1,88 @@ +--- +name: grill-with-docs +description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +--- + + + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. + +If a question can be answered by exploring the codebase, explore the codebase instead. + + + + + +## Domain awareness + +During codebase exploration, also look for existing documentation: + +### File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +Don't couple `CONTEXT.md` to implementation details. Only include terms that are meaningful to domain experts. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). + + diff --git a/.agents/skills/improve-codebase-architecture/DEEPENING.md b/.agents/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 00000000..ecaf5d7d --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 00000000..3197723a --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.agents/skills/improve-codebase-architecture/LANGUAGE.md b/.agents/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 00000000..530c2763 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.agents/skills/improve-codebase-architecture/SKILL.md b/.agents/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 00000000..05984a60 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/.agents/skills/setup-matt-pocock-skills/SKILL.md b/.agents/skills/setup-matt-pocock-skills/SKILL.md new file mode 100644 index 00000000..1ebc6e14 --- /dev/null +++ b/.agents/skills/setup-matt-pocock-skills/SKILL.md @@ -0,0 +1,121 @@ +--- +name: setup-matt-pocock-skills +description: Sets up an `## Agent skills` block in AGENTS.md/CLAUDE.md and `docs/agents/` so the engineering skills know this repo's issue tracker (GitHub or local markdown), triage label vocabulary, and domain doc layout. Run before first use of `to-issues`, `to-prd`, `triage`, `diagnose`, `tdd`, `improve-codebase-architecture`, or `zoom-out` — or if those skills appear to be missing context about the issue tracker, triage labels, or domain docs. +disable-model-invocation: true +--- + +# Setup Matt Pocock's Skills + +Scaffold the per-repo configuration that the engineering skills assume: + +- **Issue tracker** — where issues live (GitHub by default; local markdown is also supported out of the box) +- **Triage labels** — the strings used for the five canonical triage roles +- **Domain docs** — where `CONTEXT.md` and ADRs live, and the consumer rules for reading them + +This is a prompt-driven skill, not a deterministic script. Explore, present what you found, confirm with the user, then write. + +## Process + +### 1. Explore + +Look at the current repo to understand its starting state. Read whatever exists; don't assume: + +- `git remote -v` and `.git/config` — is this a GitHub repo? Which one? +- `AGENTS.md` and `CLAUDE.md` at the repo root — does either exist? Is there already an `## Agent skills` section in either? +- `CONTEXT.md` and `CONTEXT-MAP.md` at the repo root +- `docs/adr/` and any `src/*/docs/adr/` directories +- `docs/agents/` — does this skill's prior output already exist? +- `.scratch/` — sign that a local-markdown issue tracker convention is already in use + +### 2. Present findings and ask + +Summarise what's present and what's missing. Then walk the user through the three decisions **one at a time** — present a section, get the user's answer, then move to the next. Don't dump all three at once. + +Assume the user does not know what these terms mean. Each section starts with a short explainer (what it is, why these skills need it, what changes if they pick differently). Then show the choices and the default. + +**Section A — Issue tracker.** + +> Explainer: The "issue tracker" is where issues live for this repo. Skills like `to-issues`, `triage`, `to-prd`, and `qa` read from and write to it — they need to know whether to call `gh issue create`, write a markdown file under `.scratch/`, or follow some other workflow you describe. Pick the place you actually track work for this repo. + +Default posture: these skills were designed for GitHub. If a `git remote` points at GitHub, propose that. If a `git remote` points at GitLab (`gitlab.com` or a self-hosted host), propose GitLab. Otherwise (or if the user prefers), offer: + +- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI) +- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI) +- **Local markdown** — issues live as files under `.scratch//` in this repo (good for solo projects or repos without a remote) +- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose + +**Section B — Triage label vocabulary.** + +> Explainer: When the `triage` skill processes an incoming issue, it moves it through a state machine — needs evaluation, waiting on reporter, ready for an AFK agent to pick up, ready for a human, or won't fix. To do that, it needs to apply labels (or the equivalent in your issue tracker) that match strings *you've actually configured*. If your repo already uses different label names (e.g. `bug:triage` instead of `needs-triage`), map them here so the skill applies the right ones instead of creating duplicates. + +The five canonical roles: + +- `needs-triage` — maintainer needs to evaluate +- `needs-info` — waiting on reporter +- `ready-for-agent` — fully specified, AFK-ready (an agent can pick it up with no human context) +- `ready-for-human` — needs human implementation +- `wontfix` — will not be actioned + +Default: each role's string equals its name. Ask the user if they want to override any. If their issue tracker has no existing labels, the defaults are fine. + +**Section C — Domain docs.** + +> Explainer: Some skills (`improve-codebase-architecture`, `diagnose`, `tdd`) read a `CONTEXT.md` file to learn the project's domain language, and `docs/adr/` for past architectural decisions. They need to know whether the repo has one global context or multiple (e.g. a monorepo with separate frontend/backend contexts) so they look in the right place. + +Confirm the layout: + +- **Single-context** — one `CONTEXT.md` + `docs/adr/` at the repo root. Most repos are this. +- **Multi-context** — `CONTEXT-MAP.md` at the root pointing to per-context `CONTEXT.md` files (typically a monorepo). + +### 3. Confirm and edit + +Show the user a draft of: + +- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules) +- The contents of `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md`, `docs/agents/domain.md` + +Let them edit before writing. + +### 4. Write + +**Pick the file to edit:** + +- If `CLAUDE.md` exists, edit it. +- Else if `AGENTS.md` exists, edit it. +- If neither exists, ask the user which one to create — don't pick for them. + +Never create `AGENTS.md` when `CLAUDE.md` already exists (or vice versa) — always edit the one that's already there. + +If an `## Agent skills` block already exists in the chosen file, update its contents in-place rather than appending a duplicate. Don't overwrite user edits to the surrounding sections. + +The block: + +```markdown +## Agent skills + +### Issue tracker + +[one-line summary of where issues are tracked]. See `docs/agents/issue-tracker.md`. + +### Triage labels + +[one-line summary of the label vocabulary]. See `docs/agents/triage-labels.md`. + +### Domain docs + +[one-line summary of layout — "single-context" or "multi-context"]. See `docs/agents/domain.md`. +``` + +Then write the three docs files using the seed templates in this skill folder as a starting point: + +- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker +- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker +- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker +- [triage-labels.md](./triage-labels.md) — label mapping +- [domain.md](./domain.md) — domain doc consumer rules + layout + +For "other" issue trackers, write `docs/agents/issue-tracker.md` from scratch using the user's description. + +### 5. Done + +Tell the user the setup is complete and which engineering skills will now read from these files. Mention they can edit `docs/agents/*.md` directly later — re-running this skill is only necessary if they want to switch issue trackers or restart from scratch. diff --git a/.agents/skills/setup-matt-pocock-skills/domain.md b/.agents/skills/setup-matt-pocock-skills/domain.md new file mode 100644 index 00000000..c97d6a6d --- /dev/null +++ b/.agents/skills/setup-matt-pocock-skills/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/.agents/skills/setup-matt-pocock-skills/issue-tracker-github.md b/.agents/skills/setup-matt-pocock-skills/issue-tracker-github.md new file mode 100644 index 00000000..cce77ecb --- /dev/null +++ b/.agents/skills/setup-matt-pocock-skills/issue-tracker-github.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/.agents/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md b/.agents/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md new file mode 100644 index 00000000..1993c585 --- /dev/null +++ b/.agents/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md @@ -0,0 +1,23 @@ +# Issue tracker: GitLab + +Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations. + +## Conventions + +- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor. +- **Read an issue**: `glab issue view --comments`. Use `-F json` for machine-readable output. +- **List issues**: `glab issue list --state opened -F json` with appropriate `--label` filters. Note that GitLab uses `opened` (not `open`) for the state value. +- **Comment on an issue**: `glab issue note --message "..."`. GitLab calls comments "notes". +- **Apply / remove labels**: `glab issue update --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag. +- **Close**: `glab issue close `. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note --message "..."`, then close. +- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`. + +Infer the repo from `git remote -v` — `glab` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitLab issue. + +## When a skill says "fetch the relevant ticket" + +Run `glab issue view --comments`. diff --git a/.agents/skills/setup-matt-pocock-skills/issue-tracker-local.md b/.agents/skills/setup-matt-pocock-skills/issue-tracker-local.md new file mode 100644 index 00000000..a2f08fb0 --- /dev/null +++ b/.agents/skills/setup-matt-pocock-skills/issue-tracker-local.md @@ -0,0 +1,19 @@ +# Issue tracker: Local Markdown + +Issues and PRDs for this repo live as markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch//` +- The PRD is `.scratch//PRD.md` +- Implementation issues are `.scratch//issues/-.md`, numbered from `01` +- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings) +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. diff --git a/.agents/skills/setup-matt-pocock-skills/triage-labels.md b/.agents/skills/setup-matt-pocock-skills/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/.agents/skills/setup-matt-pocock-skills/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/.agents/skills/tdd/SKILL.md b/.agents/skills/tdd/SKILL.md new file mode 100644 index 00000000..7a989411 --- /dev/null +++ b/.agents/skills/tdd/SKILL.md @@ -0,0 +1,109 @@ +--- +name: tdd +description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. +--- + +# Test-Driven Development + +## Philosophy + +**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. + +**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure. + +**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior. + +See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines. + +## Anti-Pattern: Horizontal Slices + +**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code." + +This produces **crap tests**: + +- Tests written in bulk test _imagined_ behavior, not _actual_ behavior +- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior +- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine +- You outrun your headlights, committing to test structure before understanding the implementation + +**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it. + +``` +WRONG (horizontal): + RED: test1, test2, test3, test4, test5 + GREEN: impl1, impl2, impl3, impl4, impl5 + +RIGHT (vertical): + RED→GREEN: test1→impl1 + RED→GREEN: test2→impl2 + RED→GREEN: test3→impl3 + ... +``` + +## Workflow + +### 1. Planning + +When exploring the codebase, use the project's domain glossary so that test names and interface vocabulary match the project's language, and respect ADRs in the area you're touching. + +Before writing any code: + +- [ ] Confirm with user what interface changes are needed +- [ ] Confirm with user which behaviors to test (prioritize) +- [ ] Identify opportunities for [deep modules](deep-modules.md) (small interface, deep implementation) +- [ ] Design interfaces for [testability](interface-design.md) +- [ ] List the behaviors to test (not implementation steps) +- [ ] Get user approval on the plan + +Ask: "What should the public interface look like? Which behaviors are most important to test?" + +**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case. + +### 2. Tracer Bullet + +Write ONE test that confirms ONE thing about the system: + +``` +RED: Write test for first behavior → test fails +GREEN: Write minimal code to pass → test passes +``` + +This is your tracer bullet - proves the path works end-to-end. + +### 3. Incremental Loop + +For each remaining behavior: + +``` +RED: Write next test → fails +GREEN: Minimal code to pass → passes +``` + +Rules: + +- One test at a time +- Only enough code to pass current test +- Don't anticipate future tests +- Keep tests focused on observable behavior + +### 4. Refactor + +After all tests pass, look for [refactor candidates](refactoring.md): + +- [ ] Extract duplication +- [ ] Deepen modules (move complexity behind simple interfaces) +- [ ] Apply SOLID principles where natural +- [ ] Consider what new code reveals about existing code +- [ ] Run tests after each refactor step + +**Never refactor while RED.** Get to GREEN first. + +## Checklist Per Cycle + +``` +[ ] Test describes behavior, not implementation +[ ] Test uses public interface only +[ ] Test would survive internal refactor +[ ] Code is minimal for this test +[ ] No speculative features added +``` diff --git a/.agents/skills/tdd/deep-modules.md b/.agents/skills/tdd/deep-modules.md new file mode 100644 index 00000000..0d9720cf --- /dev/null +++ b/.agents/skills/tdd/deep-modules.md @@ -0,0 +1,33 @@ +# Deep Modules + +From "A Philosophy of Software Design": + +**Deep module** = small interface + lots of implementation + +``` +┌─────────────────────┐ +│ Small Interface │ ← Few methods, simple params +├─────────────────────┤ +│ │ +│ │ +│ Deep Implementation│ ← Complex logic hidden +│ │ +│ │ +└─────────────────────┘ +``` + +**Shallow module** = large interface + little implementation (avoid) + +``` +┌─────────────────────────────────┐ +│ Large Interface │ ← Many methods, complex params +├─────────────────────────────────┤ +│ Thin Implementation │ ← Just passes through +└─────────────────────────────────┘ +``` + +When designing interfaces, ask: + +- Can I reduce the number of methods? +- Can I simplify the parameters? +- Can I hide more complexity inside? diff --git a/.agents/skills/tdd/interface-design.md b/.agents/skills/tdd/interface-design.md new file mode 100644 index 00000000..a0a20ca4 --- /dev/null +++ b/.agents/skills/tdd/interface-design.md @@ -0,0 +1,31 @@ +# Interface Design for Testability + +Good interfaces make testing natural: + +1. **Accept dependencies, don't create them** + + ```typescript + // Testable + function processOrder(order, paymentGateway) {} + + // Hard to test + function processOrder(order) { + const gateway = new StripeGateway(); + } + ``` + +2. **Return results, don't produce side effects** + + ```typescript + // Testable + function calculateDiscount(cart): Discount {} + + // Hard to test + function applyDiscount(cart): void { + cart.total -= discount; + } + ``` + +3. **Small surface area** + - Fewer methods = fewer tests needed + - Fewer params = simpler test setup diff --git a/.agents/skills/tdd/mocking.md b/.agents/skills/tdd/mocking.md new file mode 100644 index 00000000..71cbfee6 --- /dev/null +++ b/.agents/skills/tdd/mocking.md @@ -0,0 +1,59 @@ +# When to Mock + +Mock at **system boundaries** only: + +- External APIs (payment, email, etc.) +- Databases (sometimes - prefer test DB) +- Time/randomness +- File system (sometimes) + +Don't mock: + +- Your own classes/modules +- Internal collaborators +- Anything you control + +## Designing for Mockability + +At system boundaries, design interfaces that are easy to mock: + +**1. Use dependency injection** + +Pass external dependencies in rather than creating them internally: + +```typescript +// Easy to mock +function processPayment(order, paymentClient) { + return paymentClient.charge(order.total); +} + +// Hard to mock +function processPayment(order) { + const client = new StripeClient(process.env.STRIPE_KEY); + return client.charge(order.total); +} +``` + +**2. Prefer SDK-style interfaces over generic fetchers** + +Create specific functions for each external operation instead of one generic function with conditional logic: + +```typescript +// GOOD: Each function is independently mockable +const api = { + getUser: (id) => fetch(`/users/${id}`), + getOrders: (userId) => fetch(`/users/${userId}/orders`), + createOrder: (data) => fetch('/orders', { method: 'POST', body: data }), +}; + +// BAD: Mocking requires conditional logic inside the mock +const api = { + fetch: (endpoint, options) => fetch(endpoint, options), +}; +``` + +The SDK approach means: +- Each mock returns one specific shape +- No conditional logic in test setup +- Easier to see which endpoints a test exercises +- Type safety per endpoint diff --git a/.agents/skills/tdd/refactoring.md b/.agents/skills/tdd/refactoring.md new file mode 100644 index 00000000..8a444392 --- /dev/null +++ b/.agents/skills/tdd/refactoring.md @@ -0,0 +1,10 @@ +# Refactor Candidates + +After TDD cycle, look for: + +- **Duplication** → Extract function/class +- **Long methods** → Break into private helpers (keep tests on public interface) +- **Shallow modules** → Combine or deepen +- **Feature envy** → Move logic to where data lives +- **Primitive obsession** → Introduce value objects +- **Existing code** the new code reveals as problematic diff --git a/.agents/skills/tdd/tests.md b/.agents/skills/tdd/tests.md new file mode 100644 index 00000000..ff22f809 --- /dev/null +++ b/.agents/skills/tdd/tests.md @@ -0,0 +1,61 @@ +# Good and Bad Tests + +## Good Tests + +**Integration-style**: Test through real interfaces, not mocks of internal parts. + +```typescript +// GOOD: Tests observable behavior +test("user can checkout with valid cart", async () => { + const cart = createCart(); + cart.add(product); + const result = await checkout(cart, paymentMethod); + expect(result.status).toBe("confirmed"); +}); +``` + +Characteristics: + +- Tests behavior users/callers care about +- Uses public API only +- Survives internal refactors +- Describes WHAT, not HOW +- One logical assertion per test + +## Bad Tests + +**Implementation-detail tests**: Coupled to internal structure. + +```typescript +// BAD: Tests implementation details +test("checkout calls paymentService.process", async () => { + const mockPayment = jest.mock(paymentService); + await checkout(cart, payment); + expect(mockPayment.process).toHaveBeenCalledWith(cart.total); +}); +``` + +Red flags: + +- Mocking internal collaborators +- Testing private methods +- Asserting on call counts/order +- Test breaks when refactoring without behavior change +- Test name describes HOW not WHAT +- Verifying through external means instead of interface + +```typescript +// BAD: Bypasses interface to verify +test("createUser saves to database", async () => { + await createUser({ name: "Alice" }); + const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]); + expect(row).toBeDefined(); +}); + +// GOOD: Verifies through interface +test("createUser makes user retrievable", async () => { + const user = await createUser({ name: "Alice" }); + const retrieved = await getUser(user.id); + expect(retrieved.name).toBe("Alice"); +}); +``` diff --git a/.agents/skills/to-issues/SKILL.md b/.agents/skills/to-issues/SKILL.md new file mode 100644 index 00000000..5a407161 --- /dev/null +++ b/.agents/skills/to-issues/SKILL.md @@ -0,0 +1,81 @@ +--- +name: to-issues +description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into issues, create implementation tickets, or break down work into issues. +--- + +# To Issues + +Break a plan into independently-grabbable issues using vertical slices (tracer bullets). + +The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not. + +## Process + +### 1. Gather context + +Work from whatever is already in the conversation context. If the user passes an issue reference (issue number, URL, or path) as an argument, fetch it from the issue tracker and read its full body and comments. + +### 2. Explore the codebase (optional) + +If you have not already explored the codebase, do so to understand the current state of the code. Issue titles and descriptions should use the project's domain glossary vocabulary, and respect ADRs in the area you're touching. + +### 3. Draft vertical slices + +Break the plan into **tracer bullet** issues. Each issue is a thin vertical slice that cuts through ALL integration layers end-to-end, NOT a horizontal slice of one layer. + +Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an architectural decision or a design review. AFK slices can be implemented and merged without human interaction. Prefer AFK over HITL where possible. + + +- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests) +- A completed slice is demoable or verifiable on its own +- Prefer many thin slices over few thick ones + + +### 4. Quiz the user + +Present the proposed breakdown as a numbered list. For each slice, show: + +- **Title**: short descriptive name +- **Type**: HITL / AFK +- **Blocked by**: which other slices (if any) must complete first +- **User stories covered**: which user stories this addresses (if the source material has them) + +Ask the user: + +- Does the granularity feel right? (too coarse / too fine) +- Are the dependency relationships correct? +- Should any slices be merged or split further? +- Are the correct slices marked as HITL and AFK? + +Iterate until the user approves the breakdown. + +### 5. Publish the issues to the issue tracker + +For each approved slice, publish a new issue to the issue tracker. Use the issue body template below. Apply the `needs-triage` triage label so each issue enters the normal triage flow. + +Publish issues in dependency order (blockers first) so you can reference real issue identifiers in the "Blocked by" field. + + +## Parent + +A reference to the parent issue on the issue tracker (if the source was an existing issue, otherwise omit this section). + +## What to build + +A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation. + +## Acceptance criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Blocked by + +- A reference to the blocking ticket (if any) + +Or "None - can start immediately" if no blockers. + + + +Do NOT close or modify any parent issue. diff --git a/.agents/skills/to-prd/SKILL.md b/.agents/skills/to-prd/SKILL.md new file mode 100644 index 00000000..7bdc82a0 --- /dev/null +++ b/.agents/skills/to-prd/SKILL.md @@ -0,0 +1,74 @@ +--- +name: to-prd +description: Turn the current conversation context into a PRD and publish it to the project issue tracker. Use when user wants to create a PRD from the current context. +--- + +This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know. + +The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not. + +## Process + +1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching. + +2. Sketch out the major modules you will need to build or modify to complete the implementation. Actively look for opportunities to extract deep modules that can be tested in isolation. + +A deep module (as opposed to a shallow module) is one which encapsulates a lot of functionality in a simple, testable interface which rarely changes. + +Check with the user that these modules match their expectations. Check with the user which modules they want tests written for. + +3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `needs-triage` triage label so it enters the normal triage flow. + + + +## Problem Statement + +The problem that the user is facing, from the user's perspective. + +## Solution + +The solution to the problem, from the user's perspective. + +## User Stories + +A LONG, numbered list of user stories. Each user story should be in the format of: + +1. As an , I want a , so that + + +1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending + + +This list of user stories should be extremely extensive and cover all aspects of the feature. + +## Implementation Decisions + +A list of implementation decisions that were made. This can include: + +- The modules that will be built/modified +- The interfaces of those modules that will be modified +- Technical clarifications from the developer +- Architectural decisions +- Schema changes +- API contracts +- Specific interactions + +Do NOT include specific file paths or code snippets. They may end up being outdated very quickly. + +## Testing Decisions + +A list of testing decisions that were made. Include: + +- A description of what makes a good test (only test external behavior, not implementation details) +- Which modules will be tested +- Prior art for the tests (i.e. similar types of tests in the codebase) + +## Out of Scope + +A description of the things that are out of scope for this PRD. + +## Further Notes + +Any further notes about the feature. + + diff --git a/.agents/skills/triage/AGENT-BRIEF.md b/.agents/skills/triage/AGENT-BRIEF.md new file mode 100644 index 00000000..2efecdfe --- /dev/null +++ b/.agents/skills/triage/AGENT-BRIEF.md @@ -0,0 +1,168 @@ +# Writing Agent Briefs + +An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. + +## Principles + +### Durability over precision + +The issue may sit in `ready-for-agent` for days or weeks. The codebase will change in the meantime. Write the brief so it stays useful even as files are renamed, moved, or refactored. + +- **Do** describe interfaces, types, and behavioral contracts +- **Do** name specific types, function signatures, or config shapes that the agent should look for or modify +- **Don't** reference file paths — they go stale +- **Don't** reference line numbers +- **Don't** assume the current implementation structure will remain the same + +### Behavioral, not procedural + +Describe **what** the system should do, not **how** to implement it. The agent will explore the codebase fresh and make its own implementation decisions. + +- **Good:** "The `SkillConfig` type should accept an optional `schedule` field of type `CronExpression`" +- **Bad:** "Open src/types/skill.ts and add a schedule field on line 42" +- **Good:** "When a user runs `/triage` with no arguments, they should see a summary of issues needing attention" +- **Bad:** "Add a switch statement in the main handler function" + +### Complete acceptance criteria + +The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable. + +- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification" +- **Bad:** "Triage should work correctly" + +### Explicit scope boundaries + +State what is out of scope. This prevents the agent from gold-plating or making assumptions about adjacent features. + +## Template + +```markdown +## Agent Brief + +**Category:** bug / enhancement +**Summary:** one-line description of what needs to happen + +**Current behavior:** +Describe what happens now. For bugs, this is the broken behavior. +For enhancements, this is the status quo the feature builds on. + +**Desired behavior:** +Describe what should happen after the agent's work is complete. +Be specific about edge cases and error conditions. + +**Key interfaces:** +- `TypeName` — what needs to change and why +- `functionName()` return type — what it currently returns vs what it should return +- Config shape — any new configuration options needed + +**Acceptance criteria:** +- [ ] Specific, testable criterion 1 +- [ ] Specific, testable criterion 2 +- [ ] Specific, testable criterion 3 + +**Out of scope:** +- Thing that should NOT be changed or addressed in this issue +- Adjacent feature that might seem related but is separate +``` + +## Examples + +### Good agent brief (bug) + +```markdown +## Agent Brief + +**Category:** bug +**Summary:** Skill description truncation drops mid-word, producing broken output + +**Current behavior:** +When a skill description exceeds 1024 characters, it is truncated at exactly +1024 characters regardless of word boundaries. This produces descriptions +that end mid-word (e.g. "Use when the user wants to confi"). + +**Desired behavior:** +Truncation should break at the last word boundary before 1024 characters +and append "..." to indicate truncation. + +**Key interfaces:** +- The `SkillMetadata` type's `description` field — no type change needed, + but the validation/processing logic that populates it needs to respect + word boundaries +- Any function that reads SKILL.md frontmatter and extracts the description + +**Acceptance criteria:** +- [ ] Descriptions under 1024 chars are unchanged +- [ ] Descriptions over 1024 chars are truncated at the last word boundary + before 1024 chars +- [ ] Truncated descriptions end with "..." +- [ ] The total length including "..." does not exceed 1024 chars + +**Out of scope:** +- Changing the 1024 char limit itself +- Multi-line description support +``` + +### Good agent brief (enhancement) + +```markdown +## Agent Brief + +**Category:** enhancement +**Summary:** Add `.out-of-scope/` directory support for tracking rejected feature requests + +**Current behavior:** +When a feature request is rejected, the issue is closed with a `wontfix` label +and a comment. There is no persistent record of the decision or reasoning. +Future similar requests require the maintainer to recall or search for the +prior discussion. + +**Desired behavior:** +Rejected feature requests should be documented in `.out-of-scope/.md` +files that capture the decision, reasoning, and links to all issues that +requested the feature. When triaging new issues, these files should be +checked for matches. + +**Key interfaces:** +- Markdown file format in `.out-of-scope/` — each file should have a + `# Concept Name` heading, a `**Decision:**` line, a `**Reason:**` line, + and a `**Prior requests:**` list with issue links +- The triage workflow should read all `.out-of-scope/*.md` files early + and match incoming issues against them by concept similarity + +**Acceptance criteria:** +- [ ] Closing a feature as wontfix creates/updates a file in `.out-of-scope/` +- [ ] The file includes the decision, reasoning, and link to the closed issue +- [ ] If a matching `.out-of-scope/` file already exists, the new issue is + appended to its "Prior requests" list rather than creating a duplicate +- [ ] During triage, existing `.out-of-scope/` files are checked and surfaced + when a new issue matches a prior rejection + +**Out of scope:** +- Automated matching (human confirms the match) +- Reopening previously rejected features +- Bug reports (only enhancement rejections go to `.out-of-scope/`) +``` + +### Bad agent brief + +```markdown +## Agent Brief + +**Summary:** Fix the triage bug + +**What to do:** +The triage thing is broken. Look at the main file and fix it. +The function around line 150 has the issue. + +**Files to change:** +- src/triage/handler.ts (line 150) +- src/types.ts (line 42) +``` + +This is bad because: +- No category +- Vague description ("the triage thing is broken") +- References file paths and line numbers that will go stale +- No acceptance criteria +- No scope boundaries +- No description of current vs desired behavior diff --git a/.agents/skills/triage/OUT-OF-SCOPE.md b/.agents/skills/triage/OUT-OF-SCOPE.md new file mode 100644 index 00000000..cc8ea257 --- /dev/null +++ b/.agents/skills/triage/OUT-OF-SCOPE.md @@ -0,0 +1,101 @@ +# Out-of-Scope Knowledge Base + +The `.out-of-scope/` directory in a repo stores persistent records of rejected feature requests. It serves two purposes: + +1. **Institutional memory** — why a feature was rejected, so the reasoning isn't lost when the issue is closed +2. **Deduplication** — when a new issue comes in that matches a prior rejection, the skill can surface the previous decision instead of re-litigating it + +## Directory structure + +``` +.out-of-scope/ +├── dark-mode.md +├── plugin-system.md +└── graphql-api.md +``` + +One file per **concept**, not per issue. Multiple issues requesting the same thing are grouped under one file. + +## File format + +The file should be written in a relaxed, readable style — more like a short design document than a database entry. Use paragraphs, code samples, and examples to make the reasoning clear and useful to someone encountering it for the first time. + +```markdown +# Dark Mode + +This project does not support dark mode or user-facing theming. + +## Why this is out of scope + +The rendering pipeline assumes a single color palette defined in +`ThemeConfig`. Supporting multiple themes would require: + +- A theme context provider wrapping the entire component tree +- Per-component theme-aware style resolution +- A persistence layer for user theme preferences + +This is a significant architectural change that doesn't align with the +project's focus on content authoring. Theming is a concern for downstream +consumers who embed or redistribute the output. + +```ts +// The current ThemeConfig interface is not designed for runtime switching: +interface ThemeConfig { + colors: ColorPalette; // single palette, resolved at build time + fonts: FontStack; +} +``` + +## Prior requests + +- #42 — "Add dark mode support" +- #87 — "Night theme for accessibility" +- #134 — "Dark theme option" +``` + +### Naming the file + +Use a short, descriptive kebab-case name for the concept: `dark-mode.md`, `plugin-system.md`, `graphql-api.md`. The name should be recognizable enough that someone browsing the directory understands what was rejected without opening the file. + +### Writing the reason + +The reason should be substantive — not "we don't want this" but why. Good reasons reference: + +- Project scope or philosophy ("This project focuses on X; theming is a downstream concern") +- Technical constraints ("Supporting this would require Y, which conflicts with our Z architecture") +- Strategic decisions ("We chose to use A instead of B because...") + +The reason should be durable. Avoid referencing temporary circumstances ("we're too busy right now") — those aren't real rejections, they're deferrals. + +## When to check `.out-of-scope/` + +During triage (Step 1: Gather context), read all files in `.out-of-scope/`. When evaluating a new issue: + +- Check if the request matches an existing out-of-scope concept +- Matching is by concept similarity, not keyword — "night theme" matches `dark-mode.md` +- If there's a match, surface it to the maintainer: "This is similar to `.out-of-scope/dark-mode.md` — we rejected this before because [reason]. Do you still feel the same way?" + +The maintainer may: + +- **Confirm** — the new issue gets added to the existing file's "Prior requests" list, then closed +- **Reconsider** — the out-of-scope file gets deleted or updated, and the issue proceeds through normal triage +- **Disagree** — the issues are related but distinct, proceed with normal triage + +## When to write to `.out-of-scope/` + +Only when an **enhancement** (not a bug) is rejected as `wontfix`. The flow: + +1. Maintainer decides a feature request is out of scope +2. Check if a matching `.out-of-scope/` file already exists +3. If yes: append the new issue to the "Prior requests" list +4. If no: create a new file with the concept name, decision, reason, and first prior request +5. Post a comment on the issue explaining the decision and mentioning the `.out-of-scope/` file +6. Close the issue with the `wontfix` label + +## Updating or removing out-of-scope files + +If the maintainer changes their mind about a previously rejected concept: + +- Delete the `.out-of-scope/` file +- The skill does not need to reopen old issues — they're historical records +- The new issue that triggered the reconsideration proceeds through normal triage diff --git a/.agents/skills/triage/SKILL.md b/.agents/skills/triage/SKILL.md new file mode 100644 index 00000000..3dee68f9 --- /dev/null +++ b/.agents/skills/triage/SKILL.md @@ -0,0 +1,103 @@ +--- +name: triage +description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow. +--- + +# Triage + +Move issues on the project issue tracker through a small state machine of triage roles. + +Every comment or issue posted to the issue tracker during triage **must** start with this disclaimer: + +``` +> *This was generated by AI during triage.* +``` + +## Reference docs + +- [AGENT-BRIEF.md](AGENT-BRIEF.md) — how to write durable agent briefs +- [OUT-OF-SCOPE.md](OUT-OF-SCOPE.md) — how the `.out-of-scope/` knowledge base works + +## Roles + +Two **category** roles: + +- `bug` — something is broken +- `enhancement` — new feature or improvement + +Five **state** roles: + +- `needs-triage` — maintainer needs to evaluate +- `needs-info` — waiting on reporter for more information +- `ready-for-agent` — fully specified, ready for an AFK agent +- `ready-for-human` — needs human implementation +- `wontfix` — will not be actioned + +Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else. + +These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not. + +State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding. + +## Invocation + +The maintainer invokes `/triage` and describes what they want in natural language. Interpret the request and act. Examples: + +- "Show me anything that needs my attention" +- "Let's look at #42" +- "Move #42 to ready-for-agent" +- "What's ready for agents to pick up?" + +## Show what needs attention + +Query the issue tracker and present three buckets, oldest first: + +1. **Unlabeled** — never triaged. +2. **`needs-triage`** — evaluation in progress. +3. **`needs-info` with reporter activity since the last triage notes** — needs re-evaluation. + +Show counts and a one-line summary per issue. Let the maintainer pick. + +## Triage a specific issue + +1. **Gather context.** Read the full issue (body, comments, labels, reporter, dates). Parse any prior triage notes so you don't re-ask resolved questions. Explore the codebase using the project's domain glossary, respecting ADRs in the area. Read `.out-of-scope/*.md` and surface any prior rejection that resembles this issue. + +2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the issue. Wait for direction. + +3. **Reproduce (bugs only).** Before any grilling, attempt reproduction: read the reporter's steps, trace the relevant code, run tests or commands. Report what happened — successful repro with code path, failed repro, or insufficient detail (a strong `needs-info` signal). A confirmed repro makes a much stronger agent brief. + +4. **Grill (if needed).** If the issue needs fleshing out, run a `/grill-with-docs` session. + +5. **Apply the outcome:** + - `ready-for-agent` — post an agent brief comment ([AGENT-BRIEF.md](AGENT-BRIEF.md)). + - `ready-for-human` — same structure as an agent brief, but note why it can't be delegated (judgment calls, external access, design decisions, manual testing). + - `needs-info` — post triage notes (template below). + - `wontfix` (bug) — polite explanation, then close. + - `wontfix` (enhancement) — write to `.out-of-scope/`, link to it from a comment, then close ([OUT-OF-SCOPE.md](OUT-OF-SCOPE.md)). + - `needs-triage` — apply the role. Optional comment if there's partial progress. + +## Quick state override + +If the maintainer says "move #42 to ready-for-agent", trust them and apply the role directly. Confirm what you're about to do (role changes, comment, close), then act. Skip grilling. If moving to `ready-for-agent` without a grilling session, ask whether they want to write an agent brief. + +## Needs-info template + +```markdown +## Triage Notes + +**What we've established so far:** + +- point 1 +- point 2 + +**What we still need from you (@reporter):** + +- question 1 +- question 2 +``` + +Capture everything resolved during grilling under "established so far" so the work isn't lost. Questions must be specific and actionable, not "please provide more info". + +## Resuming a previous session + +If prior triage notes exist on the issue, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions. diff --git a/.agents/skills/write-a-skill/SKILL.md b/.agents/skills/write-a-skill/SKILL.md new file mode 100644 index 00000000..7339c8a3 --- /dev/null +++ b/.agents/skills/write-a-skill/SKILL.md @@ -0,0 +1,117 @@ +--- +name: write-a-skill +description: Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill. +--- + +# Writing Skills + +## Process + +1. **Gather requirements** - ask user about: + - What task/domain does the skill cover? + - What specific use cases should it handle? + - Does it need executable scripts or just instructions? + - Any reference materials to include? + +2. **Draft the skill** - create: + - SKILL.md with concise instructions + - Additional reference files if content exceeds 500 lines + - Utility scripts if deterministic operations needed + +3. **Review with user** - present draft and ask: + - Does this cover your use cases? + - Anything missing or unclear? + - Should any section be more/less detailed? + +## Skill Structure + +``` +skill-name/ +├── SKILL.md # Main instructions (required) +├── REFERENCE.md # Detailed docs (if needed) +├── EXAMPLES.md # Usage examples (if needed) +└── scripts/ # Utility scripts (if needed) + └── helper.js +``` + +## SKILL.md Template + +```md +--- +name: skill-name +description: Brief description of capability. Use when [specific triggers]. +--- + +# Skill Name + +## Quick start + +[Minimal working example] + +## Workflows + +[Step-by-step processes with checklists for complex tasks] + +## Advanced features + +[Link to separate files: See [REFERENCE.md](REFERENCE.md)] +``` + +## Description Requirements + +The description is **the only thing your agent sees** when deciding which skill to load. It's surfaced in the system prompt alongside all other installed skills. Your agent reads these descriptions and picks the relevant skill based on the user's request. + +**Goal**: Give your agent just enough info to know: + +1. What capability this skill provides +2. When/why to trigger it (specific keywords, contexts, file types) + +**Format**: + +- Max 1024 chars +- Write in third person +- First sentence: what it does +- Second sentence: "Use when [specific triggers]" + +**Good example**: + +``` +Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when user mentions PDFs, forms, or document extraction. +``` + +**Bad example**: + +``` +Helps with documents. +``` + +The bad example gives your agent no way to distinguish this from other document skills. + +## When to Add Scripts + +Add utility scripts when: + +- Operation is deterministic (validation, formatting) +- Same code would be generated repeatedly +- Errors need explicit handling + +Scripts save tokens and improve reliability vs generated code. + +## When to Split Files + +Split into separate files when: + +- SKILL.md exceeds 100 lines +- Content has distinct domains (finance vs sales schemas) +- Advanced features are rarely needed + +## Review Checklist + +After drafting, verify: + +- [ ] Description includes triggers ("Use when...") +- [ ] SKILL.md under 100 lines +- [ ] No time-sensitive info +- [ ] Consistent terminology +- [ ] Concrete examples included +- [ ] References one level deep diff --git a/.agents/skills/zoom-out/SKILL.md b/.agents/skills/zoom-out/SKILL.md new file mode 100644 index 00000000..1e7a5dc7 --- /dev/null +++ b/.agents/skills/zoom-out/SKILL.md @@ -0,0 +1,7 @@ +--- +name: zoom-out +description: Tell the agent to zoom out and give broader context or a higher-level perspective. Use when you're unfamiliar with a section of code or need to understand how it fits into the bigger picture. +disable-model-invocation: true +--- + +I don't know this area of code well. Go up a layer of abstraction. Give me a map of all the relevant modules and callers, using the project's domain glossary vocabulary. diff --git a/.claude/skills/caveman/SKILL.md b/.claude/skills/caveman/SKILL.md new file mode 100644 index 00000000..85770a38 --- /dev/null +++ b/.claude/skills/caveman/SKILL.md @@ -0,0 +1,49 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by dropping + filler, articles, and pleasantries while keeping full technical accuracy. + Use when user says "caveman mode", "talk like caveman", "use caveman", + "less tokens", "be brief", or invokes /caveman. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Abbreviate common terms (DB/auth/config/req/res/fn/impl). Strip conjunctions. Use arrows for causality (X -> Y). One word when one word enough. + +Technical terms stay exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +### Examples + +**"Why React component re-render?"** + +> Inline obj prop -> new ref -> re-render. `useMemo`. + +**"Explain database connection pooling."** + +> Pool = reuse DB conn. Skip handshake -> fast under load. + +## Auto-Clarity Exception + +Drop caveman temporarily for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. + +Example -- destructive op: + +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> +> ```sql +> DROP TABLE users; +> ``` +> +> Caveman resume. Verify backup exist first. diff --git a/.claude/skills/diagnose/SKILL.md b/.claude/skills/diagnose/SKILL.md new file mode 100644 index 00000000..ed55bda2 --- /dev/null +++ b/.claude/skills/diagnose/SKILL.md @@ -0,0 +1,117 @@ +--- +name: diagnose +description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +--- + +# Diagnose + +A discipline for hard bugs. Skip phases only when explicitly justified. + +When exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching. + +## Phase 1 — Build a feedback loop + +**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you. + +Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** + +### Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +Build the right feedback loop, and the bug is 90% fixed. + +### Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +### Non-deterministic bugs + +The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +### When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. + +Do not proceed to Phase 2 until you have a loop you believe in. + +## Phase 2 — Reproduce + +Run the loop. Watch the bug appear. + +Confirm: + +- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. +- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. + +Do not proceed until you reproduce the bug. + +## Phase 3 — Hypothesise + +Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea. + +Each hypothesis must be **falsifiable**: state the prediction it makes. + +> Format: "If is the cause, then will make the bug disappear / will make it worse." + +If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it. + +**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK. + +## Phase 4 — Instrument + +Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.** + +Tool preference: + +1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs. +2. **Targeted logs** at the boundaries that distinguish hypotheses. +3. Never "log everything and grep". + +**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die. + +**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second. + +## Phase 5 — Fix + regression test + +Write the regression test **before the fix** — but only if there is a **correct seam** for it. + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. + +**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. + +If a correct seam exists: + +1. Turn the minimised repro into a failing test at that seam. +2. Watch it fail. +3. Apply the fix. +4. Watch it pass. +5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. + +## Phase 6 — Cleanup + post-mortem + +Required before declaring done: + +- [ ] Original repro no longer reproduces (re-run the Phase 1 loop) +- [ ] Regression test passes (or absence of seam is documented) +- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix) +- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location) +- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns + +**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started. diff --git a/.claude/skills/diagnose/scripts/hitl-loop.template.sh b/.claude/skills/diagnose/scripts/hitl-loop.template.sh new file mode 100644 index 00000000..40afc465 --- /dev/null +++ b/.claude/skills/diagnose/scripts/hitl-loop.template.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Human-in-the-loop reproduction loop. +# Copy this file, edit the steps below, and run it. +# The agent runs the script; the user follows prompts in their terminal. +# +# Usage: +# bash hitl-loop.template.sh +# +# Two helpers: +# step "" → show instruction, wait for Enter +# capture VAR "" → show question, read response into VAR +# +# At the end, captured values are printed as KEY=VALUE for the agent to parse. + +set -euo pipefail + +step() { + printf '\n>>> %s\n' "$1" + read -r -p " [Enter when done] " _ +} + +capture() { + local var="$1" question="$2" answer + printf '\n>>> %s\n' "$question" + read -r -p " > " answer + printf -v "$var" '%s' "$answer" +} + +# --- edit below --------------------------------------------------------- + +step "Open the app at http://localhost:3000 and sign in." + +capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)" + +capture ERROR_MSG "Paste the error message (or 'none'):" + +# --- edit above --------------------------------------------------------- + +printf '\n--- Captured ---\n' +printf 'ERRORED=%s\n' "$ERRORED" +printf 'ERROR_MSG=%s\n' "$ERROR_MSG" diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md new file mode 100644 index 00000000..bd04394c --- /dev/null +++ b/.claude/skills/grill-me/SKILL.md @@ -0,0 +1,10 @@ +--- +name: grill-me +description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +--- + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +If a question can be answered by exploring the codebase, explore the codebase instead. diff --git a/.claude/skills/grill-with-docs/ADR-FORMAT.md b/.claude/skills/grill-with-docs/ADR-FORMAT.md new file mode 100644 index 00000000..da7e78ec --- /dev/null +++ b/.claude/skills/grill-with-docs/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily — only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited +- **Considered Options** — only when the rejected alternatives are worth remembering +- **Consequences** — only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months. diff --git a/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md b/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md new file mode 100644 index 00000000..ddfa247c --- /dev/null +++ b/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md @@ -0,0 +1,77 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A concise description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account + +## Relationships + +- An **Order** produces one or more **Invoices** +- An **Invoice** belongs to exactly one **Customer** + +## Example dialogue + +> **Dev:** "When a **Customer** places an **Order**, do we create the **Invoice** immediately?" +> **Domain expert:** "No — an **Invoice** is only generated once a **Fulfillment** is confirmed." + +## Flagged ambiguities + +- "account" was used to mean both **Customer** and **User** — resolved: these are distinct concepts. +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid. +- **Flag conflicts explicitly.** If a term is used ambiguously, call it out in "Flagged ambiguities" with a clear resolution. +- **Keep definitions tight.** One sentence max. Define what it IS, not what it does. +- **Show relationships.** Use bold term names and express cardinality where obvious. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. +- **Write an example dialogue.** A conversation between a dev and a domain expert that demonstrates how the terms interact naturally and clarifies boundaries between related concepts. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/.claude/skills/grill-with-docs/SKILL.md b/.claude/skills/grill-with-docs/SKILL.md new file mode 100644 index 00000000..6dad6ad7 --- /dev/null +++ b/.claude/skills/grill-with-docs/SKILL.md @@ -0,0 +1,88 @@ +--- +name: grill-with-docs +description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +--- + + + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. + +If a question can be answered by exploring the codebase, explore the codebase instead. + + + + + +## Domain awareness + +During codebase exploration, also look for existing documentation: + +### File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +Don't couple `CONTEXT.md` to implementation details. Only include terms that are meaningful to domain experts. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). + + diff --git a/.claude/skills/improve-codebase-architecture/DEEPENING.md b/.claude/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 00000000..ecaf5d7d --- /dev/null +++ b/.claude/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 00000000..3197723a --- /dev/null +++ b/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.claude/skills/improve-codebase-architecture/LANGUAGE.md b/.claude/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 00000000..530c2763 --- /dev/null +++ b/.claude/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.claude/skills/improve-codebase-architecture/SKILL.md b/.claude/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 00000000..05984a60 --- /dev/null +++ b/.claude/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/.claude/skills/ralph-loop/SKILL.md b/.claude/skills/ralph-loop/SKILL.md new file mode 100644 index 00000000..97696619 --- /dev/null +++ b/.claude/skills/ralph-loop/SKILL.md @@ -0,0 +1,210 @@ +--- +name: ralph-loop +description: Run a GitHub Project (v2) of issues against the current repo using Claude Code subagents — one fresh subagent per ticket. Subscription-only alternative to `agentic-toolkit run` (which uses sandcastle + Docker + Anthropic API). Use after `/to-project` once the project is ready. +--- + +# Ralph Loop + +Implement a GitHub Project's issues by dispatching each `Ready` issue to a fresh Claude Code subagent (`subagent_type=general-purpose`). Each subagent gets a clean context window — the "ralph loop" property that keeps long-running multi-ticket work coherent. + +This skill is the subscription-based counterpart to `agentic-toolkit run`. Pick the path that fits your setup: + +| Path | Auth | Sandbox | Cost | Parallelism | +|------|------|---------|------|-------------| +| `agentic-toolkit run` (sandcastle) | `ANTHROPIC_API_KEY` | Docker container | API metered | Per-container | +| `/ralph-loop` (this skill) | Claude Code subscription | None — host repo | Subscription flat | Serial (one per Claude session) | + +**Trade-offs you accept by using this skill:** + +- No sandbox isolation. The subagent has full tool access on your host repo. **Run on a clean checkout** with no uncommitted personal work. +- Serial only — one ticket at a time per Claude Code session. Cannot parallelise across machines. +- Visible: every tick happens in the foreground Claude Code UI. You can interrupt anytime. + +## Inputs + +Confirm the following before starting. If anything is missing, ask the user. + +- **Project number** (e.g. `2`). +- **Mode** — `per-ticket` or `single-pr`. Same semantics as the runner CLI. +- **Owner** — defaults to current `gh` repo owner (`gh repo view --json owner --jq .owner.login`). +- **Repo** — defaults to current `gh` repo (`gh repo view --json name --jq .name`). +- **Target repo path** — the local checkout the subagent will work in. Defaults to the current working directory. +- **Base branch** — defaults to current HEAD of the target repo (`git -C rev-parse --abbrev-ref HEAD`). + +Pre-flight: +- `gh auth status` shows scopes `project`, `read:project`, `repo`. If `project` missing, run `gh auth refresh -s project,read:project --hostname github.com`. +- The target repo working tree is clean (`git -C status --porcelain` is empty). If not, halt and tell the user. + +## Algorithm + +The loop runs in the current Claude Code conversation. The orchestrator (you) coordinates state via `gh` and dispatches one subagent per ticket via the Agent tool. + +### 1. Read project state + +Run a GraphQL query equivalent to the runner's `PROJECT_QUERY`: + +```graphql +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + projectV2(number: $number) { + id + field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } + items(first: 100) { + nodes { + id + fieldValues(first: 20) { + nodes { ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2SingleSelectField { name } } } } + } + content { + ... on Issue { id number title body labels(first: 20) { nodes { name } } assignees(first: 5) { nodes { login } } } + } + } + } + } + } +} +``` + +Parse each item to: `{ number, nodeId, itemId, title, body, labels, status, blockedBy, kind }` where: +- `kind = "HITL"` if labels include `hitl` or `ready-for-human`, else `"AFK"`. +- `blockedBy` = numbers extracted from the `## Blocked by` section of the body. + +If the Status field is missing any of the six required options (Backlog, Ready, In progress, In review, Needs human, Done), halt and tell the user to run `/to-project`. + +### 2. Autopromote + +For each issue with `kind === "AFK"` AND `status === "Backlog"` AND every `blockedBy` is either `Done` in this project OR not present in this project: set its Status to `Ready`. + +### 3. Compute current phase + pick next + +Topologically partition issues by `Blocked by` (issues with all blockers in earlier phases form phase N). The current phase = lowest-indexed phase with any non-Done issue. + +Within the current phase, `pickNextReady` = the lowest-numbered issue with `status === "Ready"` AND `kind === "AFK"`. + +If no such issue: +- `single-pr`: jump to step 9 (finalise). +- `per-ticket`: exit with "Phase N complete. Merge open PRs, then re-invoke." + +### 4. Claim + +- Set the issue's Status to `In progress`. +- Assign to the current viewer: `gh api user --jq .login` -> `gh issue edit --add-assignee ` (best-effort; ignore failure). + +### 5. Prepare branch + +Project slug = `-p` (matches CLI's `projectSlugFrom`). + +Branch name: +- `per-ticket`: `claude//-` (slug = lowercased, non-alphanumeric -> `-`, trimmed, max 50 chars). +- `single-pr`: `claude/` (reused across tickets). + +In the target repo: +```sh +git fetch origin +git checkout +git pull +if branch exists: git checkout +else: git checkout -b +``` + +### 6. Dispatch subagent + +Use the Agent tool. `subagent_type=general-purpose`. The prompt MUST be fully self-contained — the subagent has no memory of this conversation. + +Prompt template: + +```` +You are implementing GitHub issue # in /. + +# Issue title + + +# Issue body +<BODY> + +# Working environment +- Target repo path: <TARGET-REPO-PATH> +- You are on branch: <BRANCH-NAME> +- Branch base: <BASE-BRANCH> +- Do NOT change branches. Do NOT push. Do NOT open PRs. The orchestrator handles those. + +# Your task +1. Implement the issue's acceptance criteria fully. +2. If the repo has a test suite, run it and resolve failures before reporting complete. +3. Make commits as you go using the repo's commit style. Don't squash; the orchestrator may amend. +4. If you encounter ambiguity that blocks progress (e.g. unclear acceptance criteria, missing context, conflicting requirements), commit any progress made and report blocked rather than guessing. + +# Reporting back +Your FINAL message must be exactly one of: + +- `TICKET_COMPLETE: <one-line summary of what was implemented>` +- `TICKET_BLOCKED: <one-line reason and what's needed to unblock>` + +Do not include any text after that line. The orchestrator parses it. +```` + +### 7. Validate result + +After the subagent returns: + +- Read the last line of the subagent's final message. +- Count commits: `git -C <target> rev-list --count <base-branch>..HEAD`. +- Success = subagent reported `TICKET_COMPLETE` AND commit count > 0. +- Otherwise: failure (treat `TICKET_BLOCKED`, missing tag, or zero commits all as failure). + +### 8. After-success actions + +`per-ticket`: +- `git -C <target> push -u origin <branch>`. +- `gh pr create --base <base> --head <branch> --title "<title> (#<N>)" --body "Closes #<N>\n\nImplemented by /ralph-loop."`. +- Set Status = `In review`. +- Loop back to step 1 (state may have changed; re-read). + +`single-pr`: +- Leave branch local — no push, no PR yet. +- Loop back to step 1. + +### 9. Finalise (single-pr only) + +When `pickNextReady` returns nothing: +- `commits = git -C <target> rev-list --count <base>..<branch>`. +- If `commits === 0`: halt with "No commits to PR. Nothing was implemented." +- Else: `git push -u origin <branch>`; open ONE PR `Implement project #<projectNumber>` with body listing `Closes #<number>` for every non-Done issue. Set those issues' Status to `In review`. +- Print PR URL. + +## Failure handling (v1) + +Halt on first failure. No retry. Specifically, when step 7 detects failure: + +1. Set the failing issue's Status to `Needs human`. +2. Post a comment on the issue: + ``` + ### Automated run failed (/ralph-loop) + + <subagent's TICKET_BLOCKED reason or "Subagent returned without completing"> + + <details><summary>Last subagent message</summary> + + <last 6000 chars of subagent's final message> + + </details> + ``` +3. Stop the loop. Print: `Halted on issue #<N>: <reason>`. Tell the user to fix the underlying issue and re-invoke. + +Future versions can add retry logic mirroring `failure-handler.ts` in the toolkit's `src/modules/`. + +## Idempotency + +Re-invoking the skill is safe and resumes from current state: +- Issues already `Done` are excluded from phase computation. +- Issues `In review` are skipped (their PR is awaiting human merge). +- The loop picks up at the next `Ready` issue. + +In `single-pr` mode, the shared branch is reused across invocations — do not delete it between runs unless you're abandoning the project. + +## Notes + +- The skill needs `gh` CLI authed with `project + read:project + repo` scopes. The runner's CLI shares the same requirement; if you've already used `agentic-toolkit run` on the same machine, you're set. +- This skill must run with `Bash` and `Agent` tools enabled. The subagent uses standard `general-purpose` permissions (file edit, bash, etc.). +- For privacy/security, the subagent prompt includes the full issue body — do not put secrets in issue descriptions. +- The orchestrator (you, the calling Claude session) keeps full conversation context across the entire loop. Only the subagents reset. If the orchestrator's context fills up, the user can `/clear` and re-invoke; idempotency handles resumption. diff --git a/.claude/skills/setup-matt-pocock-skills/SKILL.md b/.claude/skills/setup-matt-pocock-skills/SKILL.md new file mode 100644 index 00000000..1ebc6e14 --- /dev/null +++ b/.claude/skills/setup-matt-pocock-skills/SKILL.md @@ -0,0 +1,121 @@ +--- +name: setup-matt-pocock-skills +description: Sets up an `## Agent skills` block in AGENTS.md/CLAUDE.md and `docs/agents/` so the engineering skills know this repo's issue tracker (GitHub or local markdown), triage label vocabulary, and domain doc layout. Run before first use of `to-issues`, `to-prd`, `triage`, `diagnose`, `tdd`, `improve-codebase-architecture`, or `zoom-out` — or if those skills appear to be missing context about the issue tracker, triage labels, or domain docs. +disable-model-invocation: true +--- + +# Setup Matt Pocock's Skills + +Scaffold the per-repo configuration that the engineering skills assume: + +- **Issue tracker** — where issues live (GitHub by default; local markdown is also supported out of the box) +- **Triage labels** — the strings used for the five canonical triage roles +- **Domain docs** — where `CONTEXT.md` and ADRs live, and the consumer rules for reading them + +This is a prompt-driven skill, not a deterministic script. Explore, present what you found, confirm with the user, then write. + +## Process + +### 1. Explore + +Look at the current repo to understand its starting state. Read whatever exists; don't assume: + +- `git remote -v` and `.git/config` — is this a GitHub repo? Which one? +- `AGENTS.md` and `CLAUDE.md` at the repo root — does either exist? Is there already an `## Agent skills` section in either? +- `CONTEXT.md` and `CONTEXT-MAP.md` at the repo root +- `docs/adr/` and any `src/*/docs/adr/` directories +- `docs/agents/` — does this skill's prior output already exist? +- `.scratch/` — sign that a local-markdown issue tracker convention is already in use + +### 2. Present findings and ask + +Summarise what's present and what's missing. Then walk the user through the three decisions **one at a time** — present a section, get the user's answer, then move to the next. Don't dump all three at once. + +Assume the user does not know what these terms mean. Each section starts with a short explainer (what it is, why these skills need it, what changes if they pick differently). Then show the choices and the default. + +**Section A — Issue tracker.** + +> Explainer: The "issue tracker" is where issues live for this repo. Skills like `to-issues`, `triage`, `to-prd`, and `qa` read from and write to it — they need to know whether to call `gh issue create`, write a markdown file under `.scratch/`, or follow some other workflow you describe. Pick the place you actually track work for this repo. + +Default posture: these skills were designed for GitHub. If a `git remote` points at GitHub, propose that. If a `git remote` points at GitLab (`gitlab.com` or a self-hosted host), propose GitLab. Otherwise (or if the user prefers), offer: + +- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI) +- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI) +- **Local markdown** — issues live as files under `.scratch/<feature>/` in this repo (good for solo projects or repos without a remote) +- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose + +**Section B — Triage label vocabulary.** + +> Explainer: When the `triage` skill processes an incoming issue, it moves it through a state machine — needs evaluation, waiting on reporter, ready for an AFK agent to pick up, ready for a human, or won't fix. To do that, it needs to apply labels (or the equivalent in your issue tracker) that match strings *you've actually configured*. If your repo already uses different label names (e.g. `bug:triage` instead of `needs-triage`), map them here so the skill applies the right ones instead of creating duplicates. + +The five canonical roles: + +- `needs-triage` — maintainer needs to evaluate +- `needs-info` — waiting on reporter +- `ready-for-agent` — fully specified, AFK-ready (an agent can pick it up with no human context) +- `ready-for-human` — needs human implementation +- `wontfix` — will not be actioned + +Default: each role's string equals its name. Ask the user if they want to override any. If their issue tracker has no existing labels, the defaults are fine. + +**Section C — Domain docs.** + +> Explainer: Some skills (`improve-codebase-architecture`, `diagnose`, `tdd`) read a `CONTEXT.md` file to learn the project's domain language, and `docs/adr/` for past architectural decisions. They need to know whether the repo has one global context or multiple (e.g. a monorepo with separate frontend/backend contexts) so they look in the right place. + +Confirm the layout: + +- **Single-context** — one `CONTEXT.md` + `docs/adr/` at the repo root. Most repos are this. +- **Multi-context** — `CONTEXT-MAP.md` at the root pointing to per-context `CONTEXT.md` files (typically a monorepo). + +### 3. Confirm and edit + +Show the user a draft of: + +- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules) +- The contents of `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md`, `docs/agents/domain.md` + +Let them edit before writing. + +### 4. Write + +**Pick the file to edit:** + +- If `CLAUDE.md` exists, edit it. +- Else if `AGENTS.md` exists, edit it. +- If neither exists, ask the user which one to create — don't pick for them. + +Never create `AGENTS.md` when `CLAUDE.md` already exists (or vice versa) — always edit the one that's already there. + +If an `## Agent skills` block already exists in the chosen file, update its contents in-place rather than appending a duplicate. Don't overwrite user edits to the surrounding sections. + +The block: + +```markdown +## Agent skills + +### Issue tracker + +[one-line summary of where issues are tracked]. See `docs/agents/issue-tracker.md`. + +### Triage labels + +[one-line summary of the label vocabulary]. See `docs/agents/triage-labels.md`. + +### Domain docs + +[one-line summary of layout — "single-context" or "multi-context"]. See `docs/agents/domain.md`. +``` + +Then write the three docs files using the seed templates in this skill folder as a starting point: + +- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker +- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker +- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker +- [triage-labels.md](./triage-labels.md) — label mapping +- [domain.md](./domain.md) — domain doc consumer rules + layout + +For "other" issue trackers, write `docs/agents/issue-tracker.md` from scratch using the user's description. + +### 5. Done + +Tell the user the setup is complete and which engineering skills will now read from these files. Mention they can edit `docs/agents/*.md` directly later — re-running this skill is only necessary if they want to switch issue trackers or restart from scratch. diff --git a/.claude/skills/setup-matt-pocock-skills/domain.md b/.claude/skills/setup-matt-pocock-skills/domain.md new file mode 100644 index 00000000..c97d6a6d --- /dev/null +++ b/.claude/skills/setup-matt-pocock-skills/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/.claude/skills/setup-matt-pocock-skills/issue-tracker-github.md b/.claude/skills/setup-matt-pocock-skills/issue-tracker-github.md new file mode 100644 index 00000000..cce77ecb --- /dev/null +++ b/.claude/skills/setup-matt-pocock-skills/issue-tracker-github.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment <number> --body "..."` +- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close <number> --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view <number> --comments`. diff --git a/.claude/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md b/.claude/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md new file mode 100644 index 00000000..1993c585 --- /dev/null +++ b/.claude/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md @@ -0,0 +1,23 @@ +# Issue tracker: GitLab + +Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations. + +## Conventions + +- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor. +- **Read an issue**: `glab issue view <number> --comments`. Use `-F json` for machine-readable output. +- **List issues**: `glab issue list --state opened -F json` with appropriate `--label` filters. Note that GitLab uses `opened` (not `open`) for the state value. +- **Comment on an issue**: `glab issue note <number> --message "..."`. GitLab calls comments "notes". +- **Apply / remove labels**: `glab issue update <number> --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag. +- **Close**: `glab issue close <number>`. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note <number> --message "..."`, then close. +- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`. + +Infer the repo from `git remote -v` — `glab` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitLab issue. + +## When a skill says "fetch the relevant ticket" + +Run `glab issue view <number> --comments`. diff --git a/.claude/skills/setup-matt-pocock-skills/issue-tracker-local.md b/.claude/skills/setup-matt-pocock-skills/issue-tracker-local.md new file mode 100644 index 00000000..a2f08fb0 --- /dev/null +++ b/.claude/skills/setup-matt-pocock-skills/issue-tracker-local.md @@ -0,0 +1,19 @@ +# Issue tracker: Local Markdown + +Issues and PRDs for this repo live as markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch/<feature-slug>/` +- The PRD is `.scratch/<feature-slug>/PRD.md` +- Implementation issues are `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` +- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings) +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. diff --git a/.claude/skills/setup-matt-pocock-skills/triage-labels.md b/.claude/skills/setup-matt-pocock-skills/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/.claude/skills/setup-matt-pocock-skills/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/.claude/skills/tdd/SKILL.md b/.claude/skills/tdd/SKILL.md new file mode 100644 index 00000000..7a989411 --- /dev/null +++ b/.claude/skills/tdd/SKILL.md @@ -0,0 +1,109 @@ +--- +name: tdd +description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. +--- + +# Test-Driven Development + +## Philosophy + +**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. + +**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure. + +**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior. + +See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines. + +## Anti-Pattern: Horizontal Slices + +**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code." + +This produces **crap tests**: + +- Tests written in bulk test _imagined_ behavior, not _actual_ behavior +- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior +- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine +- You outrun your headlights, committing to test structure before understanding the implementation + +**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it. + +``` +WRONG (horizontal): + RED: test1, test2, test3, test4, test5 + GREEN: impl1, impl2, impl3, impl4, impl5 + +RIGHT (vertical): + RED→GREEN: test1→impl1 + RED→GREEN: test2→impl2 + RED→GREEN: test3→impl3 + ... +``` + +## Workflow + +### 1. Planning + +When exploring the codebase, use the project's domain glossary so that test names and interface vocabulary match the project's language, and respect ADRs in the area you're touching. + +Before writing any code: + +- [ ] Confirm with user what interface changes are needed +- [ ] Confirm with user which behaviors to test (prioritize) +- [ ] Identify opportunities for [deep modules](deep-modules.md) (small interface, deep implementation) +- [ ] Design interfaces for [testability](interface-design.md) +- [ ] List the behaviors to test (not implementation steps) +- [ ] Get user approval on the plan + +Ask: "What should the public interface look like? Which behaviors are most important to test?" + +**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case. + +### 2. Tracer Bullet + +Write ONE test that confirms ONE thing about the system: + +``` +RED: Write test for first behavior → test fails +GREEN: Write minimal code to pass → test passes +``` + +This is your tracer bullet - proves the path works end-to-end. + +### 3. Incremental Loop + +For each remaining behavior: + +``` +RED: Write next test → fails +GREEN: Minimal code to pass → passes +``` + +Rules: + +- One test at a time +- Only enough code to pass current test +- Don't anticipate future tests +- Keep tests focused on observable behavior + +### 4. Refactor + +After all tests pass, look for [refactor candidates](refactoring.md): + +- [ ] Extract duplication +- [ ] Deepen modules (move complexity behind simple interfaces) +- [ ] Apply SOLID principles where natural +- [ ] Consider what new code reveals about existing code +- [ ] Run tests after each refactor step + +**Never refactor while RED.** Get to GREEN first. + +## Checklist Per Cycle + +``` +[ ] Test describes behavior, not implementation +[ ] Test uses public interface only +[ ] Test would survive internal refactor +[ ] Code is minimal for this test +[ ] No speculative features added +``` diff --git a/.claude/skills/tdd/deep-modules.md b/.claude/skills/tdd/deep-modules.md new file mode 100644 index 00000000..0d9720cf --- /dev/null +++ b/.claude/skills/tdd/deep-modules.md @@ -0,0 +1,33 @@ +# Deep Modules + +From "A Philosophy of Software Design": + +**Deep module** = small interface + lots of implementation + +``` +┌─────────────────────┐ +│ Small Interface │ ← Few methods, simple params +├─────────────────────┤ +│ │ +│ │ +│ Deep Implementation│ ← Complex logic hidden +│ │ +│ │ +└─────────────────────┘ +``` + +**Shallow module** = large interface + little implementation (avoid) + +``` +┌─────────────────────────────────┐ +│ Large Interface │ ← Many methods, complex params +├─────────────────────────────────┤ +│ Thin Implementation │ ← Just passes through +└─────────────────────────────────┘ +``` + +When designing interfaces, ask: + +- Can I reduce the number of methods? +- Can I simplify the parameters? +- Can I hide more complexity inside? diff --git a/.claude/skills/tdd/interface-design.md b/.claude/skills/tdd/interface-design.md new file mode 100644 index 00000000..a0a20ca4 --- /dev/null +++ b/.claude/skills/tdd/interface-design.md @@ -0,0 +1,31 @@ +# Interface Design for Testability + +Good interfaces make testing natural: + +1. **Accept dependencies, don't create them** + + ```typescript + // Testable + function processOrder(order, paymentGateway) {} + + // Hard to test + function processOrder(order) { + const gateway = new StripeGateway(); + } + ``` + +2. **Return results, don't produce side effects** + + ```typescript + // Testable + function calculateDiscount(cart): Discount {} + + // Hard to test + function applyDiscount(cart): void { + cart.total -= discount; + } + ``` + +3. **Small surface area** + - Fewer methods = fewer tests needed + - Fewer params = simpler test setup diff --git a/.claude/skills/tdd/mocking.md b/.claude/skills/tdd/mocking.md new file mode 100644 index 00000000..71cbfee6 --- /dev/null +++ b/.claude/skills/tdd/mocking.md @@ -0,0 +1,59 @@ +# When to Mock + +Mock at **system boundaries** only: + +- External APIs (payment, email, etc.) +- Databases (sometimes - prefer test DB) +- Time/randomness +- File system (sometimes) + +Don't mock: + +- Your own classes/modules +- Internal collaborators +- Anything you control + +## Designing for Mockability + +At system boundaries, design interfaces that are easy to mock: + +**1. Use dependency injection** + +Pass external dependencies in rather than creating them internally: + +```typescript +// Easy to mock +function processPayment(order, paymentClient) { + return paymentClient.charge(order.total); +} + +// Hard to mock +function processPayment(order) { + const client = new StripeClient(process.env.STRIPE_KEY); + return client.charge(order.total); +} +``` + +**2. Prefer SDK-style interfaces over generic fetchers** + +Create specific functions for each external operation instead of one generic function with conditional logic: + +```typescript +// GOOD: Each function is independently mockable +const api = { + getUser: (id) => fetch(`/users/${id}`), + getOrders: (userId) => fetch(`/users/${userId}/orders`), + createOrder: (data) => fetch('/orders', { method: 'POST', body: data }), +}; + +// BAD: Mocking requires conditional logic inside the mock +const api = { + fetch: (endpoint, options) => fetch(endpoint, options), +}; +``` + +The SDK approach means: +- Each mock returns one specific shape +- No conditional logic in test setup +- Easier to see which endpoints a test exercises +- Type safety per endpoint diff --git a/.claude/skills/tdd/refactoring.md b/.claude/skills/tdd/refactoring.md new file mode 100644 index 00000000..8a444392 --- /dev/null +++ b/.claude/skills/tdd/refactoring.md @@ -0,0 +1,10 @@ +# Refactor Candidates + +After TDD cycle, look for: + +- **Duplication** → Extract function/class +- **Long methods** → Break into private helpers (keep tests on public interface) +- **Shallow modules** → Combine or deepen +- **Feature envy** → Move logic to where data lives +- **Primitive obsession** → Introduce value objects +- **Existing code** the new code reveals as problematic diff --git a/.claude/skills/tdd/tests.md b/.claude/skills/tdd/tests.md new file mode 100644 index 00000000..ff22f809 --- /dev/null +++ b/.claude/skills/tdd/tests.md @@ -0,0 +1,61 @@ +# Good and Bad Tests + +## Good Tests + +**Integration-style**: Test through real interfaces, not mocks of internal parts. + +```typescript +// GOOD: Tests observable behavior +test("user can checkout with valid cart", async () => { + const cart = createCart(); + cart.add(product); + const result = await checkout(cart, paymentMethod); + expect(result.status).toBe("confirmed"); +}); +``` + +Characteristics: + +- Tests behavior users/callers care about +- Uses public API only +- Survives internal refactors +- Describes WHAT, not HOW +- One logical assertion per test + +## Bad Tests + +**Implementation-detail tests**: Coupled to internal structure. + +```typescript +// BAD: Tests implementation details +test("checkout calls paymentService.process", async () => { + const mockPayment = jest.mock(paymentService); + await checkout(cart, payment); + expect(mockPayment.process).toHaveBeenCalledWith(cart.total); +}); +``` + +Red flags: + +- Mocking internal collaborators +- Testing private methods +- Asserting on call counts/order +- Test breaks when refactoring without behavior change +- Test name describes HOW not WHAT +- Verifying through external means instead of interface + +```typescript +// BAD: Bypasses interface to verify +test("createUser saves to database", async () => { + await createUser({ name: "Alice" }); + const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]); + expect(row).toBeDefined(); +}); + +// GOOD: Verifies through interface +test("createUser makes user retrievable", async () => { + const user = await createUser({ name: "Alice" }); + const retrieved = await getUser(user.id); + expect(retrieved.name).toBe("Alice"); +}); +``` diff --git a/.claude/skills/to-issues/SKILL.md b/.claude/skills/to-issues/SKILL.md new file mode 100644 index 00000000..5a407161 --- /dev/null +++ b/.claude/skills/to-issues/SKILL.md @@ -0,0 +1,81 @@ +--- +name: to-issues +description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into issues, create implementation tickets, or break down work into issues. +--- + +# To Issues + +Break a plan into independently-grabbable issues using vertical slices (tracer bullets). + +The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not. + +## Process + +### 1. Gather context + +Work from whatever is already in the conversation context. If the user passes an issue reference (issue number, URL, or path) as an argument, fetch it from the issue tracker and read its full body and comments. + +### 2. Explore the codebase (optional) + +If you have not already explored the codebase, do so to understand the current state of the code. Issue titles and descriptions should use the project's domain glossary vocabulary, and respect ADRs in the area you're touching. + +### 3. Draft vertical slices + +Break the plan into **tracer bullet** issues. Each issue is a thin vertical slice that cuts through ALL integration layers end-to-end, NOT a horizontal slice of one layer. + +Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an architectural decision or a design review. AFK slices can be implemented and merged without human interaction. Prefer AFK over HITL where possible. + +<vertical-slice-rules> +- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests) +- A completed slice is demoable or verifiable on its own +- Prefer many thin slices over few thick ones +</vertical-slice-rules> + +### 4. Quiz the user + +Present the proposed breakdown as a numbered list. For each slice, show: + +- **Title**: short descriptive name +- **Type**: HITL / AFK +- **Blocked by**: which other slices (if any) must complete first +- **User stories covered**: which user stories this addresses (if the source material has them) + +Ask the user: + +- Does the granularity feel right? (too coarse / too fine) +- Are the dependency relationships correct? +- Should any slices be merged or split further? +- Are the correct slices marked as HITL and AFK? + +Iterate until the user approves the breakdown. + +### 5. Publish the issues to the issue tracker + +For each approved slice, publish a new issue to the issue tracker. Use the issue body template below. Apply the `needs-triage` triage label so each issue enters the normal triage flow. + +Publish issues in dependency order (blockers first) so you can reference real issue identifiers in the "Blocked by" field. + +<issue-template> +## Parent + +A reference to the parent issue on the issue tracker (if the source was an existing issue, otherwise omit this section). + +## What to build + +A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation. + +## Acceptance criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Blocked by + +- A reference to the blocking ticket (if any) + +Or "None - can start immediately" if no blockers. + +</issue-template> + +Do NOT close or modify any parent issue. diff --git a/.claude/skills/to-prd/SKILL.md b/.claude/skills/to-prd/SKILL.md new file mode 100644 index 00000000..7bdc82a0 --- /dev/null +++ b/.claude/skills/to-prd/SKILL.md @@ -0,0 +1,74 @@ +--- +name: to-prd +description: Turn the current conversation context into a PRD and publish it to the project issue tracker. Use when user wants to create a PRD from the current context. +--- + +This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know. + +The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not. + +## Process + +1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching. + +2. Sketch out the major modules you will need to build or modify to complete the implementation. Actively look for opportunities to extract deep modules that can be tested in isolation. + +A deep module (as opposed to a shallow module) is one which encapsulates a lot of functionality in a simple, testable interface which rarely changes. + +Check with the user that these modules match their expectations. Check with the user which modules they want tests written for. + +3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `needs-triage` triage label so it enters the normal triage flow. + +<prd-template> + +## Problem Statement + +The problem that the user is facing, from the user's perspective. + +## Solution + +The solution to the problem, from the user's perspective. + +## User Stories + +A LONG, numbered list of user stories. Each user story should be in the format of: + +1. As an <actor>, I want a <feature>, so that <benefit> + +<user-story-example> +1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending +</user-story-example> + +This list of user stories should be extremely extensive and cover all aspects of the feature. + +## Implementation Decisions + +A list of implementation decisions that were made. This can include: + +- The modules that will be built/modified +- The interfaces of those modules that will be modified +- Technical clarifications from the developer +- Architectural decisions +- Schema changes +- API contracts +- Specific interactions + +Do NOT include specific file paths or code snippets. They may end up being outdated very quickly. + +## Testing Decisions + +A list of testing decisions that were made. Include: + +- A description of what makes a good test (only test external behavior, not implementation details) +- Which modules will be tested +- Prior art for the tests (i.e. similar types of tests in the codebase) + +## Out of Scope + +A description of the things that are out of scope for this PRD. + +## Further Notes + +Any further notes about the feature. + +</prd-template> diff --git a/.claude/skills/to-project/SKILL.md b/.claude/skills/to-project/SKILL.md new file mode 100644 index 00000000..fd1a3c68 --- /dev/null +++ b/.claude/skills/to-project/SKILL.md @@ -0,0 +1,78 @@ +--- +name: to-project +description: Create a GitHub Project (v2) for the issues just produced by /to-issues, configure the Status field schema the agentic-toolkit runner expects, and set initial statuses (AFK no-blockers → Ready, AFK with blockers → Backlog, HITL → Needs human). Use after /to-issues to make a project the runner can execute against. +--- + +# To Project + +Create a repo-level GitHub Project (v2) from the issues `/to-issues` just produced, and prepare it so the Domna agentic-toolkit runner can execute against it without further configuration. + +The issue tracker and issue numbers should already be known from the conversation context (the most recent `/to-issues` run). If they aren't, ask the user for the issue numbers explicitly. + +## Process + +### 1. Gather context + +Confirm with the user: + +- **Project name** — defaults to the PRD title if a PRD is in context. +- **Project description** — defaults to a one-line summary of the PRD. +- **Repo** — the repo that owns the project. Defaults to the current `gh` repo. +- **Issue set** — the issue numbers to add. Defaults to issues created in the most recent `/to-issues` run. + +### 2. Create the project + +Use `gh project create` (or the GraphQL `createProjectV2` mutation) at the repo level. Project visibility defaults to whatever the repo's default is. Capture the project number and node id from the response. + +### 3. Configure the Status field + +The runner requires a single-select field named **`Status`** with these options, in this order: + +1. `Backlog` +2. `Ready` +3. `In progress` +4. `In review` +5. `Needs human` +6. `Done` + +If the project came with a default `Status` field, replace its options to match exactly — extra options are fine; missing ones must be added. If no `Status` field exists, create one. + +### 4. Add issues to the project + +For each issue in the issue set, add it to the project as an item. + +### 5. Set initial statuses + +For each issue, decide the starting status based on its labels and `Blocked by` references: + +| Issue properties | Starting status | +|-------------------------------------------------|------------------| +| Has `hitl` or `ready-for-human` label | `Needs human` | +| Has `Blocked by` references to issues *in this set* | `Backlog` | +| Otherwise (AFK, no remaining blockers) | `Ready` | + +Apply the status via the `updateProjectV2ItemFieldValue` mutation. + +### 6. Comment on each issue + +For each issue in the project, post a comment linking back to the project URL so the issue page shows the project context. One sentence is enough; example: + +> Tracked in project: <project URL> + +### 7. Confirm to the user + +Print: + +- Project URL +- Number of issues added, broken down by initial status (e.g. "3 Ready, 2 Backlog, 1 Needs human") +- Suggested next command, e.g.: + ``` + cd path/to/agentic-toolkit + GITHUB_TOKEN=... npx . run --project <N> --mode per-ticket --owner <OWNER> --repo <REPO> --target-repo <TARGET> + ``` + +## Notes + +- The project is repo-level, not org-level, by design (smaller blast radius, simpler permissions). If you need a cross-repo project, do it manually for now. +- Projects created by this skill are intended to be short-lived: scoped to one PRD's worth of work, archived or deleted on completion. +- The runner's `setStatus` calls assume the option names listed above. Don't rename or translate them. diff --git a/.claude/skills/triage/AGENT-BRIEF.md b/.claude/skills/triage/AGENT-BRIEF.md new file mode 100644 index 00000000..2efecdfe --- /dev/null +++ b/.claude/skills/triage/AGENT-BRIEF.md @@ -0,0 +1,168 @@ +# Writing Agent Briefs + +An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. + +## Principles + +### Durability over precision + +The issue may sit in `ready-for-agent` for days or weeks. The codebase will change in the meantime. Write the brief so it stays useful even as files are renamed, moved, or refactored. + +- **Do** describe interfaces, types, and behavioral contracts +- **Do** name specific types, function signatures, or config shapes that the agent should look for or modify +- **Don't** reference file paths — they go stale +- **Don't** reference line numbers +- **Don't** assume the current implementation structure will remain the same + +### Behavioral, not procedural + +Describe **what** the system should do, not **how** to implement it. The agent will explore the codebase fresh and make its own implementation decisions. + +- **Good:** "The `SkillConfig` type should accept an optional `schedule` field of type `CronExpression`" +- **Bad:** "Open src/types/skill.ts and add a schedule field on line 42" +- **Good:** "When a user runs `/triage` with no arguments, they should see a summary of issues needing attention" +- **Bad:** "Add a switch statement in the main handler function" + +### Complete acceptance criteria + +The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable. + +- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification" +- **Bad:** "Triage should work correctly" + +### Explicit scope boundaries + +State what is out of scope. This prevents the agent from gold-plating or making assumptions about adjacent features. + +## Template + +```markdown +## Agent Brief + +**Category:** bug / enhancement +**Summary:** one-line description of what needs to happen + +**Current behavior:** +Describe what happens now. For bugs, this is the broken behavior. +For enhancements, this is the status quo the feature builds on. + +**Desired behavior:** +Describe what should happen after the agent's work is complete. +Be specific about edge cases and error conditions. + +**Key interfaces:** +- `TypeName` — what needs to change and why +- `functionName()` return type — what it currently returns vs what it should return +- Config shape — any new configuration options needed + +**Acceptance criteria:** +- [ ] Specific, testable criterion 1 +- [ ] Specific, testable criterion 2 +- [ ] Specific, testable criterion 3 + +**Out of scope:** +- Thing that should NOT be changed or addressed in this issue +- Adjacent feature that might seem related but is separate +``` + +## Examples + +### Good agent brief (bug) + +```markdown +## Agent Brief + +**Category:** bug +**Summary:** Skill description truncation drops mid-word, producing broken output + +**Current behavior:** +When a skill description exceeds 1024 characters, it is truncated at exactly +1024 characters regardless of word boundaries. This produces descriptions +that end mid-word (e.g. "Use when the user wants to confi"). + +**Desired behavior:** +Truncation should break at the last word boundary before 1024 characters +and append "..." to indicate truncation. + +**Key interfaces:** +- The `SkillMetadata` type's `description` field — no type change needed, + but the validation/processing logic that populates it needs to respect + word boundaries +- Any function that reads SKILL.md frontmatter and extracts the description + +**Acceptance criteria:** +- [ ] Descriptions under 1024 chars are unchanged +- [ ] Descriptions over 1024 chars are truncated at the last word boundary + before 1024 chars +- [ ] Truncated descriptions end with "..." +- [ ] The total length including "..." does not exceed 1024 chars + +**Out of scope:** +- Changing the 1024 char limit itself +- Multi-line description support +``` + +### Good agent brief (enhancement) + +```markdown +## Agent Brief + +**Category:** enhancement +**Summary:** Add `.out-of-scope/` directory support for tracking rejected feature requests + +**Current behavior:** +When a feature request is rejected, the issue is closed with a `wontfix` label +and a comment. There is no persistent record of the decision or reasoning. +Future similar requests require the maintainer to recall or search for the +prior discussion. + +**Desired behavior:** +Rejected feature requests should be documented in `.out-of-scope/<concept>.md` +files that capture the decision, reasoning, and links to all issues that +requested the feature. When triaging new issues, these files should be +checked for matches. + +**Key interfaces:** +- Markdown file format in `.out-of-scope/` — each file should have a + `# Concept Name` heading, a `**Decision:**` line, a `**Reason:**` line, + and a `**Prior requests:**` list with issue links +- The triage workflow should read all `.out-of-scope/*.md` files early + and match incoming issues against them by concept similarity + +**Acceptance criteria:** +- [ ] Closing a feature as wontfix creates/updates a file in `.out-of-scope/` +- [ ] The file includes the decision, reasoning, and link to the closed issue +- [ ] If a matching `.out-of-scope/` file already exists, the new issue is + appended to its "Prior requests" list rather than creating a duplicate +- [ ] During triage, existing `.out-of-scope/` files are checked and surfaced + when a new issue matches a prior rejection + +**Out of scope:** +- Automated matching (human confirms the match) +- Reopening previously rejected features +- Bug reports (only enhancement rejections go to `.out-of-scope/`) +``` + +### Bad agent brief + +```markdown +## Agent Brief + +**Summary:** Fix the triage bug + +**What to do:** +The triage thing is broken. Look at the main file and fix it. +The function around line 150 has the issue. + +**Files to change:** +- src/triage/handler.ts (line 150) +- src/types.ts (line 42) +``` + +This is bad because: +- No category +- Vague description ("the triage thing is broken") +- References file paths and line numbers that will go stale +- No acceptance criteria +- No scope boundaries +- No description of current vs desired behavior diff --git a/.claude/skills/triage/OUT-OF-SCOPE.md b/.claude/skills/triage/OUT-OF-SCOPE.md new file mode 100644 index 00000000..cc8ea257 --- /dev/null +++ b/.claude/skills/triage/OUT-OF-SCOPE.md @@ -0,0 +1,101 @@ +# Out-of-Scope Knowledge Base + +The `.out-of-scope/` directory in a repo stores persistent records of rejected feature requests. It serves two purposes: + +1. **Institutional memory** — why a feature was rejected, so the reasoning isn't lost when the issue is closed +2. **Deduplication** — when a new issue comes in that matches a prior rejection, the skill can surface the previous decision instead of re-litigating it + +## Directory structure + +``` +.out-of-scope/ +├── dark-mode.md +├── plugin-system.md +└── graphql-api.md +``` + +One file per **concept**, not per issue. Multiple issues requesting the same thing are grouped under one file. + +## File format + +The file should be written in a relaxed, readable style — more like a short design document than a database entry. Use paragraphs, code samples, and examples to make the reasoning clear and useful to someone encountering it for the first time. + +```markdown +# Dark Mode + +This project does not support dark mode or user-facing theming. + +## Why this is out of scope + +The rendering pipeline assumes a single color palette defined in +`ThemeConfig`. Supporting multiple themes would require: + +- A theme context provider wrapping the entire component tree +- Per-component theme-aware style resolution +- A persistence layer for user theme preferences + +This is a significant architectural change that doesn't align with the +project's focus on content authoring. Theming is a concern for downstream +consumers who embed or redistribute the output. + +```ts +// The current ThemeConfig interface is not designed for runtime switching: +interface ThemeConfig { + colors: ColorPalette; // single palette, resolved at build time + fonts: FontStack; +} +``` + +## Prior requests + +- #42 — "Add dark mode support" +- #87 — "Night theme for accessibility" +- #134 — "Dark theme option" +``` + +### Naming the file + +Use a short, descriptive kebab-case name for the concept: `dark-mode.md`, `plugin-system.md`, `graphql-api.md`. The name should be recognizable enough that someone browsing the directory understands what was rejected without opening the file. + +### Writing the reason + +The reason should be substantive — not "we don't want this" but why. Good reasons reference: + +- Project scope or philosophy ("This project focuses on X; theming is a downstream concern") +- Technical constraints ("Supporting this would require Y, which conflicts with our Z architecture") +- Strategic decisions ("We chose to use A instead of B because...") + +The reason should be durable. Avoid referencing temporary circumstances ("we're too busy right now") — those aren't real rejections, they're deferrals. + +## When to check `.out-of-scope/` + +During triage (Step 1: Gather context), read all files in `.out-of-scope/`. When evaluating a new issue: + +- Check if the request matches an existing out-of-scope concept +- Matching is by concept similarity, not keyword — "night theme" matches `dark-mode.md` +- If there's a match, surface it to the maintainer: "This is similar to `.out-of-scope/dark-mode.md` — we rejected this before because [reason]. Do you still feel the same way?" + +The maintainer may: + +- **Confirm** — the new issue gets added to the existing file's "Prior requests" list, then closed +- **Reconsider** — the out-of-scope file gets deleted or updated, and the issue proceeds through normal triage +- **Disagree** — the issues are related but distinct, proceed with normal triage + +## When to write to `.out-of-scope/` + +Only when an **enhancement** (not a bug) is rejected as `wontfix`. The flow: + +1. Maintainer decides a feature request is out of scope +2. Check if a matching `.out-of-scope/` file already exists +3. If yes: append the new issue to the "Prior requests" list +4. If no: create a new file with the concept name, decision, reason, and first prior request +5. Post a comment on the issue explaining the decision and mentioning the `.out-of-scope/` file +6. Close the issue with the `wontfix` label + +## Updating or removing out-of-scope files + +If the maintainer changes their mind about a previously rejected concept: + +- Delete the `.out-of-scope/` file +- The skill does not need to reopen old issues — they're historical records +- The new issue that triggered the reconsideration proceeds through normal triage diff --git a/.claude/skills/triage/SKILL.md b/.claude/skills/triage/SKILL.md new file mode 100644 index 00000000..3dee68f9 --- /dev/null +++ b/.claude/skills/triage/SKILL.md @@ -0,0 +1,103 @@ +--- +name: triage +description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow. +--- + +# Triage + +Move issues on the project issue tracker through a small state machine of triage roles. + +Every comment or issue posted to the issue tracker during triage **must** start with this disclaimer: + +``` +> *This was generated by AI during triage.* +``` + +## Reference docs + +- [AGENT-BRIEF.md](AGENT-BRIEF.md) — how to write durable agent briefs +- [OUT-OF-SCOPE.md](OUT-OF-SCOPE.md) — how the `.out-of-scope/` knowledge base works + +## Roles + +Two **category** roles: + +- `bug` — something is broken +- `enhancement` — new feature or improvement + +Five **state** roles: + +- `needs-triage` — maintainer needs to evaluate +- `needs-info` — waiting on reporter for more information +- `ready-for-agent` — fully specified, ready for an AFK agent +- `ready-for-human` — needs human implementation +- `wontfix` — will not be actioned + +Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else. + +These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not. + +State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding. + +## Invocation + +The maintainer invokes `/triage` and describes what they want in natural language. Interpret the request and act. Examples: + +- "Show me anything that needs my attention" +- "Let's look at #42" +- "Move #42 to ready-for-agent" +- "What's ready for agents to pick up?" + +## Show what needs attention + +Query the issue tracker and present three buckets, oldest first: + +1. **Unlabeled** — never triaged. +2. **`needs-triage`** — evaluation in progress. +3. **`needs-info` with reporter activity since the last triage notes** — needs re-evaluation. + +Show counts and a one-line summary per issue. Let the maintainer pick. + +## Triage a specific issue + +1. **Gather context.** Read the full issue (body, comments, labels, reporter, dates). Parse any prior triage notes so you don't re-ask resolved questions. Explore the codebase using the project's domain glossary, respecting ADRs in the area. Read `.out-of-scope/*.md` and surface any prior rejection that resembles this issue. + +2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the issue. Wait for direction. + +3. **Reproduce (bugs only).** Before any grilling, attempt reproduction: read the reporter's steps, trace the relevant code, run tests or commands. Report what happened — successful repro with code path, failed repro, or insufficient detail (a strong `needs-info` signal). A confirmed repro makes a much stronger agent brief. + +4. **Grill (if needed).** If the issue needs fleshing out, run a `/grill-with-docs` session. + +5. **Apply the outcome:** + - `ready-for-agent` — post an agent brief comment ([AGENT-BRIEF.md](AGENT-BRIEF.md)). + - `ready-for-human` — same structure as an agent brief, but note why it can't be delegated (judgment calls, external access, design decisions, manual testing). + - `needs-info` — post triage notes (template below). + - `wontfix` (bug) — polite explanation, then close. + - `wontfix` (enhancement) — write to `.out-of-scope/`, link to it from a comment, then close ([OUT-OF-SCOPE.md](OUT-OF-SCOPE.md)). + - `needs-triage` — apply the role. Optional comment if there's partial progress. + +## Quick state override + +If the maintainer says "move #42 to ready-for-agent", trust them and apply the role directly. Confirm what you're about to do (role changes, comment, close), then act. Skip grilling. If moving to `ready-for-agent` without a grilling session, ask whether they want to write an agent brief. + +## Needs-info template + +```markdown +## Triage Notes + +**What we've established so far:** + +- point 1 +- point 2 + +**What we still need from you (@reporter):** + +- question 1 +- question 2 +``` + +Capture everything resolved during grilling under "established so far" so the work isn't lost. Questions must be specific and actionable, not "please provide more info". + +## Resuming a previous session + +If prior triage notes exist on the issue, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions. diff --git a/.claude/skills/write-a-skill/SKILL.md b/.claude/skills/write-a-skill/SKILL.md new file mode 100644 index 00000000..7339c8a3 --- /dev/null +++ b/.claude/skills/write-a-skill/SKILL.md @@ -0,0 +1,117 @@ +--- +name: write-a-skill +description: Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill. +--- + +# Writing Skills + +## Process + +1. **Gather requirements** - ask user about: + - What task/domain does the skill cover? + - What specific use cases should it handle? + - Does it need executable scripts or just instructions? + - Any reference materials to include? + +2. **Draft the skill** - create: + - SKILL.md with concise instructions + - Additional reference files if content exceeds 500 lines + - Utility scripts if deterministic operations needed + +3. **Review with user** - present draft and ask: + - Does this cover your use cases? + - Anything missing or unclear? + - Should any section be more/less detailed? + +## Skill Structure + +``` +skill-name/ +├── SKILL.md # Main instructions (required) +├── REFERENCE.md # Detailed docs (if needed) +├── EXAMPLES.md # Usage examples (if needed) +└── scripts/ # Utility scripts (if needed) + └── helper.js +``` + +## SKILL.md Template + +```md +--- +name: skill-name +description: Brief description of capability. Use when [specific triggers]. +--- + +# Skill Name + +## Quick start + +[Minimal working example] + +## Workflows + +[Step-by-step processes with checklists for complex tasks] + +## Advanced features + +[Link to separate files: See [REFERENCE.md](REFERENCE.md)] +``` + +## Description Requirements + +The description is **the only thing your agent sees** when deciding which skill to load. It's surfaced in the system prompt alongside all other installed skills. Your agent reads these descriptions and picks the relevant skill based on the user's request. + +**Goal**: Give your agent just enough info to know: + +1. What capability this skill provides +2. When/why to trigger it (specific keywords, contexts, file types) + +**Format**: + +- Max 1024 chars +- Write in third person +- First sentence: what it does +- Second sentence: "Use when [specific triggers]" + +**Good example**: + +``` +Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when user mentions PDFs, forms, or document extraction. +``` + +**Bad example**: + +``` +Helps with documents. +``` + +The bad example gives your agent no way to distinguish this from other document skills. + +## When to Add Scripts + +Add utility scripts when: + +- Operation is deterministic (validation, formatting) +- Same code would be generated repeatedly +- Errors need explicit handling + +Scripts save tokens and improve reliability vs generated code. + +## When to Split Files + +Split into separate files when: + +- SKILL.md exceeds 100 lines +- Content has distinct domains (finance vs sales schemas) +- Advanced features are rarely needed + +## Review Checklist + +After drafting, verify: + +- [ ] Description includes triggers ("Use when...") +- [ ] SKILL.md under 100 lines +- [ ] No time-sensitive info +- [ ] Consistent terminology +- [ ] Concrete examples included +- [ ] References one level deep diff --git a/.claude/skills/zoom-out/SKILL.md b/.claude/skills/zoom-out/SKILL.md new file mode 100644 index 00000000..1e7a5dc7 --- /dev/null +++ b/.claude/skills/zoom-out/SKILL.md @@ -0,0 +1,7 @@ +--- +name: zoom-out +description: Tell the agent to zoom out and give broader context or a higher-level perspective. Use when you're unfamiliar with a section of code or need to understand how it fits into the bigger picture. +disable-model-invocation: true +--- + +I don't know this area of code well. Go up a layer of abstraction. Give me a map of all the relevant modules and callers, using the project's domain glossary vocabulary. diff --git a/.impeccable/critique/2026-07-08T11-21-56Z__src-app-portfolio-slug-portfolio-reporting.md b/.impeccable/critique/2026-07-08T11-21-56Z__src-app-portfolio-slug-portfolio-reporting.md new file mode 100644 index 00000000..4433454b --- /dev/null +++ b/.impeccable/critique/2026-07-08T11-21-56Z__src-app-portfolio-slug-portfolio-reporting.md @@ -0,0 +1,83 @@ +--- +target: portfolio reporting surface +total_score: 29 +p0_count: 0 +p1_count: 3 +timestamp: 2026-07-08T11-21-56Z +slug: src-app-portfolio-slug-portfolio-reporting +--- +## Design Health Score — 29/40 (Good; consistency + error-recovery are the drags) + +| # | Heuristic | Score | Key issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Scenario fetch swaps KPI content behind stale `keepPreviousData` numbers with only a text "Loading scenario…"; no dimming/pending signal. | +| 2 | Match System / Real World | 4 | Domain language is excellent and board-legible ("Skip already-compliant homes", "≈ N cars", "was EPC C"). | +| 3 | User Control & Freedom | 3 | Drill collapses, combobox Escapes — but compare columns are frozen to the first 4 scenarios with no way to change them. | +| 4 | Consistency & Standards | 2 | Money formatting genuinely inconsistent (moneyFull / moneyCompact / moneyShort / formatNumber / raw toLocaleString); chip heights differ (h-7 / h-8 / h-9). | +| 5 | Error Prevention | 3 | Filters apply reversibly; nothing destructive. Date input unvalidated. | +| 6 | Recognition over Recall | 3 | Filter chips show full state inline; but 3 EPC filters demand a lodged-vs-modelled mental model. | +| 7 | Flexibility & Efficiency | 3 | Drill-everywhere + CSV + deep links; no keyboard shortcuts. | +| 8 | Aesthetic & Minimalist | 3 | Restrained, density-appropriate; 5-across KPI band + delta pills + units + sublinks is a lot of simultaneous signal. | +| 9 | Error Recovery | 2 | Error states are bare one-liners with no retry and no `role="alert"`. | +| 10 | Help & Documentation | 3 | Tooltips carry real methodology — but hover-only, so keyboard/touch can't reach the definitions. | + +## Anti-Patterns Verdict — NOT AI slop + +**LLM review:** This reads as authored, not generated. It actively avoids the tells — no gradient text, no ghost-cards, no over-rounding, no side-stripe borders, no hero-metric template. Colour discipline is genuinely strong and documented in code (EPC ramp + semantic green/red + navy/tan is the whole palette; `DeltaPill` decouples colour from sign). The one real tell: a tiny uppercase tracked eyebrow (`text-[0.68rem] font-bold uppercase tracking-wide`) on nearly every section (KpiBand, FilterChips ×3, MeasureAllocation, InvestmentLedger GroupLabel, DrillDownShelf, DataQuality), compounded by rendering several of them at `text-gray-400`. + +**Deterministic detector:** `detect.mjs` → exit 0, **0 findings**, across all 18 .tsx files. Verified genuine (re-ran `--no-config` and per-file; no ignore config). The detector corroborates the "not slop" verdict — none of the mechanical tells are present. It found nothing the review missed; the review found what a static scan can't (semantics, interaction, control flow). + +**Where the two agree, with hard numbers:** the detector quantifies the review's soft concerns — **43× `text-gray-400`** on content-bearing text, **0 focus styles across 17 buttons**, **0 reduced-motion guards**, **2 total `aria-` attributes and 0 `role=`** on custom controls including a component literally named ScenarioCombobox. + +## Overall Impression + +A genuinely well-composed, restrained B2B reporting surface that respects its numbers — the colour system and the drill-everywhere pattern are the real wins. It's let down almost entirely on the **accessibility layer**, which is not decorative here: WCAG AA is a stated product requirement for housing-sector procurement, and the surface currently ships with no custom focus states, sub-AA label contrast, and unlabelled custom controls. The single biggest opportunity is one focused accessibility pass — it would move three heuristics at once and is mostly mechanical. + +## What's Working + +1. **A single, honest colour system enforced in code.** `getEpcColorClass` drives every EPC surface; `DeltaPill.improved` makes colour mean "good/bad" not "up/down" (a bill fall and a SAP rise are both green); `EPC_TEXT_ON` hand-tunes text colour per band for the hard yellow-D and lime-C. This is the "figures treated with more care than decoration" bar, met. +2. **The compare table refuses to crown a winner** — per-row best-marker, no hero recommendation. That's a deliberate institutional-trust choice for a board audience, correctly capped at 4 columns for desk readability. +3. **One drill pattern for every count.** The same `DrillDownShelf` serves the reporting page, data-quality, measures and ladder bands; `tabular-nums` on every figure column. The uniform interaction is why differing panels don't read as an "identical card grid." + +## Priority Issues + +**[P1] No focus indicators anywhere — 0 focus styles across 17 buttons, and the ladder's focus box is structurally broken.** Detector: zero `focus:`/`focus-visible:` in the entire surface; every control relies on the UA default ring. Worse, EPC ladder bands are `<button className="contents">` (EpcLadder.tsx:51) — `display:contents` removes the button's box, so even a default ring has nothing to draw on, on the page's primary chart interaction. +- *Why it matters:* WCAG 2.4.7 fail; a keyboard user (Sam) can't see where they are. AA is a stated requirement. +- *Fix:* add a consistent `focus-visible:` ring token to every interactive element; make each ladder row the focusable element (grid item / inner wrapper with its own ring), not `contents`. + +**[P1] Load-bearing text at `text-gray-400` (~2.5:1) — 43 occurrences.** Not decoration: KPI labels and units (KpiBand.tsx:25,31), the combobox "VIEW" label and every scenario sub-line, and the data-quality issue *definitions* (DataQualityClientArea.tsx:151) — prose a board member must read to interpret the numbers. +- *Why it matters:* AA body-text fail on interpretive labels. +- *Fix:* promote content-bearing `text-gray-400` to `gray-600` (#4b5563 ≈ 7:1); reserve gray-400 for the "· est" suffix and genuinely decorative meta. + +**[P1] Compare columns are frozen to the first 4 scenarios.** `useState(() => scenarios.slice(0,4))` (CompareClientArea.tsx:142) with no setter exposed — the user cannot choose *which* scenarios to compare, which is the entire job of the compare page. +- *Why it matters:* the page's core action is impossible; a portfolio with >4 scenarios can't compare the ones it cares about. +- *Fix:* add/replace columns through the same searchable combobox (as the wireframe promised), keeping the 4-column cap. + +**[P2] Three divergent money formatters on one screen.** `moneyShort` (MeasureAllocation.tsx:25, no sign handling) vs `moneyCompact` (primitives.tsx:100) vs `moneyFull` vs raw `toLocaleString`. The allocation bar shows "£2.48m" while the ledger beside it shows "£2,477,000" for conceptually paired money. +- *Why it matters:* undermines "numbers are the product" trust; a numerate user reads it as a data bug. +- *Fix:* delete `moneyShort`, route everything through `moneyCompact`/`moneyFull` with one rule (compact for chart labels, full for ledgers). + +**[P2] Custom controls lack semantics & state announcements.** ScenarioCombobox has no `role="combobox"/"listbox"/"option"`, `aria-expanded`, or arrow-key navigation (it's a button-list in a popover); filter toggles carry no `aria-pressed`; error messages ("Could not load measures.") have no `role="alert"`; `animate-pulse` skeletons have no `motion-reduce:animate-none`. +- *Why it matters:* screen-reader users get no state changes; vestibular-sensitive users get unguarded motion. +- *Fix:* wire ARIA roles/state on the combobox and chips, `role="alert"` on error regions, `motion-reduce:animate-none` on skeletons, and a text/glyph (not colour-only 0.5rem dot) on the compare best-marker. + +## Persona Red Flags + +**Alex (power user):** compare columns hard-frozen (can't pick scenarios); money-format drift reads as a bug; no keyboard shortcut to open the combobox or page the drill shelf; "Open in property table →" lands unfiltered (DrillDownShelf.tsx:90) so the filtered set he expected is gone. + +**Sam (accessibility-dependent):** no visible focus box on the ladder (the primary interaction); load-bearing definitions live in hover-only tooltips whose `Info` triggers are non-focusable `<span>`s; KPI labels/definitions fail 4.5:1; error messages aren't announced; compare "best" is colour + a near-invisible dot. + +## Minor Observations + +- Semantic green `#0c6b4a` and tan `#a07c42`/`#9a5b10` are hardcoded hex (6× and 5×) rather than Tailwind tokens, despite `epc_*` and brand navy/tan being real tokens — they can drift. +- Combobox option list has no `aria-activedescendant`; arrow-keys don't move between options. +- Drill shelf renders below the fold with no scroll-into-view on open — clicking a ladder band can look like nothing happened. +- `tabular-nums` applied to GoalCallout prose (ordinary words) — a copy-paste tell. +- Three chip radii in play (`rounded`, `rounded-[5px]`, `rounded-full`) and three chip heights (h-7/h-8/h-9). +- The uppercase eyebrow appears on ~7 sections; deleting them and letting the navy `font-semibold` headings stand would remove the one AI tell and the one place accent colour does decoration work. + +## Questions to Consider + +1. The KPI band is 5-across to fill the row, but a board commits budget on ~2 of those numbers (average EPC, net cost). Is the 5th ("Energy use", no delta, no drill) earning its place, or is it there for grid symmetry? +2. Every section carries the same uppercase eyebrow. If you deleted them all, would anyone miss them — or is the eyebrow doing decoration the palette rules forbid? +3. The compare table proudly refuses to crown a winner, yet the reporting page's default view is "Recommended plans — the engine's best plan per home." Do those two stances contradict, and which does the board actually trust? diff --git a/CONTEXT.md b/CONTEXT.md index 70877a02..62859a70 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -116,6 +116,28 @@ _Avoid_: job, batch, trigger request, modelling task (the pipeline's execution r The property-selecting constraints of a Modelling run: postcode, property type, built form, and **Tags** (each multi-select). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all; Tags are the Portfolio's own Tags (multi-select is **any-of**). Resolution follows the Landlord-override rule for type/built-form (an override fact beats an EPC-derived value) and a **direct membership lookup** for Tags — but in all cases the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend, which reads the same Tag membership the app writes). See [ADR-0013](./docs/adr/0013-tags-are-app-owned-property-groupings.md). _Avoid_: property query, segment, search (the postcode-search journey is unrelated) +### Reporting + +**Gross cost**: +The all-in price of delivering a Scenario's works: construction works + project delivery + contingency. Nothing a landlord pays for sits outside it. +_Avoid_: total cost, capex, cost of works (that's the construction line only) + +**Net cost**: +Gross cost minus secured funding. The figure a board commits to. +_Avoid_: net investment, cost after grants + +**Project delivery**: +The professional and management costs of delivering works, as distinct from the construction works themselves. +_Avoid_: PC cost, prelims + +**Current stock**: +The reporting view of a Portfolio with no Scenario applied — every figure is Effective performance, aggregated. Also the "before" side of any scenario comparison ("Current → After scenario"). +_Avoid_: baseline (reserved for property-level performance concepts), portfolio overview + +**Scenario overlay**: +The "After scenario" read model — the aggregate reporting picture of a Portfolio once a plan selection is applied: the portfolio-after averages and EPC-band distribution, the works-list ledger (Gross cost, Net cost, homes upgraded, funding) and the genuine SAP uplift. The *after* half of every Current → After comparison. A plan selection is either a specific **Scenario**'s plans or the **recommended-plans default** (the latest `is_default` Plan per Property); the default view is the same overlay with no EPC target and the report-view filters (compliance window, lodged baseline — [ADR-0010](./docs/adr/0010-compliance-window-is-a-report-view-parameter.md)) inert. Computed once in `getScenarioOverlay` (`src/lib/reporting/overlay.ts`) and served by both scenario-metrics endpoints. See [ADR-0015](./docs/adr/0015-scenario-overlay-is-one-read-model.md). +_Avoid_: scenario metrics (the endpoint name, not the concept), after-distribution (only the band part), scenario results (Plans hold results; the overlay aggregates them) + ### Live projects **Project**: @@ -240,6 +262,14 @@ What the user is told about an EPC's trustworthiness, derived from EPC provenanc An `expired` property is **Estimated**: its picture was modelled, not read off a current certificate. That it *also* has an (out-of-date) certificate is a matter of EPC coverage, which the reporting cards use — not of this signal. _Avoid_: estimation notification, banner (those are component names) +**Likely downgrade**: +A property whose Lodged band sits **above** its Effective band — a re-survey would likely certificate it lower. Judged on **band movement only**; SAP-point differences within a band don't qualify. +_Avoid_: SAP downgrade (point-level, the retired signal), at-risk property + +**Likely upgrade**: +A property whose Effective band sits **above** its Lodged band — Landlord overrides or SAP 10.2 scoring improved the modelled picture, so a re-survey alone would likely certificate it higher, without works. The cheapest compliance move in a portfolio. +_Avoid_: quick win, free upgrade + ## Example dialogue > **Dev:** "If the **Combiner** finishes but the user hasn't clicked Finalise, what does the user see?" diff --git a/docs/adr/0001-data-backfills-outside-drizzle.md b/docs/adr/0001-data-backfills-outside-drizzle.md new file mode 100644 index 00000000..a35d1818 --- /dev/null +++ b/docs/adr/0001-data-backfills-outside-drizzle.md @@ -0,0 +1,71 @@ +# 1. Data backfills run outside drizzle migrations + +Date: 2026-06-07 + +## Status + +Accepted + +## Context + +We needed to denormalise two join tables onto `recommendation` — `plan_id` +(from `plan_recommendations`) and `material_id` + measure columns (from +`recommendation_materials`) — as the first step toward making those columns the +source of truth and dropping the join tables. + +The original migrations (0222–0225) did everything inside drizzle: + +1. `ADD COLUMN` + validating `ADD CONSTRAINT FK` + `CREATE INDEX` +2. a single full-table `UPDATE ... FROM <join table>` to backfill + +Run against the production `recommendation` table (millions of rows) it ran for +**3.5+ hours with no end in sight** and had to be killed. Diagnosis: + +- **drizzle wraps every pending migration in ONE transaction** + (`session.transaction(...)` in `pg-core/dialect`). So: + - the `ADD COLUMN` from 0222 took an `AccessExclusiveLock` on `recommendation` + on the very first statement and **held it for the entire run** — the whole + table was unreadable/unwritable for hours. + - an unrelated migration that happened to be pending in the same batch was + coupled to this one and would have rolled back with it. + - you cannot `COMMIT` between batches, so the backfill could only be one giant + statement — huge WAL, table bloat (~2×), and **EBS IO burst balance was + being drained** with no way to report progress or resume. + - `CREATE INDEX CONCURRENTLY` is impossible (it can't run in a transaction), + so the index was built non-concurrently *before* the backfill — meaning the + `UPDATE` also had to maintain that index for every row. + +The `2026_01_06_recommendation_cover.sql` file already in the repo (a manual, +un-journaled `CREATE INDEX CONCURRENTLY`) showed a prior author hitting the same +wall and stepping outside drizzle for it. + +## Decision + +**Schema DDL stays in drizzle migrations; data backfills and online operations +do not.** + +- drizzle migrations contain only instant, metadata-only DDL: `ADD COLUMN` + (nullable, no default) and `ADD CONSTRAINT FK ... NOT VALID`. These commit in + milliseconds and release the exclusive lock immediately. +- Backfills run as standalone, idempotent, resumable `tsx` scripts that **commit + in batches** (keyset-paginated by `id`), with a configurable inter-batch pause + to protect IO burst balance. +- Index creation (`CREATE INDEX CONCURRENTLY IF NOT EXISTS`) and FK validation + (`VALIDATE CONSTRAINT`) happen in that script, online, after the data is in. + +See `src/app/db/backfill-recommendation-denormalization.ts`. + +## Consequences + +- Deploys gain a step: after `npm run migration:migrate`, run the relevant + backfill script. This is a manual ops step, not part of the migrate command. +- Backfills no longer hold long locks, no longer block unrelated migrations, stay + within IO budget, and report progress + can be resumed after interruption. +- Because the columns are populated out-of-band, any later `SET NOT NULL` / + drop-join-table step must be its own migration, gated on the backfill having + completed and verified zero unexpected NULLs (note: `material_id` is expected + to remain nullable — not every recommendation has a material). +- Trade-off: backfills are no longer atomic with their schema change, and the + schema can briefly exist with unpopulated columns. The read paths must tolerate + NULLs until the backfill finishes — which is acceptable and was already the + case (the columns are new). diff --git a/docs/adr/0002-effective-performance-is-canonical-current.md b/docs/adr/0002-effective-performance-is-canonical-current.md new file mode 100644 index 00000000..2443b0eb --- /dev/null +++ b/docs/adr/0002-effective-performance-is-canonical-current.md @@ -0,0 +1,60 @@ +# 2. Effective performance is the canonical "current" for display and reporting + +Date: 2026-06-23 + +## Status + +Accepted (supersedes the "reporting stays on lodged" position recorded mid-migration in `docs/wip/new-modelling-ui-handover.md`). + +## Context + +`property_baseline_performance` carries two baselines per property: **Lodged +performance** (`lodged_*` — what was submitted to the government EPC register) +and **Effective performance** (`effective_*` — what the modelling engine +actually scored against, after a Rebaseline). See the glossary in +[CONTEXT.md](../../CONTEXT.md#baseline-performance). They diverge whenever +`rebaseline_reason != none` — most commonly `pre_sap10`, where the lodged +certificate used an older SAP version. In today's data 509 of 510 new-approach +properties are rebaselined, so the two baselines disagree for almost everyone. + +The plans (post-retrofit SAP/EPC, savings, valuation uplift) are all scored +against the **effective** baseline. If a property's displayed "current" is the +**lodged** figure, a plan reads as flat or as a downgrade — e.g. lodged C/78 vs +an effective baseline of C/71 with a post-retrofit of C/71.4. The first cut of +the migration switched only the per-property building-passport views to +effective and deliberately left the portfolio list + reporting aggregates on +lodged "to match the gov register." That left reporting's "current" disagreeing +with every property row's "current." + +## Decision + +**Effective performance is the canonical "current" everywhere we show or +aggregate a property's present-day state — per-property views *and* reporting. +Lodged performance is shown only as an explicit secondary "Lodged EPC" surface.** + +- Per-property resolvers (`resolvePropertyHeadline`, `resolveDetailsEpcMeta`, + `resolveNewConditionReport`) return `effective_*` for new-approach properties. +- Set-based fragments gain `effectiveSapSql` / `effectiveEpcBandSql`; the + portfolio property table and the reporting analytics (`AVG(sap)`, EPC-band + distribution) both read them. The original `lodged_*` fragments remain solely + to feed the dedicated "Lodged EPC" column/badge. +- The "Lodged EPC" surface renders only when a real certificate exists + (`source = lodged`); a `predicted` property shows the "estimated from nearby + homes" signal and no lodged figure, even though its `lodged_*` columns are + populated with mirrored estimates. +- Legacy (pre-cutoff) properties are unaffected: the fragments fall back to the + `property` row columns, and the provenance signal is `none`. + +## Consequences + +- Reporting totals no longer reconcile to the gov EPC register; they reflect the + modelled (effective) baseline. This is intentional — the register view is + available per-property via the Lodged EPC column. If a register-fidelity + reporting view is ever needed, it is a *separate* surface built on the retained + `lodged_*` fragments, not a reversion. +- "Current" in the UI now means Effective performance, contradicting the + glossary's advice to avoid "current performance" for Effective. Recorded as a + flagged ambiguity in CONTEXT.md so the next reader doesn't "correct" it. +- The split is the seam that matters: per-property object resolvers and the two + display fragments are effective; the lodged fragments still exist and must not + be deleted while the Lodged EPC surfaces use them. diff --git a/docs/adr/0010-compliance-window-is-a-report-view-parameter.md b/docs/adr/0010-compliance-window-is-a-report-view-parameter.md new file mode 100644 index 00000000..84d39f19 --- /dev/null +++ b/docs/adr/0010-compliance-window-is-a-report-view-parameter.md @@ -0,0 +1,61 @@ +# 10. The compliance-window filter is a report-view parameter, not a Scenario constraint + +Date: 2026-07-07 + +## Status + +Accepted + +## Context + +A recurring landlord question on Reporting: *"what if we don't do works on +properties whose EPC keeps them compliant, from a government perspective, +beyond 2030?"* — i.e. exclude homes that can demonstrate the target band with +an in-date certificate past a cut-off date, and see what the scenario costs +without them. + +Two places this could live: as a **constraint on the Scenario** (the optimiser +never plans works for those homes) or as a **filter on the report view** (the +modelling is unchanged; the report recomputes as if those homes' plans were +dropped). + +## Decision + +**A report-view parameter** on the scenario metrics endpoint (alongside +`hideNonCompliant` and the lodged-baseline toggle), configurable in the UI: + +- **Membership rule**: a home is "compliant beyond the window" iff its + **lodged** certificate shows the selected band or better (default **C**) + **and** remains valid past the selected date (default **1 Jan 2030**). Both + band and date are user-configurable. `predicted` homes never qualify — there + is no certificate to rely on (see EPC provenance in CONTEXT.md). +- **Scenario-side only**: skipped homes' plans drop out of the works-list + aggregates (costs, funding, benefits, homes upgraded) and they hold their + current band in the after-distribution. Current-stock figures are untouched; + the filter only exists when a scenario is selected. + +## Alternatives considered + +- **Scenario constraint (optimiser skips the homes)**: the "true" answer — the + engine would redistribute budget across the remaining stock. Rejected here + because Scenario configuration is immutable ([ADR-0003]) and adding a + post-hoc exclusion would either mutate that config or mint a near-duplicate + Scenario per date/band permutation. It also couples an exploratory, + slider-like question to a full modelling round-trip. +- **Whole-report filter**: hide the homes from every figure including current + stock. Rejected: the portfolio's home count would disagree with every other + surface, and the landlord's question is about works, not about the stock. + +## Consequences + +- The filtered works list is an **approximation**: plans were optimised with + the skipped homes in scope, so shared-cost effects (e.g. scaffolding + batching) are not re-optimised away. Acceptable for an exploratory filter; + the exact answer is future work — a re-model with an explicit exclusion set, + which would be a new Scenario capability, not this filter. +- The parameter travels in the URL, so filtered views are shareable and the + PDF can reproduce them. +- If landlords start committing budgets against filtered numbers rather than + exploring with them, that is the signal to build the re-model path. + +[ADR-0003]: ./0003-app-authored-scenarios.md diff --git a/docs/adr/0015-scenario-overlay-is-one-read-model.md b/docs/adr/0015-scenario-overlay-is-one-read-model.md new file mode 100644 index 00000000..b970ce72 --- /dev/null +++ b/docs/adr/0015-scenario-overlay-is-one-read-model.md @@ -0,0 +1,70 @@ +# 15. The Scenario overlay is one read model behind both metrics endpoints + +Date: 2026-07-20 + +## Status + +Accepted + +## Context + +The reporting "After scenario" aggregate — the back half of every +Current → After comparison (portfolio-after averages, the after EPC-band +distribution, the works-list ledger, the SAP uplift) — was implemented twice, +in two Next.js route handlers: + +- `api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics` (488 lines) +- `api/portfolio/[portfolioId]/scenario/default/metrics` (316 lines) + +They differed in exactly one thing: the plan selection. The `[scenarioId]` +route scopes to `scenario_id = <id>`; the `default` route scopes to +`is_default = true` (the recommended plan per property). Everything else — the +three-query structure, the `computeLedger` wiring, the SAP band bucketing, the +compliance-window predicate ([ADR-0010]), the response shape — was duplicated +and drifting independently. The heaviest, most business-critical reporting SQL +also lived *outside* the reporting folder, so "the reporting queries" were not +discoverable in one place, and the JSON both routes returned was untyped and +re-described by each of its three consumers (the metrics body, the Compare +table, the PDF). + +## Decision + +**One read model owns the overlay; the routes are thin adapters.** + +- `getScenarioOverlay(portfolioId, scope, filters, tags)` in + `src/lib/reporting/overlay.ts` owns all overlay SQL and shaping. `scope` is a + discriminated union — a specific Scenario, or the recommended-plans default. +- The **default (recommended-plans) view is the scenario view with no EPC + target and every report-view filter inert.** The compliance-window and + lodged-baseline filters are a Scenario concept ([ADR-0010]); the default view + passes an inert filter set, so it flows through the same code path with the + filters switched off. +- The overlay's joins are **conditional on what the active filters read**, so + an unfiltered overlay never joins the lodged-certificate or legacy tables — + strictly leaner than the previous always-join `[scenarioId]` route. +- `ScenarioOverlay` is a single exported type in `src/lib/reporting/model.ts` + (client-safe): the wire contract the producer returns and every consumer + imports. +- The two route handlers now only parse and validate the request, call + `getScenarioOverlay`, and return it (or 404). + +This lands the reporting reads in `src/lib/reporting/` next to the pure +`model.ts` and the `viewState.ts` URL state, following the repo's +`src/lib/<domain>/server.ts` convention rather than colocating data access in +the route folder. + +## Consequences + +- One definition of the after-scenario figures: band thresholds, the + compliance-window rule and the ledger wiring are fixed once. The unit of test + is one function, not two HTTP handlers. +- The default view gains a formal "inert filters" contract. A future decision + to honour, say, the compliance window in the recommended view becomes a + one-line change to the default adapter, not a second query to edit. +- The plans-only query dropped several `avg_*`/`total_*` columns that were + computed but never read in the response. +- Risk: the overlay is now shared, so a change intended for one view reaches + both. Mitigated by the typed contract and the scope/filters being explicit + inputs. + +[ADR-0010]: ./0010-compliance-window-is-a-report-view-parameter.md diff --git a/docs/adr/0016-sap-epc-threshold-ladder-single-owner.md b/docs/adr/0016-sap-epc-threshold-ladder-single-owner.md new file mode 100644 index 00000000..49dc7d52 --- /dev/null +++ b/docs/adr/0016-sap-epc-threshold-ladder-single-owner.md @@ -0,0 +1,52 @@ +# 16. The SAP↔EPC band threshold ladder has a single owner + +Date: 2026-07-20 + +## Status + +Accepted + +## Context + +The RdSAP band floors — A ≥ 92, B ≥ 81, C ≥ 69, D ≥ 55, E ≥ 39, F ≥ 21, else G +— are the most safety-critical constant in the domain: they decide which band a +home is in, and thus almost every reporting figure. They were written out in at +least five places, in both TypeScript and SQL: + +- `@/app/utils.sapToEpc` — the client ladder. +- `@/app/actions/recommendations.ts` — a hand-copied `sapToEpcLetter`, its own + comment admitting it "mirrors sapToEpc". +- `@/app/utils/epc.ts` — the `EPC_TO_SAP_MIN` / `EPC_TO_SAP_MAX` range records. +- `BAND_BUCKETS_SQL` and `EPC_MIN_SAP` in *both* scenario-metrics routes. + +`@/lib/epc/bands.ts` owned the band *letters* (drift-tested against the schema +enum) but not the *thresholds*. A re-scoring would have meant finding and +editing every copy in lockstep — exactly the kind of change that silently goes +wrong. + +## Decision + +**`@/lib/epc` is the single owner of the threshold ladder.** + +- `src/lib/epc/thresholds.ts` (pure, client-safe) holds `BAND_MIN_SAP` — the one + table — plus `sapToBand`, and derives `EPC_TO_SAP_MIN` / `EPC_TO_SAP_MAX` from + it rather than hand-maintaining them. +- `src/lib/epc/thresholdsSql.ts` (server-only) holds `sapBandBucketsSql`, the + SQL twin, built from the same `BAND_MIN_SAP`. Split from the pure module so + client bundles never pull in Drizzle. +- `@/app/utils.sapToEpc` and `@/app/utils/epc` now delegate to the canonical + ladder (keeping their historic names/paths so call sites are untouched); the + hand-copied `sapToEpcLetter` is deleted; both metrics routes use + `sapBandBucketsSql` / `BAND_MIN_SAP`. + +## Consequences + +- A re-scoring changes `BAND_MIN_SAP` and nothing else — TS and SQL stay in + lockstep by construction. +- `sapToBand` is `null → "Unknown"` and throws on a negative score (SAP is never + negative — surface the bad datum rather than bucket it into G); a test pins it + to the historic `sapToEpc` output for every integer 0–100. +- Follow-up (not done here): `@/lib/scenarios/model` still defines its own + `EPC_BANDS`; the reporting routes were pointed at `@/lib/epc/bands`, but that + second copy of the band letters remains for the scenarios domain to + consolidate. diff --git a/docs/adr/0017-likely-downgrade-is-band-movement-only.md b/docs/adr/0017-likely-downgrade-is-band-movement-only.md new file mode 100644 index 00000000..e05affca --- /dev/null +++ b/docs/adr/0017-likely-downgrade-is-band-movement-only.md @@ -0,0 +1,54 @@ +# 17. Likely downgrade is band-movement only; the SAP-05 signal is retired + +Date: 2026-07-20 + +## Status + +Accepted + +## Context + +**Likely downgrade** (CONTEXT.md) is defined as band movement: a home whose +lodged (register) band sits above its effective (modelled) band, so a re-survey +would likely certificate it lower — judged on band movement only, never SAP +points. + +Reporting had two live implementations over **near-disjoint populations**: + +- The current-stock **confidence strip** counted the legacy **SAP-05 point + comparison** (`current_sap_points < sap_05_score`), restricted to legacy + (pre-cutoff) properties. +- The **data-quality page** and the **drill-down** counted **band movement** + (`lodgedEpcBandSql < effectiveEpcBandSql`). + +These do not measure the same thing. For a legacy property, `lodgedEpcBandSql` +and `effectiveEpcBandSql` both resolve to the same column +(`p.current_epc_rating`), so band movement yields **zero** for exactly the +population the SAP-05 path covers. So the two surfaces could report different +"likely downgrade" counts for the same portfolio, and CONTEXT.md already labels +the point-level SAP signal "the retired signal". + +## Decision + +**Band movement is the single definition; the SAP-05 path is retired.** + +- New shared SQL fragments `likelyDowngradeSql` / `likelyUpgradeSql` in + `@/lib/services/epcSources` — the SQL twin of `model.classifyBandMovement`. +- `server.getLikelyDowngrades` (the confidence strip) now counts band movement, + so it agrees with the data-quality page. +- `getDataQualityMetrics` and the drill-down route use the shared fragments, so + all three surfaces read one definition. + +## Consequences + +- Legacy (pre-cutoff) portfolios now report likely-downgrades via band movement, + which is **0** where lodged and effective band are the same datum. This is a + deliberate, user-visible change: those homes never had a band-movement signal, + and the point-level one is retired. New-approach homes surface real band + movement through Rebaseline ([ADR-0002]). +- The `sap_05_*` columns are no longer read by reporting. They remain on + `property_details_epc` for now; dropping them is separate schema work. +- Likely downgrade/upgrade now has one SQL definition kept in step with the pure + `classifyBandMovement` — change the movement rule in one place. + +[ADR-0002]: ./0002-effective-performance-is-canonical-current.md diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index 8f4167a3..f918ff19 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -7,6 +7,7 @@ import { } from "@/app/db/schema/recommendations"; import { eq, inArray, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { sapToBand } from "@/lib/epc/thresholds"; // Maps specific recommendation types to their parent category. // Mirrors the categorisation in RecommendationContainer. @@ -55,17 +56,6 @@ const CONTINGENCIES: Record<string, number> = { sloping_ceiling_insulation: 0.26, }; -// Local SAP → EPC letter mapping (mirrors sapToEpc in @/app/utils) -function sapToEpcLetter(sapPoints: number): string { - if (sapPoints >= 92) return "A"; - if (sapPoints >= 81) return "B"; - if (sapPoints >= 69) return "C"; - if (sapPoints >= 55) return "D"; - if (sapPoints >= 39) return "E"; - if (sapPoints >= 21) return "F"; - return "G"; -} - /** * Sets a recommendation as the default for its category within a plan. * Clears the default flag from all other recommendations in the same category, @@ -208,7 +198,7 @@ export async function updatePlanMetrics( 0, ); const postSapPoints = currentSapPoints + sapPointsGain; - const postEpcRating = sapToEpcLetter(postSapPoints) as + const postEpcRating = sapToBand(postSapPoints) as | "A" | "B" | "C" diff --git a/src/app/api/portfolio/[portfolioId]/reporting/properties/route.test.ts b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.test.ts new file mode 100644 index 00000000..499c1f32 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ──────────────────────────────────────────────────────────── +const { mockDbExecute } = vi.hoisted(() => ({ + mockDbExecute: vi.fn(), +})); + +vi.mock("@/app/db/db", () => ({ + db: { + get execute() { + return mockDbExecute; + }, + }, +})); + +import { GET } from "./route"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRequest(query: string) { + return new NextRequest( + `http://localhost/api/portfolio/785/reporting/properties${query}`, + ); +} + +const props = { params: Promise.resolve({ portfolioId: "785" }) }; + +const dbRow = { + id: 41, + address: "14 Harewood Road", + postcode: "LS17 8QT", + epc_band: "D", + sap: 56, + bills: 1340, + carbon: 3.1, + provenance: "none", + total: 128, +}; + +beforeEach(() => { + mockDbExecute.mockReset(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET reporting drill-down properties", () => { + it("rejects an unknown filter", async () => { + const res = await GET(makeRequest("?filter=vibes"), props); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid filter" }); + }); + + it("rejects a band filter without a valid band", async () => { + const res = await GET(makeRequest("?filter=band&band=Z"), props); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid band" }); + }); + + it("rejects a measure filter without its scenario and measure type", async () => { + const res = await GET(makeRequest("?filter=measure"), props); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "measure filter requires scenarioId and measureType", + }); + }); + + it("lists the homes behind a band with pagination metadata", async () => { + mockDbExecute.mockResolvedValueOnce({ rows: [dbRow] }); + const res = await GET(makeRequest("?filter=band&band=D"), props); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + properties: [ + { + id: 41, + address: "14 Harewood Road", + postcode: "LS17 8QT", + epcBand: "D", + sap: 56, + bills: 1340, + carbon: 3.1, + provenance: "none", + }, + ], + total: 128, + page: 1, + pageSize: 25, + }); + }); + + it("caps the page size at 100", async () => { + mockDbExecute.mockResolvedValueOnce({ rows: [] }); + const res = await GET( + makeRequest("?filter=estimated&pageSize=500"), + props, + ); + const body = await res.json(); + expect(body.pageSize).toBe(100); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts new file mode 100644 index 00000000..d1eb59e7 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts @@ -0,0 +1,154 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { + newApproachJoins, + carbonSql, + billsSql, + effectiveSapSql, + effectiveEpcBandSql, + estimatedSql, + isExpiredSql, + likelyDowngradeSql, + likelyUpgradeSql, + provenanceSignalSql, +} from "@/lib/services/epcSources"; +import { EPC_BANDS } from "@/lib/epc/bands"; +import { tagFilterCondition } from "@/lib/reporting/tagFilterSql"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; + +/** + * Drill-down: the homes behind a reporting figure (PRD #370, Screen C). + * One filter per request; every count on the reporting page deep-links here. + * Band-movement filters implement the CONTEXT.md Likely downgrade / upgrade + * definitions (lodged band vs effective band; real certificates only — + * lodgedEpcBandSql renders NULL for predicted homes). + */ + +const FILTERS = [ + "band", + "estimated", + "expired", + "likely-downgrade", + "likely-upgrade", + "measure", +] as const; + +type Filter = (typeof FILTERS)[number]; + +const DEFAULT_PAGE_SIZE = 25; +const MAX_PAGE_SIZE = 100; + +function parsePositiveInt(value: string | null, fallback: number): number { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export async function GET( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + const pid = BigInt(portfolioId); + const params = request.nextUrl.searchParams; + + const filter = params.get("filter") as Filter | null; + if (!filter || !FILTERS.includes(filter)) { + return NextResponse.json({ error: "Invalid filter" }, { status: 400 }); + } + + const band = params.get("band"); + if (filter === "band" && (!band || !(EPC_BANDS as readonly string[]).includes(band))) { + return NextResponse.json({ error: "Invalid band" }, { status: 400 }); + } + + const scenarioId = params.get("scenarioId"); + const measureType = params.get("measureType"); + if (filter === "measure" && (!scenarioId || !measureType)) { + return NextResponse.json( + { error: "measure filter requires scenarioId and measureType" }, + { status: 400 }, + ); + } + + const page = parsePositiveInt(params.get("page"), 1); + const pageSize = Math.min( + parsePositiveInt(params.get("pageSize"), DEFAULT_PAGE_SIZE), + MAX_PAGE_SIZE, + ); + + const filterSql = { + band: sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown') = ${band}`, + estimated: sql`${estimatedSql(sql`e`)} = true`, + expired: sql`${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false`, + "likely-downgrade": likelyDowngradeSql, + "likely-upgrade": likelyUpgradeSql, + measure: sql`EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT ON (property_id) id + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${scenarioId ? BigInt(scenarioId) : null} + AND property_id = p.id + ORDER BY property_id, created_at DESC + ) lp + JOIN recommendation r + ON r.plan_id = lp.id + AND r.default = true + AND r.already_installed = false + AND r.measure_type = ${measureType} + )`, + }[filter]; + + const result = await db.execute(sql` + SELECT + p.id, + COALESCE(p.address, p.user_inputted_address) AS address, + COALESCE(p.postcode, p.user_inputted_postcode) AS postcode, + (${effectiveEpcBandSql})::text AS epc_band, + (${effectiveSapSql})::float AS sap, + (${billsSql(sql`e`)})::float AS bills, + (${carbonSql(sql`e`)})::float AS carbon, + ${provenanceSignalSql} AS provenance, + COUNT(*) OVER()::int AS total + FROM property p + LEFT JOIN property_details_epc e ON e.property_id = p.id + ${newApproachJoins} + WHERE p.portfolio_id = ${pid} + AND ${tagFilterCondition(parseReportingViewState(params).tags)} + AND ${filterSql} + ORDER BY address NULLS LAST, p.id + LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}; + `); + + type Row = { + id: number; + address: string | null; + postcode: string | null; + epc_band: string | null; + sap: number | null; + bills: number | null; + carbon: number | null; + provenance: string; + total: number; + }; + + const rows = result.rows as Row[]; + + return NextResponse.json({ + properties: rows.map((r) => ({ + id: r.id, + address: r.address, + postcode: r.postcode, + epcBand: r.epc_band, + sap: r.sap, + bills: r.bills, + carbon: r.carbon, + provenance: r.provenance, + })), + total: rows[0]?.total ?? 0, + page, + pageSize, + }); +} diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts deleted file mode 100644 index 21aaed37..00000000 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { db } from "@/app/db/db"; -import { sql } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; - -type MeasureAggregateRow = { - measure_type: string | null; - type: string | null; - includes_battery: boolean | null; - homes_count: number; - total_cost: number | null; - average_cost: number | null; -}; - -export async function GET( - request: NextRequest, - props: { params: Promise<{ portfolioId: string; scenarioId: string }> } -) { - const { portfolioId, scenarioId } = await props.params; - - const pid = BigInt(portfolioId); - const sid = BigInt(scenarioId); - - // Latest plan per property for this scenario, then its recommendations read - // through the DENORMALISED link: join by the indexed recommendation.property_id - // and scope to the plan via recommendation.plan_id. The plan_recommendations - // join table is retired (no rows for new-approach plans), so the old EXISTS - // against it returned zero measures. See the handover / ADR notes. - const result = await db.execute(sql` - SELECT - r.measure_type, - r.type, - COUNT(DISTINCT r.property_id)::int AS homes_count, - SUM(r.estimated_cost)::float AS total_cost, - AVG(r.estimated_cost)::float AS average_cost - FROM ( - SELECT DISTINCT ON (property_id) - id, property_id - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - ORDER BY property_id, created_at DESC - ) lp - JOIN recommendation r - ON r.property_id = lp.property_id - AND r.plan_id = lp.id - AND r.default = true - AND r.already_installed = false - GROUP BY - r.measure_type, - r.type - ORDER BY total_cost DESC; - `); - - const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({ - measureType: row.measure_type ?? "unknown", - type: row.type ?? "unknown", - homesCount: row.homes_count, - totalCost: Number(row.total_cost ?? 0), - averageCost: Number(row.average_cost ?? 0), - // includesBattery: row.includes_battery ?? false, - })); - - return NextResponse.json({ - portfolioId: Number(portfolioId), - scenarioId: Number(scenarioId), - measures, - }); -} diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts new file mode 100644 index 00000000..1063de09 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ──────────────────────────────────────────────────────────── +const { mockDbExecute } = vi.hoisted(() => ({ + mockDbExecute: vi.fn(), +})); + +vi.mock("@/app/db/db", () => ({ + db: { + get execute() { + return mockDbExecute; + }, + }, +})); + +import { GET } from "./route"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRequest(query = "") { + return new NextRequest( + `http://localhost/api/portfolio/785/scenario/12/metrics${query}`, + ); +} + +function makeProps(scenarioId = "12") { + return { params: Promise.resolve({ portfolioId: "785", scenarioId }) }; +} + +/** + * Queue the route's queries. The scenario definition resolves first; the three + * data queries (plans metrics, upgraded, portfolio-after) then run in one + * Promise.all wave, so their mocks are consumed in that array order. The + * portfolio-after query now also carries the EPC-band buckets (band_*), folded + * in from the old separate distribution scan. + */ +function queueHappyPath({ + goal = "Increasing EPC", + goalValue = "C", + upgraded = { + n_units_upgraded: 100, + total_cost: 1_000_000, + contingency: 100_000, + total_funding: 200_000, + }, +} = {}) { + mockDbExecute + .mockResolvedValueOnce({ rows: [{ goal, goal_value: goalValue }] }) + .mockResolvedValueOnce({ + rows: [ + { + n_units: 100, + avg_sap: 71, + avg_carbon: 1.9, + avg_bills: 941, + total_carbon: 593, + total_bills: 294_000, + total_sap_uplift: 2_834, + }, + ], + }) + .mockResolvedValueOnce({ rows: [upgraded] }) + .mockResolvedValueOnce({ + rows: [ + { + avg_sap: 71, + avg_carbon: 1.9, + avg_bills: 941, + total_carbon: 593, + total_bills: 294_000, + band_a: 0, + band_b: 10, + band_c: 60, + band_d: 20, + band_e: 8, + band_f: 2, + band_g: 0, + band_unknown: 0, + }, + ], + }); +} + +beforeEach(() => { + mockDbExecute.mockReset(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET scenario metrics", () => { + it("rejects an invalid scenarioId", async () => { + const res = await GET(makeRequest(), makeProps("null")); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid scenarioId" }); + }); + + it("404s an unknown scenario", async () => { + mockDbExecute.mockResolvedValueOnce({ rows: [] }); + const res = await GET(makeRequest(), makeProps()); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Scenario not found" }); + }); + + it("reports the canonical all-in gross and net (CONTEXT.md definitions)", async () => { + queueHappyPath(); + const res = await GET(makeRequest(), makeProps()); + expect(res.status).toBe(200); + const body = await res.json(); + + // gross = construction + delivery(30%) + contingency; net = gross − funding + expect(body.construction_cost).toBe(1_000_000); + expect(body.pc_cost).toBe(300_000); + expect(body.contingency).toBe(100_000); + expect(body.total_funding).toBe(200_000); + expect(body.gross_cost).toBe(1_400_000); + expect(body.net_cost).toBe(1_200_000); + expect(body.gross_per_unit).toBe(14_000); + }); + + it("passes the SQL-bucketed EPC-band counts straight through", async () => { + queueHappyPath(); + const res = await GET(makeRequest(), makeProps()); + const body = await res.json(); + + // The band_* columns from the merged portfolio-after query map onto the + // scenario_epc_counts the ladder consumes — no JS re-bucketing. + expect(body.scenario_epc_counts).toEqual({ + A: 0, + B: 10, + C: 60, + D: 20, + E: 8, + F: 2, + G: 0, + Unknown: 0, + }); + }); + + // Compliance window — ADR-0010: report-view parameter, configurable band + date. + + it("rejects a compliance band outside A–G", async () => { + const res = await GET( + makeRequest("?complianceDate=2030-01-01&complianceBand=Z"), + makeProps(), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid complianceBand" }); + }); + + it("rejects an unparseable compliance date", async () => { + const res = await GET( + makeRequest("?complianceDate=soon"), + makeProps(), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid complianceDate" }); + }); + + it("accepts a valid compliance window, defaulting the band to C", async () => { + queueHappyPath(); + const res = await GET(makeRequest("?complianceDate=2030-01-01"), makeProps()); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index d56943e8..b5aa9bba 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -1,66 +1,15 @@ -import { db } from "@/app/db/db"; -import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import { sapToEpc } from "@/app/utils"; -import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; -import { - newApproachJoins, - carbonSql, - billsSql, - effectiveSapSql, -} from "@/lib/services/epcSources"; - -/* ======================= - Types -======================= */ - -type ScenarioAggregates = { - n_units: number; - avg_sap: number | null; - avg_carbon: number | null; - avg_bills: number | null; - total_carbon: number | null; - total_bills: number | null; - total_sap_uplift: number | null; -}; - -type UpgradedAggregates = { - n_units_upgraded: number; - total_cost: number | null; - contingency: number | null; - total_funding: number | null; -}; - -type PortfolioAggregates = { - avg_sap: number | null; - avg_carbon: number | null; - avg_bills: number | null; - total_carbon: number | null; - total_bills: number | null; -}; - -type EpcRow = { - effective_sap: number | null; -}; - -/* ======================= - Constants -======================= */ - -const EPC_MIN_SAP: Record<string, number> = { - A: 92, - B: 81, - C: 69, - D: 55, - E: 39, - F: 21, - G: 0, -}; - -/* ======================= - Route -======================= */ +import { EPC_BANDS } from "@/lib/epc/bands"; +import { DEFAULT_GOAL_BAND } from "@/lib/reporting/model"; +import { getScenarioOverlay, type OverlayFilters } from "@/lib/reporting/overlay"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; +/** + * Scenario overlay for a specific Scenario. A thin adapter: it parses and + * validates the request (scenario id, compliance-window params), then hands + * off to the Scenario overlay read model (@/lib/reporting/overlay), which owns + * all the "After scenario" SQL and shaping shared with the default view. + */ export async function GET( request: NextRequest, props: { params: Promise<{ portfolioId: string; scenarioId: string }> }, @@ -71,290 +20,59 @@ export async function GET( return NextResponse.json({ error: "Invalid scenarioId" }, { status: 400 }); } - const pid = BigInt(portfolioId); - const sid = BigInt(scenarioId); - const hideNonCompliant = - request.nextUrl.searchParams.get("hideNonCompliant") === "true"; - const useOriginalBaseline = - request.nextUrl.searchParams.get("useOriginalBaseline") === "true"; + const params = request.nextUrl.searchParams; + const hideNonCompliant = params.get("hideNonCompliant") === "true"; + const useLodgedBaseline = params.get("useLodgedBaseline") === "true"; - /* ---------------------------------------------------------- - Query 0 — scenario definition - ---------------------------------------------------------- */ - const scenarioResult = await db.execute(sql` - SELECT goal, goal_value - FROM scenario - WHERE id = ${sid} - AND portfolio_id = ${pid} - LIMIT 1 - `); + // Compliance window (ADR-0010): complianceDate activates the filter; + // complianceBand defaults to C. + const complianceDateParam = params.get("complianceDate"); + const complianceBandParam = params.get("complianceBand"); - const scenario = scenarioResult.rows[0] as - | { goal: PortfolioGoalType; goal_value: string } - | undefined; + if ( + complianceBandParam !== null && + !(EPC_BANDS as readonly string[]).includes(complianceBandParam) + ) { + return NextResponse.json( + { error: "Invalid complianceBand" }, + { status: 400 }, + ); + } + if ( + complianceDateParam !== null && + (!/^\d{4}-\d{2}-\d{2}$/.test(complianceDateParam) || + Number.isNaN(Date.parse(complianceDateParam))) + ) { + return NextResponse.json( + { error: "Invalid complianceDate" }, + { status: 400 }, + ); + } - if (!scenario) { + const filters: OverlayFilters = { + hideNonCompliant, + useLodgedBaseline, + compliance: { + active: complianceDateParam !== null, + band: complianceBandParam ?? DEFAULT_GOAL_BAND, + // Placeholder date when inactive keeps the ::date cast valid; ignored + // while inactive. + date: complianceDateParam ?? "2030-01-01", + }, + }; + + const tags = parseReportingViewState(params).tags; + + const overlay = await getScenarioOverlay( + BigInt(portfolioId), + { kind: "scenario", scenarioId: BigInt(scenarioId) }, + filters, + tags, + ); + + if (!overlay) { return NextResponse.json({ error: "Scenario not found" }, { status: 404 }); } - const minSap = - scenario.goal === "Increasing EPC" - ? EPC_MIN_SAP[scenario.goal_value] - : null; - - /* ---------------------------------------------------------- - QUERY 1 — Scenario metrics (PLANS ONLY) - ---------------------------------------------------------- */ - const scenarioMetricsResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - AND ( - ${hideNonCompliant} = false - OR ( - ${minSap}::float IS NOT NULL - AND post_sap_points >= ${minSap}::float - ) - ) - ORDER BY property_id, created_at DESC - ) - SELECT - COUNT(*)::int AS n_units, - AVG(post_sap_points)::float AS avg_sap, - AVG(post_co2_emissions)::float AS avg_carbon, - AVG(post_energy_bill)::float AS avg_bills, - SUM(post_co2_emissions)::float AS total_carbon, - SUM(post_energy_bill)::float AS total_bills, - SUM( - CASE - -- TEMP (demo): baseline is the effective SAP, not p.current_sap_points - -- (NULL for new-approach → uplift summed to 0 → £/SAP showed 0). Count - -- genuine gains only (post > baseline); sub-baseline plans excluded. - WHEN cost_of_works > 0 - AND post_sap_points > (${effectiveSapSql}) - THEN post_sap_points - (${effectiveSapSql}) - ELSE 0 - END - )::float AS total_sap_uplift - FROM latest_plans lp - JOIN property p ON p.id = lp.property_id - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - -- Conditional filter: only restrict by original_sap_points when the toggle is on - -- AND the scenario has an EPC target. Written as an OR chain so Postgres evaluates - -- it as a single WHERE clause — avoiding the need to dynamically build the query - -- string in application code (which would require string concatenation and risks - -- SQL injection). The OR short-circuits left-to-right: if the first or second - -- condition is true, the third is never evaluated, so all rows pass through. - WHERE ( - ${useOriginalBaseline} = false -- toggle off → include everything - OR ${minSap}::float IS NULL -- no EPC target → nothing to filter on - OR p.original_sap_points < ${minSap}::float -- actual filter - ); - `); - - const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; - - /* ---------------------------------------------------------- - QUERY 1b — Upgrade costs (PLANS ONLY) - ---------------------------------------------------------- */ - const upgradedResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - AND ( - ${hideNonCompliant} = false - OR ( - ${minSap}::float IS NOT NULL - AND post_sap_points >= ${minSap}::float - ) - ) - ORDER BY property_id, created_at DESC - ) - SELECT - COUNT(*)::int AS n_units_upgraded, - SUM(cost_of_works)::float AS total_cost, - SUM(contingency_cost)::float AS contingency, - SUM( - COALESCE(fp.project_funding, 0) + - COALESCE(fp.total_uplift, 0) - )::float AS total_funding - FROM latest_plans lp - JOIN property p ON p.id = lp.property_id - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0 - -- TEMP (demo): exclude plans whose post SAP is below the effective baseline - -- (target-level post_sap on already-compliant homes) — not real upgrades. - -- COALESCE keeps rows we can't compare. See ADR-0002. - AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true) - AND ( - ${useOriginalBaseline} = false - OR ${minSap}::float IS NULL - OR p.original_sap_points < ${minSap}::float - ); - `); - - const upgraded = upgradedResult.rows[0] as UpgradedAggregates; - - /* ---------------------------------------------------------- - QUERY 2 — Portfolio AFTER scenario (ALL properties) - ---------------------------------------------------------- */ - const portfolioMetricsResult = await db.execute(sql` - SELECT - AVG(effective_sap)::float AS avg_sap, - AVG(effective_carbon)::float AS avg_carbon, - AVG(effective_bills)::float AS avg_bills, - SUM(effective_carbon)::float AS total_carbon, - SUM(effective_bills)::float AS total_bills - FROM ( - SELECT - /* ---------- SAP ---------- */ - CASE - WHEN lp.id IS NOT NULL THEN lp.post_sap_points - ELSE p.current_sap_points - END AS effective_sap, - - /* ---------- Carbon ---------- */ - CASE - WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions - ELSE ${carbonSql(sql`e`)} - END AS effective_carbon, - - /* ---------- Bills ---------- */ - CASE - WHEN lp.id IS NOT NULL THEN lp.post_energy_bill - ELSE ${billsSql(sql`e`)} - END AS effective_bills - - FROM property p - LEFT JOIN property_details_epc e - ON e.property_id = p.id - ${newApproachJoins} - - LEFT JOIN LATERAL ( - SELECT * - FROM plan - WHERE plan.property_id = p.id - AND plan.portfolio_id = ${pid} - AND plan.scenario_id = ${sid} - AND ( - ${hideNonCompliant} = false - OR ( - ${minSap}::float IS NOT NULL - AND plan.post_sap_points >= ${minSap}::float - ) - ) - AND ( - ${useOriginalBaseline} = false - OR ${minSap}::float IS NULL - OR p.original_sap_points < ${minSap}::float - ) - ORDER BY created_at DESC - LIMIT 1 - ) lp ON true - - WHERE p.portfolio_id = ${pid} - ) q; - `); - - const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; - - /* ---------------------------------------------------------- - QUERY 3 — EPC band distribution (ALL properties) - ---------------------------------------------------------- */ - const epcRows = await db.execute(sql` - SELECT - CASE - -- A retrofit scenario can't make a property worse. The engine writes a - -- target-level post_sap (e.g. ~C) even for properties already above the - -- target, so post_sap can sit BELOW the baseline — which made the chart - -- show e.g. B properties "improving" down to C. Clamp to the baseline. - WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) - -- No qualifying plan → unchanged property. Use the effective - -- (re-baselined) baseline to match the "before" distribution; NOT - -- p.current_sap_points (NULL for new-approach → "Unknown"). See ADR-0002. - ELSE ${effectiveSapSql} - END AS effective_sap - FROM property p - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - LEFT JOIN LATERAL ( - SELECT * - FROM plan - WHERE plan.property_id = p.id - AND plan.portfolio_id = ${pid} - AND plan.scenario_id = ${sid} - AND ( - ${hideNonCompliant} = false - OR ( - ${minSap}::float IS NOT NULL - AND plan.post_sap_points >= ${minSap}::float - ) - ) - AND ( - ${useOriginalBaseline} = false - OR ${minSap}::float IS NULL - OR p.original_sap_points < ${minSap}::float - ) - ORDER BY created_at DESC - LIMIT 1 - ) lp ON true - WHERE p.portfolio_id = ${pid}; - `); - - const scenario_epc_counts: Record<string, number> = { - A: 0, - B: 0, - C: 0, - D: 0, - E: 0, - F: 0, - G: 0, - Unknown: 0, - }; - - for (const row of epcRows.rows as EpcRow[]) { - const band = sapToEpc(row.effective_sap); - scenario_epc_counts[band] += 1; - } - - /* ---------------------------------------------------------- - RESPONSE - ---------------------------------------------------------- */ - - const constructionCost = upgraded.total_cost ?? 0; - const nUpgraded = upgraded.n_units_upgraded ?? 0; - const pc_cost = constructionCost * 0.3; - - return NextResponse.json({ - /* -------- portfolio-after-scenario -------- */ - avg_sap: - portfolioAgg.avg_sap !== null - ? Number(portfolioAgg.avg_sap).toFixed(1) - : null, - avg_carbon: portfolioAgg.avg_carbon, - avg_bills: portfolioAgg.avg_bills, - total_carbon: portfolioAgg.total_carbon, - total_bills: portfolioAgg.total_bills, - - /* -------- scenario-only -------- */ - n_units: scenarioAgg.n_units, - n_units_upgraded: nUpgraded, - construction_cost: constructionCost, - contingency: upgraded.contingency ?? 0, - total_funding: upgraded.total_funding ?? 0, - net_cost: constructionCost - (upgraded.total_funding ?? 0), - total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0, - gross_per_unit: - nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0, - - /* -------- shared -------- */ - scenario_epc_counts, - pc_cost, - }); + return NextResponse.json(overlay); } diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/measures/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/measures/route.ts deleted file mode 100644 index 3640c773..00000000 --- a/src/app/api/portfolio/[portfolioId]/scenario/default/measures/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { db } from "@/app/db/db"; -import { sql } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; - -type MeasureAggregateRow = { - measure_type: string | null; - type: string | null; - includes_battery: boolean | null; - homes_count: number; - total_cost: number | null; - average_cost: number | null; -}; - -export async function GET( - request: NextRequest, - props: { params: Promise<{ portfolioId: string }> }, -) { - const { portfolioId } = await props.params; - - const pid = BigInt(portfolioId); - - // Latest default plan per property, then its recommendations read through the - // DENORMALISED link: join by the indexed recommendation.property_id and scope - // to the plan via recommendation.plan_id. The plan_recommendations join table - // is retired (no rows for new-approach plans), so the old EXISTS against it - // returned zero measures. - const result = await db.execute(sql` - SELECT - r.measure_type, - r.type, - COUNT(DISTINCT r.property_id)::int AS homes_count, - SUM(r.estimated_cost)::float AS total_cost, - AVG(r.estimated_cost)::float AS average_cost - FROM ( - SELECT DISTINCT ON (property_id) - id, property_id - FROM plan - WHERE portfolio_id = ${pid} - AND is_default = true - ORDER BY property_id, created_at DESC - ) lp - JOIN recommendation r - ON r.property_id = lp.property_id - AND r.plan_id = lp.id - AND r.default = true - AND r.already_installed = false - GROUP BY - r.measure_type, - r.type - ORDER BY total_cost DESC; - `); - - const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({ - measureType: row.measure_type ?? "unknown", - type: row.type ?? "unknown", - homesCount: row.homes_count, - totalCost: Number(row.total_cost ?? 0), - averageCost: Number(row.average_cost ?? 0), - // includesBattery: row.includes_battery ?? false, - })); - - return NextResponse.json({ - portfolioId: Number(portfolioId), - measures, - }); -} diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts index b5f56284..6cf822ff 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts @@ -1,66 +1,18 @@ -import { db } from "@/app/db/db"; -import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import { sapToEpc } from "@/app/utils"; -import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; import { - newApproachJoins, - carbonSql, - billsSql, - effectiveSapSql, -} from "@/lib/services/epcSources"; - -/* ======================= - Types -======================= */ - -type ScenarioAggregates = { - n_units: number; - avg_sap: number | null; - avg_carbon: number | null; - avg_bills: number | null; - total_carbon: number | null; - total_bills: number | null; - total_sap_uplift: number | null; -}; - -type UpgradedAggregates = { - n_units_upgraded: number; - total_cost: number | null; - contingency: number | null; - total_funding: number | null; -}; - -type PortfolioAggregates = { - avg_sap: number | null; - avg_carbon: number | null; - avg_bills: number | null; - total_carbon: number | null; - total_bills: number | null; -}; - -type EpcRow = { - effective_sap: number | null; -}; - -/* ======================= - Constants -======================= */ - -const EPC_MIN_SAP: Record<string, number> = { - A: 92, - B: 81, - C: 69, - D: 55, - E: 39, - F: 21, - G: 0, -}; - -/* ======================= - Route -======================= */ + getScenarioOverlay, + INERT_FILTERS, +} from "@/lib/reporting/overlay"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; +/** + * Scenario overlay for the recommended-plans (`is_default`) view. A thin + * adapter over the Scenario overlay read model (@/lib/reporting/overlay): the + * default view is the scenario view with no EPC target and every report-view + * filter inert (the compliance-window / lodged-baseline filters are a Scenario + * concept, ADR-0010), so it passes INERT_FILTERS. Only the view-wide tag + * filter still applies. + */ export async function GET( request: NextRequest, props: { params: Promise<{ portfolioId: string }> }, @@ -71,212 +23,16 @@ export async function GET( return NextResponse.json({ error: "Invalid portfolioId" }, { status: 400 }); } - const pid = BigInt(portfolioId); - const hideNonCompliant = - request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + const tags = parseReportingViewState(request.nextUrl.searchParams).tags; - /* ---------------------------------------------------------- - QUERY 1 — Scenario metrics (PLANS ONLY) - ---------------------------------------------------------- */ - const scenarioMetricsResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND is_default = true - ORDER BY property_id, created_at DESC - ) - SELECT - COUNT(*)::int AS n_units, - AVG(post_sap_points)::float AS avg_sap, - AVG(post_co2_emissions)::float AS avg_carbon, - AVG(post_energy_bill)::float AS avg_bills, - SUM(post_co2_emissions)::float AS total_carbon, - SUM(post_energy_bill)::float AS total_bills, - SUM( - CASE - -- TEMP (demo): baseline is the effective SAP, not p.current_sap_points - -- (NULL for new-approach → uplift summed to 0 → £/SAP showed 0). Count - -- genuine gains only (post > baseline); sub-baseline "downgrade" plans - -- are excluded rather than dragging the uplift negative. See ADR-0002. - WHEN cost_of_works > 0 - AND post_sap_points > (${effectiveSapSql}) - THEN post_sap_points - (${effectiveSapSql}) - ELSE 0 - END - )::float AS total_sap_uplift - FROM latest_plans lp - JOIN property p ON p.id = lp.property_id - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id; - `); + const overlay = await getScenarioOverlay( + BigInt(portfolioId), + { kind: "default" }, + INERT_FILTERS, + tags, + ); - const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; - - /* ---------------------------------------------------------- - QUERY 1b — Upgrade costs (PLANS ONLY) - ---------------------------------------------------------- */ - const upgradedResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND is_default = true - ORDER BY property_id, created_at DESC - ) - SELECT - COUNT(*)::int AS n_units_upgraded, - SUM(cost_of_works)::float AS total_cost, - SUM(contingency_cost)::float AS contingency, - SUM( - COALESCE(fp.project_funding, 0) + - COALESCE(fp.total_uplift, 0) - )::float AS total_funding - FROM latest_plans lp - JOIN property p ON p.id = lp.property_id - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0 - -- TEMP (demo): a plan whose post SAP is below the effective baseline isn't - -- a real upgrade (target-level post_sap on already-compliant homes), so - -- exclude it from the count + cost. COALESCE keeps rows we can't compare - -- (NULL post or NULL baseline). See ADR-0002. - AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true); - `); - - const upgraded = upgradedResult.rows[0] as UpgradedAggregates; - - /* ---------------------------------------------------------- - QUERY 2 — Portfolio AFTER scenario (ALL properties) - ---------------------------------------------------------- */ - const portfolioMetricsResult = await db.execute(sql` - SELECT - AVG(effective_sap)::float AS avg_sap, - AVG(effective_carbon)::float AS avg_carbon, - AVG(effective_bills)::float AS avg_bills, - SUM(effective_carbon)::float AS total_carbon, - SUM(effective_bills)::float AS total_bills - FROM ( - SELECT - /* ---------- SAP ---------- */ - CASE - WHEN lp.id IS NOT NULL THEN lp.post_sap_points - ELSE p.current_sap_points - END AS effective_sap, - - /* ---------- Carbon ---------- */ - CASE - WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions - ELSE ${carbonSql(sql`e`)} - END AS effective_carbon, - - /* ---------- Bills ---------- */ - CASE - WHEN lp.id IS NOT NULL THEN lp.post_energy_bill - ELSE ${billsSql(sql`e`)} - END AS effective_bills - - FROM property p - LEFT JOIN property_details_epc e - ON e.property_id = p.id - ${newApproachJoins} - - LEFT JOIN LATERAL ( - SELECT * - FROM plan - WHERE plan.property_id = p.id - AND plan.portfolio_id = ${pid} - AND plan.is_default = true - ORDER BY created_at DESC - LIMIT 1 - ) lp ON true - - WHERE p.portfolio_id = ${pid} - ) q; - `); - - const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; - - /* ---------------------------------------------------------- - QUERY 3 — EPC band distribution (ALL properties) - ---------------------------------------------------------- */ - const epcRows = await db.execute(sql` - SELECT - CASE - -- A retrofit scenario can't make a property worse. The engine writes a - -- target-level post_sap (e.g. ~C) even for properties already above the - -- target, so post_sap can sit BELOW the baseline — which made the chart - -- show e.g. B properties "improving" down to C. Clamp to the baseline so - -- the post-scenario band is never worse than before. - WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) - -- No plan → unchanged property. Use the effective (re-baselined) baseline - -- so this matches the "before" distribution — NOT p.current_sap_points, - -- which is NULL for new-approach properties. See ADR-0002. - ELSE ${effectiveSapSql} - END AS effective_sap - FROM property p - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - LEFT JOIN LATERAL ( - SELECT * - FROM plan - WHERE plan.property_id = p.id - AND plan.portfolio_id = ${pid} - AND plan.is_default = true - ORDER BY created_at DESC - LIMIT 1 - ) lp ON true - WHERE p.portfolio_id = ${pid}; - `); - - const scenario_epc_counts: Record<string, number> = { - A: 0, - B: 0, - C: 0, - D: 0, - E: 0, - F: 0, - G: 0, - Unknown: 0, - }; - - for (const row of epcRows.rows as EpcRow[]) { - const band = sapToEpc(row.effective_sap); - scenario_epc_counts[band] += 1; - } - - /* ---------------------------------------------------------- - RESPONSE - ---------------------------------------------------------- */ - - const constructionCost = upgraded.total_cost ?? 0; - const nUpgraded = upgraded.n_units_upgraded ?? 0; - const pc_cost = constructionCost * 0.3; - - return NextResponse.json({ - /* -------- portfolio-after-scenario -------- */ - avg_sap: - portfolioAgg.avg_sap !== null - ? Number(portfolioAgg.avg_sap).toFixed(1) - : null, - avg_carbon: portfolioAgg.avg_carbon, - avg_bills: portfolioAgg.avg_bills, - total_carbon: portfolioAgg.total_carbon, - total_bills: portfolioAgg.total_bills, - - /* -------- scenario-only -------- */ - n_units: scenarioAgg.n_units, - n_units_upgraded: nUpgraded, - construction_cost: constructionCost, - contingency: upgraded.contingency ?? 0, - total_funding: upgraded.total_funding ?? 0, - net_cost: constructionCost - (upgraded.total_funding ?? 0), - total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0, - gross_per_unit: - nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0, - - /* -------- shared -------- */ - scenario_epc_counts, - pc_cost, - }); + // `null` is only returned for a missing scenario scope; the default scope + // always resolves. + return NextResponse.json(overlay); } diff --git a/src/app/globals.css b/src/app/globals.css index c42a622a..fb7f520e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -128,20 +128,32 @@ PAGE LAYOUT --------------------------------- */ + /* Real A4 portrait with proper margins — the report is designed to this + width, so it never renders landscape/oversized. */ @page { - margin: 0; + size: A4 portrait; + margin: 12mm; } - /* Outer page padding (NOT scaled) */ + /* Margins come from @page now; no extra padding, and NO transform scale + (a scaled root paginates badly — it was pushing whole blocks to page 2 + and leaving the first page half-empty). */ .print-page { - padding: 20px 24px; + padding: 0; + max-width: none; } - /* Inner content (scaled slightly) */ .print-root { - transform: scale(0.94); - transform-origin: top left; - width: 106.4%; /* 1 / 0.94 */ + width: 100%; + transform: none; + } + + /* Keep every colour (EPC chips, ledger bars, tints) when printing — + browsers strip backgrounds by default, which stripped the report bare. */ + .print-root, + .print-root * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; } /* --------------------------------- @@ -227,3 +239,19 @@ 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } } + +/* Reporting surface: one visible focus ring for every interactive control + (WCAG 2.4.7). Scoped to [data-reporting] so it doesn't touch the rest of + the app. */ +[data-reporting] :is(button, a, input, select, [tabindex]):focus-visible { + outline: 2px solid #2d348f; /* midblue */ + outline-offset: 2px; + border-radius: 3px; +} + +/* Respect reduced-motion for the reporting loading skeletons. */ +@media (prefers-reduced-motion: reduce) { + [data-reporting] .animate-pulse { + animation: none; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6b1dd09c..ff7782ff 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -46,7 +46,7 @@ const getSession = cache(async () => { function Footer() { return ( - <footer className="bg-brandblue text-white p-4 text-center border-t border-gray-300"> + <footer className="bg-brandblue text-white p-4 text-center border-t border-gray-300 print:hidden"> <p> © {new Date().getFullYear()} Domna. All rights reserved. Domna proprietary IP. diff --git a/src/app/portfolio/[slug]/(portfolio)/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/layout.tsx index bf5c76d9..1839ae23 100644 --- a/src/app/portfolio/[slug]/(portfolio)/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/layout.tsx @@ -19,7 +19,7 @@ export default async function PortfolioLayout(props: { return ( <section> - <div className="flex justify-center"> + <div className="flex justify-center print:hidden"> <h1 className="text-2xl text-gray-700 font-bold mt-1 mb-1"> {portfolioName} </h1> diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx deleted file mode 100644 index 976ecdec..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { useState, useMemo } from "react"; -import { - Card, - CardHeader, - CardTitle, - CardContent, -} from "@/app/shadcn_components/ui/card"; -import { BarChart } from "@tremor/react"; -import type { CustomTooltipProps } from "@tremor/react"; - -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, -} from "@/app/shadcn_components/ui/select"; - -import type { EpcBandCount, AgeBandCount, PropertyTypeCount } from "./types"; - -const friendlyKeys = { - actual: "Actual EPCs", - estimated: "Estimated EPCs", - scenario: "Scenario result", -}; - -export function BreakdownChart({ - epcBands, - ageBands, - propertyTypes, - scenarioEpcBands, -}: { - epcBands: EpcBandCount[]; - ageBands: AgeBandCount[]; - propertyTypes: PropertyTypeCount[]; - scenarioEpcBands?: Record<string, number>; -}) { - const [selected, setSelected] = useState("epc"); - - const chartData = useMemo(() => { - if (selected !== "epc") { - return selected === "age" - ? ageBands.map((d) => ({ label: d.age_band, Count: d.count })) - : propertyTypes.map((d) => ({ - label: d.type ?? "Unknown", - Count: d.count, - })); - } - - const rows: any[] = []; - - // The baseline query only returns bands that have properties, so a band - // reached ONLY after the scenario (e.g. A/B when nothing starts that high) - // is absent from epcBands — looping it alone drops those scenario bars. - // Iterate a canonical order over baseline ∪ scenario instead. - const baselineByBand = new Map( - epcBands.map((d) => [d.epc ?? "Unknown", d]), - ); - const BAND_ORDER = ["A", "B", "C", "D", "E", "F", "G", "Unknown"]; - const presentBands = BAND_ORDER.filter( - (b) => baselineByBand.has(b) || (scenarioEpcBands?.[b] ?? 0) > 0, - ); - - for (const epc of presentBands) { - const d = baselineByBand.get(epc); - const scenarioValue = scenarioEpcBands?.[epc] ?? 0; - - // Baseline (stacked) - rows.push({ - label: `${epc}`, - [friendlyKeys.actual]: d?.actual ?? 0, - [friendlyKeys.estimated]: d?.estimated ?? 0, - [friendlyKeys.scenario]: 0, - }); - - // Scenario (single bar) - rows.push({ - label: `${epc} (scenario)`, - [friendlyKeys.actual]: 0, - [friendlyKeys.estimated]: 0, - [friendlyKeys.scenario]: scenarioValue, - }); - } - - return rows; - }, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]); - - const categories = - selected === "epc" - ? [friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario] - : ["Count"]; - - const colors = - selected === "epc" ? ["#14163d", "#3943b7", "emerald"] : ["#2d348f"]; - - return ( - <Card className="border border-gray-100 bg-white"> - <CardHeader className="flex flex-col space-y-1 items-start"> - <div className="flex w-full justify-between items-center"> - <CardTitle className="text-md text-brandblue"> - Property Breakdown - </CardTitle> - - <Select value={selected} onValueChange={setSelected}> - <SelectTrigger className="w-40"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="epc">EPC Band</SelectItem> - <SelectItem value="age">Age Band</SelectItem> - <SelectItem value="type">Property Type</SelectItem> - </SelectContent> - </Select> - </div> - </CardHeader> - - <CardContent> - <BarChart - data={chartData} - index="label" - categories={categories} - colors={colors} - valueFormatter={(v) => v.toString()} - stack={selected === "epc"} - customTooltip={MyTooltip} - className="h-64" - showGridLines={false} - /> - </CardContent> - </Card> - ); -} - -function MyTooltip({ payload }: CustomTooltipProps) { - if (!payload || payload.length === 0) return null; - - return ( - <div className="rounded-md bg-white shadow-lg border border-gray-200 px-3 py-2 text-xs text-gray-700"> - {payload.map((p) => ( - <div - key={p.dataKey} - className="flex justify-between gap-4 items-center" - > - <span>{p.dataKey}:</span> - <span className="font-medium">{p.value}</span> - </div> - ))} - </div> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx deleted file mode 100644 index 8f2ab4a8..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx +++ /dev/null @@ -1,289 +0,0 @@ -"use client"; - -import { - Card, - CardHeader, - CardTitle, - CardContent, - CardFooter, -} from "@/app/shadcn_components/ui/card"; -import { motion } from "framer-motion"; -import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react"; -import { formatNumber, sapToEpc } from "@/app/utils"; -import type { - AverageMetrics, - EpcCoverageCounts, - TotalMetrics, - ScenarioOverlayMetrics, - MetricKey, -} from "./types"; - -/* ───────────────────────────────────────────── */ -/* Style maps */ -/* ───────────────────────────────────────────── */ - -const cardStyles: Record< - MetricKey, - { icon: React.ComponentType<any>; color: string } -> = { - totalHomes: { icon: Home, color: "text-purple-600" }, - avgSap: { icon: LineChart, color: "text-blue-600" }, - avgCarbon: { icon: Leaf, color: "text-emerald-600" }, - avgBills: { icon: Zap, color: "text-amber-600" }, - missingEpc: { icon: FileQuestionIcon, color: "text-red-600" }, -}; - -const epcColors: Record<string, string> = { - A: "text-epc_a", - B: "text-epc_b", - C: "text-epc_c", - D: "text-epc_d", - E: "text-epc_e", - F: "text-epc_f", - G: "text-epc_g", - Unknown: "text-gray-400", -}; - -/* ───────────────────────────────────────────── */ -/* Helpers */ -/* ───────────────────────────────────────────── */ - -function hasOverlay( - overlay: ScenarioOverlayMetrics | undefined, -): overlay is ScenarioOverlayMetrics { - return overlay !== undefined; -} - -function Skeleton({ className = "" }: { className?: string }) { - return <div className={`animate-pulse rounded bg-gray-200 ${className}`} />; -} - -/* ───────────────────────────────────────────── */ -/* Component */ -/* ───────────────────────────────────────────── */ - -export function DashboardSummaryCards({ - total, - totals, - averages, - epcCoverage, - scenarioOverlay, - loading = false, -}: { - total: number; - totals: TotalMetrics; - averages: AverageMetrics; - epcCoverage: EpcCoverageCounts; - scenarioOverlay?: ScenarioOverlayMetrics | null; - loading?: boolean; -}) { - const missingEpcCount = epcCoverage.withoutEpc; - const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0; - - const averageCurrentEpc = sapToEpc(averages.avg_sap || 0); - - const overlay = scenarioOverlay ?? undefined; - const hasScenario = hasOverlay(overlay); - - function deltaLabel(baseline: number, scenario: number) { - const diff = scenario - baseline; - if (!isFinite(diff) || diff === 0) return null; - - const sign = diff > 0 ? "▲" : "▼"; - const color = diff > 0 ? "text-red-600" : "text-emerald-600"; - - return ( - <span className={`text-sm font-medium ${color}`}> - {sign} {Math.abs(diff).toFixed(2)} - </span> - ); - } - - const cards = [ - { - key: "totalHomes", - title: "Number of Homes", - baseline: total, - subtitle: "Total properties in this portfolio.", - }, - { - key: "avgSap", - title: "Average EPC Rating", - baseline: `${averageCurrentEpc} (${Math.round(averages.avg_sap ?? 0)} pts)`, - scenario: - overlay?.avgSap && - `${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`, - subtitle: "Current SAP rating across all properties.", - isEpc: true, - }, - { - key: "avgCarbon", - title: "Carbon Emissions", - baseline: formatNumber(averages.avg_carbon ?? 0), - scenario: overlay?.avgCarbon && formatNumber(overlay.avgCarbon.scenario), - units: "tCO₂e /home", - baselineTotal: totals.total_carbon ?? 0, - scenarioTotal: overlay?.avgCarbon?.scenarioTotal, - subtitle: "Average annual CO₂ output per home.", - delta: - hasScenario && overlay?.avgCarbon - ? deltaLabel(overlay.avgCarbon.baseline, overlay.avgCarbon.scenario) - : null, - }, - { - key: "avgBills", - title: "Energy Bills", - baseline: formatNumber(averages.avg_bills ?? 0), - scenario: overlay?.avgBills && formatNumber(overlay.avgBills.scenario), - units: "/ home", - baselineTotal: totals.total_bills ?? 0, - scenarioTotal: overlay?.avgBills?.scenarioTotal, - subtitle: "Estimated annual energy bills.", - delta: - hasScenario && overlay?.avgBills - ? deltaLabel(overlay.avgBills.baseline, overlay.avgBills.scenario) - : null, - }, - ]; - - return ( - <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> - {cards.map((c) => { - const Icon = cardStyles[c.key as MetricKey].icon; - const color = cardStyles[c.key as MetricKey].color; - - return ( - <Card - key={c.key} - className="h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300" - > - {/* Header */} - <CardHeader className="flex flex-row items-center gap-2 pb-1"> - {loading ? ( - <> - <Skeleton className="h-5 w-5 rounded-full" /> - <Skeleton className="h-4 w-32" /> - </> - ) : ( - <> - <motion.div whileHover={{ scale: 1.05 }}> - <Icon className={`h-5 w-5 ${color}`} /> - </motion.div> - <CardTitle className="text-md font-medium text-gray-700"> - {c.title} - </CardTitle> - </> - )} - </CardHeader> - - {/* Content */} - <CardContent className="flex flex-1 flex-col gap-2"> - <div - className={`flex ${ - hasScenario ? "justify-between" : "justify-start" - } items-start`} - > - {/* Baseline */} - <div className="flex flex-col"> - <span className="text-xs text-gray-500">Baseline</span> - - {loading ? ( - <Skeleton className="h-8 w-28 mt-1" /> - ) : ( - <div className="flex items-baseline gap-2"> - <span - className={ - c.isEpc - ? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}` - : "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue" - } - > - {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} - </span> - {c.units && ( - <span className="text-sm text-gray-500">{c.units}</span> - )} - </div> - )} - - {c.baselineTotal !== undefined && - (loading ? ( - <Skeleton className="h-4 w-36 mt-1" /> - ) : ( - <span className="text-md text-gray-600"> - Total:{" "} - {c.key === "avgBills" - ? `£${formatNumber(c.baselineTotal)}` - : `${formatNumber(c.baselineTotal)} tCO₂e`} - </span> - ))} - </div> - - {/* Scenario */} - {hasScenario && c.scenario && ( - <div className="flex flex-col text-right"> - <span className="text-xs text-gray-500">Scenario</span> - - {loading ? ( - <Skeleton className="h-7 w-24 mt-1 ml-auto" /> - ) : ( - <div className="flex items-baseline justify-end gap-2"> - <span - className={ - c.isEpc - ? `text-2xl font-semibold ${ - epcColors[ - sapToEpc( - overlay?.avgSap?.scenario ?? - (averages.avg_sap || 0), - ) || "Unknown" - ] - }` - : "text-2xl font-semibold text-brandblue" - } - > - {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} - </span> - {c.delta && <span>{c.delta}</span>} - </div> - )} - - {c.scenarioTotal !== undefined && - (loading ? ( - <Skeleton className="h-4 w-36 mt-1 ml-auto" /> - ) : ( - <span className="text-md text-gray-600"> - Total:{" "} - {c.key === "avgBills" - ? `£${formatNumber(c.scenarioTotal)}` - : `${formatNumber(c.scenarioTotal)} tCO₂e`} - </span> - ))} - </div> - )} - </div> - - {/* Missing EPC bar */} - {c.key === "missingEpc" && ( - <div className="w-full bg-gray-200 rounded-full h-2 mt-2"> - <div - className="h-2 rounded-full bg-red-500" - style={{ width: `${missingEpcPercent}%` }} - /> - </div> - )} - </CardContent> - - <CardFooter> - {loading ? ( - <Skeleton className="h-3 w-3/4" /> - ) : ( - <p className="text-xs text-gray-500">{c.subtitle}</p> - )} - </CardFooter> - </Card> - ); - })} - </div> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx deleted file mode 100644 index 6dc57604..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { - Card, - CardHeader, - CardTitle, - CardContent, - CardFooter, -} from "@/app/shadcn_components/ui/card"; -import { motion } from "framer-motion"; -import { FileQuestion, AlertTriangle, TrendingDown } from "lucide-react"; -import type { EpcCoverageCounts } from "./types"; - -export function EpcQualityCards({ - epcCoverage, - total, - expiredEpcs, - likelyDowngrades, -}: { - epcCoverage: EpcCoverageCounts; - total: number; - expiredEpcs: number; - likelyDowngrades: number; -}) { - // Homes with no certificate at all — neither lodged nor historical. - const missing = epcCoverage.withoutEpc; - const pctMissing = total > 0 ? (missing / total) * 100 : 0; - - // Expired EPCs - const pctExpired = total > 0 ? (expiredEpcs / total) * 100 : 0; - - // Likely downgrades - const pctDowngrades = total > 0 ? (likelyDowngrades / total) * 100 : 0; - - const cards = [ - { - key: "missing", - title: "Homes Without an EPC", - icon: FileQuestion, - color: "text-red-600", - value: missing, - subtitle: `${pctMissing.toFixed(1)}% have no EPC on record`, - barColor: "bg-red-500", - barWidth: pctMissing, - gradient: "bg-gradient-to-br from-white to-red-50/20", - }, - { - key: "expired", - title: "Expired EPCs", - icon: AlertTriangle, - color: "text-amber-600", - value: expiredEpcs, - subtitle: `${pctExpired.toFixed(1)}% of homes have expired EPCs`, - barColor: "bg-amber-500", - barWidth: pctExpired, - gradient: "bg-gradient-to-br from-white to-amber-50/20", - }, - { - key: "downgrades", - title: "Likely EPC Downgrades", - icon: TrendingDown, - color: "text-brandblue", - value: likelyDowngrades, - subtitle: `${pctDowngrades.toFixed(1)}% likely EPC score reductions`, - barColor: "bg-brandblue", - barWidth: pctDowngrades, - gradient: "bg-gradient-to-br from-white to-blue-50/20", - }, - ]; - - return ( - <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> - {cards.map((c) => { - const Icon = c.icon; - return ( - <Card - key={c.key} - className={`relative h-full flex flex-col border border-gray-100 ${c.gradient} hover:shadow-md hover:-translate-y-0.5 transition-all`} - > - {/* Header */} - <CardHeader className="flex flex-row items-center gap-2 pb-1"> - <div className="p-1.5 rounded-md bg-gray-100"> - <motion.div whileHover={{ scale: 1.1 }} className="p-1"> - <Icon className={`h-4 w-4 ${c.color}`} /> - </motion.div> - </div> - <CardTitle className="text-md font-medium text-gray-600"> - {c.title} - </CardTitle> - </CardHeader> - - {/* Content */} - <CardContent className="flex flex-col pb-2"> - <div className="text-2xl font-semibold text-brandblue"> - {c.value} - </div> - - {/* Mini bar */} - <div className="w-full mt-3 bg-gray-200 rounded-full h-2"> - <div - className={`h-2 rounded-full ${c.barColor}`} - style={{ width: `${c.barWidth}%` }} - /> - </div> - </CardContent> - - {/* Footer */} - <CardFooter className="pt-0 pb-4"> - <p className="text-xs text-gray-500">{c.subtitle}</p> - </CardFooter> - </Card> - ); - })} - </div> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards.tsx deleted file mode 100644 index e0caeebc..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { - Card, - CardHeader, - CardTitle, - CardContent, - CardFooter, -} from "@/app/shadcn_components/ui/card"; -import { motion } from "framer-motion"; -import { - AlertTriangle, - FileQuestion, - ClipboardList, - ClipboardX, - PoundSterling, - Home, -} from "lucide-react"; - -type PlaceholderCard = { - key: string; - title: string; - subtitle: string; - icon: any; - color: string; -}; - -export const CONDITION_PLACEHOLDERS = [ - { - key: "awwabs", - title: "Awaabs Law Warnings", - subtitle: "Severe hazards related to damp & mould.", - icon: AlertTriangle, - color: "text-red-600", - }, - { - key: "cat12", - title: "Category 1 & 2 Hazards", - subtitle: "Safety risks identified under HHSRS.", - icon: AlertTriangle, - color: "text-orange-500", - }, - { - key: "noStockSurvey", - title: "Missing Stock Condition Survey", - subtitle: "Properties without a structural/condition survey.", - icon: ClipboardX, - color: "text-brandblue", - }, - { - key: "noDecentHomes", - title: "Missing Decent Homes Survey", - subtitle: "Properties lacking a Decent Homes standard review.", - icon: ClipboardList, - color: "text-brandblue", - }, -]; - -export const FINANCIAL_PLACEHOLDERS = [ - { - key: "rent", - title: "Rent", - subtitle: "Historic or current rent information.", - icon: PoundSterling, - color: "text-brandbrown", - }, - { - key: "valuation", - title: "Valuation", - subtitle: "Property valuation data.", - icon: Home, - color: "text-midblue", - }, -]; - -export function PlaceholderMetricCards({ - items, -}: { - items: PlaceholderCard[]; -}) { - return ( - <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> - {items.map((c) => { - const Icon = c.icon; - - return ( - <Card - key={c.key} - className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-gray-50 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300" - > - <CardHeader className="flex flex-row items-center gap-2 pb-1"> - <div className="p-1.5 rounded-md bg-gray-100"> - <motion.div - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.95 }} - className="p-1.5 rounded-md bg-gray-50" - > - <Icon className={`h-4 w-4 ${c.color}`} /> - </motion.div> - </div> - - <CardTitle className="text-md font-medium text-gray-600 mb-2"> - {c.title} - </CardTitle> - </CardHeader> - - <CardContent className="flex flex-1 flex-col pb-2 mb-2"> - <div className="text-lg font-semibold text-gray-400 italic"> - Data not provided - </div> - </CardContent> - - <CardFooter className="pt-0 pb-4"> - <p className="text-xs text-gray-500">{c.subtitle}</p> - </CardFooter> - </Card> - ); - })} - </div> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/RecommendationsOptions.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/RecommendationsOptions.tsx deleted file mode 100644 index 0aa01a0d..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/RecommendationsOptions.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/app/shadcn_components/ui/dropdown-menu"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; -import { Label } from "@/app/shadcn_components/ui/label"; -import { GripVertical } from "lucide-react"; -import { HelpCircle } from "lucide-react"; -import { Loader2 } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/app/shadcn_components/ui/tooltip"; - -import { - DndContext, - closestCenter, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, - arrayMove, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { ScenarioSummary } from "./types"; - -export interface RecommendationsOptionsProps { - disabled?: boolean; - scenarios: ScenarioSummary[] - portfolioId: number - onSuccess: () => void; -} - -interface ScenarioWithPriority { - id: number; - priority: number; // 1 = highest, 2 = next, etc. -} - -interface CategorisationTriggerRequest { - portfolio_id: number; - scenarios_to_consider?: number[] | null; - scenario_priority_order?: number[] | null; -} - -function mapScenariosToPayload( - selectedScenarios: ScenarioWithPriority[], - portfolio_id: number -): CategorisationTriggerRequest { - if (!selectedScenarios || selectedScenarios.length === 0) { - return { - portfolio_id, - scenarios_to_consider: null, - scenario_priority_order: null, - }; - } - - // Sort by priority just in case - const sorted = [...selectedScenarios].sort((a, b) => a.priority - b.priority); - - return { - portfolio_id, - scenarios_to_consider: sorted.map((s) => s.id), - scenario_priority_order: sorted.map((s) => s.id), - }; -} - - -function SortableScenarioItem({ - id, - name, - index, -}: { - id: number; - name: string; - index: number; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( - <div - ref={setNodeRef} - style={style} - className={`flex items-center gap-2 p-2 border rounded bg-muted cursor-grab - ${isDragging ? "opacity-70 scale-105 shadow-lg" : ""} - `} - {...attributes} - > - <GripVertical - className="h-4 w-4 text-muted-foreground" - {...listeners} - /> - - <span className="flex-1">{name}</span> - - <span className="text-xs text-muted-foreground"> - Priority {index + 1} - </span> - </div> - ); -} - -function sendCategorisationRequest(selectedScenarios: ScenarioWithPriority[], portfolioId: number) { - const payload = mapScenariosToPayload(selectedScenarios, portfolioId); - return fetch("/api/plan/categorisation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export function RecommendationsOptions({ - disabled = false, - scenarios, - portfolioId, - onSuccess -}: RecommendationsOptionsProps) { - const [isApplying, setIsApplying] = useState(false); - const [open, setOpen] = useState(false); - const [selectedScenarios, setSelectedScenarios] = useState<ScenarioWithPriority[]>([]); - const [warning, setWarning] = useState<string | null>(null); - - const toggleScenario = (id: number) => { - setWarning("") - setSelectedScenarios((prev) => { - const exists = prev.find((s) => s.id === id); - if (exists) { - // Remove - return prev.filter((s) => s.id !== id); - } else { - // Add at the end with next priority - return [...prev, { id, priority: prev.length + 1 }]; - } - }); - }; - - const handleSelectAll = () => { - setWarning("") - setSelectedScenarios( - scenarios.map((s, index) => ({ id: s.id, priority: index + 1 })) - ); - }; - - const handleDeselectAll = () => { - setWarning("") - setSelectedScenarios([]); - }; - - const handleDragEnd = (event: any) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - - setSelectedScenarios((items) => { - const oldIndex = items.findIndex((s) => s.id === active.id); - const newIndex = items.findIndex((s) => s.id === over.id); - - const newOrder = arrayMove(items, oldIndex, newIndex); - - // Update priority to match array index - return newOrder.map((s, index) => ({ ...s, priority: index + 1 })); - }); - }; - - const { mutate, isPending } = useMutation({ - - mutationFn: () => sendCategorisationRequest(selectedScenarios, portfolioId), - // onSuccess: () => { - // }, - // onError: () => { - // } -}); - -const handleSubmit = () => { - if (selectedScenarios.length === 1) { - setWarning("Cannot generate recommendations for a single scenario"); - return; - } - setWarning(null); - mutate(); - onSuccess(); - setOpen(false); -}; - - const handleCancel = () => { - setWarning("") - setSelectedScenarios([]); - setOpen(false); - }; - - const selectedScenarioObjects = selectedScenarios.map( - (s) => ({ - ...scenarios.find((sc) => sc.id === s.id)!, - priority: s.priority, - }) - ); - - return ( - <DropdownMenu open={open} onOpenChange={setOpen}> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - disabled={disabled || isApplying} - className={` - rounded-md px-3 py-2 text-sm font-medium transition - ${ - disabled - ? "bg-gray-200 text-gray-400 cursor-not-allowed" - : "bg-brandblue text-white hover:bg-hoverblue" - } - `} - > - Calculate Recommended - </Button> - </DropdownMenuTrigger> - - <DropdownMenuContent className="w-80 p-4 space-y-4 max-h-[50vh] overflow-y-auto"> - <div className="flex justify-between"> - <Button size="sm" variant="ghost" onClick={handleSelectAll}> - Select all - </Button> - <Button size="sm" variant="ghost" onClick={handleDeselectAll}> - Deselect all - </Button> - </div> - - <div className="space-y-2"> - <h4 className="font-semibold">Select scenarios to consider</h4> - - {scenarios.map((scenario) => ( - <div key={scenario.id} className="flex items-center gap-2"> - <Checkbox - checked={selectedScenarios.some((s) => s.id === scenario.id)} - onCheckedChange={() => toggleScenario(scenario.id)} - /> - <Label>{scenario.name}</Label> - </div> - ))} - </div> - - {selectedScenarioObjects.length > 0 && ( - <div className="space-y-2"> - <div className="flex items-center gap-1"> - <h4 className="font-semibold">Drag to prioritise</h4> - - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <HelpCircle className="h-4 w-4 text-muted-foreground" /> - </TooltipTrigger> - <TooltipContent> - <p> - This decides the order of selection if multiple scenarios have equal - outputs. - </p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - - <DndContext - collisionDetection={closestCenter} - onDragEnd={handleDragEnd} - > - <SortableContext - items={selectedScenarios} - strategy={verticalListSortingStrategy} - > - {selectedScenarioObjects.map((scenario, index) => ( - <SortableScenarioItem - key={scenario.id} - id={scenario.id} - name={scenario.name} - index={index} - /> - ))} - </SortableContext> - </DndContext> - </div> - )} - - {warning && ( - <p className="text-sm text-red-600 font-medium">{warning}</p> - )} - - <div className="flex justify-end gap-2 pt-2"> - <Button variant="ghost" size="sm" onClick={handleCancel}> - Cancel - </Button> - <Button size="sm" onClick={handleSubmit} disabled={isApplying}> - <span className="flex items-center gap-2"> - {isApplying && <Loader2 className="h-4 w-4 animate-spin" />} - Submit - </span> - </Button> - </div> - </DropdownMenuContent> - </DropdownMenu> - ); -} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 8dd01dbe..baf74d3d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -1,372 +1,788 @@ "use client"; -import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper"; -import { DashboardSummaryCards } from "./DashboardSummaryCards"; -import { BreakdownChart } from "./BreakdownChart"; -import { EpcQualityCards } from "./EpcQualityCards"; -import { ScenarioFinancialDrawer } from "./ScenarioFinancialDrawer"; -import { ScenarioMeasuresModal } from "./ScenarioMeasuresModal"; - -import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider"; +import { Suspense, use, useState } from "react"; +import { useQuery, useIsFetching } from "@tanstack/react-query"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import { formatNumber, sapToEpc } from "@/app/utils"; import { - PlaceholderMetricCards, - CONDITION_PLACEHOLDERS, - FINANCIAL_PLACEHOLDERS, -} from "@/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards"; + parseReportingViewState, + serializeReportingViewState, + serializeTagFilter, + tagFiltersEqual, + type ReportingViewState, + type TagFilter, +} from "@/lib/reporting/viewState"; +import { + selectGoalCallout, + shapeKpiDelta, + toBandCounts, + deriveLedgerView, + type ScenarioOverlay, +} from "@/lib/reporting/model"; +import { + ScenarioCombobox, + type ReportView, +} from "./components/ScenarioCombobox"; +import { KpiBand, type Kpi } from "./components/KpiBand"; +import { EpcChip, Panel } from "./components/primitives"; +import { EpcLadder } from "./components/EpcLadder"; +import { GoalCallout } from "./components/GoalCallout"; +import { + InvestmentLedger, + type LedgerView, +} from "./components/InvestmentLedger"; +import { FilterChips, type ScenarioFilters } from "./components/FilterChips"; +import { COST_HELP } from "./components/costHelp"; +import { DrillDownShelf, type DrillTarget } from "./components/DrillDownShelf"; +import { StockBreakdown } from "./components/StockBreakdown"; +import { MeasureAllocation } from "./components/MeasureAllocation"; +import { TagFilterControl } from "./components/TagFilterControl"; import type { BaselineMetrics, PropertyTypeCount, ScenarioSummary, -} from "./types"; -import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons"; -import { RecommendationsOptions } from "./RecommendationsOptions"; -import SuccessToast from "../../components/SuccessToast"; +} from "@/lib/reporting/types"; -interface ReportingClientAreaProps { - baseline: BaselineMetrics; - propertyTypes: PropertyTypeCount[]; - scenarios: ScenarioSummary[]; +interface Props { + baselinePromise: Promise<BaselineMetrics>; + propertyTypesPromise: Promise<PropertyTypeCount[]>; + scenariosPromise: Promise<ScenarioSummary[]>; + portfolioGoalPromise: Promise<string>; portfolioId: number; } -// ---------------------------------------- -// Fetcher for scenario API route -// ---------------------------------------- -async function fetchScenarioReport({ - portfolioId, - scenarioId, - hideNonCompliant, - useOriginalBaseline, -}: { - portfolioId: number; - scenarioId: number | "default"; - hideNonCompliant: boolean; - useOriginalBaseline: boolean; -}) { - const params = new URLSearchParams({ - hideNonCompliant: String(hideNonCompliant), - useOriginalBaseline: String(useOriginalBaseline), +/** Maps the combobox view onto the scenario metrics route's id segment. */ +function scenarioIdSegment(view: ReportView): number | "default" | null { + if (view === "current-stock") return null; + if (view === "recommended") return "default"; + return view; +} + +async function fetchScenarioMetrics( + portfolioId: number, + segment: number | "default", + filters: ScenarioFilters, + tags: TagFilter, +): Promise<ScenarioOverlay> { + const p = new URLSearchParams({ + hideNonCompliant: String(filters.hideNonCompliant), + useLodgedBaseline: String(filters.useLodgedBaseline), }); - - const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`; - - const res = await fetch(`${path}?${params.toString()}`); - - if (!res.ok) { - throw new Error("Failed to load scenario report"); + if (filters.complianceWindow) { + p.set("complianceDate", filters.complianceWindow.date); + p.set("complianceBand", filters.complianceWindow.band); } - - return res.json(); -} - -async function fetchScenarioMeasures({ - portfolioId, - scenarioId, -}: { - portfolioId: number; - scenarioId: number | "default"; -}) { - const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`; - - const res = await fetch(path); - - if (!res.ok) { - throw new Error("Failed to load measures"); - } - + // View-wide tag filter — the overlay must reflect the same subset as the + // baseline it's compared against. + serializeTagFilter(tags, p); + const res = await fetch( + `/api/portfolio/${portfolioId}/scenario/${segment}/metrics?${p.toString()}`, + ); + if (!res.ok) throw new Error("Failed to load scenario report"); return res.json(); } +/** + * Orchestrator. The entire reporting view — selected scenario `view`, the + * scenario `filters`, and the `tags` filter — lives in the URL, so it survives + * a refresh and browser back/forward. This reads that state from + * `useSearchParams` (single source of truth) and renders the static header + * immediately. The scenario picker and the metrics body each live behind their + * own Suspense boundary, so they stream in independently as their server data + * resolves (`use()` on the promises handed down from the page). Nothing here + * awaits, so the shell paints on first byte. + */ export function ReportingClientArea({ - baseline, - propertyTypes, - scenarios, + baselinePromise, + propertyTypesPromise, + scenariosPromise, + portfolioGoalPromise, portfolioId, -}: ReportingClientAreaProps) { - const [selectedScenarioId, setSelectedScenarioId] = useState< - number | "default" | null - >(null); - const [measuresOpen, setMeasuresOpen] = useState<boolean>(false); - const [appliedHideNonCompliant, setAppliedHideNonCompliant] = - useState<boolean>(false); - const [appliedUseOriginalBaseline, setAppliedUseOriginalBaseline] = - useState<boolean>(false); - const [showToast, setShowToast] = useState(false); +}: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); - const drawerOpen = Boolean(selectedScenarioId); + const state = parseReportingViewState(searchParams); + const view = state.view as ReportView; + const tags = state.tags; + const filters: ScenarioFilters = { + hideNonCompliant: state.hideNonCompliant, + useLodgedBaseline: state.useLodgedBaseline, + complianceWindow: state.complianceWindow, + }; - // ---------------------------------------- - // React Query: fetch scenario metrics - // ---------------------------------------- - const { - data: scenarioData, - isLoading, - isFetching, - isError, - } = useQuery({ - queryKey: [ - "scenario-report", - portfolioId, - selectedScenarioId, - appliedHideNonCompliant, - appliedUseOriginalBaseline, - ], - queryFn: () => - fetchScenarioReport({ - portfolioId, - scenarioId: selectedScenarioId!, - hideNonCompliant: appliedHideNonCompliant, - useOriginalBaseline: appliedUseOriginalBaseline, - }), - enabled: selectedScenarioId !== null, // only run when scenario selected or default selected - keepPreviousData: true, // keep showing old data while loading new scenario or applying filter - refetchOnWindowFocus: false, - }); + const isScenario = scenarioIdSegment(view) !== null; - const { - data: measuresData, - isLoading: measuresLoading, - isError: measuresError, - } = useQuery({ - queryKey: ["scenario-measures", portfolioId, selectedScenarioId], - queryFn: () => - fetchScenarioMeasures({ - portfolioId, - scenarioId: selectedScenarioId!, - }), - enabled: measuresOpen && !!selectedScenarioId, - keepPreviousData: true, - refetchOnWindowFocus: false, - }); + // Single writer for the whole view state. A changed *tag* filter must + // re-render the server (the baseline is filtered server-side) → `router.replace`, + // and a brief metrics Suspense skeleton is appropriate there (the data genuinely + // changes). A `view` or scenario-`filter` change only affects the client-fetched + // overlay, so it goes through the History API — no server round-trip, so the + // baseline Suspense never re-flashes. Next 15 syncs push/replaceState with + // `useSearchParams`, so this component re-renders from the new URL either way. + const applyViewState = (next: ReportingViewState) => { + const qs = serializeReportingViewState(next).toString(); + const url = qs ? `${pathname}?${qs}` : pathname; + if (!tagFiltersEqual(next.tags, tags)) { + router.replace(url, { scroll: false }); + return; + } + // A scenario switch is a distinct destination — leave a Back entry. Filter + // toggles just tweak the current view, so replace (Back shouldn't have to + // unwind each toggle one at a time). + if (next.view !== view) { + window.history.pushState(null, "", url); + } else { + window.history.replaceState(null, "", url); + } + }; - // ---------------------------------------- - // Build overlay for Dashboard Summary cards - // ---------------------------------------- - - const scenarioOverlay = scenarioData - ? { - avgSap: { - baseline: baseline.averages.avg_sap ?? 0, - scenario: Number(scenarioData.avg_sap), - }, - avgCarbon: { - baseline: Number(baseline.averages.avg_carbon ?? 0), - scenario: Number(scenarioData.avg_carbon), - - baselineTotal: Number(baseline.totals.total_carbon ?? 0), - scenarioTotal: Number(scenarioData.total_carbon ?? 0), - }, - avgBills: { - baseline: baseline.averages.avg_bills ?? 0, - scenario: scenarioData.avg_bills, - baselineTotal: baseline.totals.total_bills ?? 0, - scenarioTotal: scenarioData.total_bills, - }, - valuation: { baseline: null, scenario: null }, - scenarioEpcBands: scenarioData.scenario_epc_counts, - } - : null; - - // ---------------------------------------- - // Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline - // ---------------------------------------- - - const scenarioSpecific = useMemo(() => { - if (!scenarioData) return null; - - return { - constructionCost: scenarioData.construction_cost, - pcCost: scenarioData.pc_cost, - contingency: scenarioData.contingency, - funding: scenarioData.total_funding, - costPerSap: - scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - scenarioData.total_sap_uplift - : 0, - costPerCo2: - scenarioData.construction_cost > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) - : 0, - netCost: scenarioData.net_cost, - grossPerUnit: scenarioData.gross_per_unit, - nUnits: scenarioData.n_units_upgraded, - totalCarbonSaved: - (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, - totalBillsSaved: - (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, - averageCaribonSaved: - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / - scenarioData.n_units_upgraded, - averageBillsSaved: - ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / - scenarioData.n_units_upgraded, - }; - }, [scenarioData, baseline]); - - // Baseline stays baseline - const activeMetrics = baseline; - - const scenarioBusy = !!selectedScenarioId && (isLoading || isFetching); + const onView = (v: ReportView) => applyViewState({ ...state, view: v }); + const onFilters = (f: ScenarioFilters) => applyViewState({ ...state, ...f }); + const onTagsChange = (next: TagFilter) => + applyViewState({ ...state, tags: next }); return ( - <> - <div className="flex items-center justify-between gap-4"> - {/* LEFT: Scenario selector */} - <ScenarioSelectorWrapper - scenarios={scenarios} - portfolioId={portfolioId} - selectedScenarioId={selectedScenarioId} - setSelectedScenarioId={setSelectedScenarioId} - /> - - {/* RIGHT: Actions (only when scenario selected) */} - {selectedScenarioId && ( - <div className="flex items-center gap-2"> - {/* Show measures */} - <button - onClick={() => setMeasuresOpen(true)} - disabled={scenarioBusy} - className={` - rounded-md px-3 py-2 text-sm font-medium transition - ${ - scenarioBusy - ? "bg-gray-200 text-gray-400 cursor-not-allowed" - : "bg-brandblue text-white hover:bg-hoverblue" - } - `} - > - {scenarioBusy ? "Loading…" : "Show measures"} - </button> - - <ReportingFunctionalityButtons - hideNonCompliant={appliedHideNonCompliant} - useOriginalBaseline={appliedUseOriginalBaseline} - disabled={scenarioBusy} - canFilterNonCompliant={ - selectedScenarioId !== null && selectedScenarioId !== "default" - } - onApply={async ({ hideNonCompliant, useOriginalBaseline }) => { - setAppliedHideNonCompliant(hideNonCompliant); - setAppliedUseOriginalBaseline(useOriginalBaseline); - }} - /> - - {/* Download PDF */} - <button - onClick={() => { - window.open( - `/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`, - "_blank", - ); - }} - disabled={scenarioBusy} - className={` - rounded-md border px-3 py-2 text-sm font-medium transition - ${ - scenarioBusy - ? "border-gray-200 text-gray-400 cursor-not-allowed" - : "hover:bg-gray-50" - } - `} - > - Download PDF - </button> - </div> - )} - { !selectedScenarioId && - <RecommendationsOptions - disabled={scenarioBusy} - scenarios={scenarios} + <div className="space-y-4" data-reporting> + {/* Header — static, paints on first byte */} + <div className="flex items-end justify-between gap-4"> + <div> + <div className="text-[0.74rem] text-gray-600">Portfolios</div> + <h1 className="mt-0.5 text-[1.35rem] font-semibold tracking-tight text-brandblue"> + Reporting + </h1> + </div> + <div className="flex gap-2"> + <TagFilterControl portfolioId={portfolioId} - onSuccess={() => setShowToast(true)} - /> - } - </div> - - {/* LOADING + ERROR STATES */} - {isLoading && selectedScenarioId && ( - <div className="text-sm text-gray-500 mt-2">Loading scenario…</div> - )} - {isError && ( - <div className="text-sm text-red-500 mt-2"> - Could not load scenario data. - </div> - )} - - {/* --- RETROFIT SECTION --- */} - <SectionDivider - title="Retrofit Summary" - subtitle="High-level insights on performance, energy, and EPC quality." - /> - - <ScenarioFinancialDrawer - open={drawerOpen} - metrics={scenarioSpecific} - loading={scenarioBusy} - /> - - <div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2"> - <DashboardSummaryCards - total={activeMetrics.total} - totals={activeMetrics.totals} - averages={activeMetrics.averages} - epcCoverage={activeMetrics.epcCoverage} - scenarioOverlay={scenarioOverlay} - loading={scenarioBusy} - /> - - <BreakdownChart - epcBands={activeMetrics.epcBands} - ageBands={activeMetrics.ageBands} - propertyTypes={propertyTypes} - scenarioEpcBands={scenarioOverlay?.scenarioEpcBands} - /> - - <div className="lg:col-span-2"> - <EpcQualityCards - epcCoverage={activeMetrics.epcCoverage} - total={activeMetrics.total} - expiredEpcs={activeMetrics.expiredEpcs} - likelyDowngrades={activeMetrics.likelyDowngrades} + value={tags} + onChange={onTagsChange} /> + {isScenario && ( + <a + href={`/portfolio/${portfolioId}/reporting/compare`} + className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium" + > + Compare scenarios + </a> + )} + <a + href={(() => { + // Carry the full on-screen view state (view + filters + tags) so + // the report matches exactly what's being viewed — any view, not + // just a numeric scenario. + const qs = serializeReportingViewState(state).toString(); + return `/portfolio/${portfolioId}/reporting/pdf${qs ? `?${qs}` : ""}`; + })()} + target="_blank" + className="inline-flex h-8 items-center rounded-lg border border-brandblue bg-brandblue px-3.5 text-sm font-medium text-white" + > + Download report + </a> </div> </div> - {/* --- CONDITION SECTION --- */} - <SectionDivider - title="Condition" - subtitle="Awaabs Law, decent homes and compliance" - /> - <PlaceholderMetricCards items={CONDITION_PLACEHOLDERS} /> + {/* Scenario picker — streams as soon as the (fast) scenarios query lands */} + <Suspense fallback={<ScenarioRowSkeleton />}> + <ScenarioRow + scenariosPromise={scenariosPromise} + baselinePromise={baselinePromise} + view={view} + onView={onView} + filters={filters} + onFilters={onFilters} + /> + </Suspense> - {/* --- FINANCIAL SECTION --- */} - <SectionDivider - title="Financial Overview" - subtitle="Total bills, cost exposure, and potential funding pathways." - /> - <PlaceholderMetricCards items={FINANCIAL_PLACEHOLDERS} /> - - <ScenarioMeasuresModal - isOpen={measuresOpen} - onClose={() => setMeasuresOpen(false)} - isLoading={measuresLoading} - data={measuresData ?? null} - error={measuresError} - /> - - <SuccessToast - show={showToast} - showConfetti={false} - onClose={() => setShowToast(false)} - message="Recommendation process triggered" - subtext="Recommendations might take a few minutes to update" - timeoutMs={6000} - /> - </> + {/* Metrics — streams once the baseline aggregate lands */} + <Suspense fallback={<MetricsSkeleton />}> + <MetricsBody + baselinePromise={baselinePromise} + propertyTypesPromise={propertyTypesPromise} + scenariosPromise={scenariosPromise} + portfolioGoalPromise={portfolioGoalPromise} + portfolioId={portfolioId} + view={view} + filters={filters} + tags={tags} + /> + </Suspense> + </div> + ); +} + +// ── Scenario picker row ────────────────────────────────────────────────────── + +function ScenarioRow({ + scenariosPromise, + baselinePromise, + view, + onView, + filters, + onFilters, +}: { + scenariosPromise: Promise<ScenarioSummary[]>; + baselinePromise: Promise<BaselineMetrics>; + view: ReportView; + onView: (v: ReportView) => void; + filters: ScenarioFilters; + onFilters: (f: ScenarioFilters) => void; +}) { + const scenarios = use(scenariosPromise); + const isScenario = scenarioIdSegment(view) !== null; + const selectedScenario = + typeof view === "number" ? scenarios.find((s) => s.id === view) : undefined; + const showEpcFilters = selectedScenario?.goal === "Increasing EPC"; + + // Subscribe to the scenario overlay fetch (owned by MetricsBody) without + // lifting it — so applying a filter shows immediate feedback right here at + // the controls, not only down in the figures. + const fetchingCount = useIsFetching({ queryKey: ["scenario-report"] }); + const busy = isScenario && fetchingCount > 0; + + return ( + <div className="flex flex-wrap items-center gap-3"> + <ScenarioCombobox scenarios={scenarios} value={view} onChange={onView} /> + {!isScenario ? ( + // The count needs the baseline; keep it out of the combobox's critical + // path with its own boundary so the picker isn't held back by it. + <Suspense + fallback={ + <span className="text-[0.76rem] text-gray-400">… homes</span> + } + > + <HomesCount baselinePromise={baselinePromise} /> + </Suspense> + ) : ( + selectedScenario && ( + <span className="text-[0.76rem] text-gray-500"> + {selectedScenario.goal === "Increasing EPC" && ( + <> + Target{" "} + <b className="text-gray-900"> + EPC {selectedScenario.goalValue} + </b>{" "} + ·{" "} + </> + )} + {selectedScenario.budget != null && ( + <> + Budget{" "} + <b className="text-gray-900"> + £{selectedScenario.budget.toLocaleString("en-GB")}/home + </b> + </> + )} + </span> + ) + )} + {isScenario && ( + <div className="ml-auto flex items-center gap-2"> + {busy && ( + <span + className="flex items-center gap-1 text-[0.72rem] text-gray-500" + role="status" + aria-live="polite" + > + <ArrowPathIcon className="h-3.5 w-3.5 animate-spin text-gray-400" /> + Updating… + </span> + )} + <FilterChips + filters={filters} + onChange={onFilters} + showEpcFilters={!!showEpcFilters} + /> + </div> + )} + </div> + ); +} + +function HomesCount({ + baselinePromise, +}: { + baselinePromise: Promise<BaselineMetrics>; +}) { + const baseline = use(baselinePromise); + return ( + <span className="text-[0.76rem] text-gray-500">{baseline.total} homes</span> + ); +} + +// ── Metrics body ───────────────────────────────────────────────────────────── + +function MetricsBody({ + baselinePromise, + propertyTypesPromise, + scenariosPromise, + portfolioGoalPromise, + portfolioId, + view, + filters, + tags, +}: { + baselinePromise: Promise<BaselineMetrics>; + propertyTypesPromise: Promise<PropertyTypeCount[]>; + scenariosPromise: Promise<ScenarioSummary[]>; + portfolioGoalPromise: Promise<string>; + portfolioId: number; + view: ReportView; + filters: ScenarioFilters; + tags: TagFilter; +}) { + const baseline = use(baselinePromise); + const propertyTypes = use(propertyTypesPromise); + const scenarios = use(scenariosPromise); + const portfolioGoal = use(portfolioGoalPromise); + + const [drill, setDrill] = useState<DrillTarget | null>(null); + const [drillPage, setDrillPage] = useState(1); + + const segment = scenarioIdSegment(view); + const isScenario = segment !== null; + const selectedScenario = + typeof view === "number" ? scenarios.find((s) => s.id === view) : undefined; + + const { data: scenarioData, isFetching } = useQuery({ + // `tags` is in the key so the overlay refetches when the tag filter changes. + queryKey: ["scenario-report", portfolioId, segment, filters, tags], + queryFn: () => fetchScenarioMetrics(portfolioId, segment!, filters, tags), + enabled: isScenario, + keepPreviousData: true, + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + cacheTime: 30 * 60 * 1000, + }); + + const total = baseline.total; + const avg = baseline.averages; + + function openDrill(t: DrillTarget) { + setDrill(t); + setDrillPage(1); + // Bring the shelf into view so a click below the fold doesn't look inert. + // Done in the handler (not an effect) per project convention. + if (typeof window !== "undefined") { + const reduce = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + requestAnimationFrame(() => + document.getElementById("reporting-drill-shelf")?.scrollIntoView({ + behavior: reduce ? "auto" : "smooth", + block: "nearest", + }), + ); + } + } + + // ── KPI band ─────────────────────────────────────────────────────────── + const currentStockKpis: Kpi[] = [ + { + label: "Homes", + value: total, + sub: ( + <button + onClick={() => openDrill({ filter: "estimated", title: "All homes" })} + className="font-semibold text-midblue" + > + View all → + </button> + ), + }, + { + label: "Average EPC", + value: ( + <span className="flex items-center gap-1.5"> + <EpcChip band={sapToEpc(avg.avg_sap ?? 0)} /> + {Math.round(avg.avg_sap ?? 0)} + </span> + ), + unit: "SAP", + }, + { + label: "Energy bills", + value: `£${formatNumber(avg.avg_bills ?? 0)}`, + unit: "/home/yr", + sub: `£${formatNumber(baseline.totals.total_bills ?? 0)} across the portfolio`, + }, + { + label: "Carbon", + value: formatNumber(avg.avg_carbon ?? 0), + unit: "tCO₂e/home", + sub: `${formatNumber(baseline.totals.total_carbon ?? 0)} t total`, + }, + { + label: "Energy use", + value: formatNumber(avg.avg_energy_consumption ?? 0), + unit: "kWh/home", + }, + ]; + + const scenarioKpis: Kpi[] = scenarioData + ? [ + { + label: "Homes upgraded", + value: scenarioData.n_units_upgraded, + unit: `of ${total}`, + sub: ( + <a + href={`/portfolio/${portfolioId}`} + className="font-semibold text-midblue" + > + View upgraded homes → + </a> + ), + }, + (() => { + const d = shapeKpiDelta({ + current: avg.avg_sap ?? 0, + after: Number(scenarioData.avg_sap), + improvesWhenLower: false, + }); + return { + label: "Average EPC", + value: ( + <span className="flex items-center gap-1.5"> + <EpcChip band={sapToEpc(Number(scenarioData.avg_sap))} /> + {Math.round(Number(scenarioData.avg_sap))} + </span> + ), + delta: { + text: `${d.delta > 0 ? "+" : ""}${d.delta.toFixed(0)} SAP`, + improved: d.improved, + }, + sub: ( + <span className="flex items-center gap-1"> + was <EpcChip band={sapToEpc(avg.avg_sap ?? 0)} size="sm" />{" "} + {Math.round(avg.avg_sap ?? 0)} + </span> + ), + }; + })(), + (() => { + const d = shapeKpiDelta({ + current: avg.avg_bills ?? 0, + after: Number(scenarioData.avg_bills), + improvesWhenLower: true, + }); + return { + label: "Energy bills", + value: `£${formatNumber(Number(scenarioData.avg_bills))}`, + delta: { + text: `−£${formatNumber(Math.abs(d.delta))}`, + improved: d.improved, + }, + sub: `was £${formatNumber(avg.avg_bills ?? 0)}`, + }; + })(), + (() => { + const d = shapeKpiDelta({ + current: avg.avg_carbon ?? 0, + after: Number(scenarioData.avg_carbon), + improvesWhenLower: true, + }); + return { + label: "Carbon", + value: formatNumber(Number(scenarioData.avg_carbon)), + unit: "tCO₂e/home", + delta: { + text: `−${formatNumber(Math.abs(d.delta))} t`, + improved: d.improved, + }, + sub: `was ${formatNumber(avg.avg_carbon ?? 0)}`, + }; + })(), + { + label: "Gross cost /home", + value: `£${formatNumber(scenarioData.gross_per_unit ?? 0)}`, + sub: `${scenarioData.n_units_upgraded} homes`, + tip: COST_HELP.grossPerHome, + }, + ] + : []; + + // ── EPC ladder overlay + goal callout ────────────────────────────────── + const scenarioBands: Record<string, number> | undefined = + scenarioData?.scenario_epc_counts; + + const bandCounts = toBandCounts(baseline.epcBands); + const callout = isScenario + ? selectGoalCallout({ + view: "scenario", + goal: selectedScenario?.goal ?? portfolioGoal, + goalValue: selectedScenario?.goalValue ?? null, + bandCounts, + scenarioBandCounts: scenarioBands ?? {}, + }) + : selectGoalCallout({ + view: "current-stock", + goal: portfolioGoal, + bandCounts, + }); + + // ── Investment ledger ────────────────────────────────────────────────── + const ledger: LedgerView | null = scenarioData + ? deriveLedgerView(scenarioData, { + totalCarbon: baseline.totals.total_carbon, + totalBills: baseline.totals.total_bills, + }) + : null; + + const scenarioBusy = isScenario && isFetching; + // First entry into a scenario from Current stock: there's no previous overlay + // to keep, so the scenario KPIs are momentarily empty. Skeleton that band + // (the "no data yet" case → same pattern as first page load) rather than + // flashing an empty bar. A scenario→scenario switch keeps the old figures, so + // it stays on the dim+spinner path below instead. + const scenarioLoadingFirst = scenarioBusy && !scenarioData; + + return ( + <div className="space-y-4"> + {/* Refetch-with-data only: the figures stay on screen and dim, so a label + clarifies the dim means "updating". First load speaks via its skeleton. */} + {scenarioBusy && scenarioData && ( + <div + className="flex items-center gap-1.5 text-[0.8rem] text-gray-600" + role="status" + aria-live="polite" + > + <ArrowPathIcon className="h-3.5 w-3.5 animate-spin text-gray-400" /> + Updating figures… + </div> + )} + + {/* Scenario figures dim while the overlay refetches — keepPreviousData + leaves the old numbers on screen, so this signals they're being + recalculated rather than letting them look silently up to date. */} + <div + className={`space-y-4 transition-opacity duration-200 ${ + scenarioBusy && scenarioData ? "opacity-50" : "" + }`} + aria-busy={scenarioBusy} + > + {/* KPI band */} + {scenarioLoadingFirst ? ( + <KpiBandSkeleton /> + ) : ( + <KpiBand kpis={isScenario ? scenarioKpis : currentStockKpis} /> + )} + + {/* Charts + ledger */} + <div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]"> + <Panel + title="EPC distribution" + meta={ + isScenario + ? "Current vs scenario · click a band for its homes" + : "Effective performance · click a band for its homes" + } + > + <div className="mb-4"> + <EpcLadder + bands={baseline.epcBands} + scenarioBands={scenarioBands} + selectedBand={drill?.filter === "band" ? drill.band : null} + onSelectBand={(band) => + openDrill({ + filter: "band", + band, + chipBand: band, + title: `Band ${band}`, + }) + } + /> + </div> + <GoalCallout + callout={callout} + total={total} + dimensionTotals={{ carbon: baseline.totals.total_carbon ?? 0 }} + onDrill={ + callout.kind === "below-band" + ? () => + openDrill({ + filter: "band", + band: callout.band, + chipBand: callout.band, + title: `Below EPC ${callout.band}`, + }) + : undefined + } + /> + </Panel> + + {isScenario && ledger ? ( + <InvestmentLedger v={ledger} /> + ) : ( + <StockBreakdown + propertyTypes={propertyTypes} + ageBands={baseline.ageBands} + /> + )} + </div> + </div> + + {/* Where the money goes (scenario only) */} + {isScenario && segment !== null && ( + <MeasureAllocation + portfolioId={portfolioId} + scenarioId={segment} + tags={tags} + onDrillMeasure={ + typeof view === "number" + ? (t) => openDrill({ ...t, scenarioId: view }) + : undefined + } + /> + )} + + {/* Data confidence strip */} + <ConfidenceStrip + baseline={baseline} + portfolioId={portfolioId} + onDrill={openDrill} + /> + + {/* Drill-down shelf */} + {drill && ( + <DrillDownShelf + portfolioId={portfolioId} + target={drill} + page={drillPage} + onPage={setDrillPage} + onClose={() => setDrill(null)} + tags={tags} + /> + )} + </div> + ); +} + +// ── Streaming skeletons ────────────────────────────────────────────────────── + +function ScenarioRowSkeleton() { + return ( + <div className="flex flex-wrap items-center gap-3" aria-hidden> + <div className="h-9 w-60 animate-pulse rounded-lg border border-gray-200 bg-gray-100" /> + <div className="h-3 w-24 animate-pulse rounded bg-gray-100" /> + </div> + ); +} + +/** + * The five-cell KPI band in its loading shape. Shared by the initial page-load + * skeleton and the first Current-stock → scenario switch, so both "no data yet" + * moments read identically. + */ +function KpiBandSkeleton() { + return ( + <div + className="grid grid-cols-2 rounded-lg border border-gray-200 bg-white sm:grid-cols-3 lg:grid-cols-5" + aria-hidden + > + {Array.from({ length: 5 }).map((_, i) => ( + <div + key={i} + className={`space-y-2 px-5 py-4 ${i > 0 ? "border-l border-gray-100" : ""}`} + > + <div className="h-2.5 w-16 animate-pulse rounded bg-gray-100" /> + <div className="h-6 w-20 animate-pulse rounded bg-gray-100" /> + </div> + ))} + </div> + ); +} + +function MetricsSkeleton() { + return ( + <div className="space-y-4" aria-hidden> + <KpiBandSkeleton /> + {/* Charts + side panel */} + <div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]"> + <div className="h-64 animate-pulse rounded-lg border border-gray-200 bg-white" /> + <div className="h-64 animate-pulse rounded-lg border border-gray-200 bg-white" /> + </div> + {/* Confidence strip */} + <div className="h-12 animate-pulse rounded-lg border border-gray-200 bg-white" /> + </div> + ); +} + +function ConfidenceStrip({ + baseline, + portfolioId, + onDrill, +}: { + baseline: BaselineMetrics; + portfolioId: number; + onDrill: (t: DrillTarget) => void; +}) { + const total = baseline.total; + const estimated = baseline.estimatedCounts.estimated; + const lodgedPct = + total > 0 ? Math.round(((total - estimated) / total) * 100) : 0; + + const Item = ({ + label, + value, + onClick, + }: { + label: string; + value: React.ReactNode; + onClick?: () => void; + }) => ( + <button + onClick={onClick} + disabled={!onClick} + className={`tabular-nums ${onClick ? "hover:text-brandblue" : ""}`} + > + <b className="font-semibold text-gray-900">{value}</b>{" "} + <span className="text-gray-500">{label}</span> + </button> + ); + + return ( + <div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-gray-200 bg-white px-5 py-3 text-[0.8rem]"> + <span className="text-[0.78rem] font-semibold text-brandblue"> + Data confidence + </span> + <Item label="lodged EPCs" value={`${lodgedPct}%`} /> + <Item + label="estimated" + value={estimated} + onClick={() => + onDrill({ filter: "estimated", title: "Estimated EPCs" }) + } + /> + <Item + label="expired" + value={baseline.expiredEpcs} + onClick={() => onDrill({ filter: "expired", title: "Expired EPCs" })} + /> + <Item + label="likely downgrades" + value={baseline.likelyDowngrades} + onClick={() => + onDrill({ filter: "likely-downgrade", title: "Likely downgrades" }) + } + /> + <button + onClick={() => + onDrill({ filter: "likely-upgrade", title: "Likely upgrades" }) + } + className="text-[#0c6b4a] hover:underline" + > + Likely upgrades → + </button> + <a + href={`/portfolio/${portfolioId}/reporting/data-quality`} + className="ml-auto font-semibold text-midblue" + > + Review data quality → + </a> + </div> ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx deleted file mode 100644 index 9a70414f..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx +++ /dev/null @@ -1,222 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/app/shadcn_components/ui/dropdown-menu"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; - -export interface ReportingFunctionalityButtonsProps { - /** Currently applied value */ - hideNonCompliant: boolean; - - /** Currently applied value */ - useOriginalBaseline: boolean; - - /** - * Explicit user action. - * Parent decides what "apply" means (refetch, mutate, etc). - */ - onApply: (options: { - hideNonCompliant: boolean; - useOriginalBaseline: boolean; - }) => Promise<void> | void; - - disabled?: boolean; - - /* Whether filters are available (only for specific non-default scenarios) */ - canFilterNonCompliant?: boolean; -} - -export function ReportingFunctionalityButtons({ - hideNonCompliant, - useOriginalBaseline, - onApply, - disabled = false, - canFilterNonCompliant = true, -}: ReportingFunctionalityButtonsProps) { - const [draftHideNonCompliant, setDraftHideNonCompliant] = - useState<boolean>(hideNonCompliant); - const [draftUseOriginalBaseline, setDraftUseOriginalBaseline] = - useState<boolean>(useOriginalBaseline); - - const [isApplying, setIsApplying] = useState(false); - - async function handleApply() { - try { - setIsApplying(true); - await onApply({ - hideNonCompliant: draftHideNonCompliant, - useOriginalBaseline: draftUseOriginalBaseline, - }); - } finally { - setIsApplying(false); - } - } - - async function handleReset() { - try { - // reset the filter and trigger the fetch - setIsApplying(true); - setDraftHideNonCompliant(false); - setDraftUseOriginalBaseline(false); - await onApply({ hideNonCompliant: false, useOriginalBaseline: false }); - } finally { - setIsApplying(false); - } - } - - return ( - <DropdownMenu - onOpenChange={(open) => { - if (open) { - setDraftHideNonCompliant(hideNonCompliant); - setDraftUseOriginalBaseline(useOriginalBaseline); - } - }} - > - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - disabled={disabled || isApplying} - className={` - relative flex items-center gap-2 - ${ - hideNonCompliant || useOriginalBaseline - ? "border-brandmidblue/40 bg-brandlightblue/40" - : "" - } - `} - > - {/* Filter icon */} - <svg - className={`h-4 w-4 ${ - hideNonCompliant || useOriginalBaseline ? "text-brandmidblue" : "text-gray-500" - }`} - viewBox="0 0 20 20" - fill="currentColor" - > - <path d="M3 4a1 1 0 011-1h12a1 1 0 01.8 1.6l-4.8 6.4V16a1 1 0 01-1.447.894l-2-1A1 1 0 018 14v-2.999L3.2 5.6A1 1 0 013 4z" /> - </svg> - Filter options - {(hideNonCompliant || useOriginalBaseline) && ( - <span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-brandmidblue" /> - )} - </Button> - </DropdownMenuTrigger> - - <DropdownMenuContent - align="end" - className="w-80 p-4 shadow-tremor-dropdown" - > - <div className="space-y-5"> - {/* Filter option */} - <div - className={`flex items-start gap-4 ${ - !canFilterNonCompliant ? "opacity-50 pointer-events-none" : "" - }`} - > - <Checkbox - id="hide-non-compliant" - checked={draftHideNonCompliant} - disabled={!canFilterNonCompliant} - onCheckedChange={(checked) => - setDraftHideNonCompliant(Boolean(checked)) - } - className="mt-1" - /> - - <label - htmlFor="hide-non-compliant" - className="cursor-pointer space-y-1" - > - <div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug"> - <svg - className="h-4 w-4 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - > - <path - fillRule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 00-1.414 1.414L9 13l4.707-4.707z" - clipRule="evenodd" - /> - </svg> - Hide non-compliant properties - </div> - <div className="text-xs text-gray-500 leading-relaxed"> - Exclude properties that don’t meet the defined upgrade targets - </div> - </label> - </div> - - {/* Use original SAP points */} - <div - className={`flex items-start gap-4 ${ - !canFilterNonCompliant ? "opacity-50 pointer-events-none" : "" - }`} - > - <Checkbox - id="use-original-baseline" - checked={draftUseOriginalBaseline} - disabled={!canFilterNonCompliant} - onCheckedChange={(checked) => - setDraftUseOriginalBaseline(Boolean(checked)) - } - className="mt-1" - /> - - <label - htmlFor="use-original-baseline" - className="cursor-pointer space-y-1" - > - <div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug"> - <svg - className="h-4 w-4 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - > - <path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" /> - <path - fillRule="evenodd" - d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" - clipRule="evenodd" - /> - </svg> - Use lodged SAP points - </div> - <div className="text-xs text-gray-500 leading-relaxed"> - Base metrics on properties below the EPC target using their lodged SAP rating, rather than the current modelled rating - </div> - </label> - </div> - - {/* Actions */} - <div className="flex justify-end gap-2 pt-4 border-t"> - <Button - variant="ghost" - size="sm" - disabled={isApplying || !canFilterNonCompliant} - onClick={handleReset} - > - Reset - </Button> - - <Button - size="sm" - className="bg-brandmidblue hover:bg-hoverblue" - disabled={isApplying || !canFilterNonCompliant} - onClick={handleApply} - > - {isApplying ? "Applying…" : "Apply filters"} - </Button> - </div> - </div> - </DropdownMenuContent> - </DropdownMenu> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx deleted file mode 100644 index 2a614dac..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx +++ /dev/null @@ -1,429 +0,0 @@ -"use client"; - -import { motion, AnimatePresence } from "framer-motion"; -import { formatNumber } from "@/app/utils"; -import clsx from "clsx"; - -/* Heroicons (outline) */ -import { - ArrowTrendingUpIcon, - ClipboardDocumentCheckIcon, - ScaleIcon, - HomeIcon, - BoltIcon, - FireIcon, - ChartBarIcon, - WrenchIcon, -} from "@heroicons/react/24/outline"; - -/* Lucide */ -import { Gauge } from "lucide-react"; - -/* ───────────────────────────────────────────── */ -/* Types */ -/* ───────────────────────────────────────────── */ - -interface ScenarioFinancialDrawerProps { - open: boolean; - metrics: any | null; - loading?: boolean; -} - -/* ───────────────────────────────────────────── */ -/* Gradient Tokens */ -/* ───────────────────────────────────────────── */ - -const gradients = { - green: "bg-gradient-to-r from-green-700 via-green-400 to-green-700", - blue: "bg-gradient-to-r from-brandblue via-sky-400 to-brandblue", - purple: "bg-gradient-to-r from-purple-700 via-purple-400 to-purple-700", -}; - -/* ───────────────────────────────────────────── */ -/* Gradient Card Shell */ -/* ───────────────────────────────────────────── */ - -function GradientCard({ - gradient, - variant, - children, -}: { - gradient: string; - variant: "green" | "blue" | "purple"; - children: React.ReactNode; -}) { - return ( - <div - className={clsx( - "relative rounded-lg p-[2px] gradient-card", - gradient, - `gradient-${variant}`, - )} - > - <div className="rounded-[7px] bg-white h-full">{children}</div> - </div> - ); -} - -/* ───────────────────────────────────────────── */ -/* Single Metric Card */ -/* ───────────────────────────────────────────── */ - -function Metric({ - label, - value, - icon: Icon, - color, - gradient, - variant = "green", - loading = false, -}: { - label: string; - value: string | number; - icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; - color: string; - gradient: string; - variant?: "green" | "blue" | "purple"; - loading?: boolean; -}) { - if (loading || !value) { - return ( - <GradientCard gradient={gradient} variant={variant}> - <div className="p-4 h-full animate-pulse"> - <div className="h-4 w-1/2 bg-gray-200 rounded mb-3" /> - <div className="h-6 w-3/4 bg-gray-200 rounded" /> - </div> - </GradientCard> - ); - } - return ( - <GradientCard gradient={gradient} variant={variant}> - <div className="flex flex-col items-center justify-center p-4 h-full text-center"> - {loading ? ( - <div className="w-full animate-pulse space-y-3"> - <div className="h-6 w-6 mx-auto rounded bg-gray-200" /> - <div className="h-8 w-2/3 mx-auto rounded bg-gray-200" /> - <div className="h-3 w-1/2 mx-auto rounded bg-gray-200" /> - </div> - ) : ( - <> - <Icon className={clsx("h-6 w-6 mb-2", color)} /> - <span className="text-3xl font-semibold text-gray-900"> - {value} - </span> - <span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500"> - {label} - </span> - </> - )} - </div> - </GradientCard> - ); -} - -/* ───────────────────────────────────────────── */ -/* Paired Metric Card (Reusable Everywhere) */ -/* ───────────────────────────────────────────── */ - -function PairedMetric({ - title, - icon: Icon, - primary, - secondary, - gradient, - iconClassName = "text-gray-700", - variant = "green", - loading = false, -}: { - title: string; - icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; - primary: { label: string; value: string }; - secondary: { label: string; value: string }; - gradient: string; - iconClassName?: string; - variant?: "green" | "blue" | "purple"; - loading?: boolean; -}) { - if (loading || !primary.value || !secondary.value) { - return ( - <GradientCard gradient={gradient} variant={variant}> - <div className="p-4 h-full animate-pulse"> - <div className="h-4 w-1/2 bg-gray-200 rounded mb-3" /> - <div className="h-6 w-3/4 bg-gray-200 rounded" /> - </div> - </GradientCard> - ); - } - - return ( - <GradientCard gradient={gradient} variant={variant}> - <div className="p-4 h-full"> - {loading ? ( - <div className="animate-pulse space-y-4"> - <div className="h-4 w-1/3 rounded bg-gray-200" /> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="h-3 w-2/3 rounded bg-gray-200" /> - <div className="h-6 w-full rounded bg-gray-200" /> - </div> - <div className="space-y-2"> - <div className="h-3 w-2/3 rounded bg-gray-200" /> - <div className="h-6 w-full rounded bg-gray-200" /> - </div> - </div> - </div> - ) : ( - <> - <div className="flex items-center gap-2 mb-3"> - <Icon className={clsx("h-5 w-5", iconClassName)} /> - <span className="text-sm font-semibold text-gray-900"> - {title} - </span> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div> - <p className="text-xs text-gray-500">{primary.label}</p> - <p className="text-xl font-semibold text-gray-900"> - {primary.value} - </p> - </div> - - <div> - <p className="text-xs text-gray-500">{secondary.label}</p> - <p className="text-xl font-semibold text-gray-900"> - {secondary.value} - </p> - </div> - </div> - </> - )} - </div> - </GradientCard> - ); -} - -/* ───────────────────────────────────────────── */ -/* Section Header */ -/* ───────────────────────────────────────────── */ - -function Section({ - title, - subtitle, - icon: Icon, - gradient, - accentColor, - children, -}: { - title: string; - subtitle: string; - icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; - gradient: string; - accentColor: string; - children: React.ReactNode; -}) { - return ( - <div className="space-y-4"> - <div className="flex items-start gap-3"> - <div className={clsx("w-1 rounded-full self-stretch", gradient)} /> - - <div - className={clsx( - "rounded-lg p-2 bg-white shadow-sm border", - accentColor, - )} - > - <Icon className="h-5 w-5" /> - </div> - - <div className="pt-0.5"> - <h4 className="text-base font-semibold text-gray-900">{title}</h4> - <p className="text-xs text-gray-500">{subtitle}</p> - </div> - </div> - - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 print-grid-3"> - {children} - </div> - </div> - ); -} - -/* ───────────────────────────────────────────── */ -/* Loading Skeleton for dashboard cards */ -/* ───────────────────────────────────────────── */ - -function LoadingOverlay() { - return ( - <div className="absolute inset-0 z-20 rounded-lg bg-white/70 backdrop-blur-sm"> - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-6 animate-pulse"> - {Array.from({ length: 9 }).map((_, i) => ( - <div key={i} className="h-28 rounded-lg bg-gray-200" /> - ))} - </div> - </div> - ); -} - -/* ───────────────────────────────────────────── */ -/* Main Drawer */ -/* ───────────────────────────────────────────── */ - -export function ScenarioFinancialDrawer({ - open, - metrics, - loading = false, -}: ScenarioFinancialDrawerProps) { - return ( - <AnimatePresence initial={false}> - {open && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.35, ease: "easeInOut" }} - className="overflow-hidden" - > - <div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6 space-y-6"> - <h3 className="text-lg font-semibold text-brandblue"> - Scenario Impact Summary - </h3> - - {/* BENEFITS */} - <Section - title="Benefits" - subtitle="Impact for occupants and the environment" - icon={ArrowTrendingUpIcon} - gradient={gradients.green} - accentColor="border-green-200 text-green-700" - > - <PairedMetric - title="Carbon impact" - icon={BoltIcon} - iconClassName="text-green-700" - primary={{ - label: "Total carbon saved (t/yr)", - value: metrics ? formatNumber(metrics.totalCarbonSaved) : "", - }} - secondary={{ - label: "Average per unit (t/yr)", - value: metrics - ? formatNumber(metrics.averageCaribonSaved) - : "", - }} - gradient={gradients.green} - variant="green" - loading={loading} - /> - - <PairedMetric - title="Bill savings" - icon={FireIcon} - iconClassName="text-green-700" - primary={{ - label: "Total bill savings (£/yr)", - value: metrics - ? `£${formatNumber(metrics.totalBillsSaved)}` - : "", - }} - secondary={{ - label: "Average per unit (£/yr)", - value: metrics - ? `£${formatNumber(metrics.averageBillsSaved)}` - : "", - }} - gradient={gradients.green} - variant="green" - loading={loading} - /> - - <Metric - label="Homes upgraded" - value={metrics ? metrics.nUnits : ""} - icon={HomeIcon} - color="text-green-700" - gradient={gradients.green} - variant="green" - loading={loading} - /> - </Section> - - {/* COSTS */} - <Section - title="Costs" - subtitle="Investment required to deliver the works" - icon={ClipboardDocumentCheckIcon} - gradient={gradients.blue} - accentColor="border-brandblue text-brandblue" - > - <PairedMetric - title="Delivery costs" - icon={WrenchIcon} - iconClassName="text-blue-600" - primary={{ - label: "Construction works", - value: metrics - ? `£${formatNumber(metrics.constructionCost)}` - : "", - }} - secondary={{ - label: "Project delivery", - value: metrics ? `£${formatNumber(metrics.pcCost)}` : "", - }} - gradient={gradients.blue} - variant="blue" - loading={loading} - /> - - <Metric - label="Gross cost per unit" - value={metrics ? `£${formatNumber(metrics.grossPerUnit)}` : ""} - icon={HomeIcon} - color="text-blue-600" - gradient={gradients.blue} - variant="blue" - loading={loading} - /> - - <Metric - label="Contingency" - value={metrics ? `£${formatNumber(metrics.contingency)}` : ""} - icon={Gauge} - color="text-blue-600" - gradient={gradients.blue} - variant="blue" - loading={loading} - /> - </Section> - - {/* COST EFFECTIVENESS */} - <Section - title="Cost effectiveness" - subtitle="Value for money of the investment" - icon={ScaleIcon} - gradient={gradients.purple} - accentColor="border-purple-200 text-purple-700" - > - <PairedMetric - title="Efficiency metrics" - icon={ChartBarIcon} - iconClassName="text-purple-700" - primary={{ - label: "£ per SAP point", - value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "", - }} - secondary={{ - label: "£ per tonne CO₂", - value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "", - }} - gradient={gradients.purple} - variant="purple" - loading={loading} - /> - </Section> - </div> - </motion.div> - )} - </AnimatePresence> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx deleted file mode 100644 index 43961b31..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx +++ /dev/null @@ -1,320 +0,0 @@ -"use client"; - -import { - Dialog, - DialogBackdrop, - DialogPanel, - DialogTitle, - Transition, -} from "@headlessui/react"; -import { Fragment, useMemo } from "react"; - -/* ------------------------------------------------ - Types ------------------------------------------------- */ - -interface ScenarioMeasuresModalProps { - isOpen: boolean; - onClose: () => void; - isLoading: boolean; - data: any | null; - error: unknown; -} - -type ScenarioMeasure = { - measureType: string; - homesCount: number; - totalCost: number; - averageCost: number; -}; - -export type MeasureCategory = - | "Wall insulation" - | "Roof insulation" - | "Floor insulation" - | "Ventilation & airtightness" - | "Windows & glazing" - | "Solar" - | "Heating" - | "Heating controls" - | "Lighting" - | "Scaffolding & enabling works" - | "Other"; - -/* ------------------------------------------------ - Category mapping ------------------------------------------------- */ - -export const MEASURE_CATEGORY_MAP: Record<string, MeasureCategory> = { - internal_wall_insulation: "Wall insulation", - external_wall_insulation: "Wall insulation", - cavity_wall_insulation: "Wall insulation", - cavity_wall_extraction: "Wall insulation", - - loft_insulation: "Roof insulation", - flat_roof_insulation: "Roof insulation", - room_roof_insulation: "Roof insulation", - - suspended_floor_insulation: "Floor insulation", - solid_floor_insulation: "Floor insulation", - exposed_floor_insulation: "Floor insulation", - - mechanical_ventilation: "Ventilation & airtightness", - trickle_vent: "Ventilation & airtightness", - door_undercut: "Ventilation & airtightness", - sealing_fireplace: "Ventilation & airtightness", - - windows_glazing: "Windows & glazing", - double_glazing: "Windows & glazing", - secondary_glazing: "Windows & glazing", - - solar_pv: "Solar", - solar_battery: "Solar", - - air_source_heat_pump: "Heating", - boiler_upgrade: "Heating", - high_heat_retention_storage_heaters: "Heating", - - roomstat_programmer_trvs: "Heating controls", - time_temperature_zone_control: "Heating controls", - - low_energy_lighting_installation: "Lighting", - - scaffolding: "Scaffolding & enabling works", -}; - -function getMeasureCategory(measureType: string): MeasureCategory { - return MEASURE_CATEGORY_MAP[measureType] ?? "Other"; -} - -/* ------------------------------------------------ - Helpers ------------------------------------------------- */ - -function toTitleCase(value: string) { - return value - .replaceAll("_", " ") - .toLowerCase() - .replace(/\b\w/g, (char) => char.toUpperCase()); -} - -type GroupedMeasures = { - category: MeasureCategory; - rows: ScenarioMeasure[]; - homesTotal: number; - costTotal: number; -}; - -function groupMeasuresByCategory( - measures: ScenarioMeasure[] -): GroupedMeasures[] { - const map = new Map<MeasureCategory, ScenarioMeasure[]>(); - - for (const m of measures) { - const category = getMeasureCategory(m.measureType); - if (!map.has(category)) map.set(category, []); - map.get(category)!.push(m); - } - - return Array.from(map.entries()).map(([category, rows]) => ({ - category, - rows, - homesTotal: rows.reduce((s, r) => s + r.homesCount, 0), - costTotal: rows.reduce((s, r) => s + r.totalCost, 0), - })); -} - -/* ------------------------------------------------ - CSV download ------------------------------------------------- */ - -function downloadMeasuresCsv(groups: GroupedMeasures[]) { - const lines: string[] = []; - lines.push("Category,Measure,Homes,Total cost (£),Average cost (£)"); - - for (const group of groups) { - for (const m of group.rows) { - lines.push( - [ - group.category, - toTitleCase(m.measureType), - m.homesCount, - m.totalCost.toFixed(0), - m.averageCost.toFixed(2), - ].join(",") - ); - } - - // Subtotal - lines.push( - [ - group.category, - "Subtotal", - group.homesTotal, - group.costTotal.toFixed(0), - "", - ].join(",") - ); - } - - const blob = new Blob([lines.join("\n")], { - type: "text/csv;charset=utf-8;", - }); - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "scenario-measures.csv"; - link.click(); -} - -/* ------------------------------------------------ - Component ------------------------------------------------- */ - -export function ScenarioMeasuresModal({ - isOpen, - onClose, - isLoading, - data, - error, -}: ScenarioMeasuresModalProps) { - const measures = useMemo<ScenarioMeasure[]>(() => data?.measures ?? [], [data?.measures]); - - const grouped = useMemo(() => groupMeasuresByCategory(measures), [measures]); - - return ( - <Transition show={isOpen} as={Fragment}> - <Dialog - as="div" - className="fixed inset-0 z-50 overflow-y-auto" - onClose={onClose} - > - <div className="min-h-screen px-4 text-center"> - {isLoading && ( - <DialogBackdrop className="fixed inset-0 bg-black/30" /> - )} - - <span className="inline-block h-screen align-middle" /> - - <DialogPanel className="inline-block w-full max-w-5xl p-6 my-8 text-left align-middle bg-white shadow-xl rounded-2xl"> - <DialogTitle className="text-lg font-semibold text-gray-900"> - Scenario measures - </DialogTitle> - - {/* Actions */} - <div className="mt-4 flex items-center justify-between"> - <p className="text-sm text-gray-500"> - {measures.length} measures - </p> - - <button - onClick={() => downloadMeasuresCsv(grouped)} - disabled={!measures.length} - className="rounded-md border px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50" - > - Download CSV - </button> - </div> - - {/* Content */} - <div className="mt-4"> - {isLoading && ( - <div className="text-sm text-gray-500">Loading measures…</div> - )} - - {Boolean(error) && ( - <div className="text-sm text-red-600"> - Failed to load measures. - </div> - )} - - {!isLoading && grouped.length > 0 && ( - <div className="overflow-x-auto rounded-xl border border-gray-200"> - <table className="w-full text-sm"> - <thead className="bg-gray-50"> - <tr> - <th className="px-4 py-2 text-left font-medium text-gray-700"> - Measure - </th> - <th className="px-4 py-2 text-right font-medium text-gray-700"> - Homes - </th> - <th className="px-4 py-2 text-right font-medium text-gray-700"> - Total cost - </th> - <th className="px-4 py-2 text-right font-medium text-gray-700"> - Avg. cost - </th> - </tr> - </thead> - - <tbody> - {grouped.map((group) => ( - <Fragment key={group.category}> - {/* Category header */} - <tr className="bg-gray-100 border-t border-brandmidblue border-b"> - <td - colSpan={4} - className="px-4 py-3 text-sm font-semibold text-gray-700 tracking-wide" - > - {group.category} - </td> - </tr> - - {/* Rows */} - {group.rows.map((row) => ( - <tr key={row.measureType} className="border-t"> - <td className="px-4 py-2"> - {toTitleCase(row.measureType)} - </td> - <td className="px-4 py-2 text-right"> - {row.homesCount.toLocaleString()} - </td> - <td className="px-4 py-2 text-right"> - £{row.totalCost.toLocaleString()} - </td> - <td className="px-4 py-2 text-right text-gray-600"> - £ - {row.averageCost.toLocaleString(undefined, { - maximumFractionDigits: 0, - })} - </td> - </tr> - ))} - - {/* Subtotal */} - <tr className="border-t bg-gray-50"> - <td className="px-4 py-2 font-medium text-gray-700"> - Subtotal - </td> - <td className="px-4 py-2 text-right font-medium"> - {group.homesTotal.toLocaleString()} - </td> - <td className="px-4 py-2 text-right font-medium"> - £{group.costTotal.toLocaleString()} - </td> - <td /> - </tr> - </Fragment> - ))} - </tbody> - </table> - </div> - )} - </div> - - <div className="mt-6 flex justify-end"> - <button - onClick={onClose} - className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-gray-50" - > - Close - </button> - </div> - </DialogPanel> - </div> - </Dialog> - </Transition> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider.tsx deleted file mode 100644 index e76e9c65..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; - -export function SectionDivider({ - title, - subtitle, -}: { - title: string; - subtitle?: string; -}) { - return ( - <div className="w-full mb-4"> - {/* Title */} - <div className="flex flex-col"> - <h2 className="text-xl font-semibold text-brandblue tracking-tight"> - {title} - </h2> - - {subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>} - </div> - - {/* Animated gradient line */} - <motion.div - initial={{ width: 0 }} - whileInView={{ width: "100%" }} - transition={{ duration: 0.6, ease: "easeOut" }} - className="h-1 mt-2 rounded-full bg-gradient-to-r from-brandblue via-midblue to-brandlightblue" - /> - </div> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts new file mode 100644 index 00000000..b6f03b16 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts @@ -0,0 +1,19 @@ +"use server"; + +import { queryScenarioMeasures } from "@/lib/reporting/server"; +import type { Measure } from "@/lib/reporting/measures"; +import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState"; + +/** + * Server action behind the "where the money goes" panel. Runs the grouped + * measures query directly on the server — no API round-trip — so the panel + * background-loads while the rest of the page is interactive. Honours the + * view-wide tag filter so the spend breakdown matches the filtered figures. + */ +export async function loadScenarioMeasures( + portfolioId: number, + scenarioId: number | "default", + tags: TagFilter = emptyTagFilter(), +): Promise<Measure[]> { + return queryScenarioMeasures(portfolioId, scenarioId, tags); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx new file mode 100644 index 00000000..7702f9f1 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useState } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/shadcn_components/ui/popover"; +import { sapToEpc } from "@/app/utils"; +import { + pickBestIndex, + countBelowBand, + amountSaved, + capitalOutlay, + costPerSapPoint, + costPerCarbonSaved, +} from "@/lib/reporting/model"; +import { EpcChip, moneyFull } from "../components/primitives"; + +interface ScenarioMeta { + id: number; + name: string; + goal?: string; + goalValue?: string | null; + budget?: number | null; +} + +interface Baseline { + avgSap: number; + totalCarbon: number; + totalBills: number; + belowC: number; +} + +/** A comparable metric row: how to read each scenario's answer and which way is better. */ +interface RowSpec { + label: string; + group: string; + direction: "lower" | "higher" | null; + value: (m: ScenarioMetrics, b: Baseline) => number | null; + render: (v: number | null) => React.ReactNode; +} + +interface ScenarioMetrics { + avg_sap: string; + scenario_epc_counts: Record<string, number>; + n_units_upgraded: number; + total_carbon: number; + total_bills: number; + construction_cost: number; + pc_cost: number; + contingency: number; + gross_cost: number; + total_funding: number; + net_cost: number; + total_sap_uplift: number; +} + +const ROWS: RowSpec[] = [ + { + group: "Outcome", + label: "Average EPC after", + direction: "higher", + value: (m) => Number(m.avg_sap), + render: (v) => + v === null ? "—" : ( + <span className="inline-flex items-center gap-1.5"> + <EpcChip band={sapToEpc(v)} size="sm" /> {Math.round(v)} + </span> + ), + }, + { + group: "Outcome", + label: "Homes below C after", + direction: "lower", + value: (m) => countBelowBand(m.scenario_epc_counts, "C"), + render: (v) => (v === null ? "—" : v.toLocaleString("en-GB")), + }, + { + group: "Outcome", + label: "Homes upgraded", + direction: "higher", + value: (m) => m.n_units_upgraded, + render: (v) => (v === null ? "—" : v.toLocaleString("en-GB")), + }, + { + group: "Outcome", + label: "CO₂ saved /yr", + direction: "higher", + value: (m, b) => amountSaved(b.totalCarbon, m.total_carbon), + render: (v) => (v === null ? "—" : `${Math.round(v)} t`), + }, + { + group: "Outcome", + label: "Bill savings /yr", + direction: "higher", + value: (m, b) => amountSaved(b.totalBills, m.total_bills), + render: (v) => (v === null ? "—" : moneyFull(v)), + }, + { + group: "Investment", + label: "Gross cost", + direction: "lower", + value: (m) => m.gross_cost, + render: (v) => (v === null ? "—" : moneyFull(v)), + }, + { + group: "Investment", + label: "Funding secured", + direction: "higher", + value: (m) => m.total_funding, + render: (v) => (v === null ? "—" : moneyFull(v)), + }, + { + group: "Investment", + label: "Net cost", + direction: "lower", + value: (m) => m.net_cost, + render: (v) => (v === null ? "—" : moneyFull(v)), + }, + { + group: "Value for money", + label: "Cost per SAP point", + direction: "lower", + value: (m) => costPerSapPoint(capitalOutlay(m), m.total_sap_uplift), + render: (v) => (v === null ? "—" : moneyFull(v)), + }, + { + group: "Value for money", + label: "Cost per tCO₂/yr", + direction: "lower", + value: (m, b) => + costPerCarbonSaved(capitalOutlay(m), amountSaved(b.totalCarbon, m.total_carbon)), + render: (v) => (v === null ? "—" : moneyFull(v)), + }, +]; + +export function CompareClientArea({ + portfolioId, + scenarios, + baseline, +}: { + portfolioId: number; + scenarios: ScenarioMeta[]; + baseline: Baseline; +}) { + // Up to four columns stay readable at desk widths; the user picks which. + const MAX_COLUMNS = 4; + const [selected, setSelected] = useState(() => + scenarios.slice(0, MAX_COLUMNS).map((s) => s.id), + ); + // Column order follows selection order, not the scenario list order. + const columns = selected + .map((id) => scenarios.find((s) => s.id === id)) + .filter((s): s is ScenarioMeta => Boolean(s)); + const available = scenarios.filter((s) => !selected.includes(s.id)); + const canAdd = selected.length < MAX_COLUMNS && available.length > 0; + + const addColumn = (id: number) => + setSelected((ids) => + ids.length < MAX_COLUMNS && !ids.includes(id) ? [...ids, id] : ids, + ); + const removeColumn = (id: number) => + setSelected((ids) => (ids.length > 1 ? ids.filter((x) => x !== id) : ids)); + + const results = useQueries({ + queries: columns.map((s) => ({ + queryKey: ["compare-metrics", portfolioId, s.id], + queryFn: () => + fetch( + `/api/portfolio/${portfolioId}/scenario/${s.id}/metrics`, + ).then((r) => { + if (!r.ok) throw new Error("Failed to load scenario"); + return r.json() as Promise<ScenarioMetrics>; + }), + refetchOnWindowFocus: false, + })), + }); + + const loading = results.some((r) => r.isLoading); + + const groups = Array.from(new Set(ROWS.map((r) => r.group))); + + return ( + <div className="space-y-4" data-reporting> + <div className="flex items-end justify-between gap-4"> + <div> + <div className="text-[0.74rem] text-gray-600"> + Portfolios / Reporting + </div> + <h1 className="mt-0.5 text-[1.35rem] font-semibold tracking-tight text-brandblue"> + Compare scenarios + </h1> + </div> + <div className="flex items-center gap-2"> + <Popover> + <PopoverTrigger asChild> + <button + type="button" + disabled={!canAdd} + aria-haspopup="listbox" + className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium text-brandblue disabled:cursor-not-allowed disabled:text-gray-400" + > + + Add scenario + </button> + </PopoverTrigger> + <PopoverContent align="end" className="w-72 p-0"> + <div role="listbox" aria-label="Add a scenario to compare" className="max-h-80 overflow-y-auto"> + {available.map((s) => ( + <button + key={s.id} + type="button" + role="option" + aria-selected={false} + onClick={() => addColumn(s.id)} + className="flex w-full flex-col border-t border-gray-100 px-3.5 py-2.5 text-left first:border-t-0 hover:bg-gray-50" + > + <span className="text-sm font-semibold text-brandblue"> + {s.name} + </span> + <span className="text-[0.7rem] text-gray-600"> + {s.goal === "Increasing EPC" && s.goalValue + ? `Target EPC ${s.goalValue}` + : s.goal} + </span> + </button> + ))} + {available.length === 0 && ( + <p className="px-3.5 py-3 text-[0.78rem] text-gray-600"> + All scenarios are already shown. + </p> + )} + </div> + </PopoverContent> + </Popover> + <a + href={`/portfolio/${portfolioId}/reporting`} + className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium" + > + ← Back to reporting + </a> + </div> + </div> + + <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white"> + <table className="w-full text-[0.84rem]"> + <thead> + <tr> + <th className="w-[22%] px-4 py-4 text-left" /> + <th className="px-4 py-4 text-right align-bottom"> + <span className="text-[0.86rem] font-semibold text-brandblue"> + Current stock + </span> + <span className="block text-[0.68rem] font-normal text-gray-600"> + No scenario applied + </span> + </th> + {columns.map((s) => ( + <th key={s.id} className="px-4 py-4 text-right align-bottom"> + <span className="flex items-start justify-end gap-1.5"> + <span className="text-[0.86rem] font-semibold text-brandblue"> + {s.name} + </span> + {columns.length > 1 && ( + <button + type="button" + onClick={() => removeColumn(s.id)} + aria-label={`Remove ${s.name} from comparison`} + className="mt-0.5 text-gray-400 hover:text-gray-700" + > + <X className="h-3.5 w-3.5" /> + </button> + )} + </span> + <span className="block text-[0.68rem] font-normal text-gray-600"> + {s.goal === "Increasing EPC" && s.goalValue + ? `Target EPC ${s.goalValue}` + : s.goal} + {s.budget ? ` · £${(s.budget / 1000).toFixed(0)}k/home` : ""} + </span> + <a + href={`/portfolio/${portfolioId}/reporting?scenario=${s.id}`} + className="mt-1 block text-[0.7rem] font-medium text-midblue" + > + View → + </a> + </th> + ))} + </tr> + </thead> + <tbody> + {loading ? ( + <tr> + <td + colSpan={columns.length + 2} + className="px-4 py-10 text-center text-sm text-gray-600" + > + Loading scenarios… + </td> + </tr> + ) : ( + groups.map((group) => ( + <GroupRows + key={group} + group={group} + columns={columns.length} + results={results.map((r) => r.data as ScenarioMetrics)} + baseline={baseline} + /> + )) + )} + </tbody> + </table> + </div> + <p className="mt-2.5 text-[0.75rem] text-gray-600"> + The <span className="font-semibold text-[#0c6b4a]">Best</span> tag marks + the best value in each row — no overall winner is crowned; trade-offs + stay visible. + </p> + </div> + ); +} + +function GroupRows({ + group, + columns, + results, + baseline, +}: { + group: string; + columns: number; + results: ScenarioMetrics[]; + baseline: Baseline; +}) { + const rows = ROWS.filter((r) => r.group === group); + return ( + <> + <tr> + <td + colSpan={columns + 2} + className="px-4 pb-1.5 pt-4 text-[0.66rem] font-bold uppercase tracking-wide text-[#a07c42]" + > + {group} + </td> + </tr> + {rows.map((row) => { + const scenarioValues = results.map((m) => + m ? row.value(m, baseline) : null, + ); + const best = + row.direction === null + ? null + : pickBestIndex(scenarioValues, row.direction); + // "before" cell: baseline has no scenario metric, so most rows show —. + const baselineCell = + row.label === "Average EPC after" + ? row.render(baseline.avgSap) + : row.label === "Homes below C after" + ? baseline.belowC.toLocaleString("en-GB") + : "—"; + return ( + <tr key={row.label} className="border-t border-gray-100"> + <td className="px-4 py-2.5 text-left text-gray-500">{row.label}</td> + <td className="px-4 py-2.5 text-right tabular-nums text-gray-900"> + {baselineCell} + </td> + {scenarioValues.map((v, i) => ( + <td + key={i} + className={`px-4 py-2.5 text-right tabular-nums ${ + best === i ? "font-bold text-[#0c6b4a]" : "text-gray-900" + }`} + > + {best === i && ( + <span className="mr-1.5 inline-flex items-center rounded bg-[#e9f4ef] px-1.5 py-0.5 align-middle text-[0.6rem] font-bold uppercase tracking-wide text-[#0c6b4a]"> + Best + </span> + )} + {row.render(v)} + </td> + ))} + </tr> + ); + })} + </> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx new file mode 100644 index 00000000..714eca81 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx @@ -0,0 +1,45 @@ +import { + loadBaselineMetrics, + getReportingScenarios, +} from "@/lib/reporting/server"; +import { countBelowBand, toBandCounts } from "@/lib/reporting/model"; +import { CompareClientArea } from "./CompareClientArea"; + +export default async function ComparePage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const portfolioId = Number(slug); + + const [baseline, scenarioRows] = await Promise.all([ + loadBaselineMetrics(portfolioId), + getReportingScenarios(portfolioId), + ]); + + // Only modelled scenarios can be compared — the metrics route returns + // zeros for plan-less scenarios (ADR-0003). + const scenarios = scenarioRows + .filter((s) => s.status === "modelled") + .map((s) => ({ + id: s.id, + name: s.name, + goal: s.goal, + goalValue: s.goalValue, + budget: s.budget, + })); + + return ( + <div className="max-w-8xl mx-auto px-6 pb-12 pt-5"> + <CompareClientArea + portfolioId={portfolioId} + scenarios={scenarios} + baseline={{ + avgSap: baseline.averages.avg_sap ?? 0, + totalCarbon: baseline.totals.total_carbon ?? 0, + totalBills: baseline.totals.total_bills ?? 0, + belowC: countBelowBand(toBandCounts(baseline.epcBands), "C"), + }} + /> + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx new file mode 100644 index 00000000..b71168ec --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { EpcChip } from "./primitives"; +import { formatNumber } from "@/app/utils"; +import { + serializeTagFilter, + emptyTagFilter, + type TagFilter, +} from "@/lib/reporting/viewState"; + +/** + * The drill-down shelf (Screen C) — one pattern for every count on the + * page. Opens beneath the chart, lists the homes behind the clicked figure + * from /reporting/properties, and deep-links into the property table. + */ + +export interface DrillTarget { + filter: + | "band" + | "estimated" + | "expired" + | "likely-downgrade" + | "likely-upgrade" + | "measure"; + band?: string; + scenarioId?: number; + measureType?: string; + title: string; + chipBand?: string; +} + +interface PropertyRow { + id: number; + address: string | null; + postcode: string | null; + epcBand: string | null; + sap: number | null; + bills: number | null; + carbon: number | null; + provenance: string; +} + +interface DrillResponse { + properties: PropertyRow[]; + total: number; + page: number; + pageSize: number; +} + +// A compact preview — the shelf is a first look, not the full table. +const DRILL_PAGE_SIZE = 8; + +function buildQuery( + portfolioId: number, + t: DrillTarget, + page: number, + tags: TagFilter, +) { + const p = new URLSearchParams({ + filter: t.filter, + page: String(page), + pageSize: String(DRILL_PAGE_SIZE), + }); + if (t.band) p.set("band", t.band); + if (t.scenarioId != null) p.set("scenarioId", String(t.scenarioId)); + if (t.measureType) p.set("measureType", t.measureType); + // The drill list must show the same tagged subset as the figure it opened. + serializeTagFilter(tags, p); + return `/api/portfolio/${portfolioId}/reporting/properties?${p.toString()}`; +} + +export function DrillDownShelf({ + portfolioId, + target, + page, + onPage, + onClose, + tags, +}: { + portfolioId: number; + target: DrillTarget; + page: number; + onPage: (p: number) => void; + onClose: () => void; + /** View-wide tag filter; omitted (e.g. data-quality drill) → no tag scoping. */ + tags?: TagFilter; +}) { + const tagFilter = tags ?? emptyTagFilter(); + const { data, isLoading, isError } = useQuery<DrillResponse>({ + queryKey: ["reporting-drill", portfolioId, target, page, tagFilter], + queryFn: () => + fetch(buildQuery(portfolioId, target, page, tagFilter)).then((r) => { + if (!r.ok) throw new Error("Failed to load homes"); + return r.json(); + }), + keepPreviousData: true, + }); + + const total = data?.total ?? 0; + const pageSize = data?.pageSize ?? DRILL_PAGE_SIZE; + const from = total === 0 ? 0 : (page - 1) * pageSize + 1; + const to = Math.min(page * pageSize, total); + + // The property table (the portfolio home) filters client-side and doesn't + // read URL params, so we navigate to it without a filter — the shelf itself + // is the filtered view. (A URL-driven table filter is a separate follow-up.) + const tableHref = `/portfolio/${portfolioId}`; + + return ( + <div + id="reporting-drill-shelf" + className="mt-4 overflow-hidden rounded-lg border border-midblue bg-white" + > + <header className="flex items-center gap-3 border-b border-gray-100 px-5 py-3.5"> + <span className="flex items-center gap-2 text-[0.95rem] font-semibold text-brandblue"> + {target.chipBand && <EpcChip band={target.chipBand} size="md" />} + {target.title} + {total > 0 && ( + <span className="font-normal text-gray-600">· {total} homes</span> + )} + </span> + <button + type="button" + onClick={onClose} + className="ml-auto inline-flex items-center gap-1 text-[0.78rem] text-gray-600 hover:text-gray-900" + > + Collapse <X className="h-3.5 w-3.5" /> + </button> + </header> + + {isLoading && !data && ( + <div className="px-5 py-8 text-center text-sm text-gray-600"> + Loading homes… + </div> + )} + {isError && ( + <div + role="alert" + className="px-5 py-8 text-center text-sm text-red-600" + > + Could not load these homes. + </div> + )} + + {data && ( + <div className="overflow-x-auto"> + <table className="w-full text-[0.82rem]"> + <thead> + <tr className="text-[0.68rem] uppercase tracking-wide text-gray-600"> + <th className="px-5 py-2 text-left font-bold">Address</th> + <th className="px-5 py-2 text-left font-bold">EPC</th> + <th className="px-5 py-2 text-right font-bold">SAP</th> + <th className="px-5 py-2 text-right font-bold">Bills /yr</th> + <th className="px-5 py-2 text-right font-bold">CO₂ /yr</th> + <th className="px-5 py-2" /> + </tr> + </thead> + <tbody> + {data.properties.map((p) => ( + <tr key={p.id} className="border-t border-gray-100"> + <td className="px-5 py-2 font-medium text-gray-900"> + {p.address ?? "—"} + <span className="block text-[0.7rem] font-normal text-gray-600"> + {p.postcode ?? ""} + {p.provenance === "estimated" && " · estimated"} + </span> + </td> + <td className="px-5 py-2"> + {p.epcBand ? <EpcChip band={p.epcBand} size="sm" /> : "—"} + </td> + <td className="px-5 py-2 text-right tabular-nums"> + {p.sap != null ? Math.round(p.sap) : "—"} + </td> + <td className="px-5 py-2 text-right tabular-nums"> + {p.bills != null ? `£${formatNumber(p.bills)}` : "—"} + </td> + <td className="px-5 py-2 text-right tabular-nums"> + {p.carbon != null ? `${p.carbon.toFixed(1)} t` : "—"} + </td> + <td className="px-5 py-2 text-right"> + <a + href={`/portfolio/${portfolioId}/building-passport/${p.id}`} + className="whitespace-nowrap font-semibold text-midblue" + > + Open → + </a> + </td> + </tr> + ))} + {data.properties.length === 0 && ( + <tr> + <td + colSpan={6} + className="px-5 py-8 text-center text-sm text-gray-600" + > + No homes match this filter. + </td> + </tr> + )} + </tbody> + </table> + </div> + )} + + <footer className="flex items-center justify-between gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3"> + <span className="text-[0.76rem] text-gray-600"> + {from}–{to} of {total} + </span> + <div className="flex items-center gap-2"> + <button + type="button" + disabled={page <= 1} + onClick={() => onPage(page - 1)} + className="rounded-md border border-gray-200 px-3 py-1.5 text-[0.8rem] font-medium disabled:opacity-40" + > + Prev + </button> + <button + type="button" + disabled={to >= total} + onClick={() => onPage(page + 1)} + className="rounded-md border border-gray-200 px-3 py-1.5 text-[0.8rem] font-medium disabled:opacity-40" + > + Next + </button> + <a + href={tableHref} + className="rounded-md border border-brandblue bg-brandblue px-3 py-1.5 text-[0.8rem] font-medium text-white" + > + Open in property table → + </a> + </div> + </footer> + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/EpcLadder.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/EpcLadder.tsx new file mode 100644 index 00000000..cb8e821b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/EpcLadder.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { getEpcColorClass } from "@/app/utils"; +import { EpcChip } from "./primitives"; +import { EPC_BANDS } from "@/lib/epc/bands"; +import type { EpcBandCount } from "@/lib/reporting/types"; + +/** + * Horizontal EPC ladder in statutory band colours (Screen A/B). The + * estimated share is the lighter tail of each bar; the scenario overlay, + * when present, is a navy sub-bar beneath the current bar per band. + * Clicking a band opens the drill-down shelf. + */ +export function EpcLadder({ + bands, + scenarioBands, + onSelectBand, + selectedBand, +}: { + bands: EpcBandCount[]; + scenarioBands?: Record<string, number>; + onSelectBand?: (band: string) => void; + selectedBand?: string | null; +}) { + const byBand = new Map(bands.map((b) => [b.epc, b])); + const order = EPC_BANDS.filter( + (b) => byBand.has(b) || (scenarioBands?.[b] ?? 0) > 0, + ); + + const max = Math.max( + 1, + ...order.map((b) => { + const d = byBand.get(b); + const base = (d?.actual ?? 0) + (d?.estimated ?? 0); + return Math.max(base, scenarioBands?.[b] ?? 0); + }), + ); + + return ( + <div className="flex flex-col gap-2.5"> + {order.map((band) => { + const d = byBand.get(band); + const actual = d?.actual ?? 0; + const estimated = d?.estimated ?? 0; + const total = actual + estimated; + const scenario = scenarioBands?.[band]; + const bar = getEpcColorClass(band); + const selected = selectedBand === band; + + // Each band is one focusable button that is itself a 3-column grid, + // so the fixed template keeps columns aligned across rows while the + // focus box lands on a real element (not display:contents). + return ( + <button + key={band} + type="button" + onClick={() => onSelectBand?.(band)} + aria-pressed={selected} + className={`grid grid-cols-[24px_1fr_108px] items-center gap-x-3 rounded px-1 py-0.5 text-left hover:bg-gray-50 ${ + selected ? "outline outline-2 outline-offset-1 outline-midblue" : "" + }`} + aria-label={`${total} homes in band ${band}`} + > + <EpcChip band={band} size="sm" /> + <span className="flex flex-col gap-[3px]"> + <span className="flex h-[18px] overflow-hidden rounded"> + <span + className={`h-full ${bar}`} + style={{ width: `${(actual / max) * 100}%` }} + /> + <span + className={`h-full ${bar} opacity-40`} + style={{ width: `${(estimated / max) * 100}%` }} + /> + </span> + {scenario !== undefined && ( + <span className="flex h-[7px] overflow-hidden rounded"> + <span + className="h-full rounded-sm bg-brandblue" + style={{ width: `${(scenario / max) * 100}%` }} + /> + </span> + )} + </span> + <span className="text-right text-[0.78rem] tabular-nums text-gray-600"> + {scenario !== undefined ? ( + <> + {total} → <b className="font-semibold text-gray-900">{scenario}</b> + </> + ) : ( + <> + <b className="font-semibold text-gray-900">{total}</b> + {estimated > 0 && ` · ${estimated} est`} + </> + )} + </span> + </button> + ); + })} + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/FilterChips.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/FilterChips.tsx new file mode 100644 index 00000000..41af66c3 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/FilterChips.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { X, ChevronDown } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/shadcn_components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/shadcn_components/ui/select"; +import { EPC_BANDS } from "@/lib/epc/bands"; +import { InfoDot } from "./primitives"; +import { TipBody, type TipSpec } from "./tipContent"; + +/** Structured "what this filter does" copy — same shape/look as the cost ⓘs. */ +const FILTER_HELP: Record< + "reachTarget" | "lodgedBaseline" | "complianceWindow", + TipSpec +> = { + reachTarget: { + summary: + "Counts only homes whose modelled result reaches this scenario's target band.", + note: "Homes still short of target after the works drop out of every figure, so costs and outcomes cover the fully-compliant homes only.", + }, + lodgedBaseline: { + summary: + "Decides which homes need work from their official lodged EPC band on the government register, not Domna's modelled current band.", + note: "Use it to match what the register says a home needs.", + }, + complianceWindow: { + summary: + "Leaves out homes whose official (lodged) EPC already meets the band below and stays valid past the date.", + note: "The government counts these as compliant beyond then, so costs and outcomes show only the works on the remaining homes.", + }, +}; + +/** + * Inline scenario filter chips (Screen B). State is always visible and + * applies immediately — no apply step. Each chip carries a plain-language + * explanation; the compliance-window chip opens a small editor for its band + * and cut-off date. Shown only for Increasing-EPC scenarios, where these + * filters have meaning. + */ +export interface ScenarioFilters { + hideNonCompliant: boolean; + useLodgedBaseline: boolean; + complianceWindow: { band: string; date: string } | null; +} + +const DEFAULT_COMPLIANCE = { band: "C", date: "2030-01-01" }; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +/** A toggle chip with an info tooltip explaining what it does. */ +function ToggleChip({ + on, + onToggle, + label, + tip, +}: { + on: boolean; + onToggle: () => void; + label: string; + tip: React.ReactNode; +}) { + return ( + <span + className={`inline-flex h-7 items-center gap-1.5 rounded-full border pl-3 pr-2 text-[0.76rem] ${ + on + ? "border-brandblue bg-[#f2f3f9] font-medium text-brandblue" + : "border-gray-200 bg-white text-gray-500" + }`} + > + <button + type="button" + onClick={onToggle} + aria-pressed={on} + className="flex items-center gap-1.5" + > + {label} + {on && <X className="h-3 w-3 text-gray-600" />} + </button> + <InfoDot label={label} tip={tip} /> + </span> + ); +} + +export function FilterChips({ + filters, + onChange, + showEpcFilters, +}: { + filters: ScenarioFilters; + onChange: (next: ScenarioFilters) => void; + showEpcFilters: boolean; +}) { + if (!showEpcFilters) return null; + + const cw = filters.complianceWindow; + + return ( + <div className="flex flex-wrap items-center gap-2"> + <span className="text-[0.7rem] font-semibold uppercase tracking-wide text-gray-600"> + Filters + </span> + + <ToggleChip + on={filters.hideNonCompliant} + onToggle={() => + onChange({ ...filters, hideNonCompliant: !filters.hideNonCompliant }) + } + label="Only homes that reach target" + tip={<TipBody spec={FILTER_HELP.reachTarget} />} + /> + + {/* Compliance window — editable band + date */} + <Popover> + <PopoverTrigger asChild> + <button + type="button" + className={`inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-[0.76rem] ${ + cw + ? "border-brandblue bg-[#f2f3f9] font-medium text-brandblue" + : "border-gray-200 bg-white text-gray-500 hover:border-gray-300" + }`} + > + {cw + ? `Skip homes compliant past ${formatDate(cw.date)} at ${cw.band}+` + : "Skip already-compliant homes"} + <ChevronDown className="h-3 w-3 text-gray-600" /> + </button> + </PopoverTrigger> + <PopoverContent align="end" className="w-80 p-4"> + <div className="text-[0.82rem] font-semibold text-brandblue"> + Skip already-compliant homes + </div> + <div className="mt-1.5 text-[0.78rem] leading-relaxed"> + <TipBody spec={FILTER_HELP.complianceWindow} /> + </div> + + <label className="mt-3 flex items-center gap-2 text-[0.8rem] text-gray-700"> + <input + type="checkbox" + checked={!!cw} + onChange={(e) => + onChange({ + ...filters, + complianceWindow: e.target.checked + ? (cw ?? DEFAULT_COMPLIANCE) + : null, + }) + } + className="h-4 w-4 rounded border-gray-300 accent-brandblue" + /> + Apply this filter + </label> + + <div className="mt-3 grid grid-cols-2 gap-3"> + <div> + <div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-wide text-gray-600"> + Compliant at band + </div> + <Select + value={(cw ?? DEFAULT_COMPLIANCE).band} + onValueChange={(band) => + onChange({ + ...filters, + complianceWindow: { ...(cw ?? DEFAULT_COMPLIANCE), band }, + }) + } + > + <SelectTrigger className="h-9"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {EPC_BANDS.map((b) => ( + <SelectItem key={b} value={b}> + {b} or better + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div> + <div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-wide text-gray-600"> + Still valid past + </div> + <input + type="date" + value={(cw ?? DEFAULT_COMPLIANCE).date} + onChange={(e) => + onChange({ + ...filters, + complianceWindow: { + ...(cw ?? DEFAULT_COMPLIANCE), + date: e.target.value, + }, + }) + } + className="h-9 w-full rounded-md border border-gray-200 px-2 text-[0.8rem] text-gray-700" + /> + </div> + </div> + </PopoverContent> + </Popover> + + <ToggleChip + on={filters.useLodgedBaseline} + onToggle={() => + onChange({ + ...filters, + useLodgedBaseline: !filters.useLodgedBaseline, + }) + } + label="Judge need by lodged EPC" + tip={<TipBody spec={FILTER_HELP.lodgedBaseline} />} + /> + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/GoalCallout.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/GoalCallout.tsx new file mode 100644 index 00000000..fbe44b61 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/GoalCallout.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { GoalCallout as GoalCalloutData } from "@/lib/reporting/model"; +import { formatNumber } from "@/app/utils"; + +const DIMENSION_LABEL: Record<string, string> = { + carbon: "carbon", + energy: "energy use", + valuation: "valuation", +}; + +/** + * The goal-aware callout beneath the EPC ladder. Current-stock views frame + * the portfolio's own goal; scenario views frame the before→after movement + * on that goal (grilling decision, per selectGoalCallout). + */ +export function GoalCallout({ + callout, + total, + dimensionTotals, + onDrill, +}: { + callout: GoalCalloutData; + total: number; + dimensionTotals?: { carbon?: number; energy?: number }; + onDrill?: () => void; +}) { + let body: React.ReactNode; + let good = false; + + if (callout.kind === "below-band") { + const pct = total > 0 ? Math.round((callout.count / total) * 100) : 0; + body = ( + <> + <b> + {callout.count} homes ({pct}%) + </b>{" "} + sit below EPC {callout.band} — this portfolio's goal band. + </> + ); + } else if (callout.kind === "below-band-movement") { + good = callout.after < callout.before; + const beforePct = total > 0 ? Math.round((callout.before / total) * 100) : 0; + const afterPct = total > 0 ? Math.round((callout.after / total) * 100) : 0; + body = ( + <> + Below {callout.band} falls from <b>{callout.before} homes to {callout.after}</b>{" "} + ({beforePct}% → {afterPct}%). + </> + ); + } else if (callout.kind === "dimension-total") { + const dim = DIMENSION_LABEL[callout.dimension]; + const carbon = dimensionTotals?.carbon; + body = + callout.dimension === "carbon" && carbon !== undefined ? ( + <> + <b>{formatNumber(carbon)} tCO₂e a year</b> across the portfolio — + scenarios target this {dim} number. + </> + ) : ( + <>This portfolio's goal is to improve {dim} — scenarios target it.</> + ); + } else { + good = true; + body = <>This scenario improves {DIMENSION_LABEL[callout.dimension]}.</>; + } + + return ( + <div + className={`mt-auto flex items-center justify-between gap-3 rounded-lg px-3.5 py-2.5 text-[0.82rem] ${ + good ? "bg-[#e9f4ef] text-[#114d38]" : "bg-[#faf1e2] text-[#6d4a12]" + }`} + > + <span className="tabular-nums">{body}</span> + {onDrill && ( + <button + type="button" + onClick={onDrill} + className="whitespace-nowrap font-semibold text-midblue" + > + View homes → + </button> + )} + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx new file mode 100644 index 00000000..ae43d86e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { + Wallet, + Coins, + Landmark, + Sprout, + Banknote, + Leaf, + Scale, + Gauge, + Cloud, + type LucideIcon, +} from "lucide-react"; +import { InfoDot, moneyFull } from "./primitives"; +import { COST_HELP } from "./costHelp"; +import { carsOffTheRoad, type LedgerView } from "@/lib/reporting/model"; + +// LedgerView now lives in the reporting model (derived by deriveLedgerView); +// re-exported here so existing imports from this component keep resolving. +export type { LedgerView }; + +// The cost-composition palette echoes the data-quality evidence bar: quiet +// navy → gold → grey, so the reporting surfaces read as one system. +const COST_COLORS = { + construction: "#14163d", + delivery: "#a07c42", + contingency: "#c5cad8", +} as const; + +function GroupLabel({ + icon: Icon, + children, +}: { + icon: LucideIcon; + children: React.ReactNode; +}) { + return ( + <div className="flex items-center gap-1.5 px-5 pb-1.5 pt-4 text-[0.68rem] font-bold uppercase tracking-wide text-[#a07c42]"> + <Icon className="h-3.5 w-3.5" /> + {children} + </div> + ); +} + +/** A row led by an icon chip. `tone` tints benefits green, keeps the rest quiet. */ +function IconRow({ + icon: Icon, + label, + value, + note, + tip, + tone = "neutral", +}: { + icon: LucideIcon; + label: string; + value: string; + note?: string; + tip?: React.ReactNode; + tone?: "neutral" | "benefit"; +}) { + const chip = + tone === "benefit" + ? "bg-[#e9f4ef] text-[#0c6b4a]" + : "bg-gray-50 text-gray-500"; + const valueColor = tone === "benefit" ? "text-[#0c6b4a]" : "text-brandblue"; + return ( + <div className="flex items-center justify-between gap-3 py-1 text-[0.84rem]"> + <span className="inline-flex items-center gap-2 text-gray-600"> + <span + className={`inline-flex h-6 w-6 items-center justify-center rounded-md ${chip}`} + > + <Icon className="h-3.5 w-3.5" /> + </span> + {label} + {tip && <InfoDot label={label} tip={tip} />} + </span> + <span + className={`whitespace-nowrap tabular-nums font-semibold ${valueColor}`} + > + {value} + {note && <span className="font-normal text-gray-500"> · {note}</span>} + </span> + </div> + ); +} + +export function InvestmentLedger({ v }: { v: LedgerView }) { + const costParts = [ + { + label: "Construction works", + value: v.constructionCost, + color: COST_COLORS.construction, + help: COST_HELP.construction, + }, + { + label: "Project delivery", + value: v.projectDelivery, + color: COST_COLORS.delivery, + help: COST_HELP.projectDelivery, + }, + { + label: "Contingency", + value: v.contingency, + color: COST_COLORS.contingency, + help: COST_HELP.contingency, + }, + ]; + const grossForBar = v.grossCost || 1; + + return ( + <div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white"> + <header className="flex items-center gap-2 px-5 pt-4"> + <Wallet className="h-4 w-4 text-brandblue" /> + <h3 className="text-[0.88rem] font-semibold text-brandblue"> + Investment ledger + </h3> + </header> + + {/* Net cost — the headline figure a board commits to. */} + <div className="mx-5 mt-3 rounded-lg border border-gray-100 bg-[#f6f7fb] px-4 py-3"> + <div className="flex items-center justify-between"> + <span className="inline-flex items-center gap-1 text-[0.68rem] font-bold uppercase tracking-wide text-gray-500"> + Net cost + <InfoDot label="Net cost" tip={COST_HELP.net} /> + </span> + <span className="inline-flex items-center gap-1 text-[0.72rem] font-medium text-gray-500"> + {moneyFull(v.grossPerHome)} + <span className="text-gray-400">gross/home</span> + <InfoDot label="Gross cost per home" tip={COST_HELP.grossPerHome} /> + </span> + </div> + <div className="mt-1 text-[1.6rem] font-bold leading-none tabular-nums text-brandblue"> + {moneyFull(v.netCost)} + </div> + <div className="mt-1.5 text-[0.72rem] text-gray-500"> + {moneyFull(v.grossCost)} gross · {moneyFull(v.funding)} funding + secured + </div> + </div> + + {/* Cost breakdown — a proportion bar + rows keyed to it by colour. */} + <GroupLabel icon={Coins}>Cost breakdown</GroupLabel> + <div className="px-5"> + <div className="mb-3 flex h-2 overflow-hidden rounded-full"> + {costParts.map((p) => ( + <span + key={p.label} + style={{ + width: `${(p.value / grossForBar) * 100}%`, + background: p.color, + }} + /> + ))} + </div> + {costParts.map((p) => ( + <div + key={p.label} + className="flex items-center justify-between gap-3 py-1 text-[0.84rem]" + > + <span className="inline-flex items-center gap-2 text-gray-500"> + <span + className="h-2 w-2 rounded-full" + style={{ background: p.color }} + /> + {p.label} + <InfoDot label={p.label} tip={p.help} /> + </span> + <span className="tabular-nums font-semibold text-brandblue"> + {moneyFull(p.value)} + </span> + </div> + ))} + <div className="mt-1 flex items-center justify-between gap-3 border-t border-gray-100 py-1 pt-2 text-[0.84rem]"> + <span className="inline-flex items-center gap-2 text-gray-600"> + <Landmark className="h-3.5 w-3.5 text-[#0c6b4a]" /> + Funding secured + </span> + <span className="tabular-nums font-semibold text-[#0c6b4a]"> + −{moneyFull(v.funding)} + </span> + </div> + </div> + + {/* Annual benefits — recurring, so tinted green like the savings they are. */} + <GroupLabel icon={Sprout}>Annual benefits</GroupLabel> + <div className="px-5"> + <IconRow + icon={Banknote} + label="Bill savings" + value={moneyFull(v.billSavings)} + note={`${moneyFull(v.billSavingsPerHome)}/home`} + tip={COST_HELP.billSavings} + tone="benefit" + /> + <IconRow + icon={Leaf} + label="Carbon saved" + value={`${Math.round(v.carbonSaved)} t`} + note={`≈ ${carsOffTheRoad(v.carbonSaved)} cars`} + tip={COST_HELP.carbonSaved} + tone="benefit" + /> + </div> + + {/* Value for money */} + <GroupLabel icon={Scale}>Value for money</GroupLabel> + <div className="px-5 pb-4"> + <IconRow + icon={Gauge} + label="Cost per SAP point" + value={moneyFull(v.costPerSap)} + tip={COST_HELP.costPerSap} + /> + <IconRow + icon={Cloud} + label="Cost per tCO₂/yr" + value={moneyFull(v.costPerCarbon)} + tip={COST_HELP.costPerCarbon} + /> + </div> + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/KpiBand.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/KpiBand.tsx new file mode 100644 index 00000000..e55235bf --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/KpiBand.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { EpcChip, DeltaPill, InfoDot } from "./primitives"; + +export interface Kpi { + label: string; + value: React.ReactNode; + unit?: string; + sub?: React.ReactNode; + delta?: { text: string; improved: boolean }; + /** Optional "what this includes/excludes" note, shown as an ⓘ by the label. */ + tip?: React.ReactNode; +} + +/** + * The five-across KPI band (Screen A/B). Colour appears only in EPC chips + * and delta pills — the numbers themselves stay navy. + */ +export function KpiBand({ kpis }: { kpis: Kpi[] }) { + return ( + <div className="grid grid-cols-2 rounded-lg border border-gray-200 bg-white sm:grid-cols-3 lg:grid-cols-5"> + {kpis.map((k, i) => ( + <div + key={k.label} + className={`px-5 py-4 ${i > 0 ? "border-l border-gray-100" : ""}`} + > + <div className="flex items-center gap-1 text-[0.68rem] font-bold uppercase tracking-wide text-gray-600"> + {k.label} + {k.tip && <InfoDot label={k.label} tip={k.tip} />} + </div> + <div className="mt-1.5 flex items-baseline gap-1.5 text-[1.6rem] font-semibold leading-none tracking-tight text-brandblue tabular-nums"> + {k.value} + {k.unit && ( + <span className="text-[0.7rem] font-medium text-gray-600"> + {k.unit} + </span> + )} + {k.delta && ( + <DeltaPill improved={k.delta.improved}>{k.delta.text}</DeltaPill> + )} + </div> + {k.sub && ( + <div className="mt-1.5 text-[0.75rem] tabular-nums text-gray-500"> + {k.sub} + </div> + )} + </div> + ))} + </div> + ); +} + +export { EpcChip }; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx new file mode 100644 index 00000000..265b6111 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + groupMeasures, + measureLabel, + type Measure, + type MeasureGroup, +} from "@/lib/reporting/measures"; +import { InfoDot, moneyCompact, moneyFull } from "./primitives"; +import { COST_HELP } from "./costHelp"; +import { loadScenarioMeasures } from "../actions"; +import type { DrillTarget } from "./DrillDownShelf"; +import type { TagFilter } from "@/lib/reporting/viewState"; + +/** Palette for the allocation bar — navy through tan, quiet greys for the tail. */ +const SEGMENT_COLORS = [ + "#14163d", + "#3943b7", + "#7a81dd", + "#a07c42", + "#c9bd9b", + "#c5cad8", +]; + +/** + * "Where the money goes" (Screen B lower). A single allocation bar by + * measure category answers the shape of the spend; the full grouped table + * expands inline (no modal) and each measure drills to its homes via the + * shared shelf. + */ +export function MeasureAllocation({ + portfolioId, + scenarioId, + tags, + onDrillMeasure, +}: { + portfolioId: number; + scenarioId: number | "default"; + tags: TagFilter; + /** Undefined disables per-measure drill (e.g. the default pseudo-scenario has no numeric id). */ + onDrillMeasure?: (t: DrillTarget) => void; +}) { + const [expanded, setExpanded] = useState(false); + // Load on demand. The measure breakdown is a heavy aggregate on large + // portfolios; auto-firing it on every page load let slow/killed queries + // pile up and saturate the DB, taking the whole reporting page down. The + // user opts in, so a slow breakdown can never knock over the page. + const [requested, setRequested] = useState(false); + + const { data, isLoading, isError, refetch } = useQuery<Measure[]>({ + // `tags` in the key so the breakdown re-queries when the tag filter changes. + queryKey: ["scenario-measures", portfolioId, scenarioId, tags], + queryFn: () => loadScenarioMeasures(portfolioId, scenarioId, tags), + enabled: requested, + refetchOnWindowFocus: false, + retry: false, + // Measures for a scenario don't change within a session — keep them + // cached so switching scenarios and back is instant, not a re-query. + staleTime: 5 * 60 * 1000, + cacheTime: 30 * 60 * 1000, + }); + + const groups: MeasureGroup[] = data ? groupMeasures(data) : []; + const grandTotal = groups.reduce((s, g) => s + g.costTotal, 0); + const installations = groups.reduce( + (s, g) => s + g.rows.reduce((n, r) => n + r.homesCount, 0), + 0, + ); + + const drillHref = onDrillMeasure + ? (t: DrillTarget) => () => onDrillMeasure(t) + : undefined; + + return ( + <section className="overflow-hidden rounded-lg border border-gray-200 bg-white"> + <header className="flex items-baseline justify-between gap-3 px-5 pt-4"> + <h3 className="text-[0.88rem] font-semibold text-brandblue"> + Where the money goes + {installations > 0 && ( + <span className="ml-2 text-[0.74rem] font-normal text-gray-600"> + {installations} installations + </span> + )} + </h3> + {data && ( + <button + onClick={() => setExpanded((e) => !e)} + className="text-[0.76rem] font-semibold text-midblue" + > + {expanded ? "Hide measures" : "All measures & CSV →"} + </button> + )} + </header> + + <div className="px-5 pb-5 pt-4"> + {!requested && ( + <div className="flex flex-col items-start gap-2"> + <button + onClick={() => setRequested(true)} + className="inline-flex h-8 items-center rounded-lg border border-brandblue bg-brandblue px-3.5 text-[0.8rem] font-medium text-white" + > + Show spend breakdown + </button> + <p className="text-[0.74rem] text-gray-600"> + The measure-by-measure breakdown is a heavier query on large + portfolios, so it loads on request. + </p> + </div> + )} + + {requested && isLoading && ( + <div className="animate-pulse" aria-label="Loading measures"> + <div className="h-[22px] w-full rounded bg-gray-100" /> + <div className="mt-3.5 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 lg:grid-cols-3"> + {Array.from({ length: 6 }).map((_, i) => ( + <div key={i} className="flex items-center gap-2"> + <span className="h-2.5 w-2.5 flex-none rounded-sm bg-gray-200" /> + <span className="h-3 w-32 rounded bg-gray-100" /> + </div> + ))} + </div> + </div> + )} + {requested && isError && ( + <div + role="alert" + className="flex items-center gap-3 text-sm text-red-600" + > + Could not load the spend breakdown. + <button + onClick={() => refetch()} + className="rounded-md border border-gray-200 px-2.5 py-1 text-[0.78rem] font-medium text-gray-700" + > + Retry + </button> + </div> + )} + + {data && groups.length > 0 && ( + <> + <div className="flex h-[22px] overflow-hidden rounded"> + {groups.map((g, i) => ( + <span + key={g.category} + title={`${g.category} · ${moneyCompact(g.costTotal)}`} + style={{ + width: `${(g.costTotal / grandTotal) * 100}%`, + background: SEGMENT_COLORS[i % SEGMENT_COLORS.length], + }} + /> + ))} + </div> + <div className="mt-3.5 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 lg:grid-cols-3"> + {groups.map((g, i) => ( + <span + key={g.category} + className="flex items-center gap-2 text-[0.78rem] text-gray-500" + > + <span + className="h-2.5 w-2.5 flex-none rounded-sm" + style={{ + background: SEGMENT_COLORS[i % SEGMENT_COLORS.length], + }} + /> + {g.category}{" "} + <b className="font-semibold text-gray-900"> + {moneyCompact(g.costTotal)} + </b>{" "} + · {g.homesTotal} homes + </span> + ))} + </div> + <p className="mt-3 flex items-center gap-1 text-[0.72rem] text-gray-500"> + Construction works only — excludes project delivery and + contingency. + <InfoDot + label="Where the money goes" + tip={COST_HELP.whereMoneyGoes} + /> + </p> + </> + )} + + {data && groups.length === 0 && ( + <p className="text-sm text-gray-600"> + No measures in this scenario yet. + </p> + )} + </div> + + {expanded && data && ( + <div className="border-t border-gray-100"> + <div className="flex items-center justify-between px-5 py-2.5"> + <span className="text-[0.74rem] text-gray-600"> + {data.length} measure types + </span> + <button + onClick={() => downloadCsv(groups)} + className="rounded-md border border-gray-200 px-3 py-1.5 text-[0.78rem] font-medium" + > + Download CSV + </button> + </div> + <div className="overflow-x-auto"> + <table className="w-full text-[0.82rem]"> + <thead> + <tr className="text-[0.68rem] uppercase tracking-wide text-gray-600"> + <th className="px-5 py-2 text-left font-bold">Measure</th> + <th className="px-5 py-2 text-right font-bold">Homes</th> + <th className="px-5 py-2 text-right font-bold">Total</th> + <th className="px-5 py-2 text-right font-bold">Avg</th> + <th className="px-5 py-2" /> + </tr> + </thead> + <tbody> + {groups.map((g) => ( + <MeasureGroupRows + key={g.category} + group={g} + onDrill={drillHref} + /> + ))} + </tbody> + </table> + </div> + </div> + )} + </section> + ); +} + +function MeasureGroupRows({ + group, + onDrill, +}: { + group: MeasureGroup; + onDrill?: (t: DrillTarget) => () => void; +}) { + return ( + <> + <tr> + <td + colSpan={5} + className="bg-gray-50 px-5 py-2 text-[0.74rem] font-semibold text-brandblue" + > + {group.category} · {group.homesTotal} homes ·{" "} + {moneyFull(group.costTotal)} + </td> + </tr> + {group.rows.map((r) => ( + <tr key={r.measureType} className="border-t border-gray-100"> + <td className="px-5 py-2">{measureLabel(r.measureType)}</td> + <td className="px-5 py-2 text-right tabular-nums">{r.homesCount}</td> + <td className="px-5 py-2 text-right tabular-nums"> + {moneyFull(r.totalCost)} + </td> + <td className="px-5 py-2 text-right tabular-nums text-gray-500"> + {moneyFull(r.averageCost)} + </td> + <td className="px-5 py-2 text-right"> + {onDrill && ( + <button + onClick={onDrill({ + filter: "measure", + measureType: r.measureType, + title: measureLabel(r.measureType), + })} + className="whitespace-nowrap font-semibold text-midblue" + > + Homes → + </button> + )} + </td> + </tr> + ))} + </> + ); +} + +function downloadCsv(groups: MeasureGroup[]) { + const lines = ["Category,Measure,Homes,Total cost (£),Average cost (£)"]; + for (const g of groups) { + for (const r of g.rows) { + lines.push( + [ + g.category, + measureLabel(r.measureType), + r.homesCount, + r.totalCost.toFixed(0), + r.averageCost.toFixed(0), + ].join(","), + ); + } + lines.push( + [g.category, "Subtotal", g.homesTotal, g.costTotal.toFixed(0), ""].join( + ",", + ), + ); + } + const blob = new Blob([lines.join("\n")], { + type: "text/csv;charset=utf-8;", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "scenario-measures.csv"; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx new file mode 100644 index 00000000..c2455cc3 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/shadcn_components/ui/popover"; +import { ChevronDown, Check, Search } from "lucide-react"; +import type { ScenarioSummary } from "@/lib/reporting/types"; + +// ReportView is part of the reporting view-state (viewState.ts owns it); +// re-exported here so existing imports from this component keep resolving. +export type { ReportView } from "@/lib/reporting/viewState"; +import type { ReportView } from "@/lib/reporting/viewState"; + +const GOAL_SHORT: Record<string, string> = { + "Increasing EPC": "Target EPC", + "Reducing CO2 emissions": "Reducing CO₂", + "Energy Savings": "Energy savings", + "Valuation Improvement": "Valuation", +}; + +function scenarioGoalLine(s: ScenarioSummary): string { + const goal = s.goal ? (GOAL_SHORT[s.goal] ?? s.goal) : ""; + const target = + s.goal === "Increasing EPC" && s.goalValue ? ` ${s.goalValue}` : ""; + const budget = s.budget ? ` · £${(s.budget / 1000).toFixed(0)}k/home` : ""; + return `${goal}${target}${budget}`.trim(); +} + +export function ScenarioCombobox({ + scenarios, + value, + onChange, +}: { + scenarios: ScenarioSummary[]; + value: ReportView; + onChange: (v: ReportView) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const selectedLabel = + value === "current-stock" + ? "Current stock" + : value === "recommended" + ? "Recommended plans" + : (scenarios.find((s) => s.id === value)?.name ?? "Scenario"); + + const q = query.trim().toLowerCase(); + const matches = scenarios.filter((s) => s.name.toLowerCase().includes(q)); + + function pick(v: ReportView) { + onChange(v); + setOpen(false); + setQuery(""); + } + + const Row = ({ + active, + disabled, + name, + sub, + onSelect, + }: { + active: boolean; + disabled?: boolean; + name: string; + sub: string; + onSelect: () => void; + }) => ( + <button + type="button" + role="option" + aria-selected={active} + disabled={disabled} + onClick={onSelect} + className={`flex w-full items-center gap-2 border-t border-gray-100 px-3.5 py-2.5 text-left first:border-t-0 disabled:cursor-not-allowed disabled:opacity-55 ${ + active ? "bg-brandlightblue" : "hover:bg-gray-50" + }`} + > + <span className="min-w-0 flex-1"> + <span className="block truncate text-sm font-semibold text-brandblue"> + {name} + </span> + <span className="block truncate text-[0.7rem] text-gray-600">{sub}</span> + </span> + {disabled && ( + <span className="rounded bg-[#faf1e2] px-1.5 py-0.5 text-[0.62rem] font-bold text-[#9a5b10]"> + Awaiting modelling + </span> + )} + {active && <Check className="h-4 w-4 text-midblue" />} + </button> + ); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <button + type="button" + aria-haspopup="listbox" + aria-expanded={open} + aria-label={`View: ${selectedLabel}. Change scenario`} + className="inline-flex h-9 items-center gap-2.5 rounded-lg border border-gray-200 bg-white px-3 text-sm hover:border-gray-300" + > + <span className="text-[0.7rem] font-bold tracking-wide text-gray-600"> + VIEW + </span> + <span className="font-semibold text-brandblue">{selectedLabel}</span> + <ChevronDown className="h-3.5 w-3.5 text-gray-600" /> + </button> + </PopoverTrigger> + <PopoverContent align="start" className="w-80 p-0"> + <div className="flex items-center gap-2 border-b border-gray-100 px-3.5 py-2 text-gray-600"> + <Search className="h-3.5 w-3.5" /> + <input + autoFocus + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search scenarios…" + aria-label="Search scenarios" + className="w-full bg-transparent text-sm text-brandblue outline-none placeholder:text-gray-600" + /> + </div> + <div role="listbox" aria-label="Scenarios" className="max-h-80 overflow-y-auto"> + <Row + active={value === "current-stock"} + name="Current stock" + sub="No scenario applied" + onSelect={() => pick("current-stock")} + /> + <Row + active={value === "recommended"} + name="Recommended plans" + sub="The engine's best plan per home" + onSelect={() => pick("recommended")} + /> + {matches.map((s) => ( + <Row + key={s.id} + active={value === s.id} + disabled={s.status === "awaiting_modelling"} + name={s.name} + sub={scenarioGoalLine(s)} + onSelect={() => pick(s.id)} + /> + ))} + {matches.length === 0 && q && ( + <p className="px-3.5 py-3 text-[0.78rem] text-gray-600"> + No scenarios match “{query}”. + </p> + )} + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/StockBreakdown.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/StockBreakdown.tsx new file mode 100644 index 00000000..b42a580b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/StockBreakdown.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { Panel } from "./primitives"; +import { PROPERTY_TYPE_LABELS } from "@/lib/epc/labels"; +import type { AgeBandCount, PropertyTypeCount } from "@/lib/reporting/types"; + +type Tab = "age" | "type"; + +/** + * Stock breakdown panel (Screen A) — age and property-type cuts as one-click + * tabs sharing the ladder's row rhythm. Local authority is a future tab + * pending a coverage check. + */ +export function StockBreakdown({ + ageBands, + propertyTypes, +}: { + ageBands: AgeBandCount[]; + propertyTypes: PropertyTypeCount[]; +}) { + const [tab, setTab] = useState<Tab>("age"); + + const rows = + tab === "age" + ? ageBands.map((a) => ({ label: a.age_band, count: a.count })) + : propertyTypes.map((p) => ({ + label: p.type ? (PROPERTY_TYPE_LABELS[p.type] ?? p.type) : "Unknown", + count: p.count, + })); + + const max = Math.max(1, ...rows.map((r) => r.count)); + + return ( + <Panel + title="Stock breakdown" + action={ + <span className="inline-flex gap-3.5 text-[0.76rem] font-medium"> + {(["age", "type"] as Tab[]).map((t) => ( + <button + key={t} + onClick={() => setTab(t)} + className={`pb-0.5 ${ + tab === t + ? "border-b-2 border-brandblue text-brandblue" + : "text-gray-600" + }`} + > + {t === "age" ? "Age" : "Type"} + </button> + ))} + </span> + } + > + <div className="grid grid-cols-[88px_1fr_40px] items-center gap-x-3 gap-y-2.5 text-[0.76rem] text-gray-500"> + {rows.map((r) => ( + <div key={r.label} className="contents"> + <span className="truncate">{r.label}</span> + <span> + <span + className="block h-[18px] rounded bg-brandblue opacity-80" + style={{ width: `${(r.count / max) * 100}%` }} + /> + </span> + <span className="text-right font-medium tabular-nums text-gray-900"> + {r.count} + </span> + </div> + ))} + </div> + </Panel> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/TagFilterControl.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/TagFilterControl.tsx new file mode 100644 index 00000000..519f356b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/TagFilterControl.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Tag as TagIcon, Check, Ban } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/shadcn_components/ui/popover"; +import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags"; +import { TagChip } from "@/app/portfolio/[slug]/components/TagChip"; +import type { TagFilter, IncludeMode } from "@/lib/reporting/viewState"; + +/** + * The reporting tag filter (view-wide). Each tag can be Included (home must + * carry it) or Excluded (home must not) — mutually exclusive per tag. When two + * or more tags are included, an Any/All switch chooses union vs intersection. + * Purely presentational + local wiring: it hands a new {@link TagFilter} up to + * the parent, which pushes it into the URL. + */ +export function TagFilterControl({ + portfolioId, + value, + onChange, +}: { + portfolioId: number; + value: TagFilter; + onChange: (next: TagFilter) => void; +}) { + const { data: tags = [], isLoading } = usePortfolioTags(String(portfolioId)); + const activeCount = value.include.length + value.exclude.length; + + const toggleInclude = (id: string) => + onChange({ + ...value, + include: value.include.includes(id) + ? value.include.filter((x) => x !== id) + : [...value.include, id], + exclude: value.exclude.filter((x) => x !== id), + }); + + const toggleExclude = (id: string) => + onChange({ + ...value, + exclude: value.exclude.includes(id) + ? value.exclude.filter((x) => x !== id) + : [...value.exclude, id], + include: value.include.filter((x) => x !== id), + }); + + const setMode = (includeMode: IncludeMode) => + onChange({ ...value, includeMode }); + + const clear = () => + onChange({ include: [], exclude: [], includeMode: "any" }); + + const trigger = ( + <button + type="button" + aria-haspopup="dialog" + className={`inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium ${ + activeCount > 0 + ? "border-brandblue bg-[#f2f3f9] text-brandblue" + : "border-gray-200 bg-white text-gray-600 hover:border-gray-300" + }`} + > + <TagIcon className="h-3.5 w-3.5" /> + Tags + {activeCount > 0 && ( + <span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-brandblue px-1 text-[0.62rem] font-bold text-white"> + {activeCount} + </span> + )} + </button> + ); + + return ( + <Popover> + <PopoverTrigger asChild>{trigger}</PopoverTrigger> + <PopoverContent align="end" className="w-[22rem] p-0"> + <div className="flex items-center justify-between border-b border-gray-100 px-3.5 py-2.5"> + <span className="text-[0.8rem] font-semibold text-brandblue"> + Filter by tag + </span> + {activeCount > 0 && ( + <button + type="button" + onClick={clear} + className="text-[0.72rem] font-medium text-midblue hover:underline" + > + Clear + </button> + )} + </div> + + {value.include.length >= 2 && ( + <div className="flex items-center gap-2 border-b border-gray-100 px-3.5 py-2 text-[0.74rem]"> + <span className="text-gray-500">Match</span> + <div className="inline-flex overflow-hidden rounded-md border border-gray-200"> + {(["any", "all"] as const).map((m) => ( + <button + key={m} + type="button" + aria-pressed={value.includeMode === m} + onClick={() => setMode(m)} + className={`px-2.5 py-1 font-medium ${ + value.includeMode === m + ? "bg-brandblue text-white" + : "bg-white text-gray-600 hover:bg-gray-50" + }`} + > + {m === "any" ? "any" : "all"} + </button> + ))} + </div> + <span className="text-gray-500">of the included tags</span> + </div> + )} + + <div className="max-h-72 overflow-y-auto py-1"> + {isLoading ? ( + <p className="px-3.5 py-3 text-[0.78rem] text-gray-500"> + Loading tags… + </p> + ) : tags.length === 0 ? ( + <p className="px-3.5 py-3 text-[0.78rem] text-gray-500"> + This portfolio has no tags yet. + </p> + ) : ( + tags.map((t) => { + const included = value.include.includes(t.id); + const excluded = value.exclude.includes(t.id); + return ( + <div + key={t.id} + className="flex items-center gap-2 px-3.5 py-1.5 hover:bg-gray-50" + > + <span className="min-w-0 flex-1"> + <TagChip name={t.name} colour={t.colour} /> + <span className="ml-1.5 text-[0.68rem] tabular-nums text-gray-400"> + {t.propertyCount} + </span> + </span> + <button + type="button" + aria-pressed={included} + onClick={() => toggleInclude(t.id)} + className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[0.72rem] font-medium ${ + included + ? "border-[#0c6b4a] bg-[#e9f4ef] text-[#0c6b4a]" + : "border-gray-200 bg-white text-gray-500 hover:border-gray-300" + }`} + > + <Check className="h-3 w-3" /> + Include + </button> + <button + type="button" + aria-pressed={excluded} + onClick={() => toggleExclude(t.id)} + className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[0.72rem] font-medium ${ + excluded + ? "border-[#b91c1c] bg-[#fbeaea] text-[#b91c1c]" + : "border-gray-200 bg-white text-gray-500 hover:border-gray-300" + }`} + > + <Ban className="h-3 w-3" /> + Exclude + </button> + </div> + ); + }) + )} + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/costHelp.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/costHelp.tsx new file mode 100644 index 00000000..568a6bef --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/costHelp.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from "react"; +import { PROJECT_DELIVERY_RATE } from "@/lib/reporting/model"; +import { TipBody, type TipSpec } from "./tipContent"; + +const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100); + +/** + * Structured "what this figure covers" copy for the scenario cost surfaces — + * a one-line definition plus scannable Includes/Excludes lists, so the KPI + * band, the Investment ledger and the "where the money goes" breakdown all + * explain a term the same way. Wording follows the domain glossary + * (CONTEXT.md → Reporting): Gross = construction works + project delivery + + * contingency; Net = gross − secured funding. + */ +const GROSS_PARTS = ["Construction works", "Project delivery", "Contingency"]; + +const SPECS = { + grossPerHome: { + summary: "Gross cost ÷ the homes upgraded in this scenario.", + includes: GROSS_PARTS, + excludes: ["Funding (deducted to reach net)", "VAT", "Internal staff time"], + }, + net: { + summary: + "Gross cost minus secured funding — the figure a board commits to.", + includes: GROSS_PARTS, + excludes: ["VAT", "Internal staff time"], + note: "Net = gross − secured funding.", + }, + construction: { + summary: + "The principal contractor's price for the physical works — the measures installed.", + excludes: ["Project delivery", "Contingency"], + }, + projectDelivery: { + summary: + "Professional and management costs of delivering the works — design, project management, overheads.", + note: `Estimated at ${deliveryPct}% of construction works.`, + }, + contingency: { + summary: + "A risk allowance for unforeseen costs, on top of construction works and project delivery.", + note: "Part of gross cost, and so of net.", + }, + billSavings: { + summary: + "Modelled reduction in residents' annual energy bills once the works are in.", + note: "This scenario vs current stock.", + }, + carbonSaved: { + summary: "Modelled annual carbon saved once the works are in.", + note: "This scenario vs current stock.", + }, + costPerSap: { + summary: "Capital cost ÷ total SAP points gained. Lower is better.", + includes: ["Construction works", "Project delivery"], + excludes: ["Contingency", "Funding"], + }, + costPerCarbon: { + summary: "Capital cost ÷ tonnes of CO₂ saved a year. Lower is better.", + includes: ["Construction works", "Project delivery"], + excludes: ["Contingency", "Funding"], + }, + whereMoneyGoes: { + summary: + "The construction works only — the cost of the measures installed.", + excludes: ["Project delivery", "Contingency"], + note: 'Totals the "Construction works" ledger line, not gross cost.', + }, +} satisfies Record<string, TipSpec>; + +/** Rendered tooltip bodies, keyed by figure. `tip={COST_HELP.net}` etc. */ +export const COST_HELP = Object.fromEntries( + Object.entries(SPECS).map(([key, spec]) => [ + key, + <TipBody key={key} spec={spec} />, + ]), +) as Record<keyof typeof SPECS, ReactNode>; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx new file mode 100644 index 00000000..a4a24973 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx @@ -0,0 +1,148 @@ +"use client"; + +/** + * Shared reporting primitives — the one visual vocabulary for the redesign + * (PRD #370). EPC colour is the only strong colour; deltas carry semantic + * green/red; everything else is quiet navy-on-white. + */ + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; +import { Info } from "lucide-react"; +import { getEpcColorClass } from "@/app/utils"; + +/** + * A small "ⓘ" affordance that reveals an explanation on hover/focus. The one + * way the reporting surfaces disclose what a figure includes or excludes, so + * the KPI band, ledger and spend breakdown all read the same. + */ +export function InfoDot({ + label, + tip, +}: { + label: string; + tip: React.ReactNode; +}) { + return ( + <TooltipProvider delayDuration={100}> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + aria-label={`About: ${label}`} + className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:text-gray-900" + > + <Info className="h-2.5 w-2.5" /> + </button> + </TooltipTrigger> + <TooltipContent + side="top" + sideOffset={6} + className="w-[300px] max-w-[calc(100vw-2rem)] rounded-lg border-gray-200 bg-white px-4 py-3.5 text-[0.82rem] font-normal leading-relaxed text-gray-700 shadow-lg" + > + {tip} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); +} + +const EPC_TEXT_ON: Record<string, string> = { + A: "text-white", + B: "text-white", + C: "text-[#2c3510]", + D: "text-[#4d3f04]", + E: "text-[#5c3410]", + F: "text-white", + G: "text-white", + Unknown: "text-gray-600", +}; + +export function EpcChip({ + band, + size = "md", +}: { + band: string; + size?: "sm" | "md"; +}) { + const bg = band === "Unknown" ? "bg-gray-300" : getEpcColorClass(band); + const dims = + size === "sm" + ? "min-w-[19px] h-[19px] text-[0.68rem] rounded-[5px]" + : "min-w-[24px] h-6 text-sm rounded-md"; + return ( + <span + className={`inline-flex items-center justify-center px-1.5 font-bold ${bg} ${EPC_TEXT_ON[band] ?? "text-white"} ${dims}`} + > + {band} + </span> + ); +} + +/** + * A signed delta pill. `improved` decides the colour, not the sign — + * a fall in bills is good, a rise in SAP is good. + */ +export function DeltaPill({ + children, + improved, +}: { + children: React.ReactNode; + improved: boolean; +}) { + const tone = improved + ? "text-[#0c6b4a] bg-[#e9f4ef]" + : "text-[#b91c1c] bg-[#fbeaea]"; + return ( + <span + className={`inline-flex items-center rounded-[5px] px-1.5 py-0.5 text-[0.67rem] font-bold tabular-nums ${tone}`} + > + {children} + </span> + ); +} + +export function Panel({ + title, + meta, + action, + children, + className = "", +}: { + title: string; + meta?: React.ReactNode; + action?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( + <section + className={`flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white ${className}`} + > + <header className="flex items-baseline justify-between gap-3 px-5 pt-4"> + <h3 className="text-[0.88rem] font-semibold text-brandblue"> + {title} + {meta && ( + <span className="ml-2 text-[0.74rem] font-normal text-gray-600"> + {meta} + </span> + )} + </h3> + {action} + </header> + <div className="flex flex-1 flex-col px-5 pb-5 pt-4">{children}</div> + </section> + ); +} + +// Money formatters live in the reporting model (the single source, shared with +// the headline + PDF); re-exported here so components keep importing them from +// the primitives barrel. `moneyCompact` is the model's `formatMoneyCompact`. +export { + formatMoneyCompact as moneyCompact, + moneyFull, +} from "@/lib/reporting/model"; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/tipContent.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/tipContent.tsx new file mode 100644 index 00000000..1c7e4573 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/tipContent.tsx @@ -0,0 +1,52 @@ +import { Check, Minus } from "lucide-react"; + +/** + * Shared shape + renderer for the reporting help tooltips (cost figures and + * scenario filters alike): a one-line summary, optional scannable + * Includes/Excludes lists, and an optional footnote — so every ⓘ across the + * reporting view reads the same way. + */ +export interface TipSpec { + summary: string; + includes?: string[]; + excludes?: string[]; + note?: string; +} + +function Bullets({ items, kind }: { items: string[]; kind: "in" | "out" }) { + const Icon = kind === "in" ? Check : Minus; + return ( + <div> + <div className="text-[0.64rem] font-bold uppercase tracking-wide text-gray-400"> + {kind === "in" ? "Includes" : "Excludes"} + </div> + <ul className="mt-1 space-y-1"> + {items.map((it) => ( + <li key={it} className="flex items-start gap-1.5"> + <Icon + className={`mt-[3px] h-3 w-3 flex-none ${ + kind === "in" ? "text-[#0c6b4a]" : "text-gray-400" + }`} + /> + <span>{it}</span> + </li> + ))} + </ul> + </div> + ); +} + +export function TipBody({ spec }: { spec: TipSpec }) { + return ( + <div className="space-y-2.5"> + <p className="font-medium text-gray-800">{spec.summary}</p> + {spec.includes && <Bullets items={spec.includes} kind="in" />} + {spec.excludes && <Bullets items={spec.excludes} kind="out" />} + {spec.note && ( + <p className="border-t border-gray-100 pt-2 text-gray-500"> + {spec.note} + </p> + )} + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx new file mode 100644 index 00000000..f3a6f1e4 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useState } from "react"; +import { Clock } from "lucide-react"; +import type { DataQualityMetrics } from "@/lib/reporting/types"; +import { DrillDownShelf, type DrillTarget } from "../components/DrillDownShelf"; + +const UNLOCK_MODULES = [ + { + name: "Condition & compliance", + unlocks: + "Awaab's Law warnings, HHSRS Category 1 & 2 hazard counts and Decent Homes compliance.", + columns: [ + "hazard_category", + "hazard_severity", + "survey_date", + "decent_homes_pass", + ], + }, + { + name: "Rent & valuation", + unlocks: + "Financial exposure, value uplift per scenario and payback framing in the investment ledger.", + columns: ["rent_pa", "valuation", "valuation_date"], + }, +]; + +export function DataQualityClientArea({ + portfolioId, + metrics, +}: { + portfolioId: number; + metrics: DataQualityMetrics; +}) { + const [drill, setDrill] = useState<DrillTarget | null>(null); + const [page, setPage] = useState(1); + + const { + total, + inDate, + expired, + estimated, + likelyDowngrades, + likelyUpgrades, + } = metrics; + const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0); + + function open(t: DrillTarget) { + setDrill(t); + setPage(1); + } + + const composition = [ + { label: "In date · " + inDate, value: inDate, color: "#14163d" }, + { label: "Expired · " + expired, value: expired, color: "#a07c42" }, + { label: "Estimated · " + estimated, value: estimated, color: "#c5cad8" }, + ]; + + const issues = [ + { + key: "estimated" as const, + name: "Estimated EPCs", + def: "No certificate exists; performance predicted from nearby homes of similar archetype.", + count: estimated, + impact: + "These homes' SAP, bills and carbon are modelled, not certificated — they move averages and band counts.", + good: false, + }, + { + key: "expired" as const, + name: "Expired EPCs", + def: "Real certificate, older than 10 years.", + count: expired, + impact: + "Still counted as lodged, but the home may have changed since assessment. Re-assess before relying on these bands.", + good: false, + }, + { + key: "likely-downgrade" as const, + name: "Likely downgrades", + def: "Lodged band sits above the effective band — a re-survey would likely certificate lower. Band movement only.", + count: likelyDowngrades, + impact: + "The certificate on file is better than the home's true position, so compliance demonstrated by these EPCs is fragile.", + good: false, + }, + { + key: "likely-upgrade" as const, + name: "Likely upgrades", + def: "Effective band sits above the lodged band — Landlord overrides or SAP 10.2 scoring improved the picture.", + count: likelyUpgrades, + impact: + "These homes would likely certificate higher with a re-survey alone — the cheapest compliance moves in the portfolio.", + good: true, + }, + ]; + + return ( + <div className="space-y-4" data-reporting> + <div className="flex items-end justify-between gap-4"> + <div> + <div className="text-[0.74rem] text-gray-600"> + Portfolios / Reporting + </div> + <h1 className="mt-0.5 text-[1.35rem] font-semibold tracking-tight text-brandblue"> + Data quality + </h1> + </div> + <a + href={`/portfolio/${portfolioId}/reporting`} + className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium" + > + ← Back to reporting + </a> + </div> + + {/* Evidence composition */} + <section className="rounded-lg border border-gray-200 bg-white px-5 pb-5 pt-4"> + <h2 className="text-[0.88rem] font-semibold text-brandblue"> + EPC evidence + <span className="ml-2 text-[0.74rem] font-normal text-gray-600"> + every home counted once, by strongest evidence + </span> + </h2> + <div className="mt-4 flex h-[18px] overflow-hidden rounded"> + {composition.map((c) => ( + <span + key={c.label} + style={{ + width: `${total > 0 ? (c.value / total) * 100 : 0}%`, + background: c.color, + }} + /> + ))} + </div> + <div className="mt-3 flex flex-wrap gap-4 text-[0.72rem] text-gray-500"> + {composition.map((c) => ( + <span key={c.label} className="flex items-center gap-1.5"> + <span + className="inline-block h-2.5 w-2.5 rounded-sm" + style={{ background: c.color }} + /> + {c.label} + </span> + ))} + </div> + </section> + + {/* Issues */} + <section className="overflow-hidden rounded-lg border border-gray-200 bg-white"> + {issues.map((issue, i) => ( + <div + key={issue.key} + className={`grid grid-cols-[210px_70px_1fr_130px] items-center gap-4 px-5 py-4 text-[0.83rem] ${ + i > 0 ? "border-t border-gray-100" : "" + }`} + > + <div> + <div className="font-semibold text-gray-900">{issue.name}</div> + <div className="mt-0.5 text-[0.73rem] leading-snug text-gray-600"> + {issue.def} + </div> + </div> + <div + className={`text-right text-[1.1rem] font-semibold tabular-nums ${ + issue.good ? "text-[#0c6b4a]" : "text-brandblue" + }`} + > + {issue.count} + <span className="block text-[0.68rem] font-medium text-gray-600"> + {pct(issue.count)}% + </span> + </div> + <div className="text-[0.78rem] leading-relaxed text-gray-500"> + {issue.impact} + </div> + <div className="text-right"> + <button + onClick={() => + open({ + filter: issue.key, + title: issue.name, + }) + } + className="text-[0.78rem] font-semibold text-midblue" + > + View homes → + </button> + </div> + </div> + ))} + </section> + + {drill && ( + <DrillDownShelf + portfolioId={portfolioId} + target={drill} + page={page} + onPage={setPage} + onClose={() => setDrill(null)} + /> + )} + + {/* Not yet provided */} + <section className="overflow-hidden rounded-lg border border-gray-200 bg-white"> + <h2 className="px-5 pt-4 text-[0.88rem] font-semibold text-brandblue"> + Not yet provided + <span className="ml-2 text-[0.74rem] font-normal text-gray-600"> + data that unlocks new report sections + </span> + </h2> + {UNLOCK_MODULES.map((m, i) => ( + <div + key={m.name} + className={`grid grid-cols-[180px_1fr_160px] items-start gap-5 px-5 py-4 ${ + i > 0 + ? "border-t border-gray-100" + : "mt-2 border-t border-gray-100" + }`} + > + <div> + <div className="text-[0.83rem] font-semibold text-gray-900"> + {m.name} + </div> + <span className="mt-1.5 inline-block rounded bg-[#faf1e2] px-1.5 py-0.5 text-[0.64rem] font-bold text-[#9a5b10]"> + No data + </span> + </div> + <div> + <p className="text-[0.78rem] leading-relaxed text-gray-500"> + Unlocks {m.unlocks} + </p> + <div className="mt-2 flex flex-wrap items-center gap-1.5"> + {m.columns.map((c) => ( + <span + key={c} + className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[0.66rem] text-gray-500" + > + {c} + </span> + ))} + <span className="text-[0.68rem] text-gray-600"> + column set to confirm with the data team + </span> + </div> + </div> + <div className="flex items-center justify-end"> + <span className="inline-flex items-center gap-1.5 rounded-full bg-gray-50 px-2.5 py-1 text-[0.7rem] font-medium text-gray-400"> + <Clock className="h-3.5 w-3.5" /> + Coming soon + </span> + </div> + </div> + ))} + </section> + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/page.tsx new file mode 100644 index 00000000..e88ea75c --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/page.tsx @@ -0,0 +1,16 @@ +import { getDataQualityMetrics } from "@/lib/reporting/server"; +import { DataQualityClientArea } from "./DataQualityClientArea"; + +export default async function DataQualityPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const portfolioId = Number(slug); + const metrics = await getDataQualityMetrics(portfolioId); + + return ( + <div className="mx-auto max-w-7xl px-6 pb-12 pt-5"> + <DataQualityClientArea portfolioId={portfolioId} metrics={metrics} /> + </div> + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts deleted file mode 100644 index 725c1f95..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { db } from "@/app/db/db"; -import { sql, eq } from "drizzle-orm"; -import { scenario } from "@/app/db/schema/recommendations"; -import { - NEW_APPROACH_CUTOFF, - newApproachJoins, - carbonSql, - billsSql, - energyConsumptionSql, - estimatedSql, - isExpiredSql, - withoutEpcSql, - effectiveSapSql, - effectiveEpcBandSql, - propertyTypeSql, - constructionYearSql, -} from "@/lib/services/epcSources"; - -import type { - AverageMetrics, - AgeBandCount, - EpcBandCount, - EpcCoverageCounts, - PropertyTypeCount, - BaselineMetrics, - TotalMetrics, -} from "./types"; - -export async function getPortfolioCounts(portfolioId: number): Promise<number> { - const result = await db.execute<{ total: number }>(sql` - SELECT COUNT(*)::int AS total - FROM property - WHERE portfolio_id = ${portfolioId}; - `); - - return result.rows[0].total; -} - -export async function getAverages( - portfolioId: number, -): Promise<AverageMetrics> { - const result = await db.execute<AverageMetrics>(sql` - SELECT - AVG(${effectiveSapSql})::float AS avg_sap, - AVG(${carbonSql(sql`e`)})::float AS avg_carbon, - AVG(${billsSql(sql`e`)})::float AS avg_bills, - AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0]; -} - -export async function getTotals(portfolioId: number): Promise<TotalMetrics> { - const result = await db.execute<TotalMetrics>(sql` - SELECT - SUM(${carbonSql(sql`e`)})::float AS total_carbon, - SUM(${billsSql(sql`e`)})::float AS total_bills - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0]; -} - -export async function getCountByAgeBand( - portfolioId: number, -): Promise<AgeBandCount[]> { - const result = await db.execute<AgeBandCount>(sql` - SELECT - CASE - WHEN q.built_year < 1900 THEN '<1900' - WHEN q.built_year BETWEEN 1900 AND 1929 THEN '1900–1929' - WHEN q.built_year BETWEEN 1930 AND 1949 THEN '1930–1949' - WHEN q.built_year BETWEEN 1950 AND 1975 THEN '1950–1975' - WHEN q.built_year BETWEEN 1976 AND 1999 THEN '1976–1999' - WHEN q.built_year >= 2000 THEN '2000+' - ELSE 'Unknown' - END AS age_band, - COUNT(*)::int AS count - FROM ( - SELECT ${constructionYearSql} AS built_year - FROM property p - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId} - ) q - GROUP BY age_band - ORDER BY age_band; - `); - - return result.rows; -} - -export async function getCountByEpcBand( - portfolioId: number, -): Promise<EpcBandCount[]> { - const result = await db.execute<EpcBandCount>(sql` - SELECT * - FROM ( - SELECT - COALESCE((${effectiveEpcBandSql})::text, 'Unknown') AS epc, - COUNT(*) FILTER ( - WHERE ${estimatedSql(sql`e`)} = false - )::int AS actual, - COUNT(*) FILTER ( - WHERE ${estimatedSql(sql`e`)} = true - )::int AS estimated - FROM property p - LEFT JOIN property_details_epc e - ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId} - GROUP BY epc - ) q - ORDER BY - CASE - WHEN q.epc = 'A' THEN 1 - WHEN q.epc = 'B' THEN 2 - WHEN q.epc = 'C' THEN 3 - WHEN q.epc = 'D' THEN 4 - WHEN q.epc = 'E' THEN 5 - WHEN q.epc = 'F' THEN 6 - WHEN q.epc = 'G' THEN 7 - ELSE 8 - END; - `); - - return result.rows; -} - -/** - * The "Homes Without an EPC" card: homes for which we found neither a lodgement - * record nor a historical one. - * - * Deliberately NOT `estimatedSql`. A home whose prediction was conditioned on an - * expired certificate is estimated, but it is not *without an EPC* — it has one, - * and it belongs in the Expired card instead. The two cards partition the homes - * that have some certificate vs none. - */ -export async function getEpcCoverageCounts( - portfolioId: number, -): Promise<EpcCoverageCounts> { - const result = await db.execute<EpcCoverageCounts>(sql` - SELECT - SUM(CASE WHEN ${withoutEpcSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS "withoutEpc", - SUM(CASE WHEN ${withoutEpcSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS "withEpc" - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0]; -} - -export async function getCountByPropertyType( - portfolioId: number, -): Promise<PropertyTypeCount[]> { - const result = await db.execute<PropertyTypeCount>(sql` - SELECT ${propertyTypeSql} AS type, COUNT(*)::int AS count - FROM property p - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId} - GROUP BY type - ORDER BY count DESC; - `); - - return result.rows; -} - -/** - * The "Expired EPCs" card: homes where we found a certificate — lodged or - * historical — and it is out of date. - * - * The old `AND estimated = false` guard is gone, and must stay gone: a home whose - * prediction was conditioned on an expired certificate IS estimated, so that guard - * would exclude exactly the homes this card exists to count. The "a gap-fill has no - * certificate to expire" rule it was really enforcing now lives inside - * `isExpiredSql`, where it can't be forgotten by a caller. - */ -export async function getExpiredEpcCount(portfolioId: number): Promise<number> { - const result = await db.execute<{ expired: number }>(sql` - SELECT - SUM(CASE WHEN ${isExpiredSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS expired - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0].expired; -} - -export async function getLikelyDowngrades( - portfolioId: number, -): Promise<number> { - const result = await db.execute<{ downgrades: number }>(sql` - SELECT - COUNT(*)::int AS downgrades - FROM property p - JOIN property_details_epc e - ON e.property_id = p.id - WHERE p.portfolio_id = ${portfolioId} - -- GAP: the SAP-05 "likely downgrade" signal has no equivalent in the new - -- pipeline (handled there via Rebaseline). Restrict to legacy properties. - AND p.updated_at < ${NEW_APPROACH_CUTOFF}::date - AND e.sap_05_overwritten = true - AND p.current_sap_points IS NOT NULL - AND e.sap_05_score IS NOT NULL - AND p.current_sap_points < e.sap_05_score; - `); - - return result.rows[0].downgrades; -} - -export async function loadBaselineMetrics( - portfolioId: number, -): Promise<BaselineMetrics> { - const [ - total, - averages, - totals, - ageBands, - epcBands, - epcCoverage, - expiredEpcs, - likelyDowngrades, - ] = await Promise.all([ - getPortfolioCounts(portfolioId), - getAverages(portfolioId), - getTotals(portfolioId), - getCountByAgeBand(portfolioId), - getCountByEpcBand(portfolioId), - getEpcCoverageCounts(portfolioId), - getExpiredEpcCount(portfolioId), - getLikelyDowngrades(portfolioId), - ]); - - return { - total, - averages, - totals, - ageBands, - epcBands, - epcCoverage, - expiredEpcs, - likelyDowngrades, - }; -} - -export async function getScenarios(portfolioId: number) { - const rows = await db - .select() - .from(scenario) - .where(eq(scenario.portfolioId, BigInt(portfolioId))); - - // Normalise response (only return what we need right now) - return rows.map((s) => ({ - id: Number(s.id), - name: s.name ?? `Scenario ${s.id}`, - // we will compute the performance metrics in another function - })); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx index 739436ec..78a71919 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx @@ -1,36 +1,42 @@ import { loadBaselineMetrics, getCountByPropertyType, - getScenarios, -} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions"; + getPortfolioGoal, + getReportingScenarios, +} from "@/lib/reporting/server"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; import { ReportingClientArea } from "./ReportingClientArea"; export default async function ReportingPage(props: { params: Promise<{ slug: string }>; + searchParams: Promise<Record<string, string | string[] | undefined>>; }) { const params = await props.params; - const portfolioId = params.slug; + const portfolioId = Number(params.slug); - const [baseline, propertyTypes] = await Promise.all([ - loadBaselineMetrics(Number(portfolioId)), - getCountByPropertyType(Number(portfolioId)), - ]); - const scenarios = await getScenarios(Number(portfolioId)); + // The tag filter is URL-driven: read it here so the server-rendered baseline + // (current-stock figures) reflects the tagged subset. Changing tags navigates, + // re-running these queries and streaming the filtered figures back in. + const tags = parseReportingViewState(await props.searchParams).tags; + + // Kick every query off in parallel but DON'T await here — the promises are + // handed to the client area, which unwraps each with `use()` inside its own + // Suspense boundary. So the page shell + scenario picker paint immediately and + // each section streams in as its data lands, instead of the whole page + // blocking on the slowest query (the old Promise.all). + const baselinePromise = loadBaselineMetrics(portfolioId, tags); + const propertyTypesPromise = getCountByPropertyType(portfolioId, tags); + const portfolioGoalPromise = getPortfolioGoal(portfolioId); + const scenariosPromise = getReportingScenarios(portfolioId); return ( - <div className="max-w-8xl mx-auto px-6 pb-10 space-y-4 pt-4"> - <div className="mb-6"> - <header className="text-3xl font-semibold text-brandblue"> - Portfolio Overview - </header> - <div className="h-px bg-gray-200 mt-2" /> - </div> - + <div className="max-w-8xl mx-auto px-6 pb-12 pt-5"> <ReportingClientArea - baseline={baseline} - propertyTypes={propertyTypes} - scenarios={scenarios} - portfolioId={Number(portfolioId)} + baselinePromise={baselinePromise} + propertyTypesPromise={propertyTypesPromise} + scenariosPromise={scenariosPromise} + portfolioGoalPromise={portfolioGoalPromise} + portfolioId={portfolioId} /> </div> ); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx index 8c61df68..2dd89432 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -1,198 +1,554 @@ -import { SectionDivider } from "../SectionDivider"; -import { ScenarioFinancialDrawer } from "../ScenarioFinancialDrawer"; -import { DashboardSummaryCards } from "../DashboardSummaryCards"; -import { EpcQualityCards } from "../EpcQualityCards"; - -import { loadBaselineMetrics } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions"; - -import type { BaselineMetrics } from "../types"; import Image from "next/image"; +import { + loadBaselineMetrics, + getScenarioConfig, + getPortfolioGoal, + type ScenarioConfig, +} from "@/lib/reporting/server"; +import { measureLabel } from "@/lib/reporting/measures"; +import { + buildHeadline, + carsOffTheRoad, + countBelowBand, + shapeKpiDelta, + toBandCounts, + deriveLedgerView, + moneyFull as money, + type ScenarioOverlay, +} from "@/lib/reporting/model"; +import { sapToEpc, formatNumber, getEpcColorClass } from "@/app/utils"; +import { EPC_BANDS } from "@/lib/epc/bands"; +import { + parseReportingViewState, + serializeReportingViewState, + isTagFilterActive, + type ReportingViewState, +} from "@/lib/reporting/viewState"; +import { EpcChip } from "../components/primitives"; +import { + InvestmentLedger, + type LedgerView, +} from "../components/InvestmentLedger"; +import { getPortfolio } from "@/app/portfolio/[slug]/utils"; +import type { BaselineMetrics } from "@/lib/reporting/types"; import { AutoPrint } from "./AutoPrint"; -/* --------------------------------------------- - Base URL helper (Vercel + local) ---------------------------------------------- */ +/** + * The report PDF (PRD #370) — the print register of the reporting screen. + * Rebuilt on the same model functions, primitives and (restyled) Investment + * ledger as the screen, so the pack and the tool tell one story. Reflects the + * on-screen view state exactly: the selected view (current stock / recommended + * / a scenario) plus the active tag + scenario filters, all carried in the URL. + * + * Print-safe by construction: the content is div/span only (globals.css hides + * every <button> in print), and background colours are forced to print via the + * print-color-adjust rule on .print-root. + */ function getBaseUrl() { - if (process.env.VERCEL_BRANCH_URL) { + if (process.env.VERCEL_BRANCH_URL) return `https://${process.env.VERCEL_BRANCH_URL}`; - } - - if (process.env.VERCEL_URL) { - return `https://${process.env.VERCEL_URL}`; - } - - return "http://localhost:3000"; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + // Local dev: the server may be on any port (not always 3000). + return `http://localhost:${process.env.PORT ?? 3000}`; } -/* --------------------------------------------- - Server-side fetch for scenario metrics ---------------------------------------------- */ - -async function fetchScenarioReport({ - portfolioId, - scenarioId, -}: { - portfolioId: number; - scenarioId: number; -}) { +/** Fetch the scenario/recommended overlay, carrying the view's filters + tags. */ +async function fetchScenarioReport( + portfolioId: number, + segment: number | "default", + state: ReportingViewState, +): Promise<ScenarioOverlay> { + const qs = serializeReportingViewState(state).toString(); const res = await fetch( - `${getBaseUrl()}/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`, - { cache: "no-store" } + `${getBaseUrl()}/api/portfolio/${portfolioId}/scenario/${segment}/metrics${qs ? `?${qs}` : ""}`, + { cache: "no-store" }, ); - - if (!res.ok) { - throw new Error("Failed to load scenario report"); - } - + if (!res.ok) throw new Error("Failed to load scenario report"); return res.json(); } -/* --------------------------------------------- - Page ---------------------------------------------- */ +const generatedOn = () => + new Date().toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + +// ── Shared presentational pieces (print-safe: no <button>) ────────────────── + +function Cover({ title, subtitle }: { title: string; subtitle: string }) { + return ( + <header className="flex items-center gap-3 border-b-2 border-[#14163d] pb-3"> + <Image + src="/domna_logo_blue_transparent_background.png" + alt="Domna" + width={124} + height={36} + /> + <div className="ml-1 border-l border-gray-300 pl-3"> + <div className="text-[0.7rem] font-bold uppercase tracking-wide text-[#a07c42]"> + Retrofit report + </div> + <div className="text-[1.05rem] font-semibold leading-tight text-[#14163d]"> + {title} + </div> + </div> + <div className="ml-auto text-right text-[0.72rem] text-gray-500"> + <div className="font-medium text-gray-700">{subtitle}</div> + <div>Generated {generatedOn()}</div> + </div> + </header> + ); +} + +function FilterNote({ state }: { state: ReportingViewState }) { + const parts: string[] = []; + const nTags = state.tags.include.length + state.tags.exclude.length; + if (isTagFilterActive(state.tags)) + parts.push(`${nTags} tag filter${nTags > 1 ? "s" : ""}`); + if (state.hideNonCompliant) parts.push("only homes that reach target"); + if (state.useLodgedBaseline) parts.push("judged by lodged EPC"); + if (state.complianceWindow) + parts.push(`skipping homes compliant past ${state.complianceWindow.date}`); + if (!parts.length) return null; + return ( + <div className="inline-flex items-center gap-2 rounded-md bg-[#f2f3f9] px-3 py-1.5 text-[0.72rem] text-[#14163d]"> + <span className="font-semibold text-[#a07c42]">Filtered view</span> + <span className="text-gray-600">{parts.join(" · ")}</span> + </div> + ); +} + +function Tile({ + label, + value, + sub, +}: { + label: string; + value: React.ReactNode; + sub?: React.ReactNode; +}) { + return ( + <div className="rounded-lg border border-gray-200 px-3.5 py-3"> + <div className="text-[0.62rem] font-bold uppercase tracking-wide text-gray-500"> + {label} + </div> + <div className="mt-1 flex items-baseline gap-1.5 text-[1.15rem] font-semibold leading-none tabular-nums text-[#14163d]"> + {value} + </div> + {sub && ( + <div className="mt-1 text-[0.66rem] tabular-nums text-gray-500"> + {sub} + </div> + )} + </div> + ); +} + +function Delta({ text, improved }: { text: string; improved: boolean }) { + return ( + <span + className={`ml-0.5 rounded-[4px] px-1 py-0.5 text-[0.6rem] font-bold ${ + improved ? "bg-[#e9f4ef] text-[#0c6b4a]" : "bg-[#fbeaea] text-[#b91c1c]" + }`} + > + {text} + </span> + ); +} + +/** EPC distribution as div bars (EpcLadder is button-based → hidden in print). */ +function EpcDistribution({ + bandCounts, + scenarioBands, +}: { + bandCounts: Record<string, number>; + scenarioBands?: Record<string, number>; +}) { + const bands = EPC_BANDS.filter( + (b) => (bandCounts[b] ?? 0) > 0 || (scenarioBands?.[b] ?? 0) > 0, + ); + const max = Math.max( + 1, + ...bands.map((b) => Math.max(bandCounts[b] ?? 0, scenarioBands?.[b] ?? 0)), + ); + return ( + <div className="flex flex-col gap-2"> + {bands.map((band) => { + const before = bandCounts[band] ?? 0; + const after = scenarioBands?.[band]; + return ( + <div + key={band} + className="grid grid-cols-[24px_1fr_96px] items-center gap-x-3" + > + <EpcChip band={band} size="sm" /> + <span className="flex flex-col gap-[3px]"> + <span + className={`h-[15px] rounded-sm ${getEpcColorClass(band)}`} + style={{ width: `${Math.max((before / max) * 100, 0.5)}%` }} + /> + {after !== undefined && ( + <span + className="h-[7px] rounded-sm bg-[#14163d]" + style={{ width: `${Math.max((after / max) * 100, 0.5)}%` }} + /> + )} + </span> + <span className="text-right text-[0.72rem] tabular-nums text-gray-600"> + {after !== undefined ? ( + <> + {before} → <b className="text-gray-900">{after}</b> + </> + ) : ( + <b className="text-gray-900">{before}</b> + )} + </span> + </div> + ); + })} + <p className="mt-1 text-[0.68rem] text-gray-500"> + {scenarioBands + ? "Bar = current stock (band colour); line beneath = after scenario (navy)." + : "Homes by current effective EPC band."} + </p> + </div> + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( + <h2 className="mb-3 text-[0.82rem] font-semibold text-[#14163d]"> + {children} + </h2> + ); +} + +function goalPhrase(goal: string, goalValue: string | null): string { + if (goal === "Increasing EPC") return `Reach EPC ${goalValue ?? "target"}`; + if (goal === "Reducing CO2 emissions") return "Reduce CO₂ emissions"; + if (goal === "Energy Savings") return "Cut energy use"; + if (goal === "Valuation Improvement") return "Improve valuation"; + return goal; +} + +function BriefRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + <div className="flex items-baseline justify-between gap-4 border-t border-gray-100 py-1 text-[0.76rem] first:border-t-0"> + <span className="flex-none text-gray-500">{label}</span> + <span className="text-right font-medium text-[#14163d]">{value}</span> + </div> + ); +} + +/** + * Plain-language brief of the scenario's (immutable) configuration — the + * question it answers and the constraints it was modelled under. Fills the + * left column beneath the EPC distribution. + */ +function ScenarioBrief({ + config, + className = "", +}: { + config: ScenarioConfig; + className?: string; +}) { + const stock = + config.housingType?.toLowerCase() === "social" + ? "social homes" + : config.housingType?.toLowerCase() === "private" + ? "private homes" + : "homes"; + const excluded = config.exclusions.length + ? config.exclusions.map(measureLabel).join(", ") + : "None — every measure available"; + const budget = + config.budget != null + ? `£${config.budget.toLocaleString("en-GB")} per home` + : "No cap"; + // Lowercase the leading verb for mid-sentence use, keeping EPC/CO₂ caps. + const gp = goalPhrase(config.goal, config.goalValue); + const goalLead = gp.charAt(0).toLowerCase() + gp.slice(1); + + return ( + <section + className={`avoid-break rounded-lg border border-gray-200 p-4 ${className}`} + > + <h2 className="mb-2 text-[0.82rem] font-semibold text-[#14163d]"> + About this scenario + </h2> + <p className="mb-2 text-[0.78rem] leading-relaxed text-gray-600"> + This scenario asks what it takes to {goalLead} across the + portfolio's {stock}, and models the works and costs to get there. + </p> + <div> + <BriefRow + label="Goal" + value={goalPhrase(config.goal, config.goalValue)} + /> + <BriefRow label="Stock" value={config.housingType} /> + <BriefRow label="Budget" value={budget} /> + <BriefRow + label="Fabric-first" + value={config.fabricFirst ? "Enabled" : "Off"} + /> + <BriefRow label="Excluded measures" value={excluded} /> + </div> + </section> + ); +} + +function RecommendedBrief({ className = "" }: { className?: string }) { + return ( + <section + className={`avoid-break rounded-lg border border-gray-200 p-4 ${className}`} + > + <h2 className="mb-2 text-[0.82rem] font-semibold text-[#14163d]"> + About recommended plans + </h2> + <p className="text-[0.78rem] leading-relaxed text-gray-600"> + Recommended plans take the engine's best plan for each home — the + most cost-effective route to the biggest improvement — rather than one + portfolio-wide target. The figures aggregate those per-home plans. + </p> + </section> + ); +} + +function Footer() { + return ( + <footer className="mt-2 border-t border-gray-200 pt-3 text-[0.64rem] leading-relaxed text-gray-400"> + Domna · Retrofit report · Generated {generatedOn()}. Figures are modelled + estimates; gross cost = construction works + project delivery + + contingency, net = gross − secured funding. Excludes VAT and internal + staff time. Commercial-in-confidence. + </footer> + ); +} + +// ── Page ──────────────────────────────────────────────────────────────────── export default async function ReportingPdfPage(props: { params: Promise<{ slug: string }>; - searchParams: Promise<{ scenarioId?: string }>; + searchParams: Promise<Record<string, string | string[] | undefined>>; }) { - const params = await props.params; - const searchParams = await props.searchParams; - const scenarioId = Number(searchParams.scenarioId); + const { slug } = await props.params; + const state = parseReportingViewState(await props.searchParams); + const portfolioId = Number(slug); + const { view, tags } = state; - if (!scenarioId) { - return <div>No scenario selected</div>; + const segment: number | "default" | null = + view === "current-stock" ? null : view === "recommended" ? "default" : view; + + const [baseline, portfolio]: [BaselineMetrics, { name: string }] = + await Promise.all([ + loadBaselineMetrics(portfolioId, tags), + getPortfolio(slug), + ]); + const portfolioName = portfolio.name; + const bandCounts = toBandCounts(baseline.epcBands); + const avg = baseline.averages; + const total = baseline.total; + + // ── Current stock (no scenario overlay, no ledger) ────────────────────── + if (segment === null) { + const belowC = countBelowBand(bandCounts, "C"); + return ( + <div className="print-page mx-auto max-w-[190mm] bg-white"> + <div className="print-root space-y-3 p-8 text-[#14163d] print:p-0"> + <AutoPrint /> + <Cover title="Current stock" subtitle={portfolioName} /> + + <p className="max-w-[62ch] text-[1.02rem] font-medium leading-relaxed"> + {total.toLocaleString()} homes at an average EPC of{" "} + {sapToEpc(avg.avg_sap ?? 0)} ({Math.round(avg.avg_sap ?? 0)} SAP).{" "} + {belowC.toLocaleString()} sit below EPC C. + </p> + <FilterNote state={state} /> + + <div className="print-grid-3 grid grid-cols-3 gap-3"> + <Tile + label="Homes" + value={total.toLocaleString()} + sub={`${belowC.toLocaleString()} below EPC C`} + /> + <Tile + label="Average EPC" + value={ + <span className="flex items-center gap-1.5"> + <EpcChip band={sapToEpc(avg.avg_sap ?? 0)} size="sm" /> + {Math.round(avg.avg_sap ?? 0)} + <span className="text-[0.62rem] font-medium text-gray-500"> + SAP + </span> + </span> + } + /> + <Tile + label="Energy bills" + value={`£${formatNumber(avg.avg_bills ?? 0)}`} + sub="per home / year" + /> + <Tile + label="Carbon" + value={`${formatNumber(avg.avg_carbon ?? 0)}`} + sub="tCO₂e per home / year" + /> + <Tile + label="Energy use" + value={`${formatNumber(avg.avg_energy_consumption ?? 0)}`} + sub="kWh per home / year" + /> + <Tile + label="Portfolio bills" + value={money(baseline.totals.total_bills ?? 0)} + sub="per year across the portfolio" + /> + </div> + + <section className="avoid-break"> + <SectionTitle>EPC distribution — current stock</SectionTitle> + <EpcDistribution bandCounts={bandCounts} /> + </section> + + <Footer /> + </div> + </div> + ); } - const portfolioId = Number(params.slug); - - /* --------------------------------------------- - Fetch baseline + scenario (parallel) - --------------------------------------------- */ - - const [baseline, scenarioData]: [BaselineMetrics, any] = await Promise.all([ - loadBaselineMetrics(portfolioId), - fetchScenarioReport({ portfolioId, scenarioId }), + // ── Scenario / recommended ────────────────────────────────────────────── + const [scenarioData, config, portfolioGoal] = await Promise.all([ + fetchScenarioReport(portfolioId, segment, state), + typeof segment === "number" + ? getScenarioConfig(segment) + : Promise.resolve(null), + getPortfolioGoal(portfolioId), ]); - /* --------------------------------------------- - Scenario-only metrics for drawer - --------------------------------------------- */ + const goal = config?.goal ?? portfolioGoal; + const title = + segment === "default" + ? "Recommended plans" + : (config?.name ?? `Scenario ${segment}`); - const scenarioOverlay = scenarioData - ? { - avgSap: { - baseline: baseline.averages.avg_sap ?? 0, - scenario: Number(scenarioData.avg_sap), - }, - avgCarbon: { - baseline: Number(baseline.averages.avg_carbon ?? 0), - scenario: Number(scenarioData.avg_carbon), + const ledger: LedgerView = deriveLedgerView(scenarioData, { + totalCarbon: baseline.totals.total_carbon, + totalBills: baseline.totals.total_bills, + }); + // Bound for the headline + the "cars off the road" / CO₂-saved tiles below. + const carbonSaved = ledger.carbonSaved; - baselineTotal: Number(baseline.totals.total_carbon ?? 0), - scenarioTotal: Number(scenarioData.total_carbon ?? 0), - }, - avgBills: { - baseline: baseline.averages.avg_bills ?? 0, - scenario: scenarioData.avg_bills, - baselineTotal: baseline.totals.total_bills ?? 0, - scenarioTotal: scenarioData.total_bills, - }, - valuation: { baseline: null, scenario: null }, - scenarioEpcBands: scenarioData.scenario_epc_counts, - } - : null; - - const scenarioSpecific = { - constructionCost: scenarioData.construction_cost, - pcCost: scenarioData.pc_cost, - contingency: scenarioData.contingency, - funding: scenarioData.total_funding, - costPerSap: - scenarioData.construction_cost > 0 - ? scenarioData.gross_per_unit / - (scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0)) - : 0, - costPerCo2: - scenarioData.construction_cost > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - scenarioData.total_carbon - : 0, + const headline = buildHeadline({ + goal, + goalValue: config?.goalValue ?? null, + homesUpgraded: scenarioData.n_units_upgraded, netCost: scenarioData.net_cost, - grossPerUnit: scenarioData.gross_per_unit, - nUnits: scenarioData.n_units_upgraded, - totalCarbonSaved: - (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, - totalBillsSaved: - (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, - averageCaribonSaved: - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / - scenarioData.n_units_upgraded, - averageBillsSaved: - ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / - scenarioData.n_units_upgraded, - }; + carbonSavedPerYear: carbonSaved, + }); + + const scenarioBands: Record<string, number> = + scenarioData.scenario_epc_counts; + const belowCBefore = countBelowBand(bandCounts, "C"); + const belowCAfter = countBelowBand(scenarioBands, "C"); + + const sapDelta = shapeKpiDelta({ + current: avg.avg_sap ?? 0, + after: Number(scenarioData.avg_sap), + improvesWhenLower: false, + }); return ( - <div className="print-page"> - <div className="print-root space-y-4"> + <div className="print-page mx-auto max-w-[190mm] bg-white"> + <div className="print-root space-y-3 p-8 text-[#14163d] print:p-0"> <AutoPrint /> - {/* ------------------------------------------------ - Branded header - ------------------------------------------------ */} - <header className="flex items-center gap-3 border-b pb-2"> - <Image - src="/domna_logo_blue_transparent_background.png" - alt="Domna Logo" - width={140} - height={40} + <Cover title={title} subtitle={portfolioName} /> + + <p className="max-w-[62ch] text-[1.02rem] font-medium leading-relaxed"> + {headline} + </p> + <FilterNote state={state} /> + + <div className="print-grid-3 grid grid-cols-3 gap-3"> + <Tile + label="Homes upgraded" + value={scenarioData.n_units_upgraded.toLocaleString()} + sub={`of ${total.toLocaleString()} homes`} /> - <div> - <h1 className="text-xl font-semibold">Retrofit Scenario Report</h1> + <Tile + label="Average EPC" + value={ + <span className="flex items-center gap-1"> + <EpcChip band={sapToEpc(avg.avg_sap ?? 0)} size="sm" />→ + <EpcChip + band={sapToEpc(Number(scenarioData.avg_sap))} + size="sm" + /> + <Delta + text={`${sapDelta.delta > 0 ? "+" : ""}${sapDelta.delta.toFixed(0)}`} + improved={sapDelta.improved} + /> + </span> + } + sub={`${Math.round(avg.avg_sap ?? 0)} → ${Math.round(Number(scenarioData.avg_sap))} SAP`} + /> + <Tile + label="Homes below EPC C" + value={ + <span> + {belowCBefore.toLocaleString()} →{" "} + <b>{belowCAfter.toLocaleString()}</b> + </span> + } + sub={`≈ ${carsOffTheRoad(carbonSaved)} cars off the road`} + /> + <Tile + label="Bills saved / yr" + value={money(ledger.billSavings)} + sub={`${money(ledger.billSavingsPerHome)} per home`} + /> + <Tile + label="Carbon saved / yr" + value={`${formatNumber(carbonSaved)} t`} + sub="vs current stock" + /> + <Tile + label="Net cost" + value={money(scenarioData.net_cost)} + sub={`${money(scenarioData.gross_per_unit)} gross / home`} + /> + </div> + + {/* Equal-height columns; the About box and the ledger card each grow + to fill their column, so their bottom edges always align. */} + <div className="grid grid-cols-[1.1fr_1fr] items-stretch gap-4"> + <div className="flex flex-col gap-4"> + <section className="avoid-break"> + <SectionTitle> + EPC distribution — current vs scenario + </SectionTitle> + <EpcDistribution + bandCounts={bandCounts} + scenarioBands={scenarioBands} + /> + </section> + {config ? ( + <ScenarioBrief config={config} className="flex-1" /> + ) : segment === "default" ? ( + <RecommendedBrief className="flex-1" /> + ) : null} </div> - </header> - {/* ------------------------------------------------ - Scenario Impact Summary - ------------------------------------------------ */} - <SectionDivider - title="Scenario Impact Summary" - subtitle="Financial, carbon and energy outcomes" - /> + <section className="avoid-break flex flex-col"> + <SectionTitle>Investment ledger</SectionTitle> + <div className="flex flex-1 flex-col [&>div]:w-full [&>div]:flex-1"> + <InvestmentLedger v={ledger} /> + </div> + </section> + </div> - <ScenarioFinancialDrawer open={true} metrics={scenarioSpecific} /> - - {/* ------------------------------------------------ - Portfolio Summary (baseline) - ------------------------------------------------ */} - <div className="page-break" /> - <SectionDivider - title="Portfolio Summary" - subtitle="Headline performance indicators" - /> - - <DashboardSummaryCards - total={baseline.total} - totals={baseline.totals} - averages={baseline.averages} - epcCoverage={baseline.epcCoverage} - scenarioOverlay={scenarioOverlay} - /> - - {/* ------------------------------------------------ - EPC Quality (baseline) - ------------------------------------------------ */} - <SectionDivider - title="EPC Quality" - subtitle="Condition, compliance and performance" - /> - - <EpcQualityCards - epcCoverage={baseline.epcCoverage} - total={baseline.total} - expiredEpcs={baseline.expiredEpcs} - likelyDowngrades={baseline.likelyDowngrades} - /> + <Footer /> </div> </div> ); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelector.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelector.tsx deleted file mode 100644 index fd7e555e..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelector.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, -} from "@/app/shadcn_components/ui/select"; -import type { FC } from "react"; - -export interface ScenarioOption { - id: number; - name: string; -} - -interface ScenarioSelectorProps { - scenarios: ScenarioOption[]; - selected: number | null | "default"; - onChange: (id: number | null | "default") => void; -} - -export const ScenarioSelector: FC<ScenarioSelectorProps> = ({ - scenarios, - selected, - onChange, -}) => { - return ( - <div className="flex items-center gap-3"> - <span className="text-sm text-gray-600">Scenario:</span> - - <Select - value={ - selected === null - ? "none" - : selected === "default" - ? "default" - : String(selected) - } - onValueChange={(val) => { - if (val === "none") onChange(null); - else if (val === "default") onChange("default"); - else onChange(Number(val)); - }} - > - <SelectTrigger className="w-56 bg-white border-gray-200 shadow-sm"> - <SelectValue placeholder="Select scenario" /> - </SelectTrigger> - - <SelectContent> - <SelectItem value="none">No scenario (baseline only)</SelectItem> - - <SelectItem value="default"> - Best option (recommended plans) - </SelectItem> - - {scenarios.map((s) => ( - <SelectItem key={s.id} value={String(s.id)}> - {s.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - ); -}; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelectorWrapper.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelectorWrapper.tsx deleted file mode 100644 index 6386a2b1..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelectorWrapper.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { ScenarioSelector } from "./scenarioSelector"; - -export function ScenarioSelectorWrapper({ - scenarios, - portfolioId, - selectedScenarioId, - setSelectedScenarioId, -}: { - scenarios: { id: number; name: string }[]; - portfolioId: number; - selectedScenarioId: number | null | "default"; - setSelectedScenarioId: (id: number | null | "default") => void; -}) { - function handleSelect(id: number | null | "default") { - setSelectedScenarioId(id); - } - const selectionMeta = useMemo(() => { - if (selectedScenarioId === null) { - return { - label: "Baseline", - description: "Current portfolio performance", - className: "bg-gray-100 text-gray-600 border border-gray-200", - }; - } - - if (selectedScenarioId === "default") { - return { - label: "Recommended", - description: "Best upgrade plan per property", - className: "bg-brandmidblue text-white border border-brandblue", - }; - } - - const scenario = scenarios.find((s) => s.id === selectedScenarioId); - - return { - label: scenario?.name ?? "Scenario", - description: "Custom upgrade scenario", - className: "bg-white text-gray-700 border border-gray-300", - }; - }, [selectedScenarioId, scenarios]); - - return ( - <div className="flex items-center gap-4"> - <ScenarioSelector - scenarios={scenarios} - selected={selectedScenarioId} - onChange={handleSelect} - /> - - <div className="flex items-center gap-3"> - <div - className={` - inline-flex items-center rounded-full px-3 py-1 text-xs font-medium - ${selectionMeta.className} - `} - > - {selectionMeta.label} - </div> - - <span className="text-xs text-gray-400"> - {selectionMeta.description} - </span> - </div> - </div> - ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts deleted file mode 100644 index abec170d..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -// src/server/reports/types.ts - -export type AverageMetrics = { - avg_sap: number | null; - avg_carbon: number | null; - avg_bills: number | null; - avg_energy_consumption: number | null; -}; - -export type TotalMetrics = { - total_carbon: number | null; - total_bills: number | null; -}; - -export type AgeBandCount = { - age_band: string; - count: number; -}; - -export type EpcBandCount = { - epc: string; - actual: number; - estimated: number; -}; - -/** - * Whether a home has any certificate at all — lodged or historical. - * NOT the same question as `estimated` (was the picture modelled?): a home - * conditioned on an expired certificate is estimated but is not withoutEpc. - */ -export type EpcCoverageCounts = { - withoutEpc: number; - withEpc: number; -}; - -export type PropertyTypeCount = { - type: string | null; - count: number; -}; - -export interface BaselineMetrics { - total: number; - averages: AverageMetrics; - totals: TotalMetrics; - ageBands: AgeBandCount[]; - epcBands: EpcBandCount[]; - epcCoverage: EpcCoverageCounts; - expiredEpcs: number; - likelyDowngrades: number; -} - -export type MetricKey = - | "totalHomes" - | "avgSap" - | "avgCarbon" - | "avgBills" - | "missingEpc"; - -export interface ScenarioSummary { - id: number; - name: string; -} - -export interface ScenarioOverlayMetrics { - avgSap?: { - baseline: number; - scenario: number; - }; - - avgCarbon?: { - baseline: number; - scenario: number; - baselineTotal: number; - scenarioTotal: number; - }; - - avgBills?: { - baseline: number; - scenario: number; - baselineTotal: number; - scenarioTotal: number; - }; - - valuation?: { - baseline: number | null; - scenario: number | null; - }; -} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/utils.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/utils.ts deleted file mode 100644 index 8b137891..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/utils.ts +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/utils.ts b/src/app/utils.ts index 2c42a139..e44178d2 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -1,5 +1,6 @@ import { Rating } from "./db/schema/property"; import { KeyboardEvent } from "react"; +import { sapToBand } from "@/lib/epc/thresholds"; export function handleNumericKeyDown(event: KeyboardEvent<HTMLInputElement>) { /** @@ -106,30 +107,11 @@ export const serializeBigInt = (_: any, value: any): string | any => { return typeof value === "bigint" ? value.toString() : value; }; +// The SAP→band ladder lives in @/lib/epc/thresholds (the single source of +// truth, shared with the SQL bucketing). This keeps the historic name/return +// type callers already depend on. export function sapToEpc(sapPoints: number | null): string { - if (sapPoints === null) { - return "Unknown"; - } - - if (sapPoints < 0) { - throw new Error("SAP points should be above 0."); - } - - if (sapPoints >= 92) { - return "A"; - } else if (sapPoints >= 81) { - return "B"; - } else if (sapPoints >= 69) { - return "C"; - } else if (sapPoints >= 55) { - return "D"; - } else if (sapPoints >= 39) { - return "E"; - } else if (sapPoints >= 21) { - return "F"; - } else { - return "G"; - } + return sapToBand(sapPoints); } // Handles "D55" (new format) and "56" (legacy numeric-only format) diff --git a/src/app/utils/epc.ts b/src/app/utils/epc.ts index 6091fd84..8030c9a2 100644 --- a/src/app/utils/epc.ts +++ b/src/app/utils/epc.ts @@ -1,25 +1,6 @@ -export const EPC_TO_SAP_MIN: Record< - "A" | "B" | "C" | "D" | "E" | "F" | "G", - number -> = { - A: 92, - B: 81, - C: 69, - D: 55, - E: 39, - F: 21, - G: 0, -}; - -export const EPC_TO_SAP_MAX: Record< - "A" | "B" | "C" | "D" | "E" | "F" | "G", - number -> = { - A: 100, - B: 91, - C: 80, - D: 68, - E: 54, - F: 38, - G: 20, -}; +/** + * Re-export of the canonical SAP↔EPC range ladder. The single source of + * truth now lives in @/lib/epc/thresholds (derived from BAND_MIN_SAP); this + * path is kept so existing imports (`@/app/utils/epc`) keep working. + */ +export { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/lib/epc/thresholds"; diff --git a/src/lib/epc/bands.test.ts b/src/lib/epc/bands.test.ts new file mode 100644 index 00000000..805bb82c --- /dev/null +++ b/src/lib/epc/bands.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { EPC_BANDS, GOALS } from "./bands"; +import { EPC_BANDS as SCENARIO_BANDS } from "@/lib/scenarios/model"; +import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; + +// bands.ts is a schema-free mirror so client bundles don't drag in the +// Postgres driver. These guard it against drifting from the real sources. +describe("epc/bands mirror stays in sync with the schema", () => { + it("matches the canonical EPC band order", () => { + expect([...EPC_BANDS]).toEqual([...SCENARIO_BANDS]); + }); + + it("mirrors the portfolio goal strings", () => { + expect(GOALS.EPC).toBe(PORTFOLIO_GOALS.EPC); + expect(GOALS.CO2).toBe(PORTFOLIO_GOALS.CO2); + expect(GOALS.ENERGY).toBe(PORTFOLIO_GOALS.ENERGY); + expect(GOALS.VALUATION).toBe(PORTFOLIO_GOALS.VALUATION); + expect(GOALS.NONE).toBe(PORTFOLIO_GOALS.NONE); + }); +}); diff --git a/src/lib/epc/bands.ts b/src/lib/epc/bands.ts new file mode 100644 index 00000000..e2ca30d9 --- /dev/null +++ b/src/lib/epc/bands.ts @@ -0,0 +1,19 @@ +/** + * EPC bands and portfolio goal strings as pure constants — no Drizzle + * schema, no db connection. Client bundles (the reporting UI) import from + * here so they don't drag the Postgres driver in via the schema modules. + * The schema remains the source of truth for the *stored* enum; these + * mirror its values and are guarded against drift by a test. + */ + +export const EPC_BANDS = ["A", "B", "C", "D", "E", "F", "G"] as const; +export type EpcBand = (typeof EPC_BANDS)[number]; + +/** Mirrors PORTFOLIO_GOALS in @/app/db/schema/portfolio (drift-tested). */ +export const GOALS = { + EPC: "Increasing EPC", + CO2: "Reducing CO2 emissions", + ENERGY: "Energy Savings", + VALUATION: "Valuation Improvement", + NONE: "None", +} as const; diff --git a/src/lib/epc/labels.ts b/src/lib/epc/labels.ts new file mode 100644 index 00000000..c700820c --- /dev/null +++ b/src/lib/epc/labels.ts @@ -0,0 +1,22 @@ +/** + * Human labels for EPC code enums — pure display constants, no db. Client + * bundles import from here; epcSources re-exports them so existing + * server-side imports are unchanged. + */ + +export const PROPERTY_TYPE_LABELS: Record<string, string> = { + "0": "House", + "1": "Bungalow", + "2": "Flat", + "3": "Maisonette", + "4": "Park home", +}; + +export const BUILT_FORM_LABELS: Record<string, string> = { + "1": "Detached", + "2": "Semi-Detached", + "3": "End-Terrace", + "4": "Mid-Terrace", + "5": "Enclosed End-Terrace", + "6": "Enclosed Mid-Terrace", +}; diff --git a/src/lib/epc/thresholds.test.ts b/src/lib/epc/thresholds.test.ts new file mode 100644 index 00000000..a12bb89d --- /dev/null +++ b/src/lib/epc/thresholds.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + BAND_MIN_SAP, + EPC_TO_SAP_MIN, + EPC_TO_SAP_MAX, + sapToBand, +} from "./thresholds"; +import { EPC_BANDS } from "./bands"; + +describe("sapToBand", () => { + it("maps each band's floor to that band", () => { + expect(sapToBand(92)).toBe("A"); + expect(sapToBand(81)).toBe("B"); + expect(sapToBand(69)).toBe("C"); + expect(sapToBand(55)).toBe("D"); + expect(sapToBand(39)).toBe("E"); + expect(sapToBand(21)).toBe("F"); + expect(sapToBand(0)).toBe("G"); + }); + + it("maps one below a floor to the worse band", () => { + expect(sapToBand(91)).toBe("B"); + expect(sapToBand(68)).toBe("D"); + expect(sapToBand(20)).toBe("G"); + }); + + it("returns Unknown for null and throws for negatives", () => { + expect(sapToBand(null)).toBe("Unknown"); + expect(() => sapToBand(-1)).toThrow(); + }); + + it("reproduces the historic sapToEpc ladder for every integer 0–100", () => { + const legacy = (sap: number): string => { + if (sap >= 92) return "A"; + if (sap >= 81) return "B"; + if (sap >= 69) return "C"; + if (sap >= 55) return "D"; + if (sap >= 39) return "E"; + if (sap >= 21) return "F"; + return "G"; + }; + for (let sap = 0; sap <= 100; sap++) { + expect(sapToBand(sap)).toBe(legacy(sap)); + } + }); +}); + +describe("range ladder", () => { + it("EPC_TO_SAP_MIN aliases the canonical floors", () => { + expect(EPC_TO_SAP_MIN).toBe(BAND_MIN_SAP); + }); + + it("EPC_TO_SAP_MAX is one below the next-better floor (A capped at 100)", () => { + expect(EPC_TO_SAP_MAX).toEqual({ + A: 100, + B: 91, + C: 80, + D: 68, + E: 54, + F: 38, + G: 20, + }); + }); + + it("every band's range is contiguous with the next", () => { + EPC_BANDS.forEach((band, i) => { + if (i === 0) return; + expect(EPC_TO_SAP_MAX[band] + 1).toBe(BAND_MIN_SAP[EPC_BANDS[i - 1]]); + }); + }); +}); diff --git a/src/lib/epc/thresholds.ts b/src/lib/epc/thresholds.ts new file mode 100644 index 00000000..4033d89e --- /dev/null +++ b/src/lib/epc/thresholds.ts @@ -0,0 +1,53 @@ +/** + * The SAP → EPC band threshold ladder — the single source of truth for + * "what SAP score is band C". A band owns every SAP point from its own + * minimum up to (not including) the next-better band's minimum; A has no + * upper bound (capped at 100 for range display), G runs down to 0. These + * are the government RdSAP band boundaries. + * + * Every SAP↔band mapping in the app derives from `BAND_MIN_SAP`: the client + * `sapToBand`, the `EPC_TO_SAP_MIN`/`EPC_TO_SAP_MAX` display ranges here, and + * the SQL bucket fragment in `./thresholdsSql`. A re-scoring changes this one + * table and nothing else. Pure — no Drizzle, client-safe (see ./bands). + */ + +import { EPC_BANDS, type EpcBand } from "./bands"; + +/** Inclusive SAP floor per band — the ladder every other mapping derives from. */ +export const BAND_MIN_SAP: Record<EpcBand, number> = { + A: 92, + B: 81, + C: 69, + D: 55, + E: 39, + F: 21, + G: 0, +}; + +/** Inclusive SAP range floor per band — alias of BAND_MIN_SAP for range math. */ +export const EPC_TO_SAP_MIN = BAND_MIN_SAP; + +/** + * Inclusive SAP range ceiling per band, derived from the ladder: one below + * the next-better band's floor (A capped at 100). Never hand-maintained. + */ +export const EPC_TO_SAP_MAX: Record<EpcBand, number> = Object.fromEntries( + EPC_BANDS.map((band, i) => [ + band, + i === 0 ? 100 : BAND_MIN_SAP[EPC_BANDS[i - 1]] - 1, + ]), +) as Record<EpcBand, number>; + +/** + * Maps a SAP score to its EPC band. `null` → "Unknown"; a negative score + * throws (SAP is never negative — surface the bad datum rather than bucket + * it into G). Mirrored in SQL by `sapBandBucketsSql`. + */ +export function sapToBand(sapPoints: number | null): EpcBand | "Unknown" { + if (sapPoints === null) return "Unknown"; + if (sapPoints < 0) throw new Error("SAP points should be above 0."); + for (const band of EPC_BANDS) { + if (sapPoints >= BAND_MIN_SAP[band]) return band; + } + return "G"; +} diff --git a/src/lib/epc/thresholdsSql.ts b/src/lib/epc/thresholdsSql.ts new file mode 100644 index 00000000..df3acd03 --- /dev/null +++ b/src/lib/epc/thresholdsSql.ts @@ -0,0 +1,49 @@ +/** + * SQL twin of `sapToBand` — the same threshold ladder (BAND_MIN_SAP), + * expressed as conditional-aggregation columns so the scenario-overlay + * queries can bucket a whole portfolio's SAP scores into EPC bands in one + * pass. Server-only (imports Drizzle); the pure ladder lives in ./thresholds + * so client bundles never pull this in. + */ + +import { sql, type SQL } from "drizzle-orm"; +import { EPC_BANDS } from "./bands"; +import { BAND_MIN_SAP } from "./thresholds"; + +/** + * Seven `COUNT(*) FILTER (…)` columns bucketing `sapExpr` into EPC bands, + * aliased `band_a … band_g`, plus `band_unknown` for NULL SAP. Derived from + * BAND_MIN_SAP so the SQL and the TS `sapToBand` can never drift. + * + * `sapExpr` must be a bare column/expression already in scope (e.g. + * `sql\`band_sap\``), not a subquery. + */ +export function sapBandBucketsSql(sapExpr: SQL): SQL { + const bandCols = EPC_BANDS.map((band, i) => { + const min = BAND_MIN_SAP[band]; + const alias = sql.raw(`band_${band.toLowerCase()}`); + + // Best band (A): open-topped, everything at or above its floor. + if (i === 0) { + return sql`COUNT(*) FILTER (WHERE ${sapExpr} >= ${min})::int AS ${alias}`; + } + + const upper = BAND_MIN_SAP[EPC_BANDS[i - 1]]; + + // Worst band (G): its floor is 0, so guard NULL explicitly rather than + // relying on the `>= 0` comparison to exclude it. + if (i === EPC_BANDS.length - 1) { + return sql`COUNT(*) FILTER (WHERE ${sapExpr} IS NOT NULL AND ${sapExpr} < ${upper})::int AS ${alias}`; + } + + return sql`COUNT(*) FILTER (WHERE ${sapExpr} >= ${min} AND ${sapExpr} < ${upper})::int AS ${alias}`; + }); + + return sql.join( + [ + ...bandCols, + sql`COUNT(*) FILTER (WHERE ${sapExpr} IS NULL)::int AS band_unknown`, + ], + sql`,\n `, + ); +} diff --git a/src/lib/reporting/measures.test.ts b/src/lib/reporting/measures.test.ts new file mode 100644 index 00000000..cf3a7e2b --- /dev/null +++ b/src/lib/reporting/measures.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { groupMeasures, measureCategory, measureLabel } from "./measures"; + +describe("measureCategory", () => { + it("maps known measure types to their category", () => { + expect(measureCategory("cavity_wall_insulation")).toBe("Wall insulation"); + expect(measureCategory("solar_pv")).toBe("Solar"); + expect(measureCategory("air_source_heat_pump")).toBe("Heating"); + }); + + it("falls back to Other for unknown measure types", () => { + expect(measureCategory("mystery_measure")).toBe("Other"); + }); +}); + +describe("measureLabel", () => { + it("title-cases a snake_case measure type", () => { + expect(measureLabel("air_source_heat_pump")).toBe("Air Source Heat Pump"); + }); +}); + +describe("groupMeasures", () => { + const measures = [ + { measureType: "loft_insulation", homesCount: 146, totalCost: 186_000, averageCost: 1274 }, + { measureType: "solar_pv", homesCount: 121, totalCost: 684_000, averageCost: 5653 }, + { measureType: "cavity_wall_insulation", homesCount: 78, totalCost: 349_000, averageCost: 4474 }, + { measureType: "external_wall_insulation", homesCount: 14, totalCost: 63_000, averageCost: 4500 }, + ]; + + it("orders categories by spend, biggest first", () => { + const groups = groupMeasures(measures); + expect(groups.map((g) => g.category)).toEqual([ + "Solar", + "Wall insulation", + "Roof insulation", + ]); + }); + + it("rolls up cost and homes within a category", () => { + const wall = groupMeasures(measures).find( + (g) => g.category === "Wall insulation", + )!; + expect(wall.costTotal).toBe(412_000); + expect(wall.homesTotal).toBe(92); + // rows ordered by spend within the category + expect(wall.rows.map((r) => r.measureType)).toEqual([ + "cavity_wall_insulation", + "external_wall_insulation", + ]); + }); +}); diff --git a/src/lib/reporting/measures.ts b/src/lib/reporting/measures.ts new file mode 100644 index 00000000..aa061dd5 --- /dev/null +++ b/src/lib/reporting/measures.ts @@ -0,0 +1,97 @@ +/** + * Measure grouping for the reporting "where the money goes" allocation and + * the measures shelf (PRD #370). Pure — the category taxonomy and the + * roll-up, with no data access. Moved out of the retired + * ScenarioMeasuresModal so it survives that component's deletion. + */ + +export type MeasureCategory = + | "Wall insulation" + | "Roof insulation" + | "Floor insulation" + | "Ventilation & airtightness" + | "Windows & glazing" + | "Solar" + | "Heating" + | "Heating controls" + | "Lighting" + | "Scaffolding & enabling works" + | "Other"; + +export const MEASURE_CATEGORY_MAP: Record<string, MeasureCategory> = { + internal_wall_insulation: "Wall insulation", + external_wall_insulation: "Wall insulation", + cavity_wall_insulation: "Wall insulation", + cavity_wall_extraction: "Wall insulation", + loft_insulation: "Roof insulation", + flat_roof_insulation: "Roof insulation", + room_roof_insulation: "Roof insulation", + suspended_floor_insulation: "Floor insulation", + solid_floor_insulation: "Floor insulation", + exposed_floor_insulation: "Floor insulation", + mechanical_ventilation: "Ventilation & airtightness", + trickle_vent: "Ventilation & airtightness", + door_undercut: "Ventilation & airtightness", + sealing_fireplace: "Ventilation & airtightness", + windows_glazing: "Windows & glazing", + double_glazing: "Windows & glazing", + secondary_glazing: "Windows & glazing", + solar_pv: "Solar", + solar_battery: "Solar", + air_source_heat_pump: "Heating", + boiler_upgrade: "Heating", + high_heat_retention_storage_heaters: "Heating", + roomstat_programmer_trvs: "Heating controls", + time_temperature_zone_control: "Heating controls", + low_energy_lighting_installation: "Lighting", + scaffolding: "Scaffolding & enabling works", +}; + +export function measureCategory(measureType: string): MeasureCategory { + return MEASURE_CATEGORY_MAP[measureType] ?? "Other"; +} + +export function measureLabel(measureType: string): string { + return measureType + .replaceAll("_", " ") + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export interface Measure { + measureType: string; + homesCount: number; + totalCost: number; + averageCost: number; +} + +export interface MeasureGroup { + category: MeasureCategory; + rows: Measure[]; + homesTotal: number; + costTotal: number; +} + +/** + * Rolls measures up by category, ordered by spend (biggest first), rows + * within a category also by spend. Homes are summed as installations — + * a home appearing under two measures counts in both, matching how the + * allocation bar reads ("N installations"). + */ +export function groupMeasures(measures: Measure[]): MeasureGroup[] { + const byCategory = new Map<MeasureCategory, Measure[]>(); + for (const m of measures) { + const cat = measureCategory(m.measureType); + if (!byCategory.has(cat)) byCategory.set(cat, []); + byCategory.get(cat)!.push(m); + } + + return Array.from(byCategory.entries()) + .map(([category, rows]) => ({ + category, + rows: [...rows].sort((a, b) => b.totalCost - a.totalCost), + homesTotal: rows.reduce((s, r) => s + r.homesCount, 0), + costTotal: rows.reduce((s, r) => s + r.totalCost, 0), + })) + .sort((a, b) => b.costTotal - a.costTotal); +} diff --git a/src/lib/reporting/model.test.ts b/src/lib/reporting/model.test.ts new file mode 100644 index 00000000..1e4da15a --- /dev/null +++ b/src/lib/reporting/model.test.ts @@ -0,0 +1,452 @@ +import { describe, expect, it } from "vitest"; +import { + amountSaved, + buildHeadline, + capitalOutlay, + carsOffTheRoad, + classifyBandMovement, + computeLedger, + costPerCarbonSaved, + costPerSapPoint, + deriveLedgerView, + isCompliantBeyondWindow, + pickBestIndex, + selectGoalCallout, + shapeKpiDelta, + toBandCounts, + TONNES_CO2_PER_CAR_PER_YEAR, + type ScenarioOverlay, +} from "./model"; + +describe("computeLedger", () => { + it("computes all-in gross and net from scenario aggregates", () => { + // CONTEXT.md: gross = construction + project delivery + contingency; + // net = gross − funding. Delivery is the disclosed 30% assumption. + expect( + computeLedger({ + constructionCost: 1_000_000, + contingency: 100_000, + funding: 200_000, + homesUpgraded: 100, + }), + ).toEqual({ + constructionCost: 1_000_000, + projectDelivery: 300_000, + contingency: 100_000, + funding: 200_000, + grossCost: 1_400_000, + netCost: 1_200_000, + grossPerHome: 14_000, + }); + }); + + it("reports zero gross-per-home when nothing is upgraded, not a division blow-up", () => { + const ledger = computeLedger({ + constructionCost: 0, + contingency: 0, + funding: 0, + homesUpgraded: 0, + }); + expect(ledger.grossPerHome).toBe(0); + expect(ledger.grossCost).toBe(0); + expect(ledger.netCost).toBe(0); + }); + + it("lets funding exceed gross — a surplus reads as negative net, never clamped", () => { + const ledger = computeLedger({ + constructionCost: 100_000, + contingency: 0, + funding: 200_000, + homesUpgraded: 10, + }); + expect(ledger.grossCost).toBe(130_000); + expect(ledger.netCost).toBe(-70_000); + }); +}); + +describe("value-for-money helpers", () => { + it("amountSaved treats nulls as zero", () => { + expect(amountSaved(100, 60)).toBe(40); + expect(amountSaved(null, 60)).toBe(-60); + expect(amountSaved(100, null)).toBe(100); + expect(amountSaved(null, null)).toBe(0); + }); + + it("capitalOutlay is construction + project delivery only", () => { + expect(capitalOutlay({ construction_cost: 1000, pc_cost: 300 })).toBe(1300); + }); + + it("costPerSapPoint is null when there was no positive uplift", () => { + expect(costPerSapPoint(1300, 10)).toBe(130); + expect(costPerSapPoint(1300, 0)).toBeNull(); + expect(costPerSapPoint(1300, -5)).toBeNull(); + }); + + it("costPerCarbonSaved is null when nothing was saved", () => { + expect(costPerCarbonSaved(1300, 13)).toBe(100); + expect(costPerCarbonSaved(1300, 0)).toBeNull(); + expect(costPerCarbonSaved(1300, -2)).toBeNull(); + }); +}); + +describe("deriveLedgerView", () => { + const overlay: ScenarioOverlay = { + avg_sap: "72.0", + avg_carbon: 2, + avg_bills: 900, + total_carbon: 200, + total_bills: 90000, + n_units: 100, + n_units_upgraded: 50, + construction_cost: 100000, + contingency: 10000, + total_funding: 40000, + gross_cost: 140000, + net_cost: 100000, + total_sap_uplift: 500, + gross_per_unit: 2800, + scenario_epc_counts: { C: 100 }, + pc_cost: 30000, + }; + + it("derives savings, per-home and value-for-money figures", () => { + const v = deriveLedgerView(overlay, { + totalCarbon: 260, + totalBills: 120000, + }); + // capital = construction + project delivery = 130000 + expect(v.carbonSaved).toBe(60); // 260 - 200 + expect(v.billSavings).toBe(30000); // 120000 - 90000 + expect(v.billSavingsPerHome).toBe(600); // 30000 / 50 + expect(v.costPerSap).toBe(260); // 130000 / 500 + expect(v.costPerCarbon).toBe(130000 / 60); + // passthrough ledger figures + expect(v.grossCost).toBe(140000); + expect(v.netCost).toBe(100000); + expect(v.grossPerHome).toBe(2800); + }); + + it("guards divide-by-zero: not-applicable ratios read as 0, no homes → /1", () => { + const v = deriveLedgerView( + { ...overlay, n_units_upgraded: 0, total_sap_uplift: 0 }, + { totalCarbon: 200, totalBills: 90000 }, + ); + expect(v.billSavingsPerHome).toBe(0); // billSaved 0 / 1 + expect(v.costPerSap).toBe(0); // no uplift + expect(v.costPerCarbon).toBe(0); // nothing saved + }); +}); + +describe("isCompliantBeyondWindow", () => { + const window = { band: "C", date: new Date("2030-01-01") } as const; + + it("skips a lodged home at the window band whose certificate outlives the window", () => { + expect( + isCompliantBeyondWindow( + { + provenance: "lodged", + lodgedBand: "C", + expiryDate: new Date("2031-06-15"), + }, + window, + ), + ).toBe(true); + }); + + it("keeps a home below the window band even with a long-lived certificate", () => { + expect( + isCompliantBeyondWindow( + { + provenance: "lodged", + lodgedBand: "D", + expiryDate: new Date("2033-01-01"), + }, + window, + ), + ).toBe(false); + }); + + it("never skips a predicted home — mirrored lodged estimates are not a certificate", () => { + // CONTEXT.md (EPC provenance): predicted homes carry lodged_* columns + // with mirrored estimates, so band + expiry alone must not qualify them. + expect( + isCompliantBeyondWindow( + { + provenance: "predicted", + lodgedBand: "B", + expiryDate: new Date("2035-01-01"), + }, + window, + ), + ).toBe(false); + }); + + it("keeps a home whose certificate expires exactly on the window date", () => { + expect( + isCompliantBeyondWindow( + { + provenance: "lodged", + lodgedBand: "B", + expiryDate: new Date("2030-01-01"), + }, + window, + ), + ).toBe(false); + }); + + it("keeps a lodged home with no expiry on record", () => { + expect( + isCompliantBeyondWindow( + { provenance: "lodged", lodgedBand: "B", expiryDate: null }, + window, + ), + ).toBe(false); + }); + + it("skips at better-than-window bands, using a custom window", () => { + expect( + isCompliantBeyondWindow( + { + provenance: "lodged", + lodgedBand: "A", + expiryDate: new Date("2032-07-01"), + }, + { band: "B", date: new Date("2031-12-31") }, + ), + ).toBe(true); + }); +}); + +describe("classifyBandMovement", () => { + // CONTEXT.md: Likely downgrade / Likely upgrade are band-movement signals + // between Lodged and Effective. SAP drift within a band never qualifies — + // structurally so: the classifier only sees bands. + + it("flags a likely downgrade when the certificate flatters the home", () => { + expect( + classifyBandMovement({ + provenance: "lodged", + lodgedBand: "C", + effectiveBand: "D", + }), + ).toBe("likely-downgrade"); + }); + + it("flags a likely upgrade when the modelled picture beats the certificate", () => { + expect( + classifyBandMovement({ + provenance: "lodged", + lodgedBand: "D", + effectiveBand: "C", + }), + ).toBe("likely-upgrade"); + }); + + it("reports no movement when lodged and effective agree on the band", () => { + expect( + classifyBandMovement({ + provenance: "lodged", + lodgedBand: "D", + effectiveBand: "D", + }), + ).toBe("none"); + }); + + it("never signals a predicted home — no certificate, nothing to re-survey against", () => { + expect( + classifyBandMovement({ + provenance: "predicted", + lodgedBand: "D", + effectiveBand: "C", + }), + ).toBe("none"); + }); + + it("stays silent when either band is missing", () => { + expect( + classifyBandMovement({ + provenance: "lodged", + lodgedBand: null, + effectiveBand: "C", + }), + ).toBe("none"); + expect( + classifyBandMovement({ + provenance: "lodged", + lodgedBand: "C", + effectiveBand: null, + }), + ).toBe("none"); + }); +}); + +describe("pickBestIndex", () => { + // Compare view (Screen D): mark the best value in each row without + // crowning an overall winner. Nulls (baseline / missing) never win. + + it("picks the lowest for a lower-is-better row", () => { + expect(pickBestIndex([null, 50, 74, 103], "lower")).toBe(1); + }); + + it("picks the highest for a higher-is-better row", () => { + expect(pickBestIndex([null, 312, 368, 219], "higher")).toBe(2); + }); + + it("returns null when every comparable value is missing", () => { + expect(pickBestIndex([null, null], "lower")).toBeNull(); + }); + + it("ignores nulls rather than treating them as zero", () => { + expect(pickBestIndex([null, null, 5], "lower")).toBe(2); + }); +}); + +describe("toBandCounts", () => { + it("totals actual + estimated per band into a plain record", () => { + expect( + toBandCounts([ + { epc: "C", actual: 68, estimated: 12 }, + { epc: "D", actual: 104, estimated: 24 }, + { epc: "Unknown", actual: 6, estimated: 0 }, + ]), + ).toEqual({ C: 80, D: 128, Unknown: 6 }); + }); +}); + +describe("selectGoalCallout", () => { + // Grilling decision: current stock follows portfolio.goal; the scenario + // view follows the Scenario's goal. EPC goals at portfolio level default + // to band C (the portfolio carries no band value). + const bandCounts = { B: 14, C: 92, D: 128, E: 64, F: 18, G: 8, Unknown: 6 }; + + it("gives an EPC-goal portfolio its homes-below-band count on current stock", () => { + expect( + selectGoalCallout({ + view: "current-stock", + goal: "Increasing EPC", + bandCounts, + }), + ).toEqual({ kind: "below-band", band: "C", count: 218 }); + }); + + it("gives a CO2-goal portfolio its dimension with no compliance framing", () => { + expect( + selectGoalCallout({ + view: "current-stock", + goal: "Reducing CO2 emissions", + bandCounts, + }), + ).toEqual({ kind: "dimension-total", dimension: "carbon" }); + }); + + it("falls back to the sector's below-C lens when the portfolio goal is None", () => { + expect( + selectGoalCallout({ view: "current-stock", goal: "None", bandCounts }), + ).toEqual({ kind: "below-band", band: "C", count: 218 }); + }); + + it("shows below-band movement for an EPC scenario, at the scenario's own target band", () => { + expect( + selectGoalCallout({ + view: "scenario", + goal: "Increasing EPC", + goalValue: "B", + bandCounts, + scenarioBandCounts: { B: 120, C: 150, D: 30, E: 12, Unknown: 6 }, + }), + ).toEqual({ + kind: "below-band-movement", + band: "B", + before: 310, // C..G below B + after: 192, + }); + }); + + it("shows dimension movement for a value-less scenario goal", () => { + expect( + selectGoalCallout({ + view: "scenario", + goal: "Energy Savings", + goalValue: null, + bandCounts, + scenarioBandCounts: {}, + }), + ).toEqual({ kind: "dimension-movement", dimension: "energy" }); + }); +}); + +describe("carsOffTheRoad", () => { + it("converts annual tonnes saved into whole cars via the named factor", () => { + expect(carsOffTheRoad(312)).toBe( + Math.round(312 / TONNES_CO2_PER_CAR_PER_YEAR), + ); + expect(Number.isInteger(carsOffTheRoad(312))).toBe(true); + }); + + it("reports zero cars for zero or negative savings", () => { + expect(carsOffTheRoad(0)).toBe(0); + expect(carsOffTheRoad(-5)).toBe(0); + }); +}); + +describe("shapeKpiDelta", () => { + it("treats a drop as an improvement for lower-is-better metrics", () => { + expect( + shapeKpiDelta({ current: 1284, after: 941, improvesWhenLower: true }), + ).toEqual({ delta: -343, improved: true }); + }); + + it("treats a rise as an improvement for higher-is-better metrics", () => { + expect( + shapeKpiDelta({ current: 58, after: 71, improvesWhenLower: false }), + ).toEqual({ delta: 13, improved: true }); + }); + + it("reports no improvement when nothing moved", () => { + expect( + shapeKpiDelta({ current: 100, after: 100, improvesWhenLower: true }), + ).toEqual({ delta: 0, improved: false }); + }); +}); + +describe("buildHeadline", () => { + it("tells an EPC scenario's story in one board-quotable sentence", () => { + expect( + buildHeadline({ + goal: "Increasing EPC", + goalValue: "C", + homesUpgraded: 218, + netCost: 2_477_000, + carbonSavedPerYear: 312, + }), + ).toBe( + "Reaching EPC C across 218 homes costs £2.48m net and cuts carbon by 312 tonnes a year — like taking 173 cars off the road.", + ); + }); + + it("leads with carbon for a CO2 scenario", () => { + expect( + buildHeadline({ + goal: "Reducing CO2 emissions", + goalValue: null, + homesUpgraded: 246, + netCost: 1_948_000, + carbonSavedPerYear: 368, + }), + ).toBe( + "Cutting carbon by 368 tonnes a year across 246 homes costs £1.95m net — like taking 204 cars off the road.", + ); + }); + + it("drops the car clause when no carbon is saved", () => { + expect( + buildHeadline({ + goal: "Valuation Improvement", + goalValue: null, + homesUpgraded: 187, + netCost: 850_000, + carbonSavedPerYear: 0, + }), + ).toBe("Improving valuation across 187 homes costs £850k net."); + }); +}); diff --git a/src/lib/reporting/model.ts b/src/lib/reporting/model.ts new file mode 100644 index 00000000..f688ef6e --- /dev/null +++ b/src/lib/reporting/model.ts @@ -0,0 +1,445 @@ +/** + * Reporting domain model — the pure logic behind the Reporting page. + * + * See CONTEXT.md (Reporting: Gross cost, Net cost, Project delivery, + * Current stock; Baseline performance: Likely downgrade, Likely upgrade) + * and ADR-0010 (the compliance-window filter is a report-view parameter). + * PRD: issue #370. + */ + +import { EPC_BANDS, GOALS, type EpcBand } from "@/lib/epc/bands"; + +/** + * Project delivery is not yet a data point: it is estimated as a fixed + * share of construction works. Disclosed wherever gross cost is shown. + */ +export const PROJECT_DELIVERY_RATE = 0.3; + +export interface LedgerInput { + constructionCost: number; + contingency: number; + funding: number; + homesUpgraded: number; +} + +export interface Ledger { + constructionCost: number; + projectDelivery: number; + contingency: number; + funding: number; + grossCost: number; + netCost: number; + grossPerHome: number; +} + +/** + * The Scenario overlay — the "After scenario" read model that every reporting + * surface consumes (the client metrics body, the Compare table, the PDF). This + * is the wire contract of the scenario-metrics endpoints: keep the field names + * in lockstep with getScenarioOverlay (the server read model) and its readers. + * + * `avg_sap` is a pre-rounded string (`toFixed(1)`) or null — the one field the + * server formats rather than the client. Costs are the canonical ledger figures + * (CONTEXT.md: Gross cost, Net cost, Project delivery). `scenario_epc_counts` + * is the after-scenario band distribution keyed A…G plus "Unknown". + */ +export interface ScenarioOverlay { + avg_sap: string | null; + avg_carbon: number | null; + avg_bills: number | null; + total_carbon: number | null; + total_bills: number | null; + n_units: number; + n_units_upgraded: number; + construction_cost: number; + contingency: number; + total_funding: number; + gross_cost: number; + net_cost: number; + total_sap_uplift: number; + gross_per_unit: number; + scenario_epc_counts: Record<string, number>; + pc_cost: number; +} + +export function computeLedger(input: LedgerInput): Ledger { + const projectDelivery = input.constructionCost * PROJECT_DELIVERY_RATE; + const grossCost = input.constructionCost + projectDelivery + input.contingency; + + return { + constructionCost: input.constructionCost, + projectDelivery, + contingency: input.contingency, + funding: input.funding, + grossCost, + netCost: grossCost - input.funding, + grossPerHome: input.homesUpgraded > 0 ? grossCost / input.homesUpgraded : 0, + }; +} + +/* ------------------------------------------------------------------ + Ledger view — the derived "value for money" figures every scenario + surface renders (the metrics body, the PDF, the Compare table). These + turn a ScenarioOverlay + the current-stock totals into the figures the + InvestmentLedger reads; previously hand-derived in each of the three + surfaces, where they could silently disagree. +------------------------------------------------------------------ */ + +export interface LedgerView { + constructionCost: number; + projectDelivery: number; + contingency: number; + funding: number; + grossCost: number; + netCost: number; + grossPerHome: number; + billSavings: number; + billSavingsPerHome: number; + carbonSaved: number; + costPerSap: number; + costPerCarbon: number; +} + +/** A total's improvement vs the current-stock baseline (nulls read as 0). */ +export function amountSaved( + baselineTotal: number | null, + afterTotal: number | null, +): number { + return (baselineTotal ?? 0) - (afterTotal ?? 0); +} + +/** + * Capital outlay — construction works + project delivery. The numerator of + * the £/SAP and £/CO₂ value-for-money ratios (contingency and funding sit + * outside it). + */ +export function capitalOutlay(overlay: { + construction_cost: number; + pc_cost: number; +}): number { + return overlay.construction_cost + overlay.pc_cost; +} + +/** Cost per SAP point gained; null when there was no positive uplift. */ +export function costPerSapPoint( + capital: number, + sapUplift: number, +): number | null { + return sapUplift > 0 ? capital / sapUplift : null; +} + +/** Cost per tonne of CO₂ saved per year; null when nothing was saved. */ +export function costPerCarbonSaved( + capital: number, + carbonSaved: number, +): number | null { + return carbonSaved > 0 ? capital / carbonSaved : null; +} + +/** + * The full derived ledger for a scenario overlay against the current-stock + * baseline totals. The one home-count guard (`|| 1`) avoids a divide-by-zero + * in the per-home savings. Not-applicable ratios read as 0 here (the ledger + * renders a number); the Compare table keeps its own null-for-"—" rendering + * off the same `costPer*` helpers. + */ +export function deriveLedgerView( + overlay: ScenarioOverlay, + baseline: { totalCarbon: number | null; totalBills: number | null }, +): LedgerView { + const carbonSaved = amountSaved(baseline.totalCarbon, overlay.total_carbon); + const billSaved = amountSaved(baseline.totalBills, overlay.total_bills); + const homes = overlay.n_units_upgraded || 1; + const capital = capitalOutlay(overlay); + + return { + constructionCost: overlay.construction_cost, + projectDelivery: overlay.pc_cost, + contingency: overlay.contingency, + funding: overlay.total_funding, + grossCost: overlay.gross_cost, + netCost: overlay.net_cost, + grossPerHome: overlay.gross_per_unit, + billSavings: billSaved, + billSavingsPerHome: billSaved / homes, + carbonSaved, + costPerSap: costPerSapPoint(capital, overlay.total_sap_uplift) ?? 0, + costPerCarbon: costPerCarbonSaved(capital, carbonSaved) ?? 0, + }; +} + +/* ------------------------------------------------------------------ + Compliance window — ADR-0010 +------------------------------------------------------------------ */ + +export interface CertificateEvidence { + provenance: "lodged" | "predicted"; + lodgedBand: EpcBand | null; + expiryDate: Date | null; +} + +export interface ComplianceWindow { + band: EpcBand; + date: Date; +} + +/** A is better than B; lower index = better band. */ +function bandAtLeast(band: EpcBand, threshold: EpcBand): boolean { + return EPC_BANDS.indexOf(band) <= EPC_BANDS.indexOf(threshold); +} + +/** + * A home may be skipped from a scenario's works view iff its lodged + * certificate demonstrates the window band or better AND outlives the + * window date (ADR-0010). + */ +export function isCompliantBeyondWindow( + evidence: CertificateEvidence, + window: ComplianceWindow, +): boolean { + // A predicted home has no real certificate — its lodged_* values are + // mirrored estimates (CONTEXT.md, EPC provenance) and never qualify. + if (evidence.provenance !== "lodged") { + return false; + } + if (evidence.lodgedBand === null || evidence.expiryDate === null) { + return false; + } + return ( + bandAtLeast(evidence.lodgedBand, window.band) && + evidence.expiryDate.getTime() > window.date.getTime() + ); +} + +/* ------------------------------------------------------------------ + Band movement — Likely downgrade / Likely upgrade (CONTEXT.md) +------------------------------------------------------------------ */ + +export type BandMovement = "likely-downgrade" | "likely-upgrade" | "none"; + +export interface BandMovementInput { + provenance: "lodged" | "predicted"; + lodgedBand: EpcBand | null; + effectiveBand: EpcBand | null; +} + +/** + * Compares the certificate's band with the effective (modelled) band. + * Lodged above effective → a re-survey would likely certificate lower; + * effective above lodged → a re-survey alone would likely certificate + * higher. Band movement only — SAP drift within a band never qualifies. + */ +export function classifyBandMovement(input: BandMovementInput): BandMovement { + // No real certificate (predicted) or an incomplete picture → no signal. + if ( + input.provenance !== "lodged" || + input.lodgedBand === null || + input.effectiveBand === null + ) { + return "none"; + } + + const lodged = EPC_BANDS.indexOf(input.lodgedBand); + const effective = EPC_BANDS.indexOf(input.effectiveBand); + + if (lodged < effective) return "likely-downgrade"; + if (lodged > effective) return "likely-upgrade"; + return "none"; +} + +/* ------------------------------------------------------------------ + Goal-aware callout +------------------------------------------------------------------ */ + +/** EPC-goal portfolios carry no band value; C is the sector default. */ +export const DEFAULT_GOAL_BAND: EpcBand = "C"; + +export type GoalDimension = "carbon" | "energy" | "valuation"; + +export type GoalCallout = + | { kind: "below-band"; band: EpcBand; count: number } + | { kind: "dimension-total"; dimension: GoalDimension } + | { kind: "below-band-movement"; band: EpcBand; before: number; after: number } + | { kind: "dimension-movement"; dimension: GoalDimension }; + +const GOAL_DIMENSIONS: Partial<Record<string, GoalDimension>> = { + [GOALS.CO2]: "carbon", + [GOALS.ENERGY]: "energy", + [GOALS.VALUATION]: "valuation", +}; + +/** + * Index of the best value in a compare row (Screen D). Nulls — baseline + * columns and missing data — never win. Returns null when nothing is + * comparable, so no cell is marked. + */ +export function pickBestIndex( + values: (number | null)[], + direction: "lower" | "higher", +): number | null { + let bestIndex: number | null = null; + let best = direction === "lower" ? Infinity : -Infinity; + values.forEach((v, i) => { + if (v === null) return; + if (direction === "lower" ? v < best : v > best) { + best = v; + bestIndex = i; + } + }); + return bestIndex; +} + +/** Collapses baseline band rows (actual + estimated) into a plain band→count map. */ +export function toBandCounts( + bands: { epc: string; actual: number; estimated: number }[], +): Record<string, number> { + const counts: Record<string, number> = {}; + for (const b of bands) { + counts[b.epc] = (counts[b.epc] ?? 0) + b.actual + b.estimated; + } + return counts; +} + +/** Counts homes in bands strictly worse than the target; Unknown never counts. */ +export function countBelowBand( + bandCounts: Record<string, number>, + band: EpcBand, +): number { + return EPC_BANDS.filter((b) => EPC_BANDS.indexOf(b) > EPC_BANDS.indexOf(band)) + .reduce((sum, b) => sum + (bandCounts[b] ?? 0), 0); +} + +export type GoalCalloutInput = + | { + view: "current-stock"; + goal: string; + bandCounts: Record<string, number>; + } + | { + view: "scenario"; + goal: string; + goalValue: string | null; + bandCounts: Record<string, number>; + scenarioBandCounts: Record<string, number>; + }; + +export function selectGoalCallout(input: GoalCalloutInput): GoalCallout { + const dimension = GOAL_DIMENSIONS[input.goal]; + + if (input.view === "current-stock") { + if (dimension) { + return { kind: "dimension-total", dimension }; + } + return { + kind: "below-band", + band: DEFAULT_GOAL_BAND, + count: countBelowBand(input.bandCounts, DEFAULT_GOAL_BAND), + }; + } + + if (dimension) { + return { kind: "dimension-movement", dimension }; + } + const band = (input.goalValue as EpcBand) ?? DEFAULT_GOAL_BAND; + return { + kind: "below-band-movement", + band, + before: countBelowBand(input.bandCounts, band), + after: countBelowBand(input.scenarioBandCounts, band), + }; +} + +/* ------------------------------------------------------------------ + Carbon equivalents & KPI deltas +------------------------------------------------------------------ */ + +/** + * Average annual tailpipe CO₂ of a UK car (BEIS/DEFRA GHG conversion + * factors, average car at average mileage ≈ 1.8 tCO₂e/yr). Methodology + * pending sign-off (PRD #370) — cite the source wherever this renders. + */ +export const TONNES_CO2_PER_CAR_PER_YEAR = 1.8; + +export function carsOffTheRoad(tonnesPerYear: number): number { + if (tonnesPerYear <= 0) return 0; + return Math.round(tonnesPerYear / TONNES_CO2_PER_CAR_PER_YEAR); +} + +export interface KpiDelta { + delta: number; + improved: boolean; +} + +export function shapeKpiDelta(input: { + current: number; + after: number; + improvesWhenLower: boolean; +}): KpiDelta { + const delta = input.after - input.current; + const improved = input.improvesWhenLower ? delta < 0 : delta > 0; + return { delta, improved }; +} + +/* ------------------------------------------------------------------ + Board headline +------------------------------------------------------------------ */ + +/** + * £2,477,000 → "£2.48m"; £850,000 → "£850k"; £900 → "£900". The single + * compact money formatter for every reporting surface (headline, KPI tiles, + * the money-allocation bar) — re-exported from components/primitives as + * `moneyCompact`. + */ +export function formatMoneyCompact(amount: number): string { + const abs = Math.abs(amount); + const sign = amount < 0 ? "-" : ""; + if (abs >= 1_000_000) return `${sign}£${(abs / 1_000_000).toFixed(2)}m`; + if (abs >= 1_000) return `${sign}£${Math.round(abs / 1_000)}k`; + return `${sign}£${Math.round(abs)}`; +} + +/** £2,477,000 → "£2,477,000". The single full money formatter (ledger, PDF). */ +export function moneyFull(amount: number): string { + const sign = amount < 0 ? "−" : ""; + return `${sign}£${Math.abs(Math.round(amount)).toLocaleString("en-GB")}`; +} + +export interface HeadlineInput { + goal: string; + goalValue: string | null; + homesUpgraded: number; + netCost: number; + carbonSavedPerYear: number; +} + +/** + * One board-quotable sentence per scenario. Opens the scenario view and + * the PDF; keep it plain enough to read aloud. + */ +export function buildHeadline(input: HeadlineInput): string { + const money = formatMoneyCompact(input.netCost); + const tonnes = Math.round(input.carbonSavedPerYear); + const cars = carsOffTheRoad(input.carbonSavedPerYear); + const carClause = ` — like taking ${cars} cars off the road.`; + + if (input.goal === GOALS.EPC) { + const base = `Reaching EPC ${input.goalValue ?? DEFAULT_GOAL_BAND} across ${input.homesUpgraded} homes costs ${money} net`; + return tonnes > 0 + ? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}` + : `${base}.`; + } + + if (input.goal === GOALS.CO2) { + const base = `Cutting carbon by ${tonnes} tonnes a year across ${input.homesUpgraded} homes costs ${money} net`; + return tonnes > 0 ? `${base}${carClause}` : `${base}.`; + } + + const lead = + input.goal === GOALS.ENERGY + ? "Cutting energy use" + : "Improving valuation"; + const base = `${lead} across ${input.homesUpgraded} homes costs ${money} net`; + return tonnes > 0 + ? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}` + : `${base}.`; +} diff --git a/src/lib/reporting/overlay.ts b/src/lib/reporting/overlay.ts new file mode 100644 index 00000000..a9605e3b --- /dev/null +++ b/src/lib/reporting/overlay.ts @@ -0,0 +1,386 @@ +import "server-only"; +import { db } from "@/app/db/db"; +import { sql, type SQL } from "drizzle-orm"; +import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; +import { + newApproachJoins, + carbonSql, + billsSql, + effectiveSapSql, + effectiveEpcBandSql, + isNewApproachSql, + lodgedSapSql, +} from "@/lib/services/epcSources"; +import { sapBandBucketsSql } from "@/lib/epc/thresholdsSql"; +import { BAND_MIN_SAP } from "@/lib/epc/thresholds"; +import { computeLedger, DEFAULT_GOAL_BAND } from "./model"; +import type { ScenarioOverlay } from "./model"; +import { tagFilterCondition } from "./tagFilterSql"; +import { emptyTagFilter, type TagFilter } from "./viewState"; + +/** + * The Scenario overlay read model — the single owner of the "After scenario" + * aggregate that backs the reporting metrics endpoints. It answers one + * question in one place: given a plan selection (a specific Scenario, or the + * recommended `is_default` plans) over a filtered portfolio, what is the + * portfolio-after picture, the works-list ledger, and the after-scenario band + * distribution? + * + * Both metrics routes (`scenario/[scenarioId]/metrics` and + * `scenario/default/metrics`) are now thin adapters over this. The default + * (recommended-plans) view is the scenario view with every report-view filter + * inert and no EPC target — so it flows through the same code with the filters + * switched off. Compliance-window (ADR-0010) and lodged-baseline filters are a + * Scenario concept only; the default view passes INERT_FILTERS. + * + * The joins are conditional on what the active filters actually read, so an + * unfiltered overlay never joins the lodged-certificate / legacy tables it + * doesn't need. + */ + +/** Which plans the overlay aggregates over. */ +export type OverlayScope = + | { kind: "scenario"; scenarioId: bigint } + | { kind: "default" }; + +/** The report-view filters (ADR-0010). All off = the plain overlay. */ +export interface OverlayFilters { + hideNonCompliant: boolean; + useLodgedBaseline: boolean; + compliance: { active: boolean; band: string; date: string }; +} + +/** No filters — the default (recommended-plans) view and the unfiltered case. */ +export const INERT_FILTERS: OverlayFilters = { + hideNonCompliant: false, + useLodgedBaseline: false, + compliance: { active: false, band: DEFAULT_GOAL_BAND, date: "2030-01-01" }, +}; + +/* ======================= + Row shapes +======================= */ + +type ScenarioAggregates = { + n_units: number; + total_sap_uplift: number | null; +}; + +type UpgradedAggregates = { + n_units_upgraded: number; + total_cost: number | null; + contingency: number | null; + total_funding: number | null; +}; + +type PortfolioAggregates = { + avg_sap: number | null; + avg_carbon: number | null; + avg_bills: number | null; + total_carbon: number | null; + total_bills: number | null; + band_a: number; + band_b: number; + band_c: number; + band_d: number; + band_e: number; + band_f: number; + band_g: number; + band_unknown: number; +}; + +const BAND_BUCKETS_SQL = sapBandBucketsSql(sql`band_sap`); + +/** + * The overlay aggregate for a plan selection over a filtered portfolio. + * Returns `null` only when a `scenario` scope names a scenario that does not + * exist in this portfolio (the caller maps that to 404). + */ +export async function getScenarioOverlay( + portfolioId: bigint, + scope: OverlayScope, + filters: OverlayFilters = INERT_FILTERS, + tags: TagFilter = emptyTagFilter(), +): Promise<ScenarioOverlay | null> { + const pid = portfolioId; + const tagScope = tagFilterCondition(tags); + + /* -------------------------------------------------------- + Scenario definition — only the EPC target matters here, and only for a + specific Scenario. The default view has no target band. + -------------------------------------------------------- */ + let minSap: number | null = null; + let targetBand: string | null = null; + + if (scope.kind === "scenario") { + const scenarioResult = await db.execute(sql` + SELECT goal, goal_value + FROM scenario + WHERE id = ${scope.scenarioId} AND portfolio_id = ${pid} + LIMIT 1 + `); + const scenario = scenarioResult.rows[0] as + | { goal: PortfolioGoalType; goal_value: string } + | undefined; + + if (!scenario) return null; + + if (scenario.goal === "Increasing EPC") { + minSap = BAND_MIN_SAP[scenario.goal_value as keyof typeof BAND_MIN_SAP]; + targetBand = scenario.goal_value; + } + } + + /* -------------------------------------------------------- + Plan scope — a specific Scenario's plans, or the recommended defaults. + -------------------------------------------------------- */ + const planScopeSql: SQL = + scope.kind === "scenario" + ? sql`scenario_id = ${scope.scenarioId}` + : sql`is_default = true`; + + /* -------------------------------------------------------- + Filter fragments. Each is empty SQL when its feature is off — so an + unfiltered overlay references (and joins) none of the lodged/legacy + tables the filters would otherwise need. + -------------------------------------------------------- */ + const complianceBand = filters.compliance.band; + const complianceDate = filters.compliance.date; + + // Only join the lodged-certificate row when a filter reads it; only join the + // legacy details table when the compliance window's legacy branch reads it. + const needsEpl = filters.compliance.active || filters.useLodgedBaseline; + const needsE = filters.compliance.active; + + const eplJoin: SQL = needsEpl + ? sql`LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'` + : sql``; + // Scope the legacy (being-phased-out) table to this portfolio so its + // (property_id, portfolio_id) index is used instead of a full seq scan of + // the 600k-row table; migrated portfolios have ~0 rows here. + const eJoin: SQL = needsE + ? sql`LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${pid}` + : sql``; + + /** + * True when the row survives the compliance-window filter (ADR-0010): a home + * is skippable iff its lodged certificate shows the window band or better AND + * remains valid (within 10 years of the cut-off). Bands compare lexically (A + * best). COALESCE(false): unknown/predicted evidence is never skippable — + * mirrored lodged_* estimates don't count (new-approach requires a real epl). + */ + const passesComplianceWindow: SQL = filters.compliance.active + ? sql`AND NOT COALESCE( + CASE WHEN ${isNewApproachSql} THEN ( + epl.id IS NOT NULL + AND bp.lodged_epc_band <= ${complianceBand} + AND COALESCE(epl.registration_date, epl.inspection_date) > (${complianceDate}::date - INTERVAL '10 years') + ) ELSE ( + COALESCE(e.estimated, false) = false + AND p.current_epc_rating <= ${complianceBand} + AND e.lodgement_date > (${complianceDate}::date - INTERVAL '10 years') + ) END, + false)` + : sql``; + + // "Measure against lodged EPCs" — judge who needed upgrading by the lodged + // (register) position rather than the effective baseline. Unknown lodged + // keeps the home rather than silently dropping it. + const lodgedBaselineFilter: SQL = + filters.useLodgedBaseline && minSap !== null + ? sql`AND COALESCE((${lodgedSapSql}) < ${minSap}::float, true)` + : sql``; + + // hideNonCompliant, applied inside the plan selection: keep only plans whose + // post SAP reaches the target. No target ⇒ inert. + const planCompliantFilter: SQL = + filters.hideNonCompliant && minSap !== null + ? sql`AND post_sap_points >= ${minSap}::float` + : sql``; + const lateralCompliantFilter: SQL = + filters.hideNonCompliant && minSap !== null + ? sql`AND plan.post_sap_points >= ${minSap}::float` + : sql``; + + /** + * INTERIM guard for a backend bug (Hestia-Homes/Model#1652): the engine emits + * costed plans for homes ALREADY at the target band, inflating "Homes + * upgraded" and its costs. Exclude any home whose EFFECTIVE band already meets + * the target from the upgrade aggregate. Bands compare lexically (A best), so + * "still needs work" = effective band > target. NULL target ⇒ no-op. + */ + const stillNeedsUpgrade: SQL = sql`( + ${targetBand}::text IS NULL + OR (${effectiveEpcBandSql}) IS NULL + OR (${effectiveEpcBandSql})::text > ${targetBand}::text + )`; + + /* -------------------------------------------------------- + QUERY 1 — Scenario metrics (PLANS ONLY): the works-list count and the + genuine SAP uplift. + -------------------------------------------------------- */ + const scenarioMetricsSql = sql` + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) * + FROM plan + WHERE portfolio_id = ${pid} + AND ${planScopeSql} + ${planCompliantFilter} + ORDER BY property_id, created_at DESC + ) + SELECT + COUNT(*)::int AS n_units, + SUM( + CASE + -- Baseline is the effective SAP (NULL current_sap_points for + -- new-approach → uplift 0). Count genuine gains only. See ADR-0002. + WHEN cost_of_works > 0 AND post_sap_points > (${effectiveSapSql}) + THEN post_sap_points - (${effectiveSapSql}) + ELSE 0 + END + )::float AS total_sap_uplift + FROM latest_plans lp + JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id + ${eplJoin} + ${eJoin} + WHERE ${tagScope} + ${lodgedBaselineFilter} + ${passesComplianceWindow}; + `; + + /* -------------------------------------------------------- + QUERY 1b — Upgrade costs (PLANS ONLY): the ledger inputs. + -------------------------------------------------------- */ + const upgradedCostsSql = sql` + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) * + FROM plan + WHERE portfolio_id = ${pid} + AND ${planScopeSql} + ${planCompliantFilter} + ORDER BY property_id, created_at DESC + ) + SELECT + COUNT(*)::int AS n_units_upgraded, + SUM(cost_of_works)::float AS total_cost, + SUM(contingency_cost)::float AS contingency, + SUM(COALESCE(fp.project_funding, 0) + COALESCE(fp.total_uplift, 0))::float AS total_funding + FROM latest_plans lp + JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id + ${eplJoin} + ${eJoin} + LEFT JOIN funding_package fp ON fp.plan_id = lp.id + WHERE ${tagScope} + AND lp.cost_of_works > 0 + -- A plan whose post SAP is below the effective baseline isn't a real + -- upgrade (target-level post_sap on already-compliant homes). COALESCE + -- keeps rows we can't compare. See ADR-0002. + AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true) + AND ${stillNeedsUpgrade} + ${lodgedBaselineFilter} + ${passesComplianceWindow}; + `; + + /* -------------------------------------------------------- + QUERY 2 — Portfolio AFTER scenario (ALL properties): one LATERAL pass + yields both the average/total aggregates and the band buckets. + -------------------------------------------------------- */ + const portfolioMetricsSql = sql` + SELECT + AVG(effective_sap)::float AS avg_sap, + AVG(effective_carbon)::float AS avg_carbon, + AVG(effective_bills)::float AS avg_bills, + SUM(effective_carbon)::float AS total_carbon, + SUM(effective_bills)::float AS total_bills, + ${BAND_BUCKETS_SQL} + FROM ( + SELECT + CASE WHEN lp.id IS NOT NULL THEN lp.post_sap_points + ELSE p.current_sap_points END AS effective_sap, + CASE WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions + ELSE ${carbonSql(sql`e`)} END AS effective_carbon, + CASE WHEN lp.id IS NOT NULL THEN lp.post_energy_bill + ELSE ${billsSql(sql`e`)} END AS effective_bills, + CASE + -- A retrofit can't make a property worse. The engine writes a + -- target-level post_sap even for already-above-target homes, so + -- post_sap can sit BELOW baseline — clamp to the effective baseline. + WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) + -- No qualifying plan → unchanged. Effective (re-baselined) baseline to + -- match the "before" distribution; NOT current_sap_points (NULL for + -- new-approach → "Unknown"). See ADR-0002. + ELSE ${effectiveSapSql} + END AS band_sap + FROM property p + LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${pid} + ${newApproachJoins} + LEFT JOIN LATERAL ( + SELECT * + FROM plan + WHERE plan.property_id = p.id + AND plan.portfolio_id = ${pid} + AND plan.${planScopeSql} + ${lateralCompliantFilter} + ${lodgedBaselineFilter} + ${passesComplianceWindow} + ORDER BY created_at DESC + LIMIT 1 + ) lp ON true + WHERE p.portfolio_id = ${pid} AND ${tagScope} + ) q; + `; + + const [scenarioMetricsResult, upgradedResult, portfolioMetricsResult] = + await Promise.all([ + db.execute(scenarioMetricsSql), + db.execute(upgradedCostsSql), + db.execute(portfolioMetricsSql), + ]); + + const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; + const upgraded = upgradedResult.rows[0] as UpgradedAggregates; + const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; + + const nUpgraded = upgraded.n_units_upgraded ?? 0; + const ledger = computeLedger({ + constructionCost: upgraded.total_cost ?? 0, + contingency: upgraded.contingency ?? 0, + funding: upgraded.total_funding ?? 0, + homesUpgraded: nUpgraded, + }); + + return { + avg_sap: + portfolioAgg.avg_sap !== null + ? Number(portfolioAgg.avg_sap).toFixed(1) + : null, + avg_carbon: portfolioAgg.avg_carbon, + avg_bills: portfolioAgg.avg_bills, + total_carbon: portfolioAgg.total_carbon, + total_bills: portfolioAgg.total_bills, + + n_units: scenarioAgg.n_units, + n_units_upgraded: nUpgraded, + construction_cost: ledger.constructionCost, + contingency: ledger.contingency, + total_funding: ledger.funding, + gross_cost: ledger.grossCost, + net_cost: ledger.netCost, + total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0, + gross_per_unit: ledger.grossPerHome, + + scenario_epc_counts: { + A: portfolioAgg.band_a, + B: portfolioAgg.band_b, + C: portfolioAgg.band_c, + D: portfolioAgg.band_d, + E: portfolioAgg.band_e, + F: portfolioAgg.band_f, + G: portfolioAgg.band_g, + Unknown: portfolioAgg.band_unknown, + }, + pc_cost: ledger.projectDelivery, + }; +} diff --git a/src/lib/reporting/reportingScenarios.test.ts b/src/lib/reporting/reportingScenarios.test.ts new file mode 100644 index 00000000..f7cbf6f8 --- /dev/null +++ b/src/lib/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/lib/reporting/reportingScenarios.ts b/src/lib/reporting/reportingScenarios.ts new file mode 100644 index 00000000..8e8f1d39 --- /dev/null +++ b/src/lib/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<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", + }; +} diff --git a/src/lib/reporting/server.ts b/src/lib/reporting/server.ts new file mode 100644 index 00000000..07069e3e --- /dev/null +++ b/src/lib/reporting/server.ts @@ -0,0 +1,435 @@ +import "server-only"; +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; +import { + newApproachJoins, + carbonSql, + billsSql, + energyConsumptionSql, + estimatedSql, + isExpiredSql, + effectiveSapSql, + effectiveEpcBandSql, + likelyDowngradeSql, + likelyUpgradeSql, + propertyTypeSql, + constructionYearSql, +} from "@/lib/services/epcSources"; + +import type { + AverageMetrics, + AgeBandCount, + EpcBandCount, + EstimatedCounts, + PropertyTypeCount, + BaselineMetrics, + TotalMetrics, + ScenarioConfig, + DataQualityMetrics, +} from "./types"; +import { toReportingScenario, type ReportingScenario } from "./reportingScenarios"; +import type { Measure } from "./measures"; +import { tagFilterCondition } from "./tagFilterSql"; +import { emptyTagFilter, type TagFilter } from "./viewState"; + +export type { ReportingScenario }; +export type { ScenarioConfig, DataQualityMetrics } from "./types"; + +/** + * The reporting read model — the single owner of the reporting section's + * database reads (the baseline aggregates, the scenario list, the goal, the + * per-scenario config, the data-quality composition, and the measure-spend + * allocation). Lives in the domain layer next to the pure ./model and the + * ./overlay read model, following the repo's src/lib/<domain>/server.ts + * convention rather than sitting in the Next.js route folder. + * + * The heavier "After scenario" aggregate lives in its own ./overlay module. + */ + +/** Bands the single-pass baseline query buckets into, in display order. */ +const BASELINE_EPC_BANDS = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "Unknown", +] as const; + +/** + * One-pass baseline aggregate. Total, averages, totals, EPC-band distribution, + * estimated/actual split and expired count all share the *identical* + * `property ⋈ property_details_epc ⋈ newApproachJoins` scan, so they collapse + * into a single conditional-aggregation query rather than six separate full + * scans of the same portfolio — which was the bulk of the reporting page's + * load time on large portfolios. Age bands (correlated subqueries) and likely + * downgrades (a different, legacy-only join) stay separate and run in parallel. + */ +export async function getBaselineAggregates( + portfolioId: number, + tags: TagFilter = emptyTagFilter(), +): Promise<{ + total: number; + averages: AverageMetrics; + totals: TotalMetrics; + epcBands: EpcBandCount[]; + estimatedCounts: EstimatedCounts; + expiredEpcs: number; +}> { + const est = estimatedSql(sql`e`); + const bandExpr = sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown')`; + + // Per-band actual/estimated counts as FILTER columns — replaces the GROUP BY + // in the old getCountByEpcBand. Aliases derive from the constant band list. + const bandCols = sql.join( + BASELINE_EPC_BANDS.flatMap((b) => { + const k = b.toLowerCase(); + return [ + sql`COUNT(*) FILTER (WHERE ${bandExpr} = ${b} AND ${est} = false)::int AS ${sql.raw(`epc_${k}_actual`)}`, + sql`COUNT(*) FILTER (WHERE ${bandExpr} = ${b} AND ${est} = true)::int AS ${sql.raw(`epc_${k}_estimated`)}`, + ]; + }), + sql`, `, + ); + + const result = await db.execute<Record<string, number | null>>(sql` + SELECT + COUNT(*)::int AS total, + AVG(${effectiveSapSql})::float AS avg_sap, + AVG(${carbonSql(sql`e`)})::float AS avg_carbon, + AVG(${billsSql(sql`e`)})::float AS avg_bills, + AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption, + SUM(${carbonSql(sql`e`)})::float AS total_carbon, + SUM(${billsSql(sql`e`)})::float AS total_bills, + SUM(CASE WHEN ${est} = true THEN 1 ELSE 0 END)::int AS estimated, + SUM(CASE WHEN ${est} = false THEN 1 ELSE 0 END)::int AS actual, + SUM( + CASE + WHEN ${isExpiredSql(sql`e`)} = true AND ${est} = false THEN 1 + ELSE 0 + END + )::int AS expired, + ${bandCols} + FROM property p + -- Scope the legacy (being-phased-out) table to this portfolio so its + -- (property_id, portfolio_id) index is used instead of a full seq scan of + -- the whole 600k-row table; migrated portfolios have ~0 rows here. + LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${portfolioId} + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)}; + `); + + const row = result.rows[0]; + + // Omit zero-count bands to mirror the old GROUP BY (which only emitted bands + // that were present); the ladder + toBandCounts both tolerate the absence. + const epcBands: EpcBandCount[] = BASELINE_EPC_BANDS.map((b) => { + const k = b.toLowerCase(); + return { + epc: b, + actual: Number(row[`epc_${k}_actual`] ?? 0), + estimated: Number(row[`epc_${k}_estimated`] ?? 0), + }; + }).filter((band) => band.actual + band.estimated > 0); + + return { + total: Number(row.total ?? 0), + averages: { + avg_sap: row.avg_sap as number | null, + avg_carbon: row.avg_carbon as number | null, + avg_bills: row.avg_bills as number | null, + avg_energy_consumption: row.avg_energy_consumption as number | null, + }, + totals: { + total_carbon: row.total_carbon as number | null, + total_bills: row.total_bills as number | null, + }, + epcBands, + estimatedCounts: { + estimated: Number(row.estimated ?? 0), + actual: Number(row.actual ?? 0), + }, + expiredEpcs: Number(row.expired ?? 0), + }; +} + +export async function getCountByAgeBand( + portfolioId: number, + tags: TagFilter = emptyTagFilter(), +): Promise<AgeBandCount[]> { + const result = await db.execute<AgeBandCount>(sql` + SELECT + CASE + WHEN q.built_year < 1900 THEN '<1900' + WHEN q.built_year BETWEEN 1900 AND 1929 THEN '1900–1929' + WHEN q.built_year BETWEEN 1930 AND 1949 THEN '1930–1949' + WHEN q.built_year BETWEEN 1950 AND 1975 THEN '1950–1975' + WHEN q.built_year BETWEEN 1976 AND 1999 THEN '1976–1999' + WHEN q.built_year >= 2000 THEN '2000+' + ELSE 'Unknown' + END AS age_band, + COUNT(*)::int AS count + FROM ( + SELECT ${constructionYearSql} AS built_year + FROM property p + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)} + ) q + GROUP BY age_band + ORDER BY age_band; + `); + + return result.rows; +} + +export async function getCountByPropertyType( + portfolioId: number, + tags: TagFilter = emptyTagFilter(), +): Promise<PropertyTypeCount[]> { + const result = await db.execute<PropertyTypeCount>(sql` + SELECT ${propertyTypeSql} AS type, COUNT(*)::int AS count + FROM property p + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)} + GROUP BY type + ORDER BY count DESC; + `); + + return result.rows; +} + +/** + * Likely downgrades for the current-stock confidence strip — a home whose + * lodged (register) band sits above its effective (modelled) band, so a + * re-survey would likely certificate it lower. The single, band-movement-only + * definition (CONTEXT.md), shared with the data-quality page and the drill-down + * via `likelyDowngradeSql`; the retired SAP-05 point-level signal is gone. Runs + * on the same portfolio scan as the other baseline aggregates. + */ +export async function getLikelyDowngrades( + portfolioId: number, + tags: TagFilter = emptyTagFilter(), +): Promise<number> { + const result = await db.execute<{ downgrades: number }>(sql` + SELECT + SUM(CASE WHEN ${likelyDowngradeSql} THEN 1 ELSE 0 END)::int AS downgrades + FROM property p + -- Portfolio-scoped legacy join (see getBaselineAggregates) — index, not seq scan. + LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${portfolioId} + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)}; + `); + + return result.rows[0]?.downgrades ?? 0; +} + +export async function loadBaselineMetrics( + portfolioId: number, + tags: TagFilter = emptyTagFilter(), +): Promise<BaselineMetrics> { + // Three parallel scans, not eight: the single-pass aggregate carries total, + // averages, totals, EPC bands, estimated split and expired count; age bands + // and likely downgrades keep their own (differently-shaped) queries. + const [aggregates, ageBands, likelyDowngrades] = await Promise.all([ + getBaselineAggregates(portfolioId, tags), + getCountByAgeBand(portfolioId, tags), + getLikelyDowngrades(portfolioId, tags), + ]); + + return { + total: aggregates.total, + averages: aggregates.averages, + totals: aggregates.totals, + ageBands, + epcBands: aggregates.epcBands, + estimatedCounts: aggregates.estimatedCounts, + expiredEpcs: aggregates.expiredEpcs, + likelyDowngrades, + }; +} + +/** + * Scenarios for the reporting combobox: config plus derived modelled state + * (ADR-0003). Deliberately lighter than listScenariosWithStatus — it derives + * "modelled" from whether a scenario appears in `plan` at all + * (DISTINCT scenario_id, an index-only scan on idx_plan_portfolio_scenario), + * NOT COUNT(DISTINCT property_id) per scenario, which scans and de-dupes every + * plan row and timed out the page on very large portfolios. Reporting only + * needs the boolean, not the count. + */ +export async function getReportingScenarios( + portfolioId: number, +): Promise<ReportingScenario[]> { + const pid = BigInt(portfolioId); + const [configRes, modelledRes] = await Promise.all([ + db.execute<{ + id: string; + name: string | null; + goal: string; + goal_value: string | null; + budget: number | null; + housing_type: string; + created_at: string | Date; + }>(sql` + SELECT id, name, goal, goal_value, budget, housing_type, created_at + FROM scenario + WHERE portfolio_id = ${pid} + ORDER BY created_at DESC, id DESC + `), + db.execute<{ scenario_id: string }>(sql` + SELECT DISTINCT scenario_id + FROM plan + WHERE portfolio_id = ${pid} AND scenario_id IS NOT NULL + `), + ]); + + const modelled = new Set(modelledRes.rows.map((r) => String(r.scenario_id))); + + return configRes.rows.map((s) => toReportingScenario(s, modelled)); +} + +/** The portfolio's own goal — drives the current-stock goal callout. */ +export async function getPortfolioGoal(portfolioId: number): Promise<string> { + const result = await db.execute<{ goal: string }>(sql` + SELECT goal FROM portfolio WHERE id = ${portfolioId} LIMIT 1; + `); + return result.rows[0]?.goal ?? "None"; +} + +/** `scenario.exclusions` is a text column holding a `{a, b}`-style literal. */ +function parseExclusions(raw: string | null): string[] { + if (!raw) return []; + const inner = raw.replace(/^\{|\}$/g, "").trim(); + if (!inner) return []; + return inner + .split(",") + .map((s) => s.trim().replace(/^"|"$/g, "")) + .filter(Boolean); +} + +/** The immutable configuration of a single scenario — for the report brief. */ +export async function getScenarioConfig( + scenarioId: number, +): Promise<ScenarioConfig | null> { + const result = await db.execute<{ + name: string | null; + goal: string; + goal_value: string | null; + budget: number | null; + housing_type: string; + exclusions: string | null; + fabric_first: boolean; + }>(sql` + SELECT name, goal, goal_value, budget, housing_type, exclusions, fabric_first + FROM scenario + WHERE id = ${BigInt(scenarioId)} + LIMIT 1; + `); + const row = result.rows[0]; + if (!row) return null; + return { + name: row.name, + goal: row.goal, + goalValue: row.goal_value, + budget: row.budget, + housingType: row.housing_type, + exclusions: parseExclusions(row.exclusions), + fabricFirst: row.fabric_first, + }; +} + +/** + * Evidence composition + band-movement counts for the data-quality page + * (Screen E). Every home is counted once by strongest evidence + * (in-date lodged › expired › estimated). Likely downgrade/upgrade follow + * the CONTEXT.md band-movement definitions (lodged band vs effective band; + * real certificates only — lodgedEpcBandSql is NULL for predicted homes). + * Band letters compare lexically, so a lodged band that is a *better* + * letter than effective (lodged < effective) is a likely downgrade. + */ +export async function getDataQualityMetrics( + portfolioId: number, +): Promise<DataQualityMetrics> { + const result = await db.execute<DataQualityMetrics>(sql` + SELECT + COUNT(*)::int AS total, + SUM(CASE + WHEN ${estimatedSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = false + THEN 1 ELSE 0 END)::int AS "inDate", + SUM(CASE + WHEN ${estimatedSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = true + THEN 1 ELSE 0 END)::int AS expired, + SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated, + SUM(CASE WHEN ${likelyDowngradeSql} THEN 1 ELSE 0 END)::int AS "likelyDowngrades", + SUM(CASE WHEN ${likelyUpgradeSql} THEN 1 ELSE 0 END)::int AS "likelyUpgrades" + FROM property p + -- Portfolio-scoped legacy join (see getBaselineAggregates) — index, not seq scan. + LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${portfolioId} + ${newApproachJoins} + WHERE p.portfolio_id = ${portfolioId}; + `); + + return result.rows[0]; +} + +/** + * Grouped measure spend for a scenario's latest plans — the data behind the + * reporting "where the money goes" allocation (PRD #370). Shared by the + * server action (reporting/actions.ts) so the SQL lives in one place. + * + * The recommendations are joined on plan_id alone (it uniquely identifies the + * plan and its property), so idx_recommendation_plan_id is used; grouped by + * measure_type only, since the UI buckets by category and never reads the + * `type` variant. + */ +export async function queryScenarioMeasures( + portfolioId: number, + scenarioId: number | "default", + tags: TagFilter = emptyTagFilter(), +): Promise<Measure[]> { + const pid = BigInt(portfolioId); + + // "default" (the recommended-plans pseudo-scenario) selects the latest + // is_default plan per property rather than a specific scenario_id. + const planScope = + scenarioId === "default" + ? sql`is_default = true` + : sql`scenario_id = ${BigInt(scenarioId)}`; + + const result = await db.execute<{ + measure_type: string | null; + homes_count: number; + total_cost: number | null; + average_cost: number | null; + }>(sql` + SELECT + r.measure_type, + COUNT(DISTINCT r.property_id)::int AS homes_count, + SUM(r.estimated_cost)::float AS total_cost, + AVG(r.estimated_cost)::float AS average_cost + FROM ( + SELECT DISTINCT ON (property_id) + id, property_id + FROM plan + WHERE portfolio_id = ${pid} + AND ${planScope} + AND ${tagFilterCondition(tags, sql`plan.property_id`)} + ORDER BY property_id, created_at DESC + ) lp + JOIN recommendation r + ON r.plan_id = lp.id + AND r.default = true + AND r.already_installed = false + GROUP BY r.measure_type + ORDER BY total_cost DESC; + `); + + return result.rows.map((row) => ({ + measureType: row.measure_type ?? "unknown", + homesCount: row.homes_count, + totalCost: Number(row.total_cost ?? 0), + averageCost: Number(row.average_cost ?? 0), + })); +} 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<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/); + }); +}); 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/types.ts b/src/lib/reporting/types.ts new file mode 100644 index 00000000..f983ec91 --- /dev/null +++ b/src/lib/reporting/types.ts @@ -0,0 +1,83 @@ +/** + * Data-shape types for the reporting reads. Client-safe (no db imports) so the + * client area and the server read model (@/lib/reporting/server) can share + * them. The derived/pure calculations live in ./model; these are just the + * shapes the queries return. + */ + +export type AverageMetrics = { + avg_sap: number | null; + avg_carbon: number | null; + avg_bills: number | null; + avg_energy_consumption: number | null; +}; + +export type TotalMetrics = { + total_carbon: number | null; + total_bills: number | null; +}; + +export type AgeBandCount = { + age_band: string; + count: number; +}; + +export type EpcBandCount = { + epc: string; + actual: number; + estimated: number; +}; + +export type EstimatedCounts = { + estimated: number; + actual: number; +}; + +export type PropertyTypeCount = { + type: string | null; + count: number; +}; + +export interface BaselineMetrics { + total: number; + averages: AverageMetrics; + totals: TotalMetrics; + ageBands: AgeBandCount[]; + epcBands: EpcBandCount[]; + estimatedCounts: EstimatedCounts; + expiredEpcs: number; + likelyDowngrades: number; +} + +export interface ScenarioSummary { + id: number; + name: string; + goal?: string; + goalValue?: string | null; + budget?: number | null; + housingType?: string; + createdAt?: string; + /** Derived from plan existence (ADR-0003) — never stored. */ + status?: "modelled" | "awaiting_modelling"; +} + +/** The immutable configuration of a single scenario — for the report brief. */ +export interface ScenarioConfig { + name: string | null; + goal: string; + goalValue: string | null; + budget: number | null; + housingType: string; + exclusions: string[]; + fabricFirst: boolean; +} + +/** Evidence composition + band-movement counts for the data-quality page. */ +export type DataQualityMetrics = { + total: number; + inDate: number; + expired: number; + estimated: number; + likelyDowngrades: number; + likelyUpgrades: number; +}; diff --git a/src/lib/reporting/viewState.test.ts b/src/lib/reporting/viewState.test.ts new file mode 100644 index 00000000..e1b9d151 --- /dev/null +++ b/src/lib/reporting/viewState.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import { + parseReportingViewState, + serializeReportingViewState, + serializeTagFilter, + isTagFilterActive, + tagFiltersEqual, + emptyViewState, + emptyTagFilter, + 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("tagFiltersEqual", () => { + it("treats two empty filters as equal", () => { + expect(tagFiltersEqual(emptyTagFilter(), emptyTagFilter())).toBe(true); + }); + + it("is order-insensitive within include/exclude", () => { + expect( + tagFiltersEqual( + { include: ["1", "2"], exclude: ["9"], includeMode: "any" }, + { include: ["2", "1"], exclude: ["9"], includeMode: "any" }, + ), + ).toBe(true); + }); + + it("distinguishes a changed include set", () => { + expect( + tagFiltersEqual( + { include: ["1"], exclude: [], includeMode: "any" }, + { include: ["1", "2"], exclude: [], includeMode: "any" }, + ), + ).toBe(false); + }); + + it("distinguishes a changed exclude set", () => { + expect( + tagFiltersEqual( + { include: [], exclude: ["1"], includeMode: "any" }, + { include: [], exclude: ["2"], includeMode: "any" }, + ), + ).toBe(false); + }); + + it("distinguishes includeMode, but only when it bites (2+ includes)", () => { + // With 2+ include tags, any vs all changes the query → not equal. + expect( + tagFiltersEqual( + { include: ["1", "2"], exclude: [], includeMode: "any" }, + { include: ["1", "2"], exclude: [], includeMode: "all" }, + ), + ).toBe(false); + // With <2 include tags the mode is inert, so it must not force inequality + // (else a stray mode flip would trigger a needless server round-trip). + expect( + tagFiltersEqual( + { include: ["1"], exclude: [], includeMode: "any" }, + { include: ["1"], exclude: [], includeMode: "all" }, + ), + ).toBe(true); + }); +}); + +describe("serializeTagFilter", () => { + it("is empty for an empty filter", () => { + expect(serializeTagFilter(emptyViewState().tags).toString()).toBe(""); + }); + it("emits include + exclude, and includeMode only when 'all' with 2+", () => { + expect( + serializeTagFilter({ + include: ["1", "2"], + exclude: ["9"], + includeMode: "all", + }).toString(), + ).toBe("includeTags=1%2C2&excludeTags=9&includeMode=all"); + expect( + serializeTagFilter({ + include: ["1"], + exclude: [], + includeMode: "all", + }).toString(), + ).toBe("includeTags=1"); + }); +}); + +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..3c4afe3f --- /dev/null +++ b/src/lib/reporting/viewState.ts @@ -0,0 +1,169 @@ +/** + * 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; +} + +function sameIdSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const seen = new Set(b); + return a.every((id) => seen.has(id)); +} + +/** + * Whether two tag filters produce the same reporting view — i.e. the same + * server query. Used to decide how a view-state change is applied: a changed + * tag filter must re-render the server-side baseline (`router.replace`), while + * view/scenario-filter changes are client-only (`history.replaceState`). + * + * Order-insensitive within include/exclude (the sets, not their order, decide + * the query), and `includeMode` only counts when it bites — with fewer than two + * included tags "any" and "all" are indistinguishable, so a flip between them + * must not be treated as a change and trigger a needless round-trip. + */ +export function tagFiltersEqual(a: TagFilter, b: TagFilter): boolean { + if (!sameIdSet(a.include, b.include)) return false; + if (!sameIdSet(a.exclude, b.exclude)) return false; + if (a.include.length >= 2 && a.includeMode !== b.includeMode) return false; + return true; +} + +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, + }, + }; +} + +/** The tag-filter params only — the subset the reporting view currently drives via the URL. */ +export function serializeTagFilter( + tags: TagFilter, + into: URLSearchParams = new URLSearchParams(), +): URLSearchParams { + if (tags.include.length) into.set("includeTags", tags.include.join(",")); + if (tags.exclude.length) into.set("excludeTags", tags.exclude.join(",")); + // includeMode only bites with 2+ included tags; omit the default "any". + if (tags.includeMode === "all" && tags.include.length >= 2) { + into.set("includeMode", "all"); + } + return into; +} + +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); + } + serializeTagFilter(state.tags, p); + return p; +} diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 59c16562..3bf662b0 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -30,6 +30,7 @@ import { db } from "@/app/db/db"; import { and, eq, sql } from "drizzle-orm"; import { resolveProvenanceSignal, type ProvenanceSignal } from "./provenance"; +import { PROPERTY_TYPE_LABELS, BUILT_FORM_LABELS } from "@/lib/epc/labels"; import { epcProperty, epcEnergyElement, @@ -147,6 +148,30 @@ export const lodgedEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHE /** Lodged SAP score for the "Lodged EPC" filter — same source/gate as lodgedEpcBandSql. Legacy: the row's original_sap_points. */ export const lodgedSapSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_sap_score ELSE NULL END) ELSE p.original_sap_points END`; +/** + * Band-movement signals — the SQL twin of `classifyBandMovement` + * (@/lib/reporting/model) and the single definition of CONTEXT.md's Likely + * downgrade / Likely upgrade. Judged on EPC band movement ONLY — the lodged + * (register) band vs the effective (modelled) band — never SAP points (the + * retired point-level signal). Real certificates only: `lodgedEpcBandSql` is + * NULL for predicted homes, so both are FALSE there. Bands compare lexically + * (A best), so a *better* lodged letter than effective (lodged < effective) + * is a likely downgrade; the reverse is a likely upgrade. Requires both alias + * families in scope — add `newApproachJoins` (and the legacy `e` if the query + * needs it) to any query using these. + */ +export const likelyDowngradeSql = sql`( + (${lodgedEpcBandSql}) IS NOT NULL + AND (${effectiveEpcBandSql}) IS NOT NULL + AND (${lodgedEpcBandSql}) < (${effectiveEpcBandSql}) +)`; + +export const likelyUpgradeSql = sql`( + (${lodgedEpcBandSql}) IS NOT NULL + AND (${effectiveEpcBandSql}) IS NOT NULL + AND (${lodgedEpcBandSql}) > (${effectiveEpcBandSql}) +)`; + /** * Provenance signal for the portfolio table (mirrors resolveProvenanceSignal / * provenance.ts). New-approach only; legacy → 'none'. @@ -291,21 +316,10 @@ export const mainfuelSql = (e: Alias) => // the single source of truth for code→label mapping. // ───────────────────────────────────────────────────────────────────────────── -export const PROPERTY_TYPE_LABELS: Record<string, string> = { - "0": "House", - "1": "Bungalow", - "2": "Flat", - "3": "Maisonette", - "4": "Park home", -}; -export const BUILT_FORM_LABELS: Record<string, string> = { - "1": "Detached", - "2": "Semi-Detached", - "3": "End-Terrace", - "4": "Mid-Terrace", - "5": "Enclosed End-Terrace", - "6": "Enclosed Mid-Terrace", -}; +// Pure display constants live in @/lib/epc/labels so client bundles can +// import them without pulling in this db-backed module. Imported for local +// use here and re-exported for existing server-side callers. +export { PROPERTY_TYPE_LABELS, BUILT_FORM_LABELS }; // SAP tenure codes → the text labels already used in the data. NOTE: best-effort // (standard SAP 1/2/3); most rows store the text label directly, only a few use // codes. Confirm against the backend tenure enum. diff --git a/vitest.config.ts b/vitest.config.ts index ede31fee..ead1997f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // `server-only` is a marker package with no Node-resolvable entry under + // Vite; stub it so tests that import a server module chain still load. + "server-only": path.resolve(__dirname, "./vitest/server-only-stub.ts"), }, }, }); diff --git a/vitest/server-only-stub.ts b/vitest/server-only-stub.ts new file mode 100644 index 00000000..636d6ece --- /dev/null +++ b/vitest/server-only-stub.ts @@ -0,0 +1,6 @@ +// Empty stub for the `server-only` marker package under Vitest. The real +// package resolves to an empty module on the server and a throwing one in the +// browser via export conditions Vite doesn't apply here; aliasing it keeps +// server-only guards in place in app code without breaking Node-env tests that +// import a module chain containing one. See vitest.config.ts. +export {};