Merge pull request #416 from Hestia-Homes/feature/reporting-redesign

Reporting redesign (PRD #370) + architecture consolidation
This commit is contained in:
KhalimCK 2026-07-22 12:03:27 +01:00 committed by GitHub
commit e7ffd23f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 11952 additions and 3833 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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 **35 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 <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> 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.

View file

@ -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 "<instruction>" → show instruction, wait for Enter
# capture VAR "<question>" → 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"

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.
---
<what-to-do>
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.
</what-to-do>
<supporting-info>
## 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).
</supporting-info>

View file

@ -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.

View file

@ -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 13 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.

View file

@ -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**.

View file

@ -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).

View file

@ -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.

View file

@ -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…_

View file

@ -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`.

View file

@ -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`.

View file

@ -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.

View file

@ -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.

109
.agents/skills/tdd/SKILL.md Normal file
View file

@ -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
```

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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.

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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 **35 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 <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> 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.

View file

@ -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 "<instruction>" → show instruction, wait for Enter
# capture VAR "<question>" → 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"

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.
---
<what-to-do>
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.
</what-to-do>
<supporting-info>
## 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).
</supporting-info>

View file

@ -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.

View file

@ -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 13 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.

View file

@ -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**.

View file

@ -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).

View file

@ -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 <path> 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 <path> 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 <N> --add-assignee <login>` (best-effort; ignore failure).
### 5. Prepare branch
Project slug = `<repo>-p<projectNumber>` (matches CLI's `projectSlugFrom`).
Branch name:
- `per-ticket`: `claude/<projectSlug>/<issueNumber>-<title-slug>` (slug = lowercased, non-alphanumeric -> `-`, trimmed, max 50 chars).
- `single-pr`: `claude/<projectSlug>` (reused across tickets).
In the target repo:
```sh
git fetch origin
git checkout <base-branch>
git pull
if branch exists: git checkout <branch>
else: git checkout -b <branch>
```
### 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 #<NUMBER> in <OWNER>/<REPO>.
# Issue title
<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.

View file

@ -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.

View file

@ -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…_

View file

@ -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`.

View file

@ -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`.

View file

@ -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.

View file

@ -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.

109
.claude/skills/tdd/SKILL.md Normal file
View file

@ -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
```

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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.

View file

@ -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>

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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?

View file

@ -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?"

View file

@ -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 (02220225) 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).

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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 0100.
- 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.

View file

@ -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

View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
&copy; {new Date().getFullYear()} Domna. All rights reserved. Domna
proprietary IP.

View file

@ -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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 beforeafter 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&apos;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)} tCOe a year</b> across the portfolio
scenarios target this {dim} number.
</>
) : (
<>This portfolio&apos;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>
);
}

View file

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

Some files were not shown because too many files have changed in this diff Show more