Merge remote-tracking branch 'origin/main' into feature/uprn-confirmation-onboarding

# Conflicts:
#	src/app/portfolio/[slug]/components/PropertyTable.tsx
This commit is contained in:
Jun-te Kim 2026-07-08 08:59:17 +00:00
commit 72c6d9dd5a
42 changed files with 7703 additions and 222 deletions

View file

@ -12,7 +12,9 @@
// Install Domna's curated skill set (pinned to 0.0.5) into this workspace,
// then install npm deps. `gh repo clone` handles private-repo auth using
// the mounted host ~/.config/gh.
"postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.8 --depth 1 && bash /tmp/agentic-toolkit/setup.sh && npm install",
// impeccable (design-guidance skill, github.com/pbakaus/impeccable) is on
// trial for the home-page redesign; drop its `skills add` step to remove it.
"postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.8 --depth 1 && bash /tmp/agentic-toolkit/setup.sh && npx --yes skills@latest add pbakaus/impeccable --agent claude-code --copy --global --yes && npm install",
"forwardPorts": ["frontend:3000", "pgadmin:80"],

View file

@ -0,0 +1,67 @@
---
target: portfolios home redesign mockup (calm)
total_score: 27
p0_count: 0
p1_count: 3
timestamp: 2026-07-07T13-05-05Z
slug: portfolios-mock-html
---
# Critique: Portfolios home redesign mockup (Calm direction)
Method: dual-agent (A: design review · B: detector/mechanical evidence). Target: scratchpad/portfolios-mock.html (artifact 76ff144a). Browser visualization skipped: no browser automation in session.
## Design Health Score: 27/40 — Acceptable, near Good
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Collapsed folders hide all state; a folded folder with 2 at-risk portfolios looks identical to a healthy one |
| 2 | Match System / Real World | 3 | Mixed metaphor: star icon + "Pin" labels + "favourite" in notes — pick one word |
| 3 | User Control and Freedom | 2 | No folder create/reorder/rename/delete anywhere; users can't control the page's primary organising structure |
| 4 | Consistency and Standards | 3 | Card hover-lift implies whole-card clickability but only the name is a link; Pinned isn't collapsible while folders are |
| 5 | Error Prevention | 3 | Low-stakes page; kebab actions undesigned so unassessable |
| 6 | Recognition Rather Than Recall | 3 | Folders only discoverable via hidden kebab menu |
| 7 | Flexibility and Efficiency | 2 | No "/" search shortcut, no bulk select/move, no collapse-all, no status sort |
| 8 | Aesthetic and Minimalist Design | 3 | Pinned-card duplication; status pill outranks the name in reading order |
| 9 | Error Recovery | 3 | No-results state names query + one-click clear (good); real page needs fetch-failure states |
| 10 | Help and Documentation | 2 | One teaching empty state (pinning); nothing teaches folders |
## Anti-Patterns Verdict
Passes the product slop test (design review): no banned patterns, schema-honest fields, restrained navy palette, earned familiarity. Deterministic detector: exit 0, zero findings — but its clean bill covers only its own rule set; mechanical checks beyond it found the non-text contrast failures below.
## Priority Issues
- **[P1] Sectioned-folders IA collapses at ~15 folders × 60 portfolios.** All folders open by default, no rollup, no jump nav → endless undifferentiated scroll; collapsing destroys at-a-glance state. Fix: collapsed-by-default with persisted open state; folder-header rollups (count, Σ properties, worst-status chips like "2 at risk"); sticky folder index above ~6 folders; or move folders to a left filter rail entirely.
- **[P1] Folder lifecycle undesigned.** No create, reorder (user's own question — order is hardcoded array), rename, delete. Fix: "New folder" affordance; folder-header kebab with Rename/Reorder/Remove; default alphabetical with per-user manual override (position column).
- **[P1] Pin interaction breaks keyboard/SR users.** innerHTML re-render destroys the focused star (focus drops to body); no aria-live announcements for pin/search-count changes. Fix in real build: targeted DOM updates or focus restoration by data-id + polite live region.
- **[P2] Non-text contrast failures (computed).** Idle star/kebab #9aa2b1 on white = 2.57:1; pinned gold star #f1bb06 on white = 1.77:1 (state nearly invisible to low-vision users); card border 1.24:1. Text pairs all pass 4.5:1 (lowest 5.59:1).
- **[P2] Search is name-only, no keyboard path.** Doesn't match status/goal/folder; no "/" focus; header count stays global while filtering (should read "Showing X of Y").
## Persona Red Flags
**Alex (power user):** no keyboard route to search; only name text clickable despite whole-card hover-lift; organising 60 portfolios = 60 kebab round-trips (no bulk move); no collapse-all; no status sort; pinned duplication doubles scan work.
**Sam (accessibility):** focus wiped on pin toggle; pinned state fails 3:1 non-text contrast; no announcements for dynamic changes; h2-in-summary risks odd NVDA announcements. Keeping: native details/summary, focus ring token, aria-pressed, reduced-motion handling.
## Minor Observations
- Empty folder renders a blank dashed box (empty string default) — needs teaching copy.
- Overspend (cost > budget) clamps the bar silently and stays blue — a red state would serve "trustworthy numbers".
- Status pill above the name makes colour the first read; name should lead on a dispatch page.
- Dead CSS (.updated-inline); list view <900px drops all data columns.
- "Hit the star" too casual for institutional register; unify Pinned/Starred/Favourites naming.
- Nothing signals folders are per-user, not shared with teammates.
- Mock file is a fragment (no doctype/lang/viewport) — supplied by the Artifact wrapper at publish, but the real page needs them natively; summary contains flow content (non-conforming, works in practice).
- Real page needs skeleton sections, not spinners.
## Strengths
- Schema-honest cards ("No budget set" shown, £-of-£ bar only when both fields exist) — Design Principle 1 executed.
- Trustworthy-numbers craft: tabular-nums, consistent £M/£k, honest >100% percentages.
- 10-value status enum compressed to 8 clean pills, text + colour, never colour alone.
## Questions to Consider
- What if folders were a left filter rail instead of page sections? Solves order, scale, and jump-nav in one move.
- What is a collapsed folder for, if collapsing hides all status? The header rollup is the reconciliation, not a nice-to-have.
- Is pin-as-duplicate right, or should pinning float items within their folder?

View file

@ -0,0 +1,67 @@
---
target: implemented portfolio home page
total_score: 25
p0_count: 0
p1_count: 4
timestamp: 2026-07-07T16-54-50Z
slug: src-app-components-home
---
# Critique: Implemented portfolio home page (feature/home-portfolio-redesign)
Method: dual-agent (A: design review · B: detector/mechanical evidence). Target: src/app/components/home/* + src/app/home/page.tsx. Browser evidence skipped: deployed preview is auth-gated (next-auth redirect) and no browser automation in session.
## Design Health Score: 25/40 — Acceptable; solid skeleton, weak edges
| # | Heuristic | Score | Key Issue |
|---|-----------|:---:|-----------|
| 1 | Visibility of System Status | 3 | Folder create has no pending state; appears only after refetch |
| 2 | Match System / Real World | 4 | Enum→display translation, search in display language, honest "unfiled, not deleted" copy |
| 3 | User Control and Freedom | 2 | No undo; rename input discards silently on blur; menus lack Escape/outside-click close |
| 4 | Consistency and Standards | 2 | Three overlay vocabularies: Headless UI Dialog, window.confirm, bare details popovers |
| 5 | Error Prevention | 3 | Confirm exists; no duplicate-folder-name check; blur-cancel loses typed renames |
| 6 | Recognition Rather Than Recall | 3 | Folder ⋯ is opacity-0 hover-revealed — invisible on touch |
| 7 | Flexibility and Efficiency | 2 | No shortcuts, no bulk actions, no URL state, no Show-all |
| 8 | Aesthetic and Minimalist Design | 3 | Table crams folder into Updated cell |
| 9 | Error Recovery | 1 | Rollback message renders ONLY into sr-only region — sighted users see changes silently vanish; createMutation swallows errors |
| 10 | Help and Documentation | 2 | "Drag to reorder" hint hidden exactly where drag doesn't work (mobile) |
## Anti-Patterns Verdict
Not slop (A): no banned patterns, schema-honest, restrained register; failures are under-engineering (native confirm, raw details menus, hex drift), not AI over-decoration. Detector (B): zero findings on all four files — but regex mode is blind to the real issues below (false negatives, not false positives).
## Where A and B converge (highest-confidence findings)
- window.confirm at PortfolioHome.tsx:372 (A: register break; B: fact) — with B's irony: it's currently the page's most accessible dialog; the replacement must not regress focus handling.
- details/summary menus have no Esc/outside-click close, no menu semantics; two can be open at once (A heuristic 3/4; B verified no such JS exists).
- Invisible failure states: announceError feeds only the sr-only region (A P1; B verified role=status is sr-only).
- Touch gaps: folder ⋯ trigger invisible without hover (B fact; A recognition issue).
## B-only mechanical findings
- Contrast FAILs: placeholder gray-400 on white 2.54:1 (Tailwind default, never overridden); gray-500 counts on active rail bg 3.97:1 and hover 4.11:1. All 8 pills PASS (6.378.57). Gold star fill 1.77:1 but aria-hidden decorative with adjacent label + 17.33:1 stroke carrying state.
- Hit targets: FolderRail ⋯ 23×23 (FAIL 24px min); rail "New" ~20px tall (FAIL). CardMenu exactly 24. Star 25. Others pass.
- Table th lack scope="col"; "Your folders" header is a div not a heading; ✓/⚠ glyphs rely on aria-label-on-span/title (unreliable AT support); folder-name input has focus:outline-none with no focus state remaining.
- Hex drift: greys/blues inline (#c3ccd8 #e2e7ee #e4e9f1 #e9edf3 #f8fafc #eceefb) while brand palette exists in tailwind.config.
- Hygiene clean: no TODOs, no !important, one justified inline style, z-20 only, no unused imports.
## Priority Issues
- [P1] Replace window.confirm with inline two-step confirm in the folder menu panel ("Unfiles 12 portfolios · Remove / Cancel", red confirm, focus moves to it, Escape cancels). Chosen over dialog (over-weights a non-destructive action; modal-as-first-thought ban) and undo-toast (needs toast system + server restore API since folder recreation mints a new id).
- [P1] Make mutation failures visible: shared visible status + live region; createMutation needs error+pending handling.
- [P1] List-view CardMenu is clipped by the table's overflow-x-auto container — menu can be invisible with focus inside it. Portal/fixed/popover required.
- [P1] No bulk filing: organising 81 portfolios is one-at-a-time (~250+ clicks). Multi-select + "Move N to folder", and/or rail folders as drop targets.
- [P2] Standardise one popover primitive (Esc, outside-click, menu semantics, single-open) — heals heuristic 4.
## Persona Red Flags
Alex: no keyboard path to search/open; view/search/sort lost on refresh (no URL state); Show-more ×3 with no Show-all; cards re-sort under cursor on star.
Sam: details menus announce as disclosures not menus; clipped menu can contain focus while invisible; good: aria-pressed + live announcements on star, drag has menu fallback, focus-visible reveals hidden trigger.
## Minor
Hex drift → tokenise; "Folder order saved" announced before server confirms; star-teleport re-sort mid-scan; folder deserves own list-view column; NewPortfolioModal clashes (out of scope); no duplicate-name guard.
## Questions
- Rail folders as live drop targets — would drag-to-file make bulk-organise disappear?
- At 81 portfolios, is this a browse surface or a dispatcher (recents + starred + command palette first)?

View file

@ -104,6 +104,14 @@ _Avoid_: invasive (unused here), wet trades (a narrower, overlapping set: plaste
A Scenario constraint that takes a measure off the table for the optimiser. The *only* measure-constraint concept on a Scenario — there is no stored inclusion list; "only Solar PV + ASHP" is expressed by excluding everything else. An empty exclusion set is valid and means every measure is available.
_Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set)
**Modelling run**:
One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. A durable record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them, alongside how the work is going. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read.
_Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records)
**Run filter**:
The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). 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. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; 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).
_Avoid_: property query, segment, search (the postcode-search journey is unrelated)
## Lifecycle
A **BulkUpload** moves through these statuses:

41
PRODUCT.md Normal file
View file

@ -0,0 +1,41 @@
# Product
## Register
product
## Users
Three operator groups, all professionals in a task, none casual visitors:
- **Domna internal team** — analysts/consultants running assessments and modelling on behalf of landlords. Power users; live in the tool daily.
- **Housing-association staff** — landlord-side asset and sustainability managers tracking their portfolios' retrofit progress. Domain-expert but not tool-expert; visit weekly.
- **Contractors/approvers** — external parties with scoped capabilities on specific portfolios; arrive with a narrow job (approve, quote, deliver).
Shared context: desk work, large screens, often alongside spreadsheets and procurement documents. The home page's job is orientation and dispatch — find the right portfolio fast and understand its state at a glance.
## Product Purpose
Domna assesses UK housing portfolios for energy retrofit: ingesting property data (bulk uploads, postcode search), modelling retrofit interventions, and reporting outcomes (EPC uplift, CO2/energy savings, valuation impact) against budgets. Success looks like a landlord trusting Domna's numbers enough to commit retrofit budgets against them.
## Brand Personality
**Hypothesis (user undecided — validate against mockup variants):** calm, expert, institutional — quiet confidence appropriate to housing-sector procurement, with measured warmth from the brand tan/brown accents. Brand navy (`#14163d`) carries identity in small, deliberate doses; the surface stays neutral and legible. Not playful, not loud, never salesy inside the product.
## Anti-references
- **Generic SaaS admin templates** — Bootstrap/AdminLTE dashboards, hero-metric cards, widget walls.
- **The current home page** — a bare centered grid of identical icon cards with no data, hierarchy, or organisation.
- **Over-designed AI mockup output** — 2rem-rounded cards, giant soft shadows, decorative gradients, invented data the schema can't supply.
## Design Principles
1. **Show real state, not decoration.** Every element on a card earns its place by reflecting actual schema data (status, goal, properties, budget). No invented metrics.
2. **Orientation before information.** The home page dispatches users to the right portfolio; scanability (favourites, folders, search, sort) beats per-card completeness.
3. **Earned familiarity.** Standard affordances done precisely — the tool should disappear into the task for someone fluent in Linear/Stripe-class products.
4. **Trustworthy numbers.** Figures are the product; typography and alignment treat numeric data with more care than any decorative element.
5. **Consistency over surprise.** One component vocabulary across the app; delight lives in moments (empty states, transitions), not pages.
## Accessibility & Inclusion
WCAG 2.1 AA: ≥4.5:1 body-text contrast (≥3:1 large text), full keyboard operability, visible focus states, `prefers-reduced-motion` alternatives for all animation. B2B housing-sector procurement may require conformance evidence.

View file

@ -0,0 +1,118 @@
# 8. Modelling runs pass filters to the distributor; the backend owns resolution
Date: 2026-07-06
## Status
Accepted
Numbering note: 00040006 are referenced by CONTEXT.md but were lost to the old
`docs/adr/**` gitignore rule; 0008 avoids all collisions (0003 = app-authored
scenarios, 0007 = postcode search).
## Context
Users can now add properties (postcode search, bulk upload) and author
Scenarios (ADR-0003) before any modelling exists. The missing step is
triggering modelling for *chosen* properties: many Scenarios × a subset of the
portfolio, potentially 50,000 properties. The entry point on the Model side is
a **distributor** lambda — it fans work out to modelling workers, it does not
model synchronously. ADR-0003 recorded a known follow-up: the in-flight
"modelling running" state is not representable by derivation and needs an
explicit marker when trigger wiring lands. This is that wiring.
## Decision
- **A Modelling run is recorded as an app-created task** — the BulkUpload
trigger convention (`triggerAddressMatching`): Next.js inserts the `tasks`
row (service `modelling_run`, `source_id` = portfolio, status `waiting`)
whose **`inputs` column** (added for this — mirrors `sub_task.inputs`)
carries `{portfolio_id, scenario_ids, filters, previewed_property_count,
triggered_by}` as JSON, then passes `task_id` to the distributor, which
attaches its execution sub-tasks to the same task. **One new column, no new
table.** Run history and the per-scenario in-flight badge read the
portfolio's own `modelling_run` tasks (portfolio-scoped, a handful of rows)
and parse the app-authored inputs JSON; execution progress falls out of the
existing subtask-count summary pattern. Sub-task granularity is the
distributor's **batches**, so progress is shown as a percentage — batch
counts are internally consistent but never comparable to property or plan
numbers.
- **The distributor (`POST /v1/modelling/trigger-run`) receives
`{task_id, portfolio_id, scenario_ids, filters}`** — filters, never
property-id lists. The payload stays tiny at any portfolio size; the task's
`inputs` is the durable copy of the same request (plus provenance the
distributor doesn't need).
- **Dispatch failure marks the task `failed`** (an improvement on the
bulk-upload flow, which leaves failed triggers at `waiting`), so history
never shows a ghost run as pending.
- **The modelling backend is the single authority for resolving filters to
properties.** Resolution precedence: landlord-override fact → EPC-derived
value (new-approach EPC graph, lodged over predicted, or legacy property-row
columns) → **Unknown**. The app never re-implements this rule.
- **Preview counts are computed in-app** (amended 2026-07-06; originally a
backend dry-run endpoint): one grouped SQL statement resolves the filters —
override snapshot → EPC-derived (`propertyTypeSql`/`builtFormTypeSql` in
epcSources: new-approach graph, lodged over predicted, RdSAP codes mapped;
legacy property-row columns) → Unknown — and intersects the matched set
with `plan` for per-scenario already-modelled counts ("120 of 214 will be
re-modelled"). Both the app and the distributor read this database and
implement this documented rule; divergence is a bug in whichever side moved
without the other. Plan arithmetic is exact: one plan per
(scenario, property) per run.
- **Filter options are resolution-free in the app**: the postcode multi-select
is one `SELECT DISTINCT postcode` per portfolio; type/built-form options are
the static canonical enums plus Unknown. Filters are bounded (cap the
postcode multi-select) as a UX guard; "all" is an absent filter, never an
enumeration.
- **Run status is the task's status** — no correlation machinery needed: the
run and the execution share one record by construction. Scenario badges
gain a third derived state — "Modelling in progress" — and a per-portfolio
run history lists each run's filters, counts, initiator and derived status
with batch-based percentage progress.
- **Concurrent runs warn, never block**: triggering a scenario with a run in
flight shows who started it and when, but proceeds. Plans append and
latest-per-property wins on read, so overlap is safe; a hard lock could leak
on a dead run.
## Alternatives considered
- **A dedicated `modelling_run` table (+ scenarios join table)**: relational
scenario linkage and typed columns for who/filters/counts, but a migration,
a second record system beside tasks, and correlation machinery (either the
backend stamping run ids into tasks, or the app storing returned task ids).
The task system already records app-initiated pipeline work with inputs,
progress and status — and the badge/history queries are portfolio-scoped
over a handful of rows, so the JSON parse costs nothing that matters.
- **Pass resolved property ids to the distributor**: makes preview-vs-run
drift impossible by construction, but ~50k ids is a payload the async entry
point can't take, and it moves the resolution rule into the app — the
backend already owns it for modelling itself.
- **App-side count queries mirroring the resolution rule**: no backend
dependency for the preview, but the overrides-else-EPC rule would live in
two codebases and the shown count could disagree with what runs.
- **Status columns on the run row (two-writer, as bulk uploads)**: simple
reads, but a second source of truth that can silently never arrive; task
correlation reuses records the pipeline writes anyway.
- **Blocking concurrent runs**: prevents accidental double-spend but a stalled
pipeline would lock the scenario until someone clears task rows by hand.
## Consequences
- One backend ask must land with the distributor: accept an app-created
`task_id` and attach execution subtasks to it (rather than creating its own
task, as the current `plan_engine` entrypoint does). Its filter resolution
must match the rule above — the app's preview is the reference
implementation.
- The preview's already-modelled callout and matched counts are only as fresh
as trigger time; a property added between preview and trigger is picked up
by the run (filters re-resolve) — the run row records the previewed count so
discrepancies are visible after the fact.
- Reporting/scenario UIs gain a third derived scenario state; anything
assuming "has a run row ⇒ has plans" is wrong (a dead run may produce none).
- The tagging system (future) extends Run filters without touching the
contract shape: tags become one more optional filter key.
- Known follow-up: **email notification on run completion**. The dispatch
confirmation sets a time expectation only; "we'll email you when results
are ready" was deliberately kept out of the UI copy until a completion
notification exists. The run_id → task correlation gives the completion
signal to hang it on.

View file

@ -0,0 +1,51 @@
# 9. Portfolio organisation (folders, starring) is per-user, not shared
Date: 2026-07-07
## Status
Accepted
## Context
The home-page redesign introduced folders and starring so users can organise
their portfolios. The obvious alternative was **shared, team-level
organisation** — one folder structure per organisation that everyone sees, the
way housing-association staff might expect from a shared drive.
The user base spans three groups with conflicting mental models: Domna
analysts touching many portfolios daily, housing-association asset managers
visiting weekly, and contractors with narrow scoped access. One person's
"SHDF Wave 3" triage grouping is another's noise; a contractor can see only a
subset of any shared structure, which renders shared folders confusing
(half-empty groups) or leaky (folder names disclose portfolios they can't
see).
## Decision
Folders and starring belong to the individual user:
- `user_portfolio_folders` and `user_portfolio_config` (migration 0264) key
everything on `user_id`. A composite FK `(folder_id, user_id) →
user_portfolio_folders(id, user_id)` makes cross-user filing impossible at
the database level.
- `portfolioUsers` remains pure access control; the config layer never
grants or implies access. Config rows are created lazily and simply stop
rendering if access is revoked (they resurface on re-invite).
- Folder deletion only unfiles; it is app-mediated (unfile + delete in one
transaction) because the composite FK is `NO ACTION`.
- The UI states the model ("Folders are personal to you — teammates organise
their own") and folder URLs (`/home?folder=…`) resolve per-viewer.
## Consequences
- No permission model, naming disputes, or sync semantics for shared
structures — the feature stayed one schema PR and one page.
- Teams cannot share a curated view. If that demand materialises, shared
organisation should be **additive** (e.g. org-level folders alongside
personal ones), not a mutation of this schema — the per-user tables and
the composite FK do not accommodate multi-owner folders.
- Every user starts from an unorganised list and does their own filing;
drag-to-file exists to keep that cheap.
- Support/debugging must remember that two users looking at "the same"
home page see different groupings by design.

View file

@ -0,0 +1,792 @@
<!doctype html>
<html class="light" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Domna | Property Portfolios</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Manrope:wght@700;800;900&amp;display=swap"
rel="stylesheet"
/>
<!-- Icons -->
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
rel="stylesheet"
/>
<!-- Tailwind -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-background": "#151d21",
tertiary: "#000000",
"surface-container-highest": "#dce3e9",
"inverse-primary": "#c1c2f3",
"error-container": "#ffdad6",
primary: "#000000",
"inverse-on-surface": "#ebf2f8",
"inverse-surface": "#2a3136",
"surface-container-low": "#edf4fa",
"surface-dim": "#d4dbe1",
"on-surface-variant": "#46464e",
"tertiary-fixed": "#ffddb4",
"secondary-fixed-dim": "#bec2ff",
"outline-variant": "#c7c5cf",
"on-secondary-container": "#29308b",
"on-primary-fixed-variant": "#41436c",
"primary-fixed": "#e1e0ff",
"secondary-fixed": "#e0e0ff",
"on-tertiary-container": "#9c7f5a",
"surface-container-lowest": "#ffffff",
"on-primary": "#ffffff",
"surface-tint": "#595b85",
"on-tertiary-fixed": "#291800",
secondary: "#4e55b0",
"on-secondary-fixed": "#00036b",
"on-error": "#ffffff",
"on-tertiary-fixed-variant": "#5a4222",
"surface-variant": "#dce3e9",
"on-tertiary": "#ffffff",
"primary-container": "#15173e",
"secondary-container": "#969efe",
"on-secondary": "#ffffff",
"on-primary-container": "#7e80ad",
surface: "#f5faff",
"tertiary-container": "#291800",
"primary-fixed-dim": "#c1c2f3",
error: "#ba1a1a",
"on-error-container": "#93000a",
"on-secondary-fixed-variant": "#353c97",
outline: "#77767f",
"surface-container": "#e8eff5",
"surface-container-high": "#e2e9ef",
"on-primary-fixed": "#15173e",
"surface-bright": "#f5faff",
"tertiary-fixed-dim": "#e3c197",
"on-surface": "#151d21",
background: "#f5faff",
},
borderRadius: {
DEFAULT: "0.25rem",
lg: "0.5rem",
xl: "0.75rem",
full: "9999px",
},
fontFamily: {
headline: ["Manrope"],
body: ["Inter"],
label: ["Inter"],
},
},
},
};
</script>
<style>
body {
font-family: "Inter", sans-serif;
}
.font-headline {
font-family: "Manrope", sans-serif;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
display: inline-block;
vertical-align: middle;
}
</style>
</head>
<body class="bg-surface text-on-surface min-h-screen">
<!-- TopNavBar -->
<header
class="bg-[#f5faff]/80 dark:bg-[#14163d]/80 backdrop-blur-3xl shadow-[0_40px_60px_rgba(21,29,33,0.04)] docked full-width top-0 sticky z-50"
>
<div
class="flex justify-between items-center w-full px-8 py-4 max-w-[1440px] mx-auto"
>
<div class="flex items-center gap-8">
<span
class="text-2xl font-black text-[#14163d] dark:text-white tracking-tighter font-headline"
>Domna</span
>
<nav class="hidden md:flex items-center gap-6">
<a
class="text-[#14163d] dark:text-white border-b-2 border-[#14163d] dark:border-white pb-1 font-headline font-bold tracking-tight"
href="#"
>Portfolio</a
>
<a
class="text-[#14163d]/60 dark:text-[#f5faff]/60 hover:text-[#14163d] dark:hover:text-white transition-colors font-headline font-bold tracking-tight"
href="#"
>Reporting</a
>
<a
class="text-[#14163d]/60 dark:text-[#f5faff]/60 hover:text-[#14163d] dark:hover:text-white transition-colors font-headline font-bold tracking-tight"
href="#"
>Decent Homes</a
>
<a
class="text-[#14163d]/60 dark:text-[#f5faff]/60 hover:text-[#14163d] dark:hover:text-white transition-colors font-headline font-bold tracking-tight"
href="#"
>Projects</a
>
</nav>
</div>
<div class="flex items-center gap-4">
<div class="relative group">
<span
class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant"
>search</span
>
<input
class="pl-10 pr-4 py-2 bg-surface-container-highest rounded-lg border-none focus:ring-2 focus:ring-secondary/40 text-sm w-64 transition-all"
placeholder="Search portfolios..."
type="text"
/>
</div>
<button
class="p-2 hover:bg-[#edf4fa] dark:hover:bg-[#1c1f4a] rounded-lg transition-all scale-95 active:opacity-80"
>
<span
class="material-symbols-outlined text-[#14163d] dark:text-[#f5faff]"
>notifications</span
>
</button>
<button
class="p-2 hover:bg-[#edf4fa] dark:hover:bg-[#1c1f4a] rounded-lg transition-all scale-95 active:opacity-80"
>
<span
class="material-symbols-outlined text-[#14163d] dark:text-[#f5faff]"
>settings</span
>
</button>
<div
class="w-10 h-10 rounded-full overflow-hidden bg-surface-container-high ml-2 border-2 border-surface-container-highest"
>
<img
alt="User profile"
class="w-full h-full object-cover"
data-alt="professional headshot of a mature architect with a thoughtful expression in a bright modern studio setting"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAhDifmEzkeiUoz7-qk7MgfZWfDcrj2ZQ0YFyM_QySQW7V5p7txGp2Npp2udFSI4WGqDaDlfUTq15TP_ZSM7NpHCbeLgX_GBtuEfpFUEJt-pjry3M3WB9ldA3UH2zZuZyqvEKFESQ75eT9dHUOJBMrTa_aZXEmsyuEkGIHPY9-T0VHC5t_FgM10CZ0rWy84ATDoIJFnZDA3OblJtPVNuEeWOrJJj7PbJq5Om624QV6-49DGfnFy05CrPZm-HgqXlJ5Ah9XhHPCAQqH_"
/>
</div>
</div>
</div>
</header>
<main class="max-w-[1440px] mx-auto px-8 py-12">
<!-- Page Header -->
<div
class="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-12"
>
<div>
<h1
class="text-5xl font-black text-[#14163d] tracking-tighter font-headline mb-4"
>
Property Portfolios
</h1>
<p
class="text-on-surface-variant max-w-xl font-medium leading-relaxed"
>
Strategic oversight of energy retrofit assets. Curate, monitor, and
scale sustainability across your entire estate architecture.
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex bg-surface-container-low p-1 rounded-xl">
<button
class="p-2 bg-white shadow-sm rounded-lg text-primary transition-all"
>
<span class="material-symbols-outlined">grid_view</span>
</button>
<button
class="p-2 text-on-surface-variant hover:text-primary transition-all"
>
<span class="material-symbols-outlined">list</span>
</button>
</div>
<button
class="flex items-center gap-2 px-6 py-3 bg-gradient-to-br from-primary-container to-[#1c1f4a] text-white rounded-xl font-bold tracking-tight hover:shadow-lg hover:shadow-primary/10 transition-all active:scale-[0.98]"
>
<span class="material-symbols-outlined text-[20px]">add</span>
<span>Add New Portfolio</span>
</button>
</div>
</div>
<!-- Filter Bar -->
<div
class="flex flex-wrap items-center gap-4 mb-10 pb-6 border-b border-outline-variant/10"
>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Status:</span>
<span class="text-primary">All Active</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Tag:</span>
<span class="text-primary">Residential</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Sort by:</span>
<span class="text-primary">Budget (High)</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div class="h-6 w-[1px] bg-outline-variant/30 mx-2"></div>
<button
class="text-sm font-bold text-secondary hover:underline underline-offset-4"
>
Reset Filters
</button>
</div>
<!-- Portfolio Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Card 1 -->
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
London Social Housing
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Greater London Authority
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary">£4.2M / £6.5M</span>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 64%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating A</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">142 Units</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>High Priority</span
>
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Residential</span
>
</div>
</div>
<!-- Card 2 -->
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-surface-container-highest px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-surface-variant"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>Planning</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span
class="material-symbols-outlined text-[20px]"
style="font-variation-settings: &quot;FILL&quot; 1"
>star</span
>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Commercial Retrofit 2026
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Manchester City Council
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary"
>£0.8M / £12.0M</span
>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 7%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating B+</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">28 Assets</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Commercial</span
>
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Carbon Neutral</span
>
</div>
</div>
<!-- Card 3 -->
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Oxfordshire Eco Village
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Regional Development
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary">£2.9M / £3.1M</span>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 93%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Net Zero</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">18 Units</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Sustainability</span
>
</div>
</div>
<!-- Card 4 -->
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Northern Estate Phase II
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Leeds District
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary"
>£5.1M / £15.0M</span
>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 34%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating B</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">310 Units</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Residential</span
>
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Scale</span
>
</div>
</div>
<!-- Card 5 -->
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-surface-container-highest px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-surface-variant"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>Planning</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Birmingham Tech Hub
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Innovation District
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary">£0.1M / £4.5M</span>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 2%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating A</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">4 Assets</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Commercial</span
>
</div>
</div>
<!-- Card 6 (Empty/CTA state) -->
<button
class="group border-2 border-dashed border-outline-variant/40 hover:border-secondary/40 rounded-[2rem] flex flex-col items-center justify-center p-12 transition-all duration-300"
>
<div
class="w-16 h-16 bg-surface-container-low rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
>
<span class="material-symbols-outlined text-secondary text-3xl"
>add_circle</span
>
</div>
<span class="text-lg font-black text-[#14163d] font-headline"
>Create Portfolio</span
>
<span class="text-sm text-on-surface-variant mt-2 text-center"
>Start a new sustainability journey for your assets.</span
>
</button>
</div>
<!-- Pagination / Scalability Footer -->
<div class="mt-20 flex flex-col items-center gap-6">
<div class="flex items-center gap-2">
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant"
>
<span class="material-symbols-outlined">chevron_left</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg bg-primary-container text-white font-bold"
>
1
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold"
>
2
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold"
>
3
</button>
<span class="px-2 text-on-surface-variant">...</span>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold"
>
24
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant"
>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</div>
<p class="text-sm font-medium text-on-surface-variant">
Showing 6 of 142 portfolios
</p>
</div>
</main>
<!-- Side Navigation Placeholder (Contextual Filter) - Hidden for this view as per prompt, but showing TopNavBar structure -->
<footer class="mt-24 border-t border-outline-variant/10 py-12 px-8">
<div
class="max-w-[1440px] mx-auto flex justify-between items-center text-sm font-medium text-on-surface-variant"
>
<p>© 2024 Domna Estate Management. All rights reserved.</p>
<div class="flex gap-8">
<a class="hover:text-primary transition-colors" href="#"
>Documentation</a
>
<a class="hover:text-primary transition-colors" href="#"
>Privacy Policy</a
>
<a class="hover:text-primary transition-colors" href="#"
>Terms of Service</a
>
</div>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,972 @@
<html class="light" lang="en">
<head></head>
<body class="bg-surface text-on-surface min-h-screen">
```html
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Domna | Property Portfolios</title>
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Manrope:wght@700;800;900&amp;display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-background": "#151d21",
tertiary: "#000000",
"surface-container-highest": "#dce3e9",
"inverse-primary": "#c1c2f3",
"error-container": "#ffdad6",
primary: "#000000",
"inverse-on-surface": "#ebf2f8",
"inverse-surface": "#2a3136",
"surface-container-low": "#edf4fa",
"surface-dim": "#d4dbe1",
"on-surface-variant": "#46464e",
"tertiary-fixed": "#ffddb4",
"secondary-fixed-dim": "#bec2ff",
"outline-variant": "#c7c5cf",
"on-secondary-container": "#29308b",
"on-primary-fixed-variant": "#41436c",
"primary-fixed": "#e1e0ff",
"secondary-fixed": "#e0e0ff",
"on-tertiary-container": "#9c7f5a",
"surface-container-lowest": "#ffffff",
"on-primary": "#ffffff",
"surface-tint": "#595b85",
"on-tertiary-fixed": "#291800",
secondary: "#4e55b0",
"on-secondary-fixed": "#00036b",
"on-error": "#ffffff",
"on-tertiary-fixed-variant": "#5a4222",
"surface-variant": "#dce3e9",
"on-tertiary": "#ffffff",
"primary-container": "#14163d",
"secondary-container": "#969efe",
"on-secondary": "#ffffff",
"on-primary-container": "#7e80ad",
surface: "#f5faff",
"tertiary-container": "#291800",
"primary-fixed-dim": "#c1c2f3",
error: "#ba1a1a",
"on-error-container": "#93000a",
"on-secondary-fixed-variant": "#353c97",
outline: "#77767f",
"surface-container": "#e8eff5",
"surface-container-high": "#e2e9ef",
"on-primary-fixed": "#15173e",
"surface-bright": "#f5faff",
"tertiary-fixed-dim": "#e3c197",
"on-surface": "#151d21",
background: "#f5faff",
},
borderRadius: {
DEFAULT: "0.25rem",
lg: "0.5rem",
xl: "0.75rem",
full: "9999px",
},
fontFamily: {
headline: ["Manrope"],
body: ["Inter"],
label: ["Inter"],
},
},
},
};
</script>
<style type="text/tailwindcss">
body {
font-family: "Inter", sans-serif;
}
.font-headline {
font-family: "Manrope", sans-serif;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
display: inline-block;
vertical-align: middle;
}
#view-grid:checked ~ #grid-container {
display: grid;
}
#view-grid:checked ~ #table-container {
display: none;
}
#view-table:checked ~ #grid-container {
display: none;
}
#view-table:checked ~ #table-container {
display: block;
}
#view-grid:checked ~ div div label[for="view-grid"] {
@apply bg-white shadow-sm text-primary;
}
#view-table:checked ~ div div label[for="view-table"] {
@apply bg-white shadow-sm text-primary;
}
</style>
<input
checked=""
class="hidden"
id="view-grid"
name="view-mode"
type="radio"
/>
<input class="hidden" id="view-table" name="view-mode" type="radio" />
<header
class="bg-[#f5faff]/80 backdrop-blur-3xl shadow-[0_40px_60px_rgba(21,29,33,0.04)] top-0 sticky z-50"
>
<div
class="flex justify-between items-center w-full px-8 py-4 max-w-[1440px] mx-auto"
>
<div class="flex items-center gap-8">
<span
class="text-2xl font-black text-[#14163d] tracking-tighter font-headline"
>Domna</span
>
<nav class="hidden md:flex items-center gap-6">
<a
class="text-[#14163d] border-b-2 border-[#14163d] pb-1 font-headline font-bold tracking-tight"
href="#"
>Portfolio</a
>
<a
class="text-[#14163d]/60 hover:text-[#14163d] transition-colors font-headline font-bold tracking-tight"
href="#"
>Reporting</a
>
<a
class="text-[#14163d]/60 hover:text-[#14163d] transition-colors font-headline font-bold tracking-tight"
href="#"
>Decent Homes</a
>
<a
class="text-[#14163d]/60 hover:text-[#14163d] transition-colors font-headline font-bold tracking-tight"
href="#"
>Projects</a
>
</nav>
</div>
<div class="flex items-center gap-4">
<div class="relative group">
<span
class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant"
>search</span
>
<input
class="pl-10 pr-4 py-2 bg-surface-container-highest rounded-lg border-none focus:ring-2 focus:ring-secondary/40 text-sm w-64 transition-all"
placeholder="Search portfolios..."
type="text"
/>
</div>
<button
class="p-2 hover:bg-[#edf4fa] rounded-lg transition-all scale-95 active:opacity-80"
>
<span class="material-symbols-outlined text-[#14163d]"
>notifications</span
>
</button>
<button
class="p-2 hover:bg-[#edf4fa] rounded-lg transition-all scale-95 active:opacity-80"
>
<span class="material-symbols-outlined text-[#14163d]"
>settings</span
>
</button>
<div
class="w-10 h-10 rounded-full overflow-hidden bg-surface-container-high ml-2 border-2 border-surface-container-highest"
>
<img
alt="User profile"
class="w-full h-full object-cover"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAhDifmEzkeiUoz7-qk7MgfZWfDcrj2ZQ0YFyM_QySQW7V5p7txGp2Npp2udFSI4WGqDaDlfUTq15TP_ZSM7NpHCbeLgX_GBtuEfpFUEJt-pjry3M3WB9ldA3UH2zZuZyqvEKFESQ75eT9dHUOJBMrTa_aZXEmsyuEkGIHPY9-T0VHC5t_FgM10CZ0rWy84ATDoIJFnZDA3OblJtPVNuEeWOrJJj7PbJq5Om624QV6-49DGfnFy05CrPZm-HgqXlJ5Ah9XhHPCAQqH_"
/>
</div>
</div>
</div>
</header>
<main class="max-w-[1440px] mx-auto px-8 py-12">
<div class="mb-12">
<button
class="flex items-center gap-2 px-5 py-2.5 bg-[#14163d] text-white rounded-xl font-bold tracking-tight hover:shadow-lg hover:shadow-primary/10 transition-all active:scale-[0.98] mb-6"
>
<span class="material-symbols-outlined text-[20px]">add</span>
<span>Add New Portfolio</span>
</button>
<div
class="flex flex-col md:flex-row md:items-end justify-between gap-8"
>
<div>
<h1
class="text-5xl font-black text-[#14163d] tracking-tighter font-headline mb-4"
>
Property Portfolios
</h1>
<p
class="text-on-surface-variant max-w-xl font-medium leading-relaxed"
>
Strategic oversight of energy retrofit assets. Curate, monitor,
and scale sustainability across your entire estate architecture.
</p>
</div>
<div class="flex items-center gap-4">
<div
class="flex items-center gap-2 px-3 py-1.5 bg-surface-container-low rounded-xl border border-outline-variant/30"
>
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider pl-1"
>Show</span
>
<select
class="bg-transparent border-none text-sm font-bold text-[#14163d] focus:ring-0 p-1 cursor-pointer"
>
<option value="12">12</option>
<option selected="" value="24">24</option>
<option value="48">48</option>
<option value="96">96</option>
</select>
</div>
<div class="flex bg-surface-container-low p-1 rounded-xl">
<label
class="p-2 cursor-pointer rounded-lg text-on-surface-variant transition-all flex items-center justify-center"
for="view-grid"
>
<span class="material-symbols-outlined">grid_view</span>
</label>
<label
class="p-2 cursor-pointer rounded-lg text-on-surface-variant transition-all flex items-center justify-center"
for="view-table"
>
<span class="material-symbols-outlined">list</span>
</label>
</div>
</div>
</div>
</div>
<div
class="flex flex-wrap items-center gap-4 mb-10 pb-6 border-b border-outline-variant/10"
>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Status:</span>
<span class="text-primary font-bold">All Active</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Tag:</span>
<span class="text-primary font-bold">Residential</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Sort by:</span>
<span class="text-primary font-bold">Budget (High)</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div class="h-6 w-[1px] bg-outline-variant/30 mx-2"></div>
<button
class="text-sm font-bold text-secondary hover:underline underline-offset-4"
>
Reset Filters
</button>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
id="grid-container"
>
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
London Social Housing
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Greater London Authority
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary">£4.2M / £6.5M</span>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 64%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating A</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">142 Units</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>High Priority</span
>
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Residential</span
>
</div>
</div>
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-surface-container-highest px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-surface-variant"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>Planning</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span
class="material-symbols-outlined text-[20px]"
style="font-variation-settings: &quot;FILL&quot; 1"
>star</span
>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Commercial Retrofit 2026
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Manchester City Council
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary"
>£0.8M / £12.0M</span
>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 7%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating B+</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">28 Assets</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Commercial</span
>
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Carbon Neutral</span
>
</div>
</div>
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Oxfordshire Eco Village
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Regional Development
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary">£2.9M / £3.1M</span>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 93%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Net Zero</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">18 Units</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Sustainability</span
>
</div>
</div>
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Northern Estate Phase II
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Leeds District
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary"
>£5.1M / £15.0M</span
>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 34%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating B</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">310 Units</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Residential</span
>
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Scale</span
>
</div>
</div>
<div
class="group bg-surface-container-lowest p-6 rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] hover:translate-y-[-4px] transition-all duration-300 relative overflow-hidden"
>
<div class="flex justify-between items-start mb-6">
<div
class="bg-surface-container-highest px-3 py-1 rounded-full flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-surface-variant"
></span>
<span
class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>Planning</span
>
</div>
<div class="flex gap-1">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]">star</span>
</button>
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined text-[20px]"
>more_vert</span
>
</button>
</div>
</div>
<h3
class="text-2xl font-black text-[#14163d] font-headline mb-2 leading-tight"
>
Birmingham Tech Hub
</h3>
<p
class="text-sm text-on-surface-variant mb-6 flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]"
>location_on</span
>
Innovation District
</p>
<div class="space-y-4 mb-8">
<div class="flex justify-between items-end">
<span
class="text-xs font-bold text-on-surface-variant uppercase tracking-wider"
>Budget Utilization</span
>
<span class="text-sm font-black text-primary">£0.1M / £4.5M</span>
</div>
<div
class="h-2 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 2%"
></div>
</div>
</div>
<div
class="grid grid-cols-2 gap-4 pt-6 border-t border-outline-variant/10"
>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Target EPC</span
>
<span class="text-lg font-black text-[#14163d]">Rating A</span>
</div>
<div>
<span
class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-1"
>Properties</span
>
<span class="text-lg font-black text-[#14163d]">4 Assets</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<span
class="px-3 py-1 bg-surface-container-low rounded-lg text-[11px] font-bold text-on-secondary-container"
>Commercial</span
>
</div>
</div>
<button
class="group border-2 border-dashed border-outline-variant/40 hover:border-[#14163d]/40 rounded-[2rem] flex flex-col items-center justify-center p-12 transition-all duration-300"
>
<div
class="w-16 h-16 bg-surface-container-low rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
>
<span class="material-symbols-outlined text-[#14163d] text-3xl"
>add_circle</span
>
</div>
<span class="text-lg font-black text-[#14163d] font-headline"
>Create Portfolio</span
>
<span class="text-sm text-on-surface-variant mt-2 text-center"
>Start a new sustainability journey for your assets.</span
>
</button>
</div>
<div
class="hidden overflow-hidden bg-surface-container-lowest rounded-[2rem] shadow-[0_40px_60px_rgba(21,29,33,0.04)] border border-outline-variant/10"
id="table-container"
>
<table class="w-full text-left border-collapse">
<thead>
<tr
class="border-b border-outline-variant/10 bg-surface-container-low/50"
>
<th
class="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>
Portfolio Name
</th>
<th
class="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>
Status
</th>
<th
class="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>
Budget
</th>
<th
class="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>
Progress
</th>
<th
class="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>
Target EPC
</th>
<th
class="px-6 py-4 text-[10px] font-bold uppercase tracking-widest text-on-surface-variant"
>
Units
</th>
<th class="px-6 py-4 w-10"></th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/10">
<tr class="hover:bg-surface-container-low/30 transition-colors">
<td class="px-6 py-5">
<div class="font-bold text-[#14163d]">
London Social Housing
</div>
<div class="text-[10px] text-on-surface-variant">
Greater London Authority
</div>
</td>
<td class="px-6 py-5">
<span
class="px-2 py-0.5 bg-tertiary-fixed text-[10px] font-bold text-on-tertiary-fixed rounded-full uppercase tracking-wider"
>In Progress</span
>
</td>
<td class="px-6 py-5 font-bold text-sm">£6.5M</td>
<td class="px-6 py-5">
<div
class="w-24 h-1.5 bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container"
style="width: 64%"
></div>
</div>
</td>
<td class="px-6 py-5 font-bold">A</td>
<td class="px-6 py-5 text-sm">142</td>
<td class="px-6 py-5">
<span
class="material-symbols-outlined text-on-surface-variant cursor-pointer"
>more_vert</span
>
</td>
</tr>
<tr class="hover:bg-surface-container-low/30 transition-colors">
<td class="px-6 py-5">
<div class="font-bold text-[#14163d]">
Commercial Retrofit 2026
</div>
<div class="text-[10px] text-on-surface-variant">
Manchester City Council
</div>
</td>
<td class="px-6 py-5">
<span
class="px-2 py-0.5 bg-surface-container-highest text-[10px] font-bold text-on-surface-variant rounded-full uppercase tracking-wider"
>Planning</span
>
</td>
<td class="px-6 py-5 font-bold text-sm">£12.0M</td>
<td class="px-6 py-5">
<div
class="w-24 h-1.5 bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container"
style="width: 7%"
></div>
</div>
</td>
<td class="px-6 py-5 font-bold">B+</td>
<td class="px-6 py-5 text-sm">28</td>
<td class="px-6 py-5">
<span
class="material-symbols-outlined text-on-surface-variant cursor-pointer"
>more_vert</span
>
</td>
</tr>
<tr class="hover:bg-surface-container-low/30 transition-colors">
<td class="px-6 py-5">
<div class="font-bold text-[#14163d]">
Oxfordshire Eco Village
</div>
<div class="text-[10px] text-on-surface-variant">
Regional Development
</div>
</td>
<td class="px-6 py-5">
<span
class="px-2 py-0.5 bg-tertiary-fixed text-[10px] font-bold text-on-tertiary-fixed rounded-full uppercase tracking-wider"
>In Progress</span
>
</td>
<td class="px-6 py-5 font-bold text-sm">£3.1M</td>
<td class="px-6 py-5">
<div
class="w-24 h-1.5 bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container"
style="width: 93%"
></div>
</div>
</td>
<td class="px-6 py-5 font-bold">Net Zero</td>
<td class="px-6 py-5 text-sm">18</td>
<td class="px-6 py-5">
<span
class="material-symbols-outlined text-on-surface-variant cursor-pointer"
>more_vert</span
>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-20 flex flex-col items-center gap-6">
<div class="flex items-center gap-2">
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant"
>
<span class="material-symbols-outlined">chevron_left</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg bg-[#14163d] text-white font-bold"
>
1
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold"
>
2
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold"
>
3
</button>
<span class="px-2 text-on-surface-variant">...</span>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold"
>
24
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant"
>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</div>
<p class="text-sm font-medium text-on-surface-variant">
Showing 6 of 142 portfolios
</p>
</div>
</main>
<footer class="mt-24 border-t border-outline-variant/10 py-12 px-8">
<div
class="max-w-[1440px] mx-auto flex justify-between items-center text-sm font-medium text-on-surface-variant"
>
<p>© 2024 Domna Estate Management. All rights reserved.</p>
<div class="flex gap-8">
<a class="hover:text-primary transition-colors" href="#"
>Documentation</a
>
<a class="hover:text-primary transition-colors" href="#"
>Privacy Policy</a
>
<a class="hover:text-primary transition-colors" href="#"
>Terms of Service</a
>
</div>
</div>
</footer>
```
</body>
</html>

View file

@ -0,0 +1,540 @@
<!doctype html>
<html class="light" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Domna | Property Portfolios</title>
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Manrope:wght@700;800;900&amp;display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-background": "#151d21",
tertiary: "#000000",
"surface-container-highest": "#dce3e9",
"inverse-primary": "#c1c2f3",
"error-container": "#ffdad6",
primary: "#000000",
"inverse-on-surface": "#ebf2f8",
"inverse-surface": "#2a3136",
"surface-container-low": "#edf4fa",
"surface-dim": "#d4dbe1",
"on-surface-variant": "#46464e",
"tertiary-fixed": "#ffddb4",
"secondary-fixed-dim": "#bec2ff",
"outline-variant": "#c7c5cf",
"on-secondary-container": "#29308b",
"on-primary-fixed-variant": "#41436c",
"primary-fixed": "#e1e0ff",
"secondary-fixed": "#e0e0ff",
"on-tertiary-container": "#9c7f5a",
"surface-container-lowest": "#ffffff",
"on-primary": "#ffffff",
"surface-tint": "#595b85",
"on-tertiary-fixed": "#291800",
secondary: "#4e55b0",
"on-secondary-fixed": "#00036b",
"on-error": "#ffffff",
"on-tertiary-fixed-variant": "#5a4222",
"surface-variant": "#dce3e9",
"on-tertiary": "#ffffff",
"primary-container": "#15173e",
"secondary-container": "#969efe",
"on-secondary": "#ffffff",
"on-primary-container": "#7e80ad",
surface: "#f5faff",
"tertiary-container": "#291800",
"primary-fixed-dim": "#c1c2f3",
error: "#ba1a1a",
"on-error-container": "#93000a",
"on-secondary-fixed-variant": "#353c97",
outline: "#77767f",
"surface-container": "#e8eff5",
"surface-container-high": "#e2e9ef",
"on-primary-fixed": "#15173e",
"surface-bright": "#f5faff",
"tertiary-fixed-dim": "#e3c197",
"on-surface": "#151d21",
background: "#f5faff",
},
borderRadius: {
DEFAULT: "0.25rem",
lg: "0.5rem",
xl: "0.75rem",
full: "9999px",
},
fontFamily: {
headline: ["Manrope"],
body: ["Inter"],
label: ["Inter"],
},
},
},
};
</script>
<style>
body {
font-family: "Inter", sans-serif;
}
.font-headline {
font-family: "Manrope", sans-serif;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
display: inline-block;
vertical-align: middle;
}
</style>
</head>
<body class="bg-surface text-on-surface min-h-screen">
<header
class="bg-[#f5faff]/80 dark:bg-[#14163d]/80 backdrop-blur-3xl shadow-[0_40px_60px_rgba(21,29,33,0.04)] docked full-width top-0 sticky z-50"
>
<div
class="flex justify-between items-center w-full px-8 py-4 max-w-[1440px] mx-auto"
>
<div class="flex items-center gap-8">
<span
class="text-2xl font-black text-[#14163d] dark:text-white tracking-tighter font-headline"
>Domna</span
>
<nav class="hidden md:flex items-center gap-6">
<a
class="text-[#14163d] dark:text-white border-b-2 border-[#14163d] dark:border-white pb-1 font-headline font-bold tracking-tight"
href="#"
>Portfolio</a
>
<a
class="text-[#14163d]/60 dark:text-[#f5faff]/60 hover:text-[#14163d] dark:hover:text-white transition-colors font-headline font-bold tracking-tight"
href="#"
>Reporting</a
>
<a
class="text-[#14163d]/60 dark:text-[#f5faff]/60 hover:text-[#14163d] dark:hover:text-white transition-colors font-headline font-bold tracking-tight"
href="#"
>Decent Homes</a
>
<a
class="text-[#14163d]/60 dark:text-[#f5faff]/60 hover:text-[#14163d] dark:hover:text-white transition-colors font-headline font-bold tracking-tight"
href="#"
>Projects</a
>
</nav>
</div>
<div class="flex items-center gap-4">
<div class="relative group">
<span
class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant"
>search</span
>
<input
class="pl-10 pr-4 py-2 bg-surface-container-highest rounded-lg border-none focus:ring-2 focus:ring-secondary/40 text-sm w-64 transition-all"
placeholder="Search portfolios..."
type="text"
/>
</div>
<button
class="p-2 hover:bg-[#edf4fa] dark:hover:bg-[#1c1f4a] rounded-lg transition-all scale-95 active:opacity-80"
>
<span
class="material-symbols-outlined text-[#14163d] dark:text-[#f5faff]"
>notifications</span
>
</button>
<button
class="p-2 hover:bg-[#edf4fa] dark:hover:bg-[#1c1f4a] rounded-lg transition-all scale-95 active:opacity-80"
>
<span
class="material-symbols-outlined text-[#14163d] dark:text-[#f5faff]"
>settings</span
>
</button>
<div
class="w-10 h-10 rounded-full overflow-hidden bg-surface-container-high ml-2 border-2 border-surface-container-highest"
>
<img
alt="User profile"
class="w-full h-full object-cover"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAhDifmEzkeiUoz7-qk7MgfZWfDcrj2ZQ0YFyM_QySQW7V5p7txGp2Npp2udFSI4WGqDaDlfUTq15TP_ZSM7NpHCbeLgX_GBtuEfpFUEJt-pjry3M3WB9ldA3UH2zZuZyqvEKFESQ75eT9dHUOJBMrTa_aZXEmsyuEkGIHPY9-T0VHC5t_FgM10CZ0rWy84ATDoIJFnZDA3OblJtPVNuEeWOrJJj7PbJq5Om624QV6-49DGfnFy05CrPZm-HgqXlJ5Ah9XhHPCAQqH_"
/>
</div>
</div>
</div>
</header>
<main class="max-w-[1440px] mx-auto px-8 py-12">
<div class="mb-8">
<button
class="flex items-center gap-2 px-6 py-3 bg-gradient-to-br from-primary-container to-[#1c1f4a] text-white rounded-xl font-bold tracking-tight hover:shadow-lg hover:shadow-primary/10 transition-all active:scale-[0.98]"
>
<span class="material-symbols-outlined text-[20px]">add</span>
<span>Add New Portfolio</span>
</button>
</div>
<div
class="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-12"
>
<div>
<h1
class="text-5xl font-black text-[#14163d] tracking-tighter font-headline mb-4"
>
Property Portfolios
</h1>
<p
class="text-on-surface-variant max-w-xl font-medium leading-relaxed"
>
Strategic oversight of energy retrofit assets. Curate, monitor, and
scale sustainability across your entire estate architecture.
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex bg-surface-container-low p-1 rounded-xl">
<button
class="p-2 text-on-surface-variant hover:text-primary transition-all"
>
<span class="material-symbols-outlined">grid_view</span>
</button>
<button
class="p-2 bg-white shadow-sm rounded-lg text-primary transition-all"
>
<span class="material-symbols-outlined">list</span>
</button>
</div>
</div>
</div>
<div
class="flex flex-wrap items-center gap-4 mb-6 pb-6 border-b border-outline-variant/10"
>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Status:</span>
<span class="text-primary">All Active</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Tag:</span>
<span class="text-primary">Residential</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-surface-container-low rounded-full text-sm font-semibold text-on-surface-variant cursor-pointer hover:bg-surface-container-high transition-all"
>
<span>Sort by:</span>
<span class="text-primary">Budget (High)</span>
<span class="material-symbols-outlined text-[18px]">expand_more</span>
</div>
<div class="h-6 w-[1px] bg-outline-variant/30 mx-2"></div>
<button
class="text-sm font-bold text-secondary hover:underline underline-offset-4"
>
Reset Filters
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-4">
<thead>
<tr
class="text-on-surface-variant text-[11px] font-bold uppercase tracking-widest px-6"
>
<th class="pb-2 px-6">Portfolio Name</th>
<th class="pb-2 px-6">Status</th>
<th class="pb-2 px-6">Budget Progress</th>
<th class="pb-2 px-6">Target EPC</th>
<th class="pb-2 px-6 text-right">Properties</th>
<th class="pb-2 px-6"></th>
</tr>
</thead>
<tbody class="space-y-4">
<tr
class="bg-surface-container-lowest group hover:translate-y-[-2px] transition-all duration-300 shadow-[0_4px_12px_rgba(21,29,33,0.02)]"
>
<td class="py-5 px-6 rounded-l-[1.5rem]">
<div>
<div class="font-headline font-black text-lg text-[#14163d]">
London Social Housing
</div>
<div
class="text-xs text-on-surface-variant font-medium flex items-center gap-1 mt-1"
>
<span class="material-symbols-outlined text-[12px]"
>location_on</span
>
Greater London Authority
</div>
</div>
</td>
<td class="py-5 px-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full inline-flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[9px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
</td>
<td class="py-5 px-6 w-1/4">
<div class="space-y-2">
<div class="flex justify-between text-[11px] font-bold">
<span class="text-on-surface-variant">64%</span>
<span class="text-primary">£4.2M / £6.5M</span>
</div>
<div
class="h-1.5 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 64%"
></div>
</div>
</div>
</td>
<td class="py-5 px-6">
<span class="text-sm font-black text-[#14163d]">Rating A</span>
</td>
<td class="py-5 px-6 text-right">
<span class="text-sm font-black text-[#14163d]">142 Units</span>
</td>
<td class="py-5 px-6 rounded-r-[1.5rem] text-right">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined">more_vert</span>
</button>
</td>
</tr>
<tr
class="bg-surface-container-lowest group hover:translate-y-[-2px] transition-all duration-300 shadow-[0_4px_12px_rgba(21,29,33,0.02)]"
>
<td class="py-5 px-6 rounded-l-[1.5rem]">
<div>
<div class="font-headline font-black text-lg text-[#14163d]">
Commercial Retrofit 2026
</div>
<div
class="text-xs text-on-surface-variant font-medium flex items-center gap-1 mt-1"
>
<span class="material-symbols-outlined text-[12px]"
>location_on</span
>
Manchester City Council
</div>
</div>
</td>
<td class="py-5 px-6">
<div
class="bg-surface-container-highest px-3 py-1 rounded-full inline-flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-surface-variant"
></span>
<span
class="text-[9px] font-bold uppercase tracking-widest text-on-surface-variant"
>Planning</span
>
</div>
</td>
<td class="py-5 px-6 w-1/4">
<div class="space-y-2">
<div class="flex justify-between text-[11px] font-bold">
<span class="text-on-surface-variant">7%</span>
<span class="text-primary">£0.8M / £12.0M</span>
</div>
<div
class="h-1.5 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 7%"
></div>
</div>
</div>
</td>
<td class="py-5 px-6">
<span class="text-sm font-black text-[#14163d]">Rating B+</span>
</td>
<td class="py-5 px-6 text-right">
<span class="text-sm font-black text-[#14163d]">28 Assets</span>
</td>
<td class="py-5 px-6 rounded-r-[1.5rem] text-right">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined">more_vert</span>
</button>
</td>
</tr>
<tr
class="bg-surface-container-lowest group hover:translate-y-[-2px] transition-all duration-300 shadow-[0_4px_12px_rgba(21,29,33,0.02)]"
>
<td class="py-5 px-6 rounded-l-[1.5rem]">
<div>
<div class="font-headline font-black text-lg text-[#14163d]">
Oxfordshire Eco Village
</div>
<div
class="text-xs text-on-surface-variant font-medium flex items-center gap-1 mt-1"
>
<span class="material-symbols-outlined text-[12px]"
>location_on</span
>
Regional Development
</div>
</div>
</td>
<td class="py-5 px-6">
<div
class="bg-tertiary-fixed px-3 py-1 rounded-full inline-flex items-center gap-1.5"
>
<span
class="w-1.5 h-1.5 rounded-full bg-on-tertiary-fixed"
></span>
<span
class="text-[9px] font-bold uppercase tracking-widest text-on-tertiary-fixed"
>In Progress</span
>
</div>
</td>
<td class="py-5 px-6 w-1/4">
<div class="space-y-2">
<div class="flex justify-between text-[11px] font-bold">
<span class="text-on-surface-variant">93%</span>
<span class="text-primary">£2.9M / £3.1M</span>
</div>
<div
class="h-1.5 w-full bg-surface-container-high rounded-full overflow-hidden"
>
<div
class="h-full bg-primary-container rounded-full"
style="width: 93%"
></div>
</div>
</div>
</td>
<td class="py-5 px-6">
<span class="text-sm font-black text-[#14163d]">Net Zero</span>
</td>
<td class="py-5 px-6 text-right">
<span class="text-sm font-black text-[#14163d]">18 Units</span>
</td>
<td class="py-5 px-6 rounded-r-[1.5rem] text-right">
<button
class="p-2 hover:bg-surface-container-low rounded-full transition-all text-on-surface-variant"
>
<span class="material-symbols-outlined">more_vert</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="mt-16 flex flex-col md:flex-row items-center justify-between gap-6 px-4"
>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-on-surface-variant"
>Items per page:</span
>
<div class="relative">
<select
class="appearance-none bg-surface-container-low border-none rounded-lg text-sm font-bold text-primary pl-4 pr-10 py-2 focus:ring-0 cursor-pointer"
>
<option>10</option>
<option selected="">25</option>
<option>50</option>
<option>100</option>
</select>
<span
class="material-symbols-outlined absolute right-2 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none"
>expand_more</span
>
</div>
</div>
<div class="flex flex-col items-center gap-4">
<div class="flex items-center gap-1">
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant transition-colors"
>
<span class="material-symbols-outlined text-[20px]"
>chevron_left</span
>
</button>
<button
class="w-9 h-9 flex items-center justify-center rounded-lg bg-primary-container text-white font-bold shadow-sm"
>
1
</button>
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold transition-colors"
>
2
</button>
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold transition-colors"
>
3
</button>
<span class="px-2 text-on-surface-variant">...</span>
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant font-bold transition-colors"
>
24
</button>
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-container-low text-on-surface-variant transition-colors"
>
<span class="material-symbols-outlined text-[20px]"
>chevron_right</span
>
</button>
</div>
<p
class="text-[11px] font-bold text-on-surface-variant uppercase tracking-widest"
>
Showing 1-25 of 142 portfolios
</p>
</div>
<div class="hidden md:block w-[140px]"></div>
</div>
</main>
<footer class="mt-24 border-t border-outline-variant/10 py-12 px-8">
<div
class="max-w-[1440px] mx-auto flex flex-col md:flex-row justify-between items-center gap-6 text-sm font-medium text-on-surface-variant"
>
<p>© 2024 Domna Estate Management. All rights reserved.</p>
<div class="flex gap-8">
<a class="hover:text-primary transition-colors" href="#"
>Documentation</a
>
<a class="hover:text-primary transition-colors" href="#"
>Privacy Policy</a
>
<a class="hover:text-primary transition-colors" href="#"
>Terms of Service</a
>
</div>
</div>
</footer>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import {
userPortfolioConfig,
userPortfolioFolders,
} from "@/app/db/schema/user_portfolio_config";
import { planFolderDeletion } from "@/app/lib/portfolioUserConfig";
const RenameSchema = z.object({
name: z.string().trim().min(1).max(120),
});
const FolderIdSchema = z.string().regex(/^\d+$/);
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ folderId: string }> },
) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const { folderId } = await props.params;
if (!FolderIdSchema.safeParse(folderId).success) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
const parsed = RenameSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
}
const renamed = await db
.update(userPortfolioFolders)
.set({ name: parsed.data.name, updatedAt: new Date() })
.where(
and(
eq(userPortfolioFolders.id, BigInt(folderId)),
eq(userPortfolioFolders.userId, userId),
),
)
.returning({ id: userPortfolioFolders.id });
if (renamed.length === 0) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error renaming folder:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// Folder deletion never deletes portfolios — it unfiles them. The same-owner
// FK is NO ACTION, so unfile + delete must happen in one transaction,
// unfile first (see planFolderDeletion).
export async function DELETE(
_req: NextRequest,
props: { params: Promise<{ folderId: string }> },
) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const { folderId } = await props.params;
if (!FolderIdSchema.safeParse(folderId).success) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
const configs = await db
.select({
id: userPortfolioConfig.id,
folderId: userPortfolioConfig.folderId,
})
.from(userPortfolioConfig)
.where(eq(userPortfolioConfig.userId, userId));
const plan = planFolderDeletion({ folderId: BigInt(folderId), configs });
const deleted = await db.transaction(async (tx) => {
if (plan.unfileConfigIds.length > 0) {
await tx
.update(userPortfolioConfig)
.set({ folderId: null, updatedAt: new Date() })
.where(inArray(userPortfolioConfig.id, plan.unfileConfigIds));
}
return tx
.delete(userPortfolioFolders)
.where(
and(
eq(userPortfolioFolders.id, plan.deleteFolderId),
eq(userPortfolioFolders.userId, userId),
),
)
.returning({ id: userPortfolioFolders.id });
});
if (deleted.length === 0) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error deleting folder:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { userPortfolioFolders } from "@/app/db/schema/user_portfolio_config";
import { planFolderReorder } from "@/app/lib/portfolioUserConfig";
const ReorderSchema = z.object({
orderedIds: z.array(z.string().regex(/^\d+$/)),
});
// Drag-and-drop reorder: the client sends the FULL ordered folder id list in
// one request; positions are renumbered in a single transaction so the write
// is idempotent and can't drift.
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const parsed = ReorderSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid folder order" }, { status: 400 });
}
const current = await db
.select({ id: userPortfolioFolders.id })
.from(userPortfolioFolders)
.where(eq(userPortfolioFolders.userId, userId));
const plan = planFolderReorder({
currentIds: current.map((f) => f.id),
orderedIds: parsed.data.orderedIds.map(BigInt),
});
if ("error" in plan) {
return NextResponse.json({ error: plan.error }, { status: 400 });
}
await db.transaction(async (tx) => {
for (const { id, position } of plan.updates) {
await tx
.update(userPortfolioFolders)
.set({ position, updatedAt: new Date() })
.where(
and(
eq(userPortfolioFolders.id, id),
eq(userPortfolioFolders.userId, userId),
),
);
}
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error reordering folders:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { eq, sql } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { userPortfolioFolders } from "@/app/db/schema/user_portfolio_config";
const CreateFolderSchema = z.object({
name: z.string().trim().min(1).max(120),
});
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const parsed = CreateFolderSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
}
// New folders go last: position = current folder count.
const [created] = await db
.insert(userPortfolioFolders)
.values({
userId,
name: parsed.data.name,
position: sql`(select count(*) from ${userPortfolioFolders} where ${eq(
userPortfolioFolders.userId,
userId,
)})`,
})
.returning();
return NextResponse.json(
{
folder: {
id: created.id.toString(),
name: created.name,
position: created.position,
},
},
{ status: 201 },
);
} catch (err) {
console.error("Error creating folder:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { getPortfolios } from "@/app/home/utils";
import {
userPortfolioConfig,
userPortfolioFolders,
} from "@/app/db/schema/user_portfolio_config";
// Everything the home page renders, flat: the client groups via
// groupPortfoliosForHome so optimistic mutations can re-group locally.
export async function GET() {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const [portfolios, configs, folders] = await Promise.all([
getPortfolios(userId),
db
.select({
portfolioId: userPortfolioConfig.portfolioId,
folderId: userPortfolioConfig.folderId,
starredAt: userPortfolioConfig.starredAt,
})
.from(userPortfolioConfig)
.where(eq(userPortfolioConfig.userId, userId)),
db
.select({
id: userPortfolioFolders.id,
name: userPortfolioFolders.name,
position: userPortfolioFolders.position,
})
.from(userPortfolioFolders)
.where(eq(userPortfolioFolders.userId, userId)),
]);
const body = { portfolios, configs, folders };
// bigint isn't JSON-serialisable; ids cross the wire as strings.
return new NextResponse(
JSON.stringify(body, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
{ status: 200, headers: { "content-type": "application/json" } },
);
} catch (err) {
console.error("Error loading portfolio home:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { userPortfolioConfig } from "@/app/db/schema/user_portfolio_config";
import { buildStarChange } from "@/app/lib/portfolioUserConfig";
const ConfigSchema = z
.object({
starred: z.boolean().optional(),
folderId: z.string().regex(/^\d+$/).nullable().optional(),
})
.refine((v) => v.starred !== undefined || v.folderId !== undefined, {
message: "Nothing to update",
});
// Upserts the caller's personal config for one portfolio (star / move to
// folder). Rows are created lazily: no row means all defaults.
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
try {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}
const userId = session.user.dbId;
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Portfolio not found" }, { status: 404 });
}
const parsed = ConfigSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid config" }, { status: 400 });
}
// Config only exists for portfolios the user can actually see.
const membership = await db
.select({ id: portfolioUsers.id })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.userId, userId),
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
),
);
if (membership.length === 0) {
return NextResponse.json({ error: "Portfolio not found" }, { status: 404 });
}
const change: Partial<{
starredAt: Date | null;
folderId: bigint | null;
}> = {};
if (parsed.data.starred !== undefined) {
change.starredAt = buildStarChange({
starred: parsed.data.starred,
now: new Date(),
}).starredAt;
}
if (parsed.data.folderId !== undefined) {
change.folderId =
parsed.data.folderId === null ? null : BigInt(parsed.data.folderId);
}
await db
.insert(userPortfolioConfig)
.values({
userId,
portfolioId: BigInt(portfolioId),
...change,
})
.onConflictDoUpdate({
target: [userPortfolioConfig.userId, userPortfolioConfig.portfolioId],
set: { ...change, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Error updating portfolio config:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { normaliseRunFilters } from "@/lib/modellingRuns/model";
import { previewModellingRun } from "@/lib/modellingRuns/server";
const bodySchema = z.object({
scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50),
filters: z
.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
})
.default({}),
});
// POST — dry-run counts from the backend (same resolution code path as the
// real run; the preview is a promise, not an estimate — ADR-0008).
export async function POST(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const normalised = normaliseRunFilters(body.filters);
if (!normalised.ok) {
return NextResponse.json({ error: normalised.error }, { status: 400 });
}
try {
const result = await previewModellingRun({
portfolioId,
scenarioIds: [...new Set(body.scenarioIds)],
filters: normalised.filters,
});
return NextResponse.json(result);
} catch (err) {
console.error("POST /modelling-runs/preview error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { normaliseRunFilters } from "@/lib/modellingRuns/model";
import {
listModellingRuns,
triggerModellingRun,
} from "@/lib/modellingRuns/server";
const filtersSchema = z.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
});
const bodySchema = z.object({
scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50),
filters: filtersSchema.default({}),
previewedPropertyCount: z.number().int().nonnegative(),
});
function sessionToken(request: NextRequest): string | undefined {
return (
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value
);
}
// POST — record + dispatch a Modelling run (ADR-0008)
export async function POST(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const normalised = normaliseRunFilters(body.filters);
if (!normalised.ok) {
return NextResponse.json({ error: normalised.error }, { status: 400 });
}
try {
const outcome = await triggerModellingRun({
portfolioId,
scenarioIds: [...new Set(body.scenarioIds)],
filters: normalised.filters,
previewedPropertyCount: body.previewedPropertyCount,
triggeredBy: session.user.email,
sessionToken: sessionToken(request),
});
switch (outcome.kind) {
case "ok":
return NextResponse.json({ taskId: outcome.taskId }, { status: 202 });
case "unknown_scenarios":
return NextResponse.json(
{ error: `Unknown scenarios for this portfolio: ${outcome.missing.join(", ")}` },
{ status: 400 },
);
case "dispatch_failed":
return NextResponse.json(
{ error: "Couldn't start modelling — please try again" },
{ status: outcome.status },
);
}
} catch (err) {
console.error("POST /modelling-runs error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// GET — run history for the portfolio
export async function GET(
_request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
try {
return NextResponse.json({ runs: await listModellingRuns(portfolioId) });
} catch (err) {
console.error("GET /modelling-runs error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { and, asc, count, eq, isNotNull } from "drizzle-orm";
// GET — distinct postcodes (with counts) for the Run-filter options. One
// grouped, portfolio-scoped query (ix_property_portfolio).
export async function GET(
_request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
try {
const rows = await db
.select({ postcode: property.postcode, n: count() })
.from(property)
.where(
and(
eq(property.portfolioId, BigInt(portfolioId)),
eq(property.markedForDeletion, false),
isNotNull(property.postcode),
),
)
.groupBy(property.postcode)
.orderBy(asc(property.postcode));
return NextResponse.json({
postcodes: rows.map((r) => ({ postcode: r.postcode, count: r.n })),
});
} catch (err) {
console.error("GET /postcodes error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -1,49 +0,0 @@
import { useState } from "react";
import PlusIcon from "../PlusIcon";
import NewPortfolioModal from "./NewPortfolioModal";
const styles = {
wrapper:
"group bg-brandblue hover:bg-hoverblue shadow-xl hover:shadow-none cursor-pointer rounded-3xl flex flex-col items-center justify-center aspect-square",
header: "relative mt-2 mx-2 w-full",
imageWrapper:
"relative rounded-2xl overflow-hidden flex justify-center items-center",
wrapperAnime: "transition-all duration-500 ease-in-out",
image: "object-cover w-8/12 h-8/12 mx-auto fill-white",
textWrapper: "w-full flex justify-center items-center pt-6",
text: "pb-6 font-medium leading-none text-base tracking-wider text-gray-400",
};
const AddNewCard = () => {
const title = "New Portfolio";
const [isModalOpen, setModalIsOpen] = useState(false);
const openModal = () => {
setModalIsOpen(true);
};
return (
<a onClick={openModal}>
<div className="flex justify-center">
<div className={[styles.wrapper, styles.wrapperAnime].join(" ")}>
<div className={styles.header}>
<div className={styles.imageWrapper}>
<div className="w-1/4">
<PlusIcon color="white" />
<NewPortfolioModal
isOpen={isModalOpen}
setIsOpen={setModalIsOpen}
/>
</div>
</div>
</div>
<div className={styles.textWrapper}>
<h1 className={styles.text}>{`${title}`}</h1>
</div>
</div>
</div>
</a>
);
};
export default AddNewCard;

View file

@ -1,68 +0,0 @@
import React from "react";
import StatusBadge from "@/app/components/StatusBadge";
import { useRouter } from "next/navigation";
import { PortfolioStatus } from "@/app/db/schema/portfolio";
import { formatNumber } from "@/app/utils";
import Image from "next/image";
const styles = {
wrapper:
"group py-2 px-3 active:bg-brandmidblue font-medium leading-none text-base tracking-wider text-gray-400 hover:text-white-300 bg-white hover:bg-hoverblue shadow-2xl hover:shadow-none cursor-pointer aspect-square rounded-3xl flex flex-col items-center justify-center",
header: "relative mt-2 w-full border-brandblue",
budgetWrapper: "min-h-7 pr-4 flex justify-end w-full text-right max-h-16 my-auto text-gray-700 group-hover:text-white transition-all duration-500 ease-in-out relative",
imageWrapper: "rounded-2xl overflow-hidden flex justify-center items-center",
wrapperAnime: "transition-all duration-500 ease-in-out",
image: "object-cover mx-auto",
textWrapper: "pb-3 w-full px-4 flex justify-center items-center max-h-16 my-auto text-gray-700 text-center group-hover:text-white transition-all duration-500 ease-in-out",
};
interface CardProps {
id: bigint;
title: string;
image: string;
budget: number | null;
status: (typeof PortfolioStatus)[number];
};
const Card = ({ id, title, image, budget, status }: CardProps) => {
const router = useRouter();
function handleClick() {
router.push(`/portfolio/${id}`);
}
const budgetFormatted = budget ? "£" + formatNumber(budget) : "";
return (
<div>
<div
onClick={handleClick}
className={[styles.wrapper, styles.wrapperAnime].join(" ")}
>
<div className={styles.header}>
<div className={styles.budgetWrapper} >{budgetFormatted}</div>
<div className={styles.imageWrapper}>
<Image
src={image}
className={styles.image}
alt=""
width={80}
height={80}
/>
</div>
</div>
<div className={styles.textWrapper}>
<h1>{`${title}`}</h1>
</div>
<div className="mb-4 flex justify-end w-full">
<div className="flex justify-center w-full">
<StatusBadge status={status} />
</div>
</div>
</div>
</div>
);
};
export default Card;

View file

@ -1,35 +0,0 @@
"use client";
import Card from "./Card";
import AddNewCard from "./AddNewCard";
import type { Portfolio } from "@/app/db/schema/portfolio";
type PortfoliosType = Array<Portfolio>;
export default function CardTiles({
Portfolios,
}: {
Portfolios: PortfoliosType;
}) {
return (
<div className="flex justify-center">
<div className="grid grid-cols-1 gap-6 max-w-7xl sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<AddNewCard />
{Portfolios.map((portfolio, index) => {
const image_idx = index % 5;
return (
<Card
key={portfolio.id.toString()}
id={portfolio.id}
title={portfolio.name}
image={`house-icon.svg`}
budget={portfolio.budget}
status={portfolio.status}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,355 @@
"use client";
import { useState } from "react";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { MoreVertical, Plus, Star } from "lucide-react";
import type { FolderWire } from "@/app/lib/portfolioHomeView";
export type RailView = "all" | "starred" | { folderId: string };
// Cards set this dataTransfer type on drag; rail items accept it as a drop.
const PORTFOLIO_DRAG_TYPE = "application/x-portfolio";
export type FolderRailProps = {
folders: FolderWire[]; // already ordered by position
view: RailView;
totalCount: number;
starredCount: number;
attentionTotal: number;
countOf: (folderId: string) => number;
attentionOf: (folderId: string) => number;
onSelect: (view: RailView) => void;
onReorder: (orderedIds: string[]) => void;
onCreate: (name: string) => void;
onRename: (folderId: string, name: string) => void;
onDelete: (folderId: string) => void;
// Drag-to-file: a card dropped on a folder / Starred / All portfolios.
onDropToFolder: (portfolioId: string, folderId: string | null) => void;
onDropToStarred: (portfolioId: string) => void;
};
function AttentionBadge({ count }: { count: number }) {
if (!count) return null;
return (
<span
title={`${count} need attention`}
className="rounded-full bg-red-100 px-2 py-px text-[11px] font-semibold text-red-800 tabular-nums"
>
{count}
</span>
);
}
function railItemClasses(active: boolean) {
return `flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm ${
active ? "bg-[#e4e9f1] font-semibold" : "hover:bg-[#e9edf3]"
}`;
}
const dropHighlight = "ring-2 ring-inset ring-brandmidblue rounded-lg";
export default function FolderRail(props: FolderRailProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Two-step destructive confirm lives inside the menu popover, not a dialog:
// removal only unfiles portfolios, so OS-modal weight isn't warranted.
const [confirmingId, setConfirmingId] = useState<string | null>(null);
// Which rail target a dragged card is hovering: folder id, "starred",
// "all", or null.
const [cardDropTarget, setCardDropTarget] = useState<string | null>(null);
const isFolderView = (folderId: string) =>
typeof props.view === "object" && props.view.folderId === folderId;
const commitDrop = () => {
if (dragIndex !== null && dropIndex !== null && dropIndex !== dragIndex) {
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(dragIndex, 1);
ids.splice(dropIndex, 0, moved);
props.onReorder(ids);
}
setDragIndex(null);
setDropIndex(null);
};
const cardDropHandlers = (
targetKey: string,
onDrop: (portfolioId: string) => void,
) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(PORTFOLIO_DRAG_TYPE)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setCardDropTarget(targetKey);
},
onDragLeave: () =>
setCardDropTarget((current) => (current === targetKey ? null : current)),
onDrop: (e: React.DragEvent) => {
const portfolioId = e.dataTransfer.getData(PORTFOLIO_DRAG_TYPE);
if (!portfolioId) return;
e.preventDefault();
setCardDropTarget(null);
onDrop(portfolioId);
},
});
const nameForm = (
defaultValue: string,
onSubmit: (name: string) => void,
onDone: () => void,
) => (
<form
className="flex-1 px-1"
onSubmit={(e) => {
e.preventDefault();
const name = new FormData(e.currentTarget).get("name");
if (typeof name === "string" && name.trim()) onSubmit(name.trim());
onDone();
}}
>
<input
name="name"
defaultValue={defaultValue}
autoFocus
maxLength={120}
aria-label="Folder name"
onBlur={onDone}
onKeyDown={(e) => e.key === "Escape" && onDone()}
className="w-full rounded-md border border-brandmidblue px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-brandmidblue/30"
/>
</form>
);
return (
<nav
aria-label="Portfolio folders"
className="w-full shrink-0 lg:sticky lg:top-5 lg:w-[230px]"
>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => props.onSelect("starred")}
{...cardDropHandlers("starred", props.onDropToStarred)}
className={`${railItemClasses(props.view === "starred")} ${
cardDropTarget === "starred" ? dropHighlight : ""
}`}
>
<Star
size={14}
className="shrink-0"
fill="#f1bb06"
strokeWidth={0}
aria-hidden
/>
<span className="flex-1 truncate">Starred</span>
<span className="text-[13px] text-gray-600 tabular-nums">
{props.starredCount}
</span>
</button>
<button
type="button"
onClick={() => props.onSelect("all")}
{...cardDropHandlers("all", (portfolioId) =>
props.onDropToFolder(portfolioId, null),
)}
className={`${railItemClasses(props.view === "all")} ${
cardDropTarget === "all" ? dropHighlight : ""
}`}
>
<span className="flex-1 truncate">All portfolios</span>
<AttentionBadge count={props.attentionTotal} />
<span className="text-[13px] text-gray-600 tabular-nums">
{props.totalCount}
</span>
</button>
</div>
<hr className="my-2.5 border-[#e2e7ee]" />
<div className="mb-1.5 flex items-center px-2.5 text-xs font-semibold text-gray-500">
Your folders
<button
type="button"
onClick={() => setCreating(true)}
className="ml-auto flex items-center gap-0.5 rounded-md px-1.5 py-1 font-semibold text-brandmidblue hover:bg-[#eceefb]"
>
<Plus size={13} aria-hidden /> New
</button>
</div>
<ul className="flex flex-col gap-0.5">
{props.folders.map((folder, index) => (
<li
key={folder.id}
draggable={editingId !== folder.id}
onDragStart={(e) => {
// A card drag bubbling up must not start a folder reorder.
if (e.dataTransfer.types.includes(PORTFOLIO_DRAG_TYPE)) return;
setDragIndex(index);
}}
onDragOver={(e) => {
if (dragIndex !== null) {
e.preventDefault();
setDropIndex(index);
}
}}
onDrop={() => {
if (dragIndex !== null) commitDrop();
}}
onDragEnd={() => {
setDragIndex(null);
setDropIndex(null);
}}
className={`group flex items-center rounded-lg ${
dragIndex === index ? "opacity-40" : ""
} ${
dropIndex === index && dragIndex !== null && dragIndex !== index
? dropIndex < dragIndex
? "shadow-[0_-2px_0_#3943b7]"
: "shadow-[0_2px_0_#3943b7]"
: ""
} ${editingId === folder.id ? "" : "cursor-grab"}`}
>
{editingId === folder.id ? (
nameForm(
folder.name,
(name) => props.onRename(folder.id, name),
() => setEditingId(null),
)
) : (
<>
<button
type="button"
onClick={() => props.onSelect({ folderId: folder.id })}
{...cardDropHandlers(folder.id, (portfolioId) =>
props.onDropToFolder(portfolioId, folder.id),
)}
className={`${railItemClasses(isFolderView(folder.id))} ${
cardDropTarget === folder.id ? dropHighlight : ""
}`}
>
<span className="flex-1 truncate">{folder.name}</span>
<AttentionBadge count={props.attentionOf(folder.id)} />
<span className="text-[13px] text-gray-600 tabular-nums">
{props.countOf(folder.id)}
</span>
</button>
<Popover className="shrink-0">
<PopoverButton
aria-label={`Folder options for ${folder.name}`}
onClick={() => setConfirmingId(null)}
className="grid place-items-center rounded-lg p-1.5 text-gray-500 opacity-0 hover:bg-gray-100 focus-visible:opacity-100 group-hover:opacity-100 data-[open]:opacity-100 data-[open]:bg-gray-100"
>
<MoreVertical size={15} aria-hidden />
</PopoverButton>
<PopoverPanel
focus
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 min-w-[150px] rounded-lg border border-gray-200 bg-white p-1 shadow-md focus:outline-none"
>
{({ close }) =>
confirmingId === folder.id ? (
<div className="w-56 p-2">
<p className="mb-2 text-sm font-semibold text-gray-900">
Remove {folder.name}?
</p>
<p className="mb-3 text-[13px] text-gray-600">
{props.countOf(folder.id) === 0
? "It's empty — no portfolios are affected."
: `Its ${props.countOf(folder.id)} portfolio${
props.countOf(folder.id) === 1 ? "" : "s"
} will be unfiled, not deleted.`}
</p>
<div className="flex gap-2">
<button
type="button"
autoFocus
onClick={() => {
close();
setConfirmingId(null);
props.onDelete(folder.id);
}}
className="rounded-md bg-red-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-800"
>
Remove folder
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className="rounded-md px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
</div>
</div>
) : (
<>
{(
[
["Move up", index > 0, -1],
[
"Move down",
index < props.folders.length - 1,
1,
],
] as const
).map(([label, enabled, delta]) => (
<button
key={label}
type="button"
disabled={!enabled}
onClick={() => {
close();
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(index, 1);
ids.splice(index + delta, 0, moved);
props.onReorder(ids);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50 disabled:cursor-default disabled:text-gray-400 disabled:hover:bg-transparent"
>
{label}
</button>
))}
<div className="my-1 border-t border-gray-200" />
<button
type="button"
onClick={() => {
close();
setEditingId(folder.id);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
Rename
</button>
<button
type="button"
onClick={() => setConfirmingId(folder.id)}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm text-red-700 hover:bg-red-50"
>
Remove folder
</button>
</>
)
}
</PopoverPanel>
</Popover>
</>
)}
</li>
))}
{creating && (
<li className="flex items-center rounded-lg">
{nameForm("", props.onCreate, () => setCreating(false))}
</li>
)}
</ul>
<p className="hidden px-2.5 pt-2 text-xs text-gray-500 lg:block">
Folders are personal to you teammates organise their own. Drag
folders to reorder, or drop a portfolio card onto a folder to file it.
</p>
</nav>
);
}

View file

@ -0,0 +1,305 @@
"use client";
import Link from "next/link";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { MoreVertical, Star } from "lucide-react";
import {
formatMoney,
formatUpdatedAgo,
statusDisplay,
type FolderWire,
type PortfolioWire,
type StatusTone,
} from "@/app/lib/portfolioHomeView";
const PILL_TONES: Record<StatusTone, string> = {
early: "bg-slate-100 text-slate-600",
tender: "bg-indigo-100 text-indigo-800",
underway: "bg-blue-100 text-blue-800",
ontrack: "bg-green-100 text-green-800",
delayed: "bg-amber-100 text-amber-800",
atrisk: "bg-red-100 text-red-800",
done: "bg-emerald-100 text-emerald-900",
review: "bg-yellow-100 text-yellow-800",
};
export type CardActions = {
isStarred: (portfolioId: string) => boolean;
folderIdOf: (portfolioId: string) => string | null;
onToggleStar: (portfolio: PortfolioWire) => void;
onMoveToFolder: (portfolio: PortfolioWire, folderId: string | null) => void;
folders: FolderWire[];
};
function StatusPill({ status }: { status: string }) {
const display = statusDisplay(status);
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${PILL_TONES[display.tone]}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
{display.label}
</span>
);
}
function StarButton({
portfolio,
actions,
}: {
portfolio: PortfolioWire;
actions: CardActions;
}) {
const starred = actions.isStarred(portfolio.id);
return (
<button
type="button"
aria-pressed={starred}
aria-label={`${starred ? "Unstar" : "Star"} ${portfolio.name}`}
title={starred ? "Unstar" : "Star"}
onClick={() => actions.onToggleStar(portfolio)}
className="rounded-lg p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-brandblue focus-visible:outline focus-visible:outline-2 focus-visible:outline-brandmidblue"
>
<Star
size={17}
className={starred ? "stroke-brandblue" : ""}
fill={starred ? "#f1bb06" : "none"}
aria-hidden
/>
</button>
);
}
// Headless UI Menu: the anchor prop portals the panel to the top layer, so
// the list view's scroll container can't clip it; Esc, outside-click, and
// arrow-key navigation come with the primitive.
function CardMenu({
portfolio,
actions,
}: {
portfolio: PortfolioWire;
actions: CardActions;
}) {
const currentFolderId = actions.folderIdOf(portfolio.id);
const itemClasses =
"flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-left text-sm data-[focus]:bg-gray-50";
return (
<Menu>
<MenuButton
aria-label={`Actions for ${portfolio.name}`}
className="grid place-items-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-brandblue data-[open]:bg-gray-100 data-[open]:text-brandblue"
>
<MoreVertical size={16} aria-hidden />
</MenuButton>
<MenuItems
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 min-w-[160px] rounded-lg border border-gray-200 bg-white p-1 shadow-md focus:outline-none"
>
<div className="px-2.5 py-1 text-[11px] font-semibold text-gray-500">
Move to folder
</div>
{actions.folders.map((folder) => (
<MenuItem key={folder.id}>
<button
type="button"
onClick={() => actions.onMoveToFolder(portfolio, folder.id)}
className={itemClasses}
>
<span className="truncate">{folder.name}</span>
{currentFolderId === folder.id && (
<span className="text-brandmidblue" aria-hidden></span>
)}
</button>
</MenuItem>
))}
<MenuItem>
<button
type="button"
onClick={() => actions.onMoveToFolder(portfolio, null)}
className={itemClasses}
>
No folder
{currentFolderId === null && (
<span className="text-brandmidblue" aria-hidden></span>
)}
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}
function BudgetLine({ portfolio }: { portfolio: PortfolioWire }) {
const { budget, cost } = portfolio;
if (!budget) {
return <div className="text-[13px] text-gray-500">No budget set</div>;
}
const pct = cost != null ? Math.round((100 * cost) / budget) : null;
const over = cost != null && cost > budget;
return (
<div className="text-[13px]">
<div className="mb-1 flex justify-between text-gray-500">
{cost != null ? (
<>
<span>
<span className="font-semibold text-gray-900 tabular-nums">
{formatMoney(cost)}
</span>{" "}
of <span className="tabular-nums">{formatMoney(budget)}</span>{" "}
budget
</span>
<span
className={`tabular-nums ${over ? "font-semibold text-red-700" : ""}`}
>
{pct}%{over ? " over" : ""}
</span>
</>
) : (
<>
<span>
Budget{" "}
<span className="font-semibold text-gray-900 tabular-nums">
{formatMoney(budget)}
</span>
</span>
<span>no spend yet</span>
</>
)}
</div>
<div className="h-1 overflow-hidden rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${over ? "bg-red-700" : "bg-brandmidblue"}`}
style={{ width: `${Math.min(100, pct ?? 0)}%` }}
/>
</div>
</div>
);
}
export function PortfolioGridCard({
portfolio,
actions,
folderName,
}: {
portfolio: PortfolioWire;
actions: CardActions;
folderName?: string | null;
}) {
return (
<article
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/x-portfolio", portfolio.id);
e.dataTransfer.effectAllowed = "move";
}}
className="flex flex-col gap-2.5 rounded-xl border border-[#e2e7ee] bg-white p-4 transition-colors hover:border-[#c3ccd8]"
>
<div className="flex items-start justify-between gap-2">
<Link
href={`/portfolio/${portfolio.id}`}
className="text-[17px] font-semibold leading-snug text-gray-900 hover:text-brandmidblue hover:underline hover:underline-offset-2"
>
{portfolio.name}
</Link>
<StarButton portfolio={portfolio} actions={actions} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[13px] text-gray-500">
<StatusPill status={portfolio.status} />
<span className="font-medium text-gray-900">
{portfolio.goal === "None" ? "No goal set" : portfolio.goal}
</span>
{portfolio.numberOfProperties != null && (
<span>
<span className="tabular-nums">
{portfolio.numberOfProperties.toLocaleString()}
</span>{" "}
properties
</span>
)}
</div>
<BudgetLine portfolio={portfolio} />
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
{folderName ? `${folderName} · ` : ""}
Updated {formatUpdatedAgo(portfolio.updatedAt, new Date())}
</span>
<CardMenu portfolio={portfolio} actions={actions} />
</div>
</article>
);
}
export function PortfolioTable({
portfolios,
actions,
folderNameOf,
}: {
portfolios: PortfolioWire[];
actions: CardActions;
folderNameOf: (portfolioId: string) => string | null;
}) {
return (
<div className="overflow-x-auto rounded-xl border border-[#e2e7ee] bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#e2e7ee] text-left text-[11px] font-semibold text-gray-500">
<th scope="col" className="w-10 px-3 py-2" aria-label="Starred" />
<th scope="col" className="px-3 py-2">Portfolio</th>
<th scope="col" className="px-3 py-2">Status</th>
<th scope="col" className="px-3 py-2">Goal</th>
<th scope="col" className="px-3 py-2 text-right">Properties</th>
<th scope="col" className="px-3 py-2">Budget</th>
<th scope="col" className="px-3 py-2 text-right">Updated</th>
<th scope="col" className="w-10 px-3 py-2" aria-label="Actions" />
</tr>
</thead>
<tbody>
{portfolios.map((portfolio) => (
<tr
key={portfolio.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/x-portfolio", portfolio.id);
e.dataTransfer.effectAllowed = "move";
}}
className="border-b border-[#eef1f6] last:border-b-0 hover:bg-[#f8fafc]"
>
<td className="px-3 py-2">
<StarButton portfolio={portfolio} actions={actions} />
</td>
<td className="max-w-[280px] truncate px-3 py-2">
<Link
href={`/portfolio/${portfolio.id}`}
className="font-semibold text-gray-900 hover:text-brandmidblue hover:underline hover:underline-offset-2"
>
{portfolio.name}
</Link>
</td>
<td className="px-3 py-2">
<StatusPill status={portfolio.status} />
</td>
<td className="max-w-[180px] truncate px-3 py-2 text-gray-700">
{portfolio.goal === "None" ? "—" : portfolio.goal}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{portfolio.numberOfProperties?.toLocaleString() ?? "—"}
</td>
<td className="min-w-[170px] px-3 py-2">
<BudgetLine portfolio={portfolio} />
</td>
<td className="whitespace-nowrap px-3 py-2 text-right text-gray-500">
{folderNameOf(portfolio.id)
? `${folderNameOf(portfolio.id)} · `
: ""}
{formatUpdatedAgo(portfolio.updatedAt, new Date())}
</td>
<td className="px-3 py-2">
<CardMenu portfolio={portfolio} actions={actions} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,554 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LayoutGrid, List, Plus, Search } from "lucide-react";
import {
matchesSearch,
sortPortfolios,
statusDisplay,
upsertLocalConfig,
type HomeData,
type HomeSort,
type PortfolioWire,
} from "@/app/lib/portfolioHomeView";
import FolderRail, { type RailView } from "./FolderRail";
import {
PortfolioGridCard,
PortfolioTable,
type CardActions,
} from "./PortfolioCard";
import NewPortfolioModal from "./NewPortfolioModal";
const HOME_KEY = ["portfolio-home"];
// "Show more" batch size: bounds the initial wall without pagination chrome.
// Divisible by 2 and 3 so grid rows stay full at every breakpoint.
const PAGE_SIZE = 24;
async function fetchHome(): Promise<HomeData> {
const res = await fetch("/api/me/portfolio-home");
if (!res.ok) throw new Error("Failed to load portfolios");
return res.json();
}
async function send(url: string, method: string, body?: unknown) {
const res = await fetch(url, {
method,
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "Request failed");
}
}
// Shared optimistic-mutation plumbing: apply the local update immediately,
// roll back on error, reconcile with the server afterwards.
function useOptimisticHomeMutation<V>(
mutationFn: (vars: V) => Promise<void>,
localUpdate: (home: HomeData, vars: V) => HomeData,
announceError: (message: string) => void,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onMutate: async (vars: V) => {
await queryClient.cancelQueries(HOME_KEY);
const previous = queryClient.getQueryData<HomeData>(HOME_KEY);
if (previous) {
queryClient.setQueryData(HOME_KEY, localUpdate(previous, vars));
}
return { previous };
},
onError: (_err: unknown, _vars: V, context?: { previous?: HomeData }) => {
if (context?.previous) {
queryClient.setQueryData(HOME_KEY, context.previous);
}
announceError("Something went wrong — change undone");
},
onSettled: () => queryClient.invalidateQueries(HOME_KEY),
});
}
function viewToUrl(view: RailView): string {
if (view === "starred") return "/home?view=starred";
if (typeof view === "object") return `/home?folder=${view.folderId}`;
return "/home";
}
export default function PortfolioHome({
initialDisplay = "grid",
initialView = "all",
}: {
initialDisplay?: "grid" | "list";
initialView?: RailView;
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome);
const [view, setView] = useState<RailView>(initialView);
const [display, setDisplay] = useState<"grid" | "list">(initialDisplay);
const chooseDisplay = (value: "grid" | "list") => {
setDisplay(value);
document.cookie = `home-display=${value}; path=/; max-age=31536000; samesite=lax`;
};
const [sort, setSort] = useState<HomeSort>("updated");
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
// Changing what's listed resets how much of it is revealed. The selection
// also lands in the URL (replaceState — no navigation) so refresh and
// sharing keep the folder context.
const chooseView = (next: RailView) => {
setView(next);
setVisibleCount(PAGE_SIZE);
window.history.replaceState(null, "", viewToUrl(next));
};
const chooseQuery = (next: string) => {
setQuery(next);
setVisibleCount(PAGE_SIZE);
};
const [announcement, setAnnouncement] = useState("");
const [errorNotice, setErrorNotice] = useState("");
const [newPortfolioOpen, setNewPortfolioOpen] = useState(false);
// Failures must be seen, not just announced: feed the sr-only live region
// AND a visible notice.
const announceError = (message: string) => {
setAnnouncement(message);
setErrorNotice(message);
};
const configMutation = useOptimisticHomeMutation(
(vars: {
portfolioId: string;
change: { starred?: boolean; folderId?: string | null };
}) =>
send(`/api/me/portfolios/${vars.portfolioId}/config`, "PATCH", vars.change),
(home, vars) => ({
...home,
configs: upsertLocalConfig(home.configs, vars.portfolioId, {
...(vars.change.starred !== undefined && {
starredAt: vars.change.starred ? new Date().toISOString() : null,
}),
...(vars.change.folderId !== undefined && {
folderId: vars.change.folderId,
}),
}),
}),
announceError,
);
const reorderMutation = useOptimisticHomeMutation(
(orderedIds: string[]) =>
send("/api/me/folders/reorder", "PATCH", { orderedIds }),
(home, orderedIds) => ({
...home,
folders: home.folders.map((f) => ({
...f,
position: orderedIds.indexOf(f.id),
})),
}),
announceError,
);
const renameMutation = useOptimisticHomeMutation(
(vars: { folderId: string; name: string }) =>
send(`/api/me/folders/${vars.folderId}`, "PATCH", { name: vars.name }),
(home, vars) => ({
...home,
folders: home.folders.map((f) =>
f.id === vars.folderId ? { ...f, name: vars.name } : f,
),
}),
announceError,
);
const deleteMutation = useOptimisticHomeMutation(
(folderId: string) => send(`/api/me/folders/${folderId}`, "DELETE"),
(home, folderId) => ({
...home,
folders: home.folders.filter((f) => f.id !== folderId),
configs: home.configs.map((c) =>
c.folderId === folderId ? { ...c, folderId: null } : c,
),
}),
announceError,
);
const createMutation = useMutation({
mutationFn: (name: string) => send("/api/me/folders", "POST", { name }),
onError: () => announceError("Couldn\u2019t create the folder \u2014 try again"),
onSettled: () => queryClient.invalidateQueries(HOME_KEY),
});
if (isLoading) {
return (
<div className="mx-auto flex max-w-[1320px] gap-7 px-6 py-8" aria-busy>
<div className="hidden w-[230px] shrink-0 animate-pulse rounded-xl bg-gray-200/60 lg:block lg:h-64" />
<div className="grid flex-1 grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="h-44 animate-pulse rounded-xl bg-gray-200/60" />
))}
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="mx-auto max-w-[1320px] px-6 py-16 text-center text-gray-600">
Couldnt load your portfolios.{" "}
<button
type="button"
onClick={() => refetch()}
className="font-semibold text-brandmidblue hover:underline"
>
Try again
</button>
</div>
);
}
// ---- derived render state (all plain derivation, no memoization needed
// at this scale) ----
const configOf = new Map(data.configs.map((c) => [c.portfolioId, c]));
const folderById = new Map(data.folders.map((f) => [f.id, f]));
const orderedFolders = [...data.folders].sort(
(a, b) => a.position - b.position,
);
// A folder id from the URL or a just-deleted folder may not exist; fall
// back to All rather than rendering a ghost view.
const activeView: RailView =
typeof view === "object" && !folderById.has(view.folderId) ? "all" : view;
const isStarred = (id: string) => configOf.get(id)?.starredAt != null;
const folderIdOf = (id: string) => configOf.get(id)?.folderId ?? null;
const folderNameOf = (id: string) => {
const folderId = folderIdOf(id);
return folderId ? (folderById.get(folderId)?.name ?? null) : null;
};
const needsAttention = (p: PortfolioWire) =>
statusDisplay(p.status).needsAttention;
const inView = (p: PortfolioWire) =>
activeView === "all"
? true
: activeView === "starred"
? isStarred(p.id)
: folderIdOf(p.id) === activeView.folderId;
const scope = data.portfolios.filter(inView);
const filtered = scope.filter((p) =>
matchesSearch({ ...p, folderName: folderNameOf(p.id) }, query),
);
let rows = sortPortfolios(filtered, sort);
if (activeView === "starred") {
rows = [...rows].sort(
(a, b) =>
new Date(configOf.get(b.id)!.starredAt!).getTime() -
new Date(configOf.get(a.id)!.starredAt!).getTime(),
);
} else {
rows = [...rows].sort(
(a, b) => Number(isStarred(b.id)) - Number(isStarred(a.id)),
);
}
const visibleRows = rows.slice(0, visibleCount);
const hiddenCount = rows.length - visibleRows.length;
const viewTitle =
activeView === "all"
? "All portfolios"
: activeView === "starred"
? "Starred"
: (folderById.get(activeView.folderId)?.name ?? "Folder");
const attentionCount = rows.filter(needsAttention).length;
const propertiesSum = rows.reduce(
(sum, p) => sum + (p.numberOfProperties ?? 0),
0,
);
const cardActions: CardActions = {
isStarred,
folderIdOf,
folders: orderedFolders,
onToggleStar: (p) => {
const starred = !isStarred(p.id);
configMutation.mutate({ portfolioId: p.id, change: { starred } });
setAnnouncement(`${starred ? "Starred" : "Unstarred"} ${p.name}`);
},
onMoveToFolder: (p, folderId) => {
configMutation.mutate({ portfolioId: p.id, change: { folderId } });
setAnnouncement(
folderId
? `Moved ${p.name} to ${folderById.get(folderId)?.name}`
: `Removed ${p.name} from its folder`,
);
},
};
return (
<div className="mx-auto max-w-[1320px] px-6 py-7">
<div className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
Portfolios
</h1>
<div className="ml-auto flex flex-wrap items-center gap-2.5">
<label className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
aria-hidden
/>
<input
type="search"
value={query}
onChange={(e) => chooseQuery(e.target.value)}
placeholder="Search portfolios"
aria-label="Search portfolios by name, status, goal or folder"
className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm placeholder:text-gray-500"
/>
</label>
<select
value={sort}
onChange={(e) => setSort(e.target.value as HomeSort)}
aria-label="Sort portfolios"
className="rounded-lg border border-[#c3ccd8] bg-white px-2.5 py-2 text-sm"
>
<option value="updated">Recently updated</option>
<option value="attention">Needs attention first</option>
<option value="name">Name AZ</option>
<option value="props">Most properties</option>
</select>
<div
role="group"
aria-label="View"
className="flex overflow-hidden rounded-lg border border-[#c3ccd8] bg-white"
>
{(
[
["grid", LayoutGrid, "Grid view"],
["list", List, "List view"],
] as const
).map(([value, Icon, label]) => (
<button
key={value}
type="button"
aria-pressed={display === value}
aria-label={label}
title={label}
onClick={() => chooseDisplay(value)}
className={`grid place-items-center px-2.5 py-2 ${
display === value
? "bg-brandblue text-white"
: "text-gray-500 hover:text-gray-900"
}`}
>
<Icon size={15} aria-hidden />
</button>
))}
</div>
<button
type="button"
onClick={() => setNewPortfolioOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-brandblue px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#232866]"
>
<Plus size={15} aria-hidden /> New portfolio
</button>
</div>
</div>
{errorNotice && (
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-800">
<span className="flex-1">{errorNotice}</span>
<button
type="button"
onClick={() => setErrorNotice("")}
className="rounded-md px-2 py-0.5 font-semibold hover:bg-red-100"
>
Dismiss
</button>
</div>
)}
<div className="flex flex-col gap-4 lg:flex-row lg:gap-7">
<FolderRail
folders={orderedFolders}
view={activeView}
totalCount={data.portfolios.length}
starredCount={data.portfolios.filter((p) => isStarred(p.id)).length}
attentionTotal={data.portfolios.filter(needsAttention).length}
countOf={(folderId) =>
data.portfolios.filter((p) => folderIdOf(p.id) === folderId).length
}
attentionOf={(folderId) =>
data.portfolios.filter(
(p) => folderIdOf(p.id) === folderId && needsAttention(p),
).length
}
onSelect={chooseView}
onReorder={(orderedIds) => {
reorderMutation.mutate(orderedIds);
setAnnouncement("Folder order saved");
}}
onCreate={(name) => createMutation.mutate(name)}
onDropToFolder={(portfolioId, folderId) => {
const portfolio = data.portfolios.find((p) => p.id === portfolioId);
if (!portfolio || folderIdOf(portfolioId) === folderId) return;
configMutation.mutate({ portfolioId, change: { folderId } });
setAnnouncement(
folderId
? `Moved ${portfolio.name} to ${folderById.get(folderId)?.name}`
: `Removed ${portfolio.name} from its folder`,
);
}}
onDropToStarred={(portfolioId) => {
const portfolio = data.portfolios.find((p) => p.id === portfolioId);
if (!portfolio || isStarred(portfolioId)) return;
configMutation.mutate({ portfolioId, change: { starred: true } });
setAnnouncement(`Starred ${portfolio.name}`);
}}
onRename={(folderId, name) =>
renameMutation.mutate({ folderId, name })
}
onDelete={(folderId) => {
if (typeof activeView === "object" && activeView.folderId === folderId) {
chooseView("all");
}
deleteMutation.mutate(folderId);
setAnnouncement("Folder removed — its portfolios are unfiled");
}}
/>
<main className="min-w-0 flex-1">
<div className="mb-4">
<h2 className="text-[17px] font-semibold tracking-tight text-gray-900">
{viewTitle}
</h2>
<div className="text-[13px] text-gray-500">
{query.trim() ? (
<>
Showing <span className="tabular-nums">{rows.length}</span> of{" "}
<span className="tabular-nums">{scope.length}</span> for
{query.trim()}
</>
) : (
<>
<span className="tabular-nums">{rows.length}</span> portfolios
{propertiesSum > 0 && (
<>
{" "}
· <span className="tabular-nums">
{propertiesSum.toLocaleString()}
</span>{" "}
properties
</>
)}
{attentionCount > 0 && (
<>
{" "}
·{" "}
<b className="font-semibold text-red-700">
{attentionCount} need attention
</b>
</>
)}
</>
)}
</div>
</div>
{rows.length === 0 ? (
query.trim() ? (
<div className="py-12 text-center text-sm text-gray-500">
No portfolios in {viewTitle} match
<b className="text-gray-900">{query.trim()}</b>.{" "}
<button
type="button"
onClick={() => chooseQuery("")}
className="font-semibold text-brandmidblue hover:underline"
>
Clear search
</button>
</div>
) : (
<div className="rounded-xl border border-dashed border-[#c3ccd8] px-5 py-6 text-sm text-gray-500">
{activeView === "starred" ? (
<>
<b className="font-semibold text-gray-900">
Nothing starred yet.
</b>{" "}
Star a portfolio to keep your day-to-day work one click
away.
</>
) : activeView === "all" ? (
<>
<b className="font-semibold text-gray-900">
No portfolios yet.
</b>{" "}
Create your first portfolio to get started.
</>
) : (
<>
<b className="font-semibold text-gray-900">
This folder is empty.
</b>{" "}
Move portfolios here with the menu on any card.
</>
)}
</div>
)
) : display === "grid" ? (
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{visibleRows.map((portfolio) => (
<PortfolioGridCard
key={portfolio.id}
portfolio={portfolio}
actions={cardActions}
folderName={
activeView === "all" || activeView === "starred"
? folderNameOf(portfolio.id)
: null
}
/>
))}
</div>
) : (
<PortfolioTable
portfolios={visibleRows}
actions={cardActions}
folderNameOf={
activeView === "all" || activeView === "starred" ? folderNameOf : () => null
}
/>
)}
{hiddenCount > 0 && (
<div className="mt-5 flex flex-col items-center gap-1">
<button
type="button"
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
className="rounded-lg border border-[#c3ccd8] bg-white px-5 py-2 text-sm font-semibold text-gray-900 hover:border-brandmidblue hover:text-brandmidblue"
>
Show {Math.min(PAGE_SIZE, hiddenCount)} more
</button>
<span className="text-xs text-gray-500">
Showing <span className="tabular-nums">{visibleRows.length}</span>{" "}
of <span className="tabular-nums">{rows.length}</span>
</span>
</div>
)}
</main>
</div>
<div role="status" aria-live="polite" className="sr-only">
{announcement}
</div>
<NewPortfolioModal
isOpen={newPortfolioOpen}
setIsOpen={setNewPortfolioOpen}
/>
</div>
);
}

View file

@ -1,30 +1,36 @@
import CardTiles from "../components/home/CardTiles";
import { getPortfolios } from "./utils";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import PortfolioHome from "../components/home/PortfolioHome";
const Home = async () => {
const Home = async (props: {
searchParams: Promise<{ folder?: string; view?: string }>;
}) => {
const user = await getServerSession(AuthOptions);
if (!user?.user) {
console.error("User not found");
redirect("/");
}
const portfolios = await getPortfolios(user.user.dbId);
// Device-level display preference travels as a cookie so the server renders
// the chosen view on first paint (no client-side flicker).
const cookieStore = await cookies();
const initialDisplay =
cookieStore.get("home-display")?.value === "list" ? "list" : "grid";
// The selected rail view survives refresh via the URL; an unknown folder id
// falls back to All client-side once data loads.
const searchParams = await props.searchParams;
const initialView =
searchParams.view === "starred"
? ("starred" as const)
: /^\d+$/.test(searchParams.folder ?? "")
? { folderId: searchParams.folder! }
: ("all" as const);
return (
<>
<div className="flex justify-center">
<h1 className="text-3xl font-bold mt-3 mb-5 text-gray-700">
{" "}
Your Portfolios{" "}
</h1>
</div>
<div className="px-5">
<CardTiles Portfolios={portfolios} />
</div>
</>
<PortfolioHome initialDisplay={initialDisplay} initialView={initialView} />
);
};
export default Home;

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { PortfolioStatus } from "@/app/db/schema/portfolio";
import {
formatMoney,
formatUpdatedAgo,
matchesSearch,
sortPortfolios,
statusDisplay,
upsertLocalConfig,
} from "./portfolioHomeView";
describe("statusDisplay", () => {
it("maps lifecycle enum values to clean pill labels", () => {
expect(statusDisplay("scoping")).toEqual({
label: "Scoping",
tone: "early",
needsAttention: false,
});
expect(statusDisplay("completion; status: at risk")).toEqual({
label: "At risk",
tone: "atrisk",
needsAttention: true,
});
expect(statusDisplay("needs review")).toEqual({
label: "Needs review",
tone: "review",
needsAttention: true,
});
});
it("covers every status the schema allows", () => {
for (const status of PortfolioStatus) {
const display = statusDisplay(status);
expect(display.label, status).toBeTruthy();
expect(display.label).not.toContain(";");
}
});
});
describe("formatMoney", () => {
it("renders millions with one decimal and thousands as whole k", () => {
expect(formatMoney(6500000)).toBe("£6.5M");
expect(formatMoney(4000000)).toBe("£4M");
expect(formatMoney(745000)).toBe("£745k");
expect(formatMoney(0)).toBe("£0");
});
});
describe("matchesSearch", () => {
const lambeth = {
name: "Lambeth Regeneration",
status: "completion; status: at risk",
goal: "Increasing EPC",
folderName: "SHDF Wave 3",
};
it("matches on name, display status, goal, and folder name, case-insensitively", () => {
expect(matchesSearch(lambeth, "lambeth")).toBe(true);
expect(matchesSearch(lambeth, "at risk")).toBe(true);
expect(matchesSearch(lambeth, "epc")).toBe(true);
expect(matchesSearch(lambeth, "shdf")).toBe(true);
expect(matchesSearch(lambeth, "peabody")).toBe(false);
});
it("matches everything on an empty query and unfoldered portfolios don't match folder terms", () => {
expect(matchesSearch(lambeth, " ")).toBe(true);
expect(
matchesSearch({ ...lambeth, folderName: null }, "shdf"),
).toBe(false);
});
});
describe("sortPortfolios", () => {
const rows = [
{ name: "B", status: "scoping", numberOfProperties: 10, updatedAt: new Date("2026-07-01") },
{ name: "A", status: "completion; status: delayed", numberOfProperties: 30, updatedAt: new Date("2026-07-06") },
{ name: "C", status: "completion; status: at risk", numberOfProperties: 20, updatedAt: new Date("2026-07-03") },
];
const names = (sorted: typeof rows) => sorted.map((r) => r.name);
it("orders by the chosen key without mutating the input", () => {
expect(names(sortPortfolios(rows, "updated"))).toEqual(["A", "C", "B"]);
expect(names(sortPortfolios(rows, "attention"))).toEqual(["C", "A", "B"]);
expect(names(sortPortfolios(rows, "name"))).toEqual(["A", "B", "C"]);
expect(names(sortPortfolios(rows, "props"))).toEqual(["A", "C", "B"]);
expect(names(rows)).toEqual(["B", "A", "C"]);
});
});
describe("upsertLocalConfig", () => {
it("creates a config on first star, like the lazy DB upsert", () => {
const next = upsertLocalConfig([], "7", { starredAt: "2026-07-07T12:00:00Z" });
expect(next).toEqual([
{ portfolioId: "7", folderId: null, starredAt: "2026-07-07T12:00:00Z" },
]);
});
it("changing one field preserves the other", () => {
const configs = [
{ portfolioId: "7", folderId: "10", starredAt: "2026-07-01T00:00:00Z" },
];
expect(upsertLocalConfig(configs, "7", { starredAt: null })).toEqual([
{ portfolioId: "7", folderId: "10", starredAt: null },
]);
expect(upsertLocalConfig(configs, "7", { folderId: null })).toEqual([
{ portfolioId: "7", folderId: null, starredAt: "2026-07-01T00:00:00Z" },
]);
});
});
describe("formatUpdatedAgo", () => {
const now = new Date("2026-07-07T12:00:00Z");
it("reads naturally at every distance", () => {
expect(formatUpdatedAgo("2026-07-07T09:00:00Z", now)).toBe("today");
expect(formatUpdatedAgo("2026-07-06T09:00:00Z", now)).toBe("yesterday");
expect(formatUpdatedAgo("2026-07-02T12:00:00Z", now)).toBe("5 days ago");
expect(formatUpdatedAgo("2026-05-01T00:00:00Z", now)).toBe("2 months ago");
});
});

View file

@ -0,0 +1,191 @@
// View-model logic for the portfolio home page: pure functions the client
// components derive their render state from. Kept free of React and drizzle
// so it tests in node and survives refactors on either side.
export type StatusTone =
| "early"
| "tender"
| "underway"
| "ontrack"
| "delayed"
| "atrisk"
| "done"
| "review";
export type StatusDisplay = {
label: string;
tone: StatusTone;
needsAttention: boolean;
};
// The lifecycle enum's raw values ("completion; status: at risk") are storage
// strings, not UI copy; this is the single place they become pill text.
const STATUS_DISPLAY: Record<string, StatusDisplay> = {
scoping: { label: "Scoping", tone: "early", needsAttention: false },
survey: { label: "Survey", tone: "early", needsAttention: false },
assessment: { label: "Assessment", tone: "early", needsAttention: false },
tendering: { label: "Tendering", tone: "tender", needsAttention: false },
"project underway": {
label: "Underway",
tone: "underway",
needsAttention: false,
},
"completion; status: on track": {
label: "On track",
tone: "ontrack",
needsAttention: false,
},
"completion; status: delayed": {
label: "Delayed",
tone: "delayed",
needsAttention: true,
},
"completion; status: at risk": {
label: "At risk",
tone: "atrisk",
needsAttention: true,
},
"completion; status: completed": {
label: "Completed",
tone: "done",
needsAttention: false,
},
"needs review": { label: "Needs review", tone: "review", needsAttention: true },
};
const UNKNOWN_STATUS: StatusDisplay = {
label: "Unknown",
tone: "early",
needsAttention: false,
};
export function statusDisplay(status: string): StatusDisplay {
return STATUS_DISPLAY[status] ?? UNKNOWN_STATUS;
}
export type HomeSort = "updated" | "attention" | "name" | "props";
// Wire-format rows (ids and dates as strings — see the portfolio-home
// route's bigint serialisation).
export type LocalConfig = {
portfolioId: string;
folderId: string | null;
starredAt: string | null;
};
export type PortfolioWire = {
id: string;
name: string;
status: string;
goal: string;
budget: number | null;
cost: number | null;
numberOfProperties: number | null;
updatedAt: string;
};
export type FolderWire = { id: string; name: string; position: number };
export type HomeData = {
portfolios: PortfolioWire[];
configs: LocalConfig[];
folders: FolderWire[];
};
// Client-side mirror of the lazy DB upsert, used for optimistic cache
// updates: no row yet means "all defaults", and a change touches only the
// fields it names.
export function upsertLocalConfig(
configs: LocalConfig[],
portfolioId: string,
change: Partial<Pick<LocalConfig, "folderId" | "starredAt">>,
): LocalConfig[] {
const existing = configs.find((c) => c.portfolioId === portfolioId);
if (!existing) {
return [
...configs,
{ portfolioId, folderId: null, starredAt: null, ...change },
];
}
return configs.map((c) =>
c.portfolioId === portfolioId ? { ...c, ...change } : c,
);
}
// Severity for "needs attention first": worst state leads, healthy and
// finished work trails.
const ATTENTION_RANK: Record<StatusTone, number> = {
atrisk: 0,
delayed: 1,
review: 2,
underway: 3,
tender: 4,
early: 5,
ontrack: 6,
done: 7,
};
export function sortPortfolios<
P extends {
name: string;
status: string;
numberOfProperties: number | null;
updatedAt: Date | string;
},
>(portfolios: P[], sort: HomeSort): P[] {
const by: Record<HomeSort, (a: P, b: P) => number> = {
updated: (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
attention: (a, b) =>
ATTENTION_RANK[statusDisplay(a.status).tone] -
ATTENTION_RANK[statusDisplay(b.status).tone],
name: (a, b) => a.name.localeCompare(b.name),
props: (a, b) => (b.numberOfProperties ?? 0) - (a.numberOfProperties ?? 0),
};
return [...portfolios].sort(by[sort]);
}
// Users search in display language ("at risk"), never in enum storage
// strings, so matching goes through statusDisplay.
export function matchesSearch(
portfolio: {
name: string;
status: string;
goal: string;
folderName: string | null;
},
query: string,
): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
portfolio.name,
statusDisplay(portfolio.status).label,
portfolio.goal,
portfolio.folderName ?? "",
]
.join(" ")
.toLowerCase()
.includes(q);
}
export function formatUpdatedAgo(updatedAt: string, now: Date): string {
const days = Math.floor(
(now.getTime() - new Date(updatedAt).getTime()) / 86_400_000,
);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 28) return `${days} days ago`;
const months = Math.round(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
export function formatMoney(amount: number): string {
if (amount >= 1_000_000) {
return "£" + (amount / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
}
if (amount >= 1_000) {
return "£" + Math.round(amount / 1_000) + "k";
}
return "£" + Math.round(amount);
}

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
buildStarChange,
groupPortfoliosForHome,
planFolderDeletion,
planFolderReorder,
} from "./portfolioUserConfig";
describe("planFolderReorder", () => {
it("renumbers every folder to its index in the requested order", () => {
const plan = planFolderReorder({
currentIds: [1n, 2n, 3n],
orderedIds: [3n, 1n, 2n],
});
expect(plan).toEqual({
updates: [
{ id: 3n, position: 0 },
{ id: 1n, position: 1 },
{ id: 2n, position: 2 },
],
});
});
it("rejects an order naming a folder the user does not have", () => {
const plan = planFolderReorder({
currentIds: [1n, 2n],
orderedIds: [2n, 99n],
});
expect(plan).toEqual({
error: "Folder order does not match your folders",
});
});
it("rejects an order that lists the same folder twice", () => {
const plan = planFolderReorder({
currentIds: [1n, 2n],
orderedIds: [1n, 1n, 2n],
});
expect(plan).toEqual({
error: "Folder order does not match your folders",
});
});
});
describe("groupPortfoliosForHome", () => {
it("groups portfolios into folders ordered by position, with the rest unfiled", () => {
const grouped = groupPortfoliosForHome({
portfolios: [{ id: 1n }, { id: 2n }, { id: 3n }],
configs: [
{ portfolioId: 1n, folderId: 10n, starredAt: null },
{ portfolioId: 2n, folderId: 11n, starredAt: null },
],
folders: [
{ id: 10n, name: "SHDF Wave 3", position: 1 },
{ id: 11n, name: "Demos", position: 0 },
],
});
expect(grouped.folders).toEqual([
{ id: 11n, name: "Demos", portfolios: [{ id: 2n }] },
{ id: 10n, name: "SHDF Wave 3", portfolios: [{ id: 1n }] },
]);
expect(grouped.unfiled).toEqual([{ id: 3n }]);
});
it("floats starred portfolios to the top of their group and lists them most-recently-starred first", () => {
const grouped = groupPortfoliosForHome({
portfolios: [{ id: 1n }, { id: 2n }, { id: 3n }, { id: 4n }],
configs: [
{ portfolioId: 1n, folderId: 10n, starredAt: null },
{ portfolioId: 2n, folderId: 10n, starredAt: new Date("2026-07-01") },
{ portfolioId: 4n, folderId: null, starredAt: new Date("2026-07-05") },
],
folders: [{ id: 10n, name: "SHDF Wave 3", position: 0 }],
});
expect(grouped.folders[0].portfolios).toEqual([{ id: 2n }, { id: 1n }]);
expect(grouped.unfiled).toEqual([{ id: 4n }, { id: 3n }]);
expect(grouped.starred).toEqual([{ id: 4n }, { id: 2n }]);
});
});
describe("planFolderDeletion", () => {
it("unfiles exactly the configs in the folder, then deletes the folder", () => {
const plan = planFolderDeletion({
folderId: 10n,
configs: [
{ id: 1n, folderId: 10n },
{ id: 2n, folderId: 11n },
{ id: 3n, folderId: 10n },
{ id: 4n, folderId: null },
],
});
expect(plan).toEqual({
unfileConfigIds: [1n, 3n],
deleteFolderId: 10n,
});
});
});
describe("buildStarChange", () => {
it("starring stamps the moment, unstarring clears it", () => {
const now = new Date("2026-07-07T12:00:00Z");
expect(buildStarChange({ starred: true, now })).toEqual({ starredAt: now });
expect(buildStarChange({ starred: false, now })).toEqual({
starredAt: null,
});
});
});

View file

@ -0,0 +1,110 @@
// Planning functions for the per-user portfolio workspace (starred portfolios
// and personal folders — user_portfolio_config / user_portfolio_folders).
// Pure data-in/plan-out: route handlers and server components apply the plans
// with drizzle. See docs/design/home-redesign/ for the interaction design.
export type FolderReorderPlan =
| { updates: Array<{ id: bigint; position: number }> }
| { error: string };
// The drag-drop reorder PATCH sends the full ordered id list; the plan
// renumbers every folder to its index so positions stay dense (0..n-1).
export function planFolderReorder({
currentIds,
orderedIds,
}: {
currentIds: bigint[];
orderedIds: bigint[];
}): FolderReorderPlan {
const current = new Set(currentIds);
const requested = new Set(orderedIds);
const sameSet =
orderedIds.length === currentIds.length &&
current.size === requested.size &&
[...requested].every((id) => current.has(id));
if (!sameSet) {
return { error: "Folder order does not match your folders" };
}
return {
updates: orderedIds.map((id, index) => ({ id, position: index })),
};
}
// starred_at doubles as the flag (non-null = starred) and the "recently
// starred" sort key; this is the one place that convention is written down.
export function buildStarChange({
starred,
now,
}: {
starred: boolean;
now: Date;
}): { starredAt: Date | null } {
return { starredAt: starred ? now : null };
}
// The same-owner FK on user_portfolio_config is NO ACTION, so a folder can
// only be deleted after its configs are unfiled — both steps must run in one
// transaction, unfile first. This plan makes that ordering explicit.
export function planFolderDeletion({
folderId,
configs,
}: {
folderId: bigint;
configs: Array<{ id: bigint; folderId: bigint | null }>;
}): { unfileConfigIds: bigint[]; deleteFolderId: bigint } {
return {
unfileConfigIds: configs
.filter((c) => c.folderId === folderId)
.map((c) => c.id),
deleteFolderId: folderId,
};
}
type ConfigRow<Id> = {
portfolioId: Id;
folderId: Id | null;
starredAt: Date | string | null;
};
type FolderRow<Id> = { id: Id; name: string; position: number };
// Assembles the home page's render model from the three per-user reads.
// Generic over the portfolio payload and the id type: the server groups with
// bigints; the client re-groups optimistically with wire-format string ids.
export function groupPortfoliosForHome<Id, P extends { id: Id }>({
portfolios,
configs,
folders,
}: {
portfolios: P[];
configs: Array<ConfigRow<Id>>;
folders: Array<FolderRow<Id>>;
}): {
folders: Array<{ id: Id; name: string; portfolios: P[] }>;
unfiled: P[];
starred: P[];
} {
const configByPortfolio = new Map(configs.map((c) => [c.portfolioId, c]));
const folderOf = (p: P) => configByPortfolio.get(p.id)?.folderId ?? null;
const starredAt = (p: P) => configByPortfolio.get(p.id)?.starredAt ?? null;
const starredTime = (p: P) => new Date(starredAt(p)!).getTime();
// Starred float above unstarred; original relative order is otherwise kept.
const floatStarred = (group: P[]) =>
[...group].sort(
(a, b) => Number(starredAt(b) !== null) - Number(starredAt(a) !== null),
);
const orderedFolders = [...folders].sort((a, b) => a.position - b.position);
return {
folders: orderedFolders.map((f) => ({
id: f.id,
name: f.name,
portfolios: floatStarred(portfolios.filter((p) => folderOf(p) === f.id)),
})),
unfiled: floatStarred(portfolios.filter((p) => folderOf(p) === null)),
starred: portfolios
.filter((p) => starredAt(p) !== null)
.sort((a, b) => starredTime(b) - starredTime(a)),
};
}

View file

@ -6,11 +6,14 @@ import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio";
import {
measuresDisplayLabels,
measuresList,
type MeasureKey,
} from "@/app/db/schema/recommendations";
import { DISRUPTIVE_MEASURES, EPC_BANDS, findExactDuplicate, SCENARIO_GOALS } from "@/lib/scenarios/model";
DISRUPTIVE_MEASURES,
EPC_BANDS,
findExactDuplicate,
SCENARIO_GOALS,
SCENARIO_MEASURES,
scenarioMeasuresList,
type ScenarioMeasureKey,
} from "@/lib/scenarios/model";
import { BandChip, EPC_SOFT, GOAL_DESCRIPTIONS, GOAL_SHORT_LABELS, GoalIcon, bandInk, bandTint, formatMoney } from "../ui";
interface ExistingScenario {
@ -24,20 +27,21 @@ interface ExistingScenario {
modelled: boolean;
}
const MEASURE_GROUPS: [string, MeasureKey[]][] = [
["Insulation", ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "suspended_floor_insulation", "solid_floor_insulation"]],
["Heating & hot water", ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating", "hot_water_tank_insulation", "cylinder_thermostat"]],
["Glazing & ventilation", ["double_glazing", "secondary_glazing", "ventilation"]],
["Renewables & other", ["solar_pv", "low_energy_lighting", "fireplace"]],
// Grouped presentation of SCENARIO_MEASURES (the backend MeasureType vocabulary)
const MEASURE_GROUPS: [string, ScenarioMeasureKey[]][] = [
["Insulation", ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", "loft_insulation", "flat_roof_insulation", "sloping_ceiling_insulation", "suspended_floor_insulation", "solid_floor_insulation"]],
["Heating & hot water", ["gas_boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating_removal", "system_tune_up", "system_tune_up_zoned"]],
["Glazing & ventilation", ["double_glazing", "secondary_glazing", "mechanical_ventilation"]],
["Renewables & other", ["solar_pv", "low_energy_lighting"]],
];
const PRESETS: { label: string; hint: string; excl: MeasureKey[] }[] = [
const PRESETS: { label: string; hint: string; excl: ScenarioMeasureKey[] }[] = [
{ label: "No wet trades", hint: "Rules out plaster-and-screed work: wall and solid-floor insulation",
excl: ["internal_wall_insulation", "external_wall_insulation", "solid_floor_insulation"] },
{ label: "Fabric only", hint: "Insulation and glazing only — no heating systems or renewables",
excl: ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating", "hot_water_tank_insulation", "cylinder_thermostat", "solar_pv", "low_energy_lighting", "fireplace", "ventilation"] },
excl: ["gas_boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating_removal", "system_tune_up", "system_tune_up_zoned", "solar_pv", "low_energy_lighting", "mechanical_ventilation"] },
{ label: "Low disruption", hint: "Excludes every measure marked as disruptive",
excl: Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[] },
excl: Object.keys(DISRUPTIVE_MEASURES) as ScenarioMeasureKey[] },
];
const fieldLabel =
@ -90,7 +94,12 @@ export function NewScenarioJourney({
});
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
},
onSuccess: () => router.push(`/portfolio/${portfolioId}/scenarios`),
onSuccess: () => {
router.push(`/portfolio/${portfolioId}/scenarios`);
// The scenarios list is server-rendered; without a refresh the client
// router cache serves the pre-save page ("No scenarios yet").
router.refresh();
},
onError: (e: Error) => setErrors({ excl: e.message }),
});
@ -102,7 +111,7 @@ export function NewScenarioJourney({
if (!band) return null;
let s = `Reach EPC ${band}`;
const ex = [...excluded];
if (ex.length === 1) s += ` — excl. ${measuresDisplayLabels[ex[0] as MeasureKey]}`;
if (ex.length === 1) s += ` — excl. ${SCENARIO_MEASURES[ex[0] as ScenarioMeasureKey] ?? ex[0]}`;
else if (ex.length > 1) s += `${ex.length} exclusions`;
if (budgetNumber) s += `${formatMoney(budgetNumber)}/property`;
return s;
@ -141,7 +150,7 @@ export function NewScenarioJourney({
setErrors(e);
if (Object.keys(e).length) return;
}
if (step === 2 && excluded.size === measuresList.length) {
if (step === 2 && excluded.size === scenarioMeasuresList.length) {
setErrors({ excl: "You've excluded every measure — the engine would have nothing to work with. Allow at least one." });
return;
}
@ -385,7 +394,7 @@ export function NewScenarioJourney({
))}
</div>
<div className="mb-5 flex items-center gap-2.5">
<button className={btn} onClick={() => setExcluded(new Set(measuresList))}>
<button className={btn} onClick={() => setExcluded(new Set(scenarioMeasuresList))}>
Exclude all
</button>
<button className={btn} onClick={() => setExcluded(new Set())}>
@ -393,8 +402,8 @@ export function NewScenarioJourney({
</button>
<span className="ml-auto text-[13px] tabular-nums text-gray-600">
{excluded.size === 0
? `All ${measuresList.length} measures allowed`
: `${excluded.size} excluded · ${measuresList.length - excluded.size} allowed`}
? `All ${scenarioMeasuresList.length} measures allowed`
: `${excluded.size} excluded · ${scenarioMeasuresList.length - excluded.size} allowed`}
</span>
</div>
{MEASURE_GROUPS.map(([group, keys]) => (
@ -434,7 +443,7 @@ export function NewScenarioJourney({
<span className={off ? "text-gray-400" : "font-bold text-brandmidblue"}>
{off ? "✕" : "✓"}
</span>
{measuresDisplayLabels[k]}
{SCENARIO_MEASURES[k]}
{disruptiveReason && !off && (
<span className="font-manrope text-[9px] font-extrabold uppercase tracking-[.08em] text-amber-600">
disruptive
@ -461,14 +470,14 @@ export function NewScenarioJourney({
</p>
{(() => {
const allowedDisruptive = (
Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[]
Object.keys(DISRUPTIVE_MEASURES) as ScenarioMeasureKey[]
).filter((k) => !excluded.has(k));
return allowedDisruptive.length > 0 ? (
<div className="mb-5 max-w-[62ch] rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] text-amber-900">
This scenario still allows{" "}
<b>
{allowedDisruptive
.map((k) => measuresDisplayLabels[k])
.map((k) => SCENARIO_MEASURES[k])
.join(", ")}
</b>{" "}
disruptive measure{allowedDisruptive.length > 1 ? "s" : ""}{" "}
@ -527,7 +536,7 @@ export function NewScenarioJourney({
key={k}
className="rounded-full border border-red-100 bg-white px-3 py-0.5 text-[13px] text-red-800 line-through"
>
{measuresDisplayLabels[k as MeasureKey] ?? k}
{SCENARIO_MEASURES[k as ScenarioMeasureKey] ?? k}
</span>
))}
</span>

View file

@ -40,13 +40,23 @@ export default async function ScenariosPage(props: {
}}
/>
</div>
<Link
href={`/portfolio/${slug}/scenarios/new`}
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-[0_2px_10px_rgba(57,67,183,.35)] transition hover:shadow-[0_4px_16px_rgba(57,67,183,.45)]"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
+ New scenario
</Link>
<div className="flex flex-none items-center gap-2.5">
{scenarios.length > 0 && (
<Link
href={`/portfolio/${slug}/modelling/run`}
className="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-brandblue transition hover:bg-gray-50"
>
Run modelling
</Link>
)}
<Link
href={`/portfolio/${slug}/scenarios/new`}
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-[0_2px_10px_rgba(57,67,183,.35)] transition hover:shadow-[0_4px_16px_rgba(57,67,183,.45)]"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
+ New scenario
</Link>
</div>
</div>
<p className="mb-6 max-w-[62ch] text-sm text-gray-600">
Each scenario is a question you ask the modelling engine. Its

View file

@ -14,6 +14,7 @@ import {
PlusIcon,
MagnifyingGlassIcon,
DocumentArrowUpIcon,
PlayIcon,
} from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { HomeIcon } from "@heroicons/react/24/outline";
@ -677,6 +678,15 @@ export default function PropertyTable({
onClose={() => setBulkImportOpen(false)}
portfolioId={portfolioId}
/>
{/* Run modelling */}
<button
onClick={() => router.push(`/portfolio/${portfolioId}/modelling/run`)}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition"
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
</button>
</div>
</div>

View file

@ -0,0 +1,647 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeftIcon, CheckCircleIcon } from "@heroicons/react/24/solid";
import {
RUN_FILTER_BUILT_FORMS,
RUN_FILTER_PROPERTY_TYPES,
RunFilters,
RunStatus,
estimateRunMinutes,
historyRefetchInterval,
isLargeRun,
MAX_FILTER_POSTCODES,
selectionSummary,
} from "@/lib/modellingRuns/model";
export interface ScenarioOption {
id: string;
name: string;
goal: string;
goalValue: string | null;
housingType: string;
budget: number | null;
status: "modelled" | "awaiting" | "running";
modelledCount: number;
}
interface PreviewResponse {
matchedProperties: number;
perScenario: { scenarioId: string; alreadyModelled: number }[];
}
interface RunHistoryRow {
taskId: string;
triggeredAt: string;
triggeredBy: string;
scenarios: { id: string; name: string | null }[];
selection: string;
previewedPropertyCount: number;
planCount: number;
status: RunStatus;
}
const STATUS_LABELS: Record<RunStatus["state"], string> = {
dispatched: "Starting up",
in_progress: "In progress",
complete: "Complete",
failed: "Failed",
};
function RunBadge({ status }: { status: ScenarioOption["status"] }) {
if (status === "running") {
return (
<span className="ml-auto inline-flex flex-none items-center gap-1.5 rounded-full bg-brandblue px-2.5 py-0.5 text-xs font-semibold text-white">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-300" />
Modelling in progress
</span>
);
}
const modelled = status === "modelled";
return (
<span
className={`ml-auto inline-flex flex-none items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ${
modelled ? "bg-brandlightblue text-brandmidblue" : "bg-amber-50 text-amber-800"
}`}
>
<span className={`h-1.5 w-1.5 flex-none rounded-full ${modelled ? "bg-brandmidblue" : "bg-brandgold"}`} />
{modelled ? "Modelled" : "Awaiting modelling"}
</span>
);
}
function FilterColumn({
title,
options,
selected,
onToggle,
}: {
title: string;
options: { value: string; count?: number }[];
selected: Set<string>;
onToggle: (value: string) => void;
}) {
return (
<div>
<div className="mb-2.5 flex items-baseline gap-2">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
{title}
</span>
<span className="text-[11.5px] text-gray-400">
· {selected.size ? `${selected.size} selected` : "all"}
</span>
</div>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1.5">
{options.map((o) => {
const on = selected.has(o.value);
return (
<label
key={o.value}
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13.5px] transition ${
on ? "bg-brandlightblue" : "hover:bg-gray-50"
} ${o.value === "Unknown" ? "mt-1 border-t border-dashed border-gray-200 pt-2.5" : ""}`}
>
<input
type="checkbox"
checked={on}
onChange={() => onToggle(o.value)}
className="h-[15px] w-[15px] accent-brandmidblue"
/>
{o.value}
{o.count != null && (
<span className="ml-auto text-[11.5px] tabular-nums text-gray-400">{o.count}</span>
)}
</label>
);
})}
</div>
</div>
);
}
export default function RunModellingClient({
portfolioId,
portfolioName,
scenarios,
}: {
portfolioId: string;
portfolioName: string;
scenarios: ScenarioOption[];
}) {
const queryClient = useQueryClient();
const [selScenarios, setSelScenarios] = useState<Set<string>>(new Set());
const [selPcs, setSelPcs] = useState<Set<string>>(new Set());
const [selTypes, setSelTypes] = useState<Set<string>>(new Set());
const [selForms, setSelForms] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null);
const filters: RunFilters = {
...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}),
...(selTypes.size ? { propertyTypes: [...selTypes] } : {}),
...(selForms.size ? { builtForms: [...selForms] } : {}),
};
const scenarioIds = [...selScenarios].sort();
const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({
queryKey: ["portfolio-postcodes", portfolioId],
staleTime: 5 * 60 * 1000,
queryFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/postcodes`);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't load postcodes");
return body.postcodes;
},
});
const preview = useQuery<PreviewResponse, Error>({
queryKey: ["run-preview", portfolioId, scenarioIds, filters],
enabled: scenarioIds.length > 0 && !done,
keepPreviousData: true,
retry: false,
queryFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenarioIds, filters }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't count the selection");
return body;
},
});
const history = useQuery<RunHistoryRow[], Error>({
queryKey: ["modelling-runs", portfolioId],
// v4 signature: (data, query). Fast while a run is moving, slow at rest.
refetchInterval: (data) => historyRefetchInterval(data),
queryFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't load run history");
return body.runs;
},
});
const run = useMutation<unknown, Error, void>({
mutationFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scenarioIds,
filters,
previewedPropertyCount: preview.data?.matchedProperties ?? 0,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't start modelling — please try again");
return body;
},
onSuccess: () => {
setDone({ properties: preview.data?.matchedProperties ?? 0, scenarios: scenarioIds.length });
setSelScenarios(new Set());
queryClient.invalidateQueries(["modelling-runs", portfolioId]);
},
});
const toggle = (set: Set<string>, setter: (s: Set<string>) => void, value: string) => {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
setter(next);
};
const matched = preview.data?.matchedProperties;
const planTotal = matched != null ? matched * scenarioIds.length : null;
const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0);
const scenarioById = new Map(scenarios.map((s) => [s.id, s]));
const startRun = () => {
if (matched != null && isLargeRun(matched)) setConfirmOpen(true);
else run.mutate();
};
const filterCount = selPcs.size + selTypes.size + selForms.size;
const clearFilters = () => {
setSelPcs(new Set());
setSelTypes(new Set());
setSelForms(new Set());
};
return (
<div className="mx-auto max-w-4xl px-6 pb-48 pt-10">
<Link
href={`/portfolio/${portfolioId}`}
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-semibold text-gray-600 transition hover:text-brandblue focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brandmidblue"
>
<ArrowLeftIcon className="h-4 w-4" aria-hidden />
Back to {portfolioName}
</Link>
<h1 className="font-manrope text-3xl font-extrabold tracking-tight text-brandblue">
Run your scenarios.
</h1>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-gray-600">
Choose the scenarios you want answered, then the homes to include. The counts you see are
checked against your portfolio as you go what you see is exactly what gets modelled.
</p>
{done ? (
<div
role="status"
className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50/70 px-5 py-4"
>
<div className="flex flex-wrap items-start gap-3">
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-none text-emerald-600" aria-hidden />
<div className="min-w-0 flex-1">
<h2 className="font-manrope text-base font-extrabold text-brandblue">
Modelling started you don&apos;t need to stay on this page
</h2>
<p className="mt-1 max-w-[60ch] text-[13px] leading-relaxed text-gray-600">
We&apos;re modelling <b>{done.properties.toLocaleString()}</b>{" "}
{done.properties === 1 ? "home" : "homes"} against <b>{done.scenarios}</b>{" "}
scenario{done.scenarios === 1 ? "" : "s"} {" "}
<b>{(done.properties * done.scenarios).toLocaleString()}</b> plan
{done.properties * done.scenarios === 1 ? "" : "s"}. Results for a run this size
are typically ready within{" "}
<b>~{estimateRunMinutes(done.properties * done.scenarios)} minutes</b> progress
updates under Recent runs below as it happens.
</p>
</div>
<div className="flex flex-none items-center gap-2.5 self-center">
<button
onClick={() => setDone(null)}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-brandblue transition hover:bg-gray-50"
>
Set up another run
</button>
<Link
href={`/portfolio/${portfolioId}`}
className="rounded-xl px-4 py-2 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
Back to portfolio
</Link>
</div>
</div>
</div>
) : (
<>
{/* Step 1 — scenarios */}
<div className="mt-7 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
Step 1 · Scenarios
</span>
<h2 className="mt-0.5 font-manrope text-lg font-extrabold text-brandblue">
Which questions are we asking?
</h2>
<p className="text-[13px] text-gray-500">
Every selected scenario runs against the same property selection.
</p>
{scenarios.length === 0 ? (
<div className="mt-4 rounded-xl border-[1.5px] border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-500">
No scenarios yet {" "}
<Link
href={`/portfolio/${portfolioId}/scenarios/new`}
className="font-semibold text-brandmidblue hover:underline"
>
create your first scenario
</Link>{" "}
and come back to run it.
</div>
) : (
scenarios.map((s) => {
const on = selScenarios.has(s.id);
return (
<div key={s.id}>
<label
className={`mt-2.5 flex w-full cursor-pointer items-center gap-3.5 rounded-xl border px-4 py-3 text-left transition ${
on
? "border-brandmidblue bg-white shadow-sm"
: "border-transparent bg-gray-50 hover:bg-gray-100"
}`}
>
<input
type="checkbox"
checked={on}
onChange={() => toggle(selScenarios, setSelScenarios, s.id)}
className="h-4 w-4 shrink-0 accent-brandmidblue"
/>
<span className="min-w-0">
<span className="block truncate font-manrope text-sm font-bold text-brandblue">
{s.name}
</span>
<span className="block text-xs text-gray-500">
{s.goal === "Increasing EPC" && s.goalValue
? `Target EPC ${s.goalValue}`
: s.goal}{" "}
· {s.housingType}
{s.budget ? ` · £${s.budget.toLocaleString()} per home` : ""}
</span>
</span>
<RunBadge status={s.status} />
</label>
{on && s.status === "running" && (
<div className="ml-11 mt-1.5 rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
This scenario is already being modelled. You can still include it the
newest results are the ones you&apos;ll see.
</div>
)}
</div>
);
})
)}
</div>
{/* Step 2 — properties */}
<div className="mt-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-3">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
Step 2 · Properties
</span>
{filterCount > 0 && (
<button
onClick={clearFilters}
className="text-[13px] font-semibold text-brandmidblue transition hover:text-brandblue hover:underline"
>
Remove all filters ({filterCount})
</button>
)}
</div>
<h2 className="mt-0.5 font-manrope text-lg font-extrabold text-brandblue">
Which homes?
</h2>
<p className="text-[13px] text-gray-500">
Leave a filter untouched to include everything. What you&apos;ve told us about a home
takes priority over its EPC; &ldquo;Unknown&rdquo; picks out homes where we have
neither.
</p>
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-3">
{postcodesQuery.isError ? (
<div>
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
Postcode
</span>
<p className="mt-2.5 text-[13px] leading-relaxed text-red-700">
We couldn&apos;t load your portfolio&apos;s postcodes.{" "}
<button
onClick={() => postcodesQuery.refetch()}
className="font-semibold underline hover:text-red-800"
>
Try again
</button>
</p>
</div>
) : postcodesQuery.isLoading ? (
<div>
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
Postcode
</span>
<p className="mt-2.5 text-[13px] text-gray-500">Loading postcodes</p>
</div>
) : (
<FilterColumn
title="Postcode"
options={(postcodesQuery.data ?? []).map((p) => ({
value: p.postcode,
count: p.count,
}))}
selected={selPcs}
onToggle={(v) => toggle(selPcs, setSelPcs, v)}
/>
)}
<FilterColumn
title="Property type"
options={RUN_FILTER_PROPERTY_TYPES.map((value) => ({ value }))}
selected={selTypes}
onToggle={(v) => toggle(selTypes, setSelTypes, v)}
/>
<FilterColumn
title="Built form"
options={RUN_FILTER_BUILT_FORMS.map((value) => ({ value }))}
selected={selForms}
onToggle={(v) => toggle(selForms, setSelForms, v)}
/>
</div>
<p className="mt-3 text-[11.5px] leading-relaxed text-gray-400">
Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming
you&apos;ll be able to save a selection and reuse it.
</p>
</div>
</>
)}
{/* Recent runs always visible: after a run is triggered this is where
its live status lands, so the banner above can say "watch below" */}
<div className="mt-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex items-baseline gap-3">
<h2 className="font-manrope text-lg font-extrabold text-brandblue">Recent runs</h2>
<span className="text-[13px] text-gray-500">
what was asked, by whom, and how it went
</span>
</div>
{history.isLoading ? (
<p className="mt-4 text-sm text-gray-400">Loading</p>
) : !history.data?.length ? (
<p className="mt-4 text-sm text-gray-400">
No runs yet your first appears here the moment you trigger it.
</p>
) : (
<div className="overflow-x-auto">
<table className="mt-3 w-full border-collapse text-[13px]">
<thead>
<tr>
{["When", "Scenarios", "Selection", "Properties", "Plans", "Status"].map(
(h) => (
<th
key={h}
className="border-b border-gray-100 px-3 py-2 text-left font-manrope text-[10.5px] font-extrabold uppercase tracking-[.09em] text-gray-400"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody>
{history.data.map((r) => (
<tr key={r.taskId}>
<td className="whitespace-nowrap border-b border-gray-100 px-3 py-2.5 align-top">
{new Date(r.triggeredAt).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
<span className="block text-[11.5px] text-gray-400">{r.triggeredBy}</span>
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top">
{r.scenarios.map((s) => s.name ?? s.id).join(" · ") || "—"}
</td>
<td className="max-w-[220px] border-b border-gray-100 px-3 py-2.5 align-top text-[12px] text-gray-500">
{r.selection}
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top tabular-nums">
{r.previewedPropertyCount.toLocaleString()}
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top tabular-nums">
{r.planCount.toLocaleString()}
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top">
<span
className={`whitespace-nowrap rounded-full px-2.5 py-0.5 font-manrope text-[10px] font-extrabold uppercase tracking-[.06em] ${
r.status.state === "complete"
? "bg-emerald-50 text-emerald-700"
: r.status.state === "failed"
? "bg-red-50 text-red-700"
: "bg-brandblue text-white"
}`}
>
{STATUS_LABELS[r.status.state]}
</span>
{/* Work is chunked into batches, so show a percentage
the counts never line up with property/plan numbers */}
{r.status.state === "in_progress" && (
<>
<span className="mt-1 block text-[11px] tabular-nums text-gray-400">
{Math.round((100 * r.status.ready) / Math.max(1, r.status.total))}% done
</span>
<span className="mt-0.5 block h-1 w-20 overflow-hidden rounded-full bg-gray-100">
<span
className="block h-full rounded-full bg-brandmidblue"
style={{ width: `${Math.round((100 * r.status.ready) / Math.max(1, r.status.total))}%` }}
/>
</span>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Summary tray */}
{!done && scenarioIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 z-40 w-[min(56rem,calc(100vw-2rem))] -translate-x-1/2 rounded-2xl border border-gray-200 bg-white/95 px-5 py-4 shadow-2xl backdrop-blur">
<div className="flex flex-wrap items-center gap-3">
<span
className={`font-manrope text-xl font-extrabold tabular-nums text-brandblue ${preview.isFetching ? "opacity-40" : ""}`}
>
{matched != null ? matched.toLocaleString() : "—"}
<span className="ml-1 text-xs font-semibold text-gray-400">properties</span>
</span>
<span className="text-gray-300">×</span>
<span className="font-manrope text-xl font-extrabold tabular-nums text-brandblue">
{scenarioIds.length}
<span className="ml-1 text-xs font-semibold text-gray-400">
scenario{scenarioIds.length > 1 ? "s" : ""}
</span>
</span>
<span className="text-gray-300">=</span>
<span
className={`font-manrope text-xl font-extrabold tabular-nums text-brandblue ${preview.isFetching ? "opacity-40" : ""}`}
>
{planTotal != null ? planTotal.toLocaleString() : "—"}
<span className="ml-1 text-xs font-semibold text-gray-400">plans</span>
</span>
<span className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span
className={`h-1.5 w-1.5 rounded-full ${preview.isFetching ? "animate-pulse bg-gray-400" : preview.isError ? "bg-red-500" : "bg-emerald-600"}`}
/>
{preview.isFetching
? "Counting…"
: preview.isError
? preview.error.message
: "Checked against your portfolio just now"}
</span>
<button
onClick={startRun}
disabled={preview.isFetching || preview.isError || !matched || run.isLoading}
className="ml-auto rounded-xl px-4 py-2 text-sm font-semibold text-white transition disabled:opacity-40"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
{run.isLoading
? "Starting…"
: matched
? `Run modelling — ${planTotal!.toLocaleString()} plan${planTotal === 1 ? "" : "s"}`
: "No properties match"}
</button>
</div>
{(remodel.length > 0 || run.isError) && (
<div className="mt-2.5 flex flex-col gap-1">
{remodel.map((p) => (
<span
key={p.scenarioId}
className="inline-block w-fit rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs text-amber-800"
>
<b>{p.alreadyModelled.toLocaleString()}</b> of{" "}
{matched?.toLocaleString() ?? "—"} have already been modelled in &ldquo;
{scenarioById.get(p.scenarioId)?.name ?? p.scenarioId}&rdquo; they&apos;ll be
modelled again and the newest results shown
</span>
))}
{run.isError && <span className="text-xs text-red-600">{run.error.message}</span>}
</div>
)}
</div>
)}
{/* Large-run confirm */}
{confirmOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="large-run-title"
onKeyDown={(e) => {
if (e.key === "Escape") setConfirmOpen(false);
}}
className="fixed inset-0 z-50 flex items-center justify-center bg-brandblue/35 p-5 backdrop-blur-sm"
>
<div className="max-w-md rounded-2xl bg-white p-7 shadow-2xl">
<h3 id="large-run-title" className="font-manrope text-xl font-extrabold text-brandblue">
This is a large run
</h3>
<p className="mt-2 text-sm leading-relaxed text-gray-600">
You&apos;re about to model <b>{matched?.toLocaleString()}</b> homes across{" "}
{scenarioIds.length} scenario{scenarioIds.length > 1 ? "s" : ""} {" "}
<b>{planTotal?.toLocaleString()}</b> plans. A run this size can&apos;t be stopped
once it starts.
</p>
<div className="mt-3 rounded-xl border border-gray-100 bg-gray-50 px-3.5 py-2.5 text-[13px] leading-relaxed text-gray-600">
<span className="block">
<b className="font-semibold text-brandblue">Scenarios:</b>{" "}
{scenarioIds.map((id) => scenarioById.get(id)?.name ?? id).join(" · ")}
</span>
<span className="mt-1 block">
<b className="font-semibold text-brandblue">Homes:</b> {selectionSummary(filters)}
</span>
</div>
<div className="mt-5 flex justify-end gap-2.5">
<button
autoFocus
onClick={() => setConfirmOpen(false)}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-brandblue hover:bg-gray-50"
>
Go back
</button>
<button
onClick={() => {
setConfirmOpen(false);
run.mutate();
}}
className="rounded-xl px-4 py-2 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
Yes, run modelling
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { getPortfolio } from "../../utils";
import { listScenariosWithStatus } from "@/lib/scenarios/queries";
import { scenarioIdsWithActiveRuns } from "@/lib/modellingRuns/server";
import RunModellingClient, { ScenarioOption } from "./RunModellingClient";
export const dynamic = "force-dynamic";
export default async function Page(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const [{ name }, scenarios, activeIds] = await Promise.all([
getPortfolio(slug),
listScenariosWithStatus(BigInt(slug)),
scenarioIdsWithActiveRuns(slug),
]);
const options: ScenarioOption[] = scenarios.map((s) => ({
id: s.id.toString(),
name: s.name ?? `Scenario ${s.id}`,
goal: s.goal,
goalValue: s.goalValue,
housingType: s.housingType,
budget: s.budget,
status: activeIds.has(s.id.toString())
? "running"
: s.status === "modelled"
? "modelled"
: "awaiting",
modelledCount: s.plansCount,
}));
return (
<RunModellingClient
portfolioId={slug}
portfolioName={name ?? "Portfolio"}
scenarios={options}
/>
);
}

View file

@ -93,6 +93,12 @@ export default function AddPropertiesClient({
setDone(totals);
// Added properties change the search's alreadyInPortfolio flags
queryClient.invalidateQueries(["postcode-search", portfolioId]);
// …and the portfolio's property table. Remove, don't invalidate: the
// table isn't mounted here, and useProperties sets refetchOnMount:
// false, so a stale-marked query would never actually refetch — only
// an empty cache guarantees a fresh fetch when the table mounts.
queryClient.removeQueries(["properties", portfolioId]);
router.refresh();
},
});

View file

@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import {
buildRunRecord,
deriveRunStatus,
estimateRunMinutes,
historyRefetchInterval,
isLargeRun,
normaliseRunFilters,
parseRunConfig,
selectionSummary,
type RunStatus,
} from "./model";
describe("normaliseRunFilters", () => {
it("canonicalises postcodes, dedupes, and drops empty selections (absent = all)", () => {
const out = normaliseRunFilters({
postcodes: ["m20 4tf", "M20 4TF", "b93 8su"],
propertyTypes: [],
builtForms: undefined,
});
expect(out).toEqual({
ok: true,
filters: { postcodes: ["B93 8SU", "M20 4TF"] },
});
});
it("accepts canonical types/built forms plus Unknown, rejects anything else", () => {
const ok = normaliseRunFilters({
propertyTypes: ["House", "Unknown"],
builtForms: ["Semi-Detached"],
});
expect(ok).toEqual({
ok: true,
filters: { propertyTypes: ["House", "Unknown"], builtForms: ["Semi-Detached"] },
});
expect(normaliseRunFilters({ propertyTypes: ["Castle"] }).ok).toBe(false);
expect(normaliseRunFilters({ builtForms: ["Terraced"] }).ok).toBe(false);
});
it("caps the postcode selection at 40 — 'all postcodes' is absence, not enumeration", () => {
const many = Array.from({ length: 41 }, (_, i) => `M${Math.floor(i / 10)}${i % 10} 4TF`);
const out = normaliseRunFilters({ postcodes: many });
expect(out.ok).toBe(false);
if (!out.ok) expect(out.error).toMatch(/40/);
expect(normaliseRunFilters({ postcodes: many.slice(0, 40) }).ok).toBe(true);
});
});
describe("buildRunRecord", () => {
it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => {
const record = buildRunRecord({
portfolioId: "814",
scenarioIds: ["12", "34"],
filters: { postcodes: ["B93 8SU"], propertyTypes: ["House"] },
previewedPropertyCount: 214,
triggeredBy: "khalim@domna.homes",
});
expect(record.task).toEqual({
taskSource: "Modelling run 2 scenarios",
service: "modelling_run",
source: "portfolio_id",
sourceId: "814",
status: "waiting",
inputs: JSON.stringify({
portfolio_id: 814,
scenario_ids: [12, 34],
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
previewed_property_count: 214,
triggered_by: "khalim@domna.homes",
}),
});
// What the distributor receives — provenance fields stay on the task only
expect(record.dispatchPayload).toEqual({
portfolio_id: 814,
scenario_ids: [12, 34],
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
});
});
});
describe("parseRunConfig", () => {
it("round-trips a stored run record back to the app's shape", () => {
const { task } = buildRunRecord({
portfolioId: "814",
scenarioIds: ["12"],
filters: { builtForms: ["Detached", "Unknown"] },
previewedPropertyCount: 57,
triggeredBy: "priya@domna.homes",
});
expect(parseRunConfig(task.inputs)).toEqual({
scenarioIds: ["12"],
filters: { builtForms: ["Detached", "Unknown"] },
previewedPropertyCount: 57,
triggeredBy: "priya@domna.homes",
});
});
it("returns null for missing or malformed inputs — never throws on old rows", () => {
expect(parseRunConfig(null)).toBeNull();
expect(parseRunConfig("not json")).toBeNull();
expect(parseRunConfig('{"unrelated":true}')).toBeNull();
});
});
describe("selectionSummary", () => {
it("describes the filters in the run-history format; no filters = All properties", () => {
expect(selectionSummary({})).toBe("All properties");
expect(
selectionSummary({
postcodes: ["B93 8SU", "M20 4TF"],
propertyTypes: ["House"],
builtForms: ["Detached", "Unknown"],
}),
).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown");
});
});
describe("deriveRunStatus", () => {
const base = { taskStatus: "waiting", jobCompleted: null, total: 0, completed: 0, failed: 0 };
it("maps the task lifecycle to the four display states", () => {
// dispatched: accepted, no execution subtasks yet
expect(deriveRunStatus(base)).toEqual({ state: "dispatched" });
// in progress once execution subtasks exist, with progress
expect(
deriveRunStatus({ ...base, taskStatus: "in progress", total: 104, completed: 61 }),
).toEqual({ state: "in_progress", ready: 61, total: 104 });
// complete when the task says so
expect(
deriveRunStatus({ ...base, taskStatus: "completed", jobCompleted: new Date(), total: 104, completed: 104 }),
).toEqual({ state: "complete" });
// failed statuses win regardless of counts
expect(
deriveRunStatus({ ...base, taskStatus: "Failed", total: 104, completed: 40, failed: 3 }),
).toEqual({ state: "failed" });
});
});
describe("isLargeRun", () => {
it("gates runs of 10,000+ matched properties behind an extra confirm", () => {
expect(isLargeRun(9999)).toBe(false);
expect(isLargeRun(10000)).toBe(true);
});
});
describe("estimateRunMinutes", () => {
it("scales with plan count — roughly 300 plans per 5 minutes", () => {
expect(estimateRunMinutes(3_000)).toBe(50);
expect(estimateRunMinutes(12_000)).toBe(200);
});
it("never promises less than 5 minutes, even for tiny or zero-plan runs", () => {
expect(estimateRunMinutes(0)).toBe(5);
expect(estimateRunMinutes(1)).toBe(5);
expect(estimateRunMinutes(300)).toBe(5);
});
});
describe("historyRefetchInterval", () => {
const runs = (...states: RunStatus[]) => states.map((status) => ({ status }));
it("polls fast while any run is still moving (dispatched or in progress)", () => {
expect(historyRefetchInterval(runs({ state: "complete" }, { state: "dispatched" }))).toBe(
5_000,
);
expect(
historyRefetchInterval(runs({ state: "in_progress", ready: 1, total: 4 })),
).toBe(5_000);
});
it("settles to a slow poll when every run is finished, or history is unknown", () => {
expect(historyRefetchInterval(runs({ state: "complete" }, { state: "failed" }))).toBe(30_000);
expect(historyRefetchInterval([])).toBe(30_000);
expect(historyRefetchInterval(undefined)).toBe(30_000);
});
});

View file

@ -0,0 +1,237 @@
/**
* Modelling-run domain model pure logic for the bulk-trigger-modelling
* journey. See CONTEXT.md (Modelling run, Run filter) and ADR-0008.
*/
import { normalisePostcode } from "@/lib/postcodeSearch/model";
import {
BuiltFormTypeValues,
PropertyTypeValues,
} from "@/app/db/schema/landlord_overrides";
/**
* The Run-filter vocabularies: the canonical enum lists, which already end in
* "Unknown" the no-resolvable-value bucket (CONTEXT.md, Run filter).
*/
export const RUN_FILTER_PROPERTY_TYPES: readonly string[] = PropertyTypeValues;
export const RUN_FILTER_BUILT_FORMS: readonly string[] = BuiltFormTypeValues;
/** An absent key means unconstrained — "all" is never an enumeration. */
export interface RunFilters {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
}
export type NormaliseFiltersResult =
| { ok: true; filters: RunFilters }
| { ok: false; error: string };
/** UX guard, not a transport limit — the config is stored, not carried. */
export const MAX_FILTER_POSTCODES = 40;
export function normaliseRunFilters(input: {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
}): NormaliseFiltersResult {
const filters: RunFilters = {};
if (input.postcodes?.length) {
const seen = new Set<string>();
for (const raw of input.postcodes) {
const pc = normalisePostcode(raw);
if (!pc) return { ok: false, error: `Invalid postcode '${raw}'` };
seen.add(pc);
}
filters.postcodes = [...seen].sort();
if (filters.postcodes.length > MAX_FILTER_POSTCODES) {
return {
ok: false,
error: `Filters are limited to ${MAX_FILTER_POSTCODES} postcodes per run — leave the filter empty to include all postcodes`,
};
}
}
// The filter vocabularies are the canonical enum lists (which already end
// in "Unknown" — the no-resolvable-value bucket, see CONTEXT.md Run filter).
for (const [key, allowed] of [
["propertyTypes", PropertyTypeValues],
["builtForms", BuiltFormTypeValues],
] as const) {
const values = input[key];
if (!values?.length) continue;
const bad = values.find((v) => !allowed.includes(v));
if (bad !== undefined) {
return { ok: false, error: `Invalid ${key === "propertyTypes" ? "property type" : "built form"} '${bad}'` };
}
filters[key] = [...new Set(values)];
}
return { ok: true, filters };
}
export interface RunConfigView {
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
}
/**
* Read a stored config-subtask `inputs` back into the app's shape. Null for
* anything that isn't a modelling-run config (malformed, legacy, absent)
* history reads must never throw on old rows.
*/
export function parseRunConfig(inputs: string | null): RunConfigView | null {
if (!inputs) return null;
try {
const raw = JSON.parse(inputs) as Partial<RunConfigInputs>;
if (!Array.isArray(raw.scenario_ids) || typeof raw.triggered_by !== "string") {
return null;
}
const filters: RunFilters = {};
if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes;
if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types;
if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms;
return {
scenarioIds: raw.scenario_ids.map(String),
filters,
previewedPropertyCount: raw.previewed_property_count ?? 0,
triggeredBy: raw.triggered_by,
};
} catch {
return null;
}
}
/** Above this, the UI asks for an explicit extra confirmation before running. */
export const LARGE_RUN_THRESHOLD = 10_000;
export function isLargeRun(matchedPropertyCount: number): boolean {
return matchedPropertyCount >= LARGE_RUN_THRESHOLD;
}
export type RunStatus =
| { state: "dispatched" }
| { state: "in_progress"; ready: number; total: number }
| { state: "complete" }
| { state: "failed" };
/**
* Display state of a run from its task + execution-subtask counts (the config
* subtask is excluded by the caller). Status strings follow the task system's
* loose conventions (case-insensitive; completion also signalled by
* job_completed).
*/
export function deriveRunStatus(input: {
taskStatus: string;
jobCompleted: Date | null;
total: number;
completed: number;
failed: number;
}): RunStatus {
const s = input.taskStatus.toLowerCase();
if (["failed", "failure", "error"].includes(s)) return { state: "failed" };
if (input.jobCompleted || ["completed", "complete"].includes(s)) {
return { state: "complete" };
}
if (input.total === 0) return { state: "dispatched" };
return { state: "in_progress", ready: input.completed, total: input.total };
}
/**
* Rough results ETA shown after a run is triggered: the distributor works
* through ~300 plans per 5-minute batch window, floored at 5 minutes so a
* tiny run never promises the impossible. A hedge ("typically ready
* within"), not a contract.
*/
export function estimateRunMinutes(planCount: number): number {
return Math.max(5, Math.round(planCount / 300) * 5);
}
/**
* Run-history poll cadence: fast while any run is still moving so progress
* feels live, slow once everything has settled. Shape matches TanStack v4's
* refetchInterval callback, which receives the query data first.
*/
export function historyRefetchInterval(
runs: { status: RunStatus }[] | undefined,
): number {
const active = runs?.some(
(r) => r.status.state === "dispatched" || r.status.state === "in_progress",
);
return active ? 5_000 : 30_000;
}
/** Human summary of a Run filter, as shown in run history ("All properties"). */
export function selectionSummary(filters: RunFilters): string {
const bits: string[] = [];
if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`);
if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`);
if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`);
return bits.length ? bits.join(" · ") : "All properties";
}
export interface RunRequest {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
}
/** Backend-facing (snake_case) copy of a run's configuration. */
export interface RunConfigInputs {
portfolio_id: number;
scenario_ids: number[];
filters: {
postcodes?: string[];
property_types?: string[];
built_forms?: string[];
};
previewed_property_count: number;
triggered_by: string;
}
/**
* The row a Modelling run persists (ADR-0008): an app-authored task whose
* `inputs` carries the run's config the durable, history-readable record.
* Sub-tasks are the distributor's: per-property execution work.
*/
export function buildRunRecord(req: RunRequest): {
task: {
taskSource: string;
service: "modelling_run";
source: "portfolio_id";
sourceId: string;
status: "waiting";
inputs: string;
};
/** What the distributor receives — provenance fields stay on the task. */
dispatchPayload: Omit<RunConfigInputs, "previewed_property_count" | "triggered_by">;
} {
const filters: RunConfigInputs["filters"] = {};
if (req.filters.postcodes) filters.postcodes = req.filters.postcodes;
if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes;
if (req.filters.builtForms) filters.built_forms = req.filters.builtForms;
const configInputs: RunConfigInputs = {
portfolio_id: Number(req.portfolioId),
scenario_ids: req.scenarioIds.map(Number),
filters,
previewed_property_count: req.previewedPropertyCount,
triggered_by: req.triggeredBy,
};
return {
task: {
taskSource: `Modelling run ${req.scenarioIds.length} scenario${req.scenarioIds.length === 1 ? "" : "s"}`,
service: "modelling_run",
source: "portfolio_id",
sourceId: req.portfolioId,
status: "waiting",
inputs: JSON.stringify(configInputs),
},
dispatchPayload: {
portfolio_id: configInputs.portfolio_id,
scenario_ids: configInputs.scenario_ids,
filters,
},
};
}

View file

@ -0,0 +1,273 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { scenario } from "@/app/db/schema/recommendations";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import {
RunFilters,
RunStatus,
buildRunRecord,
deriveRunStatus,
parseRunConfig,
selectionSummary,
} from "./model";
import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources";
// Distributor entry point on the Model backend. It fans the run out to
// workers, attaching execution sub-tasks to the task_id we pass (ADR-0008).
// NOTE: sub-task granularity is BATCHES, not properties — progress counts are
// internally consistent but never comparable to the previewed property count.
const DISTRIBUTOR_ENDPOINT = "/v1/modelling/trigger-run";
type BackendResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
async function callBackend<T>(
endpoint: string,
payload: Record<string, unknown>,
sessionToken: string | undefined,
): Promise<BackendResult<T>> {
const url = process.env.FASTAPI_API_URL;
const key = process.env.FASTAPI_API_KEY;
if (!url || !key) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return { ok: false, status: 500, message: "Server misconfiguration" };
}
try {
const res = await fetch(`${url}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": key,
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error(`FastAPI ${endpoint} failed:`, res.status, errText);
return { ok: false, status: 502, message: "Modelling backend request failed" };
}
return { ok: true, data: (await res.json()) as T };
} catch (err) {
console.error(`Failed to reach FastAPI ${endpoint}:`, err);
return { ok: false, status: 502, message: "Modelling backend request failed" };
}
}
export type TriggerRunOutcome =
| { kind: "ok"; taskId: string }
| { kind: "unknown_scenarios"; missing: string[] }
| { kind: "dispatch_failed"; status: number; message: string };
/**
* Record + dispatch a Modelling run: one app-authored task whose `inputs`
* carries the run's config (BulkUpload trigger convention), then hand
* task_id to the distributor sub_tasks are its, one per property. A
* failed dispatch marks the task failed so history never shows a ghost run
* as pending (ADR-0008).
*/
export async function triggerModellingRun(args: {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
sessionToken: string | undefined;
}): Promise<TriggerRunOutcome> {
const known = await db
.select({ id: scenario.id })
.from(scenario)
.where(
and(
eq(scenario.portfolioId, BigInt(args.portfolioId)),
inArray(scenario.id, args.scenarioIds.map(BigInt)),
),
);
const knownIds = new Set(known.map((r) => r.id.toString()));
const missing = args.scenarioIds.filter((id) => !knownIds.has(id));
if (missing.length > 0) return { kind: "unknown_scenarios", missing };
const record = buildRunRecord(args);
const now = new Date();
const [task] = await db
.insert(tasks)
.values({ ...record.task, jobStarted: now })
.returning();
// Minimal dispatch payload — previewed count and triggered_by are app
// provenance, already durable on the task's inputs.
const dispatch = await callBackend(
DISTRIBUTOR_ENDPOINT,
{ task_id: task.id, ...record.dispatchPayload },
args.sessionToken,
);
if (!dispatch.ok) {
await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id));
return { kind: "dispatch_failed", status: dispatch.status, message: dispatch.message };
}
await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id));
return { kind: "ok", taskId: task.id };
}
export interface PreviewCounts {
matchedProperties: number;
perScenario: { scenarioId: string; alreadyModelled: number }[];
}
/**
* Count the properties a Run filter matches, plus how many of them already
* hold plans per selected scenario. Resolution precedence per ADR-0008:
* landlord-override snapshot EPC-derived (new-approach graph, lodged over
* predicted, or legacy property-row columns) Unknown. The distributor
* implements the same documented rule; both read this database.
* One grouped, portfolio-scoped statement no per-scenario probes.
*/
export async function previewModellingRun(args: {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
}): Promise<PreviewCounts> {
const pid = BigInt(args.portfolioId);
const inList = (values: (string | number)[]) =>
sql.join(values.map((v) => sql`${v}`), sql`, `);
const conditions = [sql`TRUE`];
if (args.filters.postcodes) {
conditions.push(sql`resolved.postcode IN (${inList(args.filters.postcodes)})`);
}
if (args.filters.propertyTypes) {
conditions.push(sql`resolved.ptype IN (${inList(args.filters.propertyTypes)})`);
}
if (args.filters.builtForms) {
conditions.push(sql`resolved.bform IN (${inList(args.filters.builtForms)})`);
}
const result = await db.execute<{
matched: number;
per_scenario: { scenario_id: string; n: number }[] | null;
}>(sql`
WITH resolved AS (
SELECT p.id, p.postcode,
COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown') AS ptype,
COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown') AS bform
FROM property p
LEFT JOIN property_overrides pot ON pot.property_id = p.id
AND pot.override_component = 'property_type' AND pot.building_part = 0
LEFT JOIN property_overrides pobf ON pobf.property_id = p.id
AND pobf.override_component = 'built_form_type' AND pobf.building_part = 0
LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'
LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted'
WHERE p.portfolio_id = ${pid} AND p.marked_for_deletion = false
),
matched AS (
SELECT resolved.id FROM resolved
WHERE ${sql.join(conditions, sql` AND `)}
),
already AS (
SELECT scenario_id, COUNT(DISTINCT property_id)::int AS n
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id IN (${inList(args.scenarioIds.map(Number))})
AND property_id IN (SELECT id FROM matched)
GROUP BY scenario_id
)
SELECT
(SELECT COUNT(*)::int FROM matched) AS matched,
(SELECT json_agg(json_build_object('scenario_id', scenario_id::text, 'n', n)) FROM already) AS per_scenario
`);
const row = result.rows[0];
return {
matchedProperties: row?.matched ?? 0,
perScenario: (row?.per_scenario ?? []).map((p) => ({
scenarioId: p.scenario_id,
alreadyModelled: p.n,
})),
};
}
export interface RunHistoryRow {
taskId: string;
triggeredAt: string;
triggeredBy: string;
scenarios: { id: string; name: string | null }[];
selection: string;
previewedPropertyCount: number;
planCount: number;
status: RunStatus;
}
/**
* Run history for a portfolio: its modelling_run tasks with the app-authored
* config parsed from task inputs and progress from sub-task counts (one
* sub-task per property, distributor-attached). One grouped query
* portfolio-scoped, never per-run probes.
*/
export async function listModellingRuns(
portfolioId: string,
limit = 20,
): Promise<RunHistoryRow[]> {
const rows = await db
.select({
taskId: tasks.id,
jobStarted: tasks.jobStarted,
jobCompleted: tasks.jobCompleted,
status: tasks.status,
configInputs: tasks.inputs,
total: sql<number>`count(${subTasks.id})::int`,
completed: sql<number>`count(case when lower(${subTasks.status}) in ('completed','complete') then 1 end)::int`,
failed: sql<number>`count(case when lower(${subTasks.status}) in ('failed','failure','error') then 1 end)::int`,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(and(eq(tasks.service, "modelling_run"), eq(tasks.sourceId, portfolioId)))
.groupBy(tasks.id)
.orderBy(desc(tasks.jobStarted))
.limit(limit);
const configs = rows.map((r) => parseRunConfig(r.configInputs));
const scenarioIds = [...new Set(configs.flatMap((c) => c?.scenarioIds ?? []))];
const names = scenarioIds.length
? await db
.select({ id: scenario.id, name: scenario.name })
.from(scenario)
.where(inArray(scenario.id, scenarioIds.map(BigInt)))
: [];
const nameById = new Map(names.map((n) => [n.id.toString(), n.name]));
return rows.map((r, i) => {
const config = configs[i];
const scenarios = (config?.scenarioIds ?? []).map((id) => ({
id,
name: nameById.get(id) ?? null,
}));
return {
taskId: r.taskId,
triggeredAt: (r.jobStarted ?? new Date(0)).toISOString(),
triggeredBy: config?.triggeredBy ?? "unknown",
scenarios,
selection: config ? selectionSummary(config.filters) : "—",
previewedPropertyCount: config?.previewedPropertyCount ?? 0,
planCount: (config?.previewedPropertyCount ?? 0) * scenarios.length,
status: deriveRunStatus({
taskStatus: r.status,
jobCompleted: r.jobCompleted,
total: r.total,
completed: r.completed,
failed: r.failed,
}),
};
});
}
/** Scenario ids with a run currently in flight — drives the third badge state. */
export async function scenarioIdsWithActiveRuns(portfolioId: string): Promise<Set<string>> {
const runs = await listModellingRuns(portfolioId, 50);
const active = new Set<string>();
for (const run of runs) {
if (run.status.state === "dispatched" || run.status.state === "in_progress") {
run.scenarios.forEach((s) => active.add(s.id));
}
}
return active;
}

View file

@ -1,16 +1,61 @@
import { describe, expect, it } from "vitest";
import { measuresList } from "@/app/db/schema/recommendations";
import {
constraintSummary,
deriveScenarioStatus,
DISRUPTIVE_MEASURES,
findExactDuplicate,
parseExclusions,
scenarioMeasuresList,
scenarioToInsertRow,
serializeExclusions,
validateScenarioConfig,
} from "./model";
describe("SCENARIO_MEASURES", () => {
// Mirror of MeasureType in Hestia-Homes/Model domain/modelling/measure_type.py.
// The engine CRASHES on exclusion tokens outside this set at modelling time
// (scenario_table._parse_exclusions is strict), so the two lists must change
// together — the backend's ADR-0056 shared-contract rule, applied here.
const BACKEND_MEASURE_TYPES = [
"cavity_wall_insulation",
"external_wall_insulation",
"internal_wall_insulation",
"loft_insulation",
"sloping_ceiling_insulation",
"flat_roof_insulation",
"suspended_floor_insulation",
"solid_floor_insulation",
"double_glazing",
"secondary_glazing",
"low_energy_lighting",
"mechanical_ventilation",
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"gas_boiler_upgrade",
"system_tune_up",
"system_tune_up_zoned",
"solar_pv",
"secondary_heating_removal",
];
it("offers exactly the backend MeasureType vocabulary", () => {
expect([...scenarioMeasuresList].sort()).toEqual([...BACKEND_MEASURE_TYPES].sort());
});
it("rejects engine-unparseable tokens at authoring time (the 1275 crash)", () => {
const result = validateScenarioConfig({
name: "Low disruption",
housingType: "Social",
goal: "Increasing EPC",
goalValue: "C",
budgetPerProperty: null,
exclusions: ["room_roof_insulation"],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors.exclusions).toMatch(/room_roof_insulation/);
});
});
describe("validateScenarioConfig", () => {
it("requires a target band when the goal is Increasing EPC", () => {
const result = validateScenarioConfig({
@ -148,7 +193,7 @@ describe("validateScenarioConfig", () => {
});
it("rejects excluding every measure — the engine needs at least one option", () => {
const result = validateScenarioConfig({ ...validBase, exclusions: [...measuresList] });
const result = validateScenarioConfig({ ...validBase, exclusions: [...scenarioMeasuresList] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors.exclusions).toBeTruthy();
});
@ -231,7 +276,7 @@ describe("findExactDuplicate", () => {
describe("DISRUPTIVE_MEASURES", () => {
it("only marks real measure keys (a typo would silently drop the badge)", () => {
for (const key of Object.keys(DISRUPTIVE_MEASURES)) {
expect(measuresList).toContain(key);
expect(scenarioMeasuresList as readonly string[]).toContain(key);
}
});
});
@ -249,7 +294,7 @@ describe("display helpers", () => {
measures: ["Solid Floor Insulation"],
});
// 18 exclusions → say what's allowed instead (in canonical measure order)
const onlyTwo = measuresList.filter(
const onlyTwo = scenarioMeasuresList.filter(
(k) => k !== "solar_pv" && k !== "air_source_heat_pump",
);
expect(constraintSummary(onlyTwo)).toEqual({

View file

@ -8,12 +8,51 @@
*/
import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio";
import {
HousingType,
measuresDisplayLabels,
measuresList,
type MeasureKey,
} from "@/app/db/schema/recommendations";
import { HousingType } from "@/app/db/schema/recommendations";
/**
* The measures a Scenario may exclude EXACTLY the backend's `MeasureType`
* values (Hestia-Homes/Model, domain/modelling/measure_type.py). The engine's
* exclusion parsing is strict: a token outside this set crashes the modelling
* run for every property in the scenario, so this list and the backend enum
* must change together (the backend's ADR-0056 shared-contract rule; pinned
* by a test). Tokens are the backend's; labels are ours.
*
* Deliberately narrower than the legacy `measuresDisplayLabels` vocabulary
* (recommendations display / the old file-based trigger): measures the new
* engine cannot recommend (room-in-roof insulation, fireplace sealing,
* hot-water tank insulation, cylinder thermostats) are not offered
* excluding the unrecommendable is meaningless, and storing the token is a
* time bomb. Room-in-roof insulation returns here when the backend
* implements it.
*/
export const SCENARIO_MEASURES = {
cavity_wall_insulation: "Cavity Wall Insulation",
external_wall_insulation: "External Wall Insulation",
internal_wall_insulation: "Internal Wall Insulation",
loft_insulation: "Loft Insulation",
sloping_ceiling_insulation: "Sloping Ceiling Insulation",
flat_roof_insulation: "Flat Roof Insulation",
suspended_floor_insulation: "Suspended Floor Insulation",
solid_floor_insulation: "Solid Floor Insulation",
double_glazing: "Double Glazing",
secondary_glazing: "Secondary Glazing",
low_energy_lighting: "Low Energy Lighting",
mechanical_ventilation: "Mechanical Ventilation",
high_heat_retention_storage_heaters: "High Heat Retention Storage Heaters",
air_source_heat_pump: "Air Source Heat Pump",
gas_boiler_upgrade: "Gas Boiler Upgrade",
system_tune_up: "Heating System Tune-up",
system_tune_up_zoned: "Heating System Tune-up (zoned)",
solar_pv: "Solar PV",
secondary_heating_removal: "Secondary Heating Removal",
} as const;
export type ScenarioMeasureKey = keyof typeof SCENARIO_MEASURES;
export const scenarioMeasuresList = Object.keys(
SCENARIO_MEASURES,
) as ScenarioMeasureKey[];
export const EPC_BANDS = ["A", "B", "C", "D", "E", "F", "G"] as const;
export type EpcBand = (typeof EPC_BANDS)[number];
@ -78,10 +117,12 @@ export function validateScenarioConfig(
// value (any stray input is dropped); the only brake is the budget.
const exclusions = [...new Set(input.exclusions)].sort();
const unknown = exclusions.filter((k) => !(measuresList as string[]).includes(k));
const unknown = exclusions.filter(
(k) => !(scenarioMeasuresList as string[]).includes(k),
);
if (unknown.length > 0) {
errors.exclusions = `Unknown measures: ${unknown.join(", ")}`;
} else if (exclusions.length === measuresList.length) {
} else if (exclusions.length === scenarioMeasuresList.length) {
errors.exclusions =
"Every measure is excluded — the engine would have nothing to work with. Allow at least one.";
}
@ -136,14 +177,16 @@ export function parseExclusions(text: string | null): string[] {
* frequently exclude them. The value is the plain-language reason shown to
* users. The "Low disruption" quick-start excludes exactly this set.
*/
export const DISRUPTIVE_MEASURES: Partial<Record<MeasureKey, string>> = {
export const DISRUPTIVE_MEASURES: Partial<Record<ScenarioMeasureKey, string>> = {
internal_wall_insulation:
"Rooms need clearing and replastering while the work is done",
solid_floor_insulation:
"Floors are dug up or raised — usually needs the home empty",
suspended_floor_insulation:
"Floorboards come up in every affected room",
room_roof_insulation: "Loft rooms are stripped back to the rafters",
// room-in-roof insulation belongs here ("loft rooms are stripped back to
// the rafters") but is not yet a backend MeasureType — restore it when the
// backend implements the measure.
};
export type ScenarioStatus = "modelled" | "awaiting_modelling";
@ -166,16 +209,19 @@ export type ConstraintSummary =
export function constraintSummary(exclusions: string[]): ConstraintSummary {
if (exclusions.length === 0) return { kind: "all" };
const excluded = new Set(exclusions);
const allowed = measuresList.filter((k) => !excluded.has(k));
const allowed = scenarioMeasuresList.filter((k) => !excluded.has(k));
if (allowed.length <= 3) {
return { kind: "only", measures: allowed.map((k) => measuresDisplayLabels[k]) };
return { kind: "only", measures: allowed.map((k) => SCENARIO_MEASURES[k]) };
}
return {
kind: "excludes",
measures: measuresList
.filter((k) => excluded.has(k))
.map((k) => measuresDisplayLabels[k as MeasureKey]),
};
const known = scenarioMeasuresList
.filter((k) => excluded.has(k))
.map((k) => SCENARIO_MEASURES[k]);
// Legacy rows may hold tokens outside today's vocabulary — display them
// as themselves rather than dropping them silently.
const legacy = exclusions.filter(
(k) => !(scenarioMeasuresList as string[]).includes(k),
);
return { kind: "excludes", measures: [...known, ...legacy] };
}
/** The stored shape a duplicate check compares against (subset of the scenario row). */

View file

@ -211,7 +211,7 @@ export const PROPERTY_TYPE_LABELS: Record<string, string> = {
"3": "Maisonette",
"4": "Park home",
};
const BUILT_FORM_LABELS: Record<string, string> = {
export const BUILT_FORM_LABELS: Record<string, string> = {
"1": "Detached",
"2": "Semi-Detached",
"3": "End-Terrace",
@ -273,6 +273,16 @@ export const propertyTypeSql = sql`CASE
ELSE p.property_type
END`;
/**
* Built-form label. New: epc_property (lodged, else predicted) RdSAP codes
* mapped via BUILT_FORM_LABELS, text passed through, mirroring
* resolvePropertyDescriptors. Legacy: property.built_form.
*/
export const builtFormTypeSql = sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${codeLabelCaseSql(sql`epl.built_form`, BUILT_FORM_LABELS)} ELSE ${codeLabelCaseSql(sql`epp.built_form`, BUILT_FORM_LABELS)} END)
ELSE p.built_form
END`;
/**
* The main building part's construction age band for one epc_property alias, as
* a correlated scalar subquery properties can also have extension parts, so a