## Screenshots
<img width="1661" height="823" alt="Screenshot 2026-04-24 133705" src="https://github.com/user-attachments/assets/10a97853-4850-4120-b087-afb4cd3f8a07" />
<img width="1181" height="641" alt="Screenshot 2026-04-24 133748" src="https://github.com/user-attachments/assets/05bb2455-1457-46df-92f7-59eb2f2e8871" />
<img width="1101" height="654" alt="Screenshot 2026-04-24 133759" src="https://github.com/user-attachments/assets/c62aff48-edc8-4ff8-bd29-05d7e699bb0d" />
<img width="547" height="840" alt="Screenshot 2026-04-24 133810" src="https://github.com/user-attachments/assets/da16b2b2-c58a-4d77-9112-efcde4a69174" />
<img width="1618" height="921" alt="Screenshot 2026-04-24 133954" src="https://github.com/user-attachments/assets/bef7256b-9e02-4f36-bd5b-9473119a16dc" />
---
## Summary
Routed Site Detail page at /dashboards/portfolio/[siteSlug], styled like Linear's project detail. Click any school card on the Portfolio dashboard (board or list view) to open it. The PR also includes substantive infrastructure additions; see "Scope" below for the honest enumeration.
Layout (per JC's whiteboard + Benji's [Linear UI doc](https://linear.app/builder-team/document/site-detail-view-ui-daeb897aa394) + AERIE-186 spec update 2026-04-24):
- Header strip — school name, slug, phase + market + status chips, sticky on lg+
- Main column (front-and-centre, lg:col-span-8) — Status Summary → Fact Sheet → Lease & Purchase → Status Updates → Buildout pillar
- Right rail (lg:col-span-4) — Pipeline Statuses → Personnel
- Independent scrollable panes on lg+; collapses to a single scroll on mobile
- Collapsible cards with localStorage-persisted open/closed state per card
Supersedes #114 — per Benji's [CHANGES_REQUESTED review](https://github.com/AI-Builder-Team/Aerie/pull/114) ("Scrap the Modal UI pieces, but keep the backend pieces"), the modal + tabbed UI was dropped.
## Scope (full enumeration)
This PR is bigger than a routed page. Here's what's actually in it:
### Site Detail page + cards
- New routed page at chat/app/(main)/dashboards/portfolio/[siteSlug]/page.tsx
- Six cards: Status Summary, Fact Sheet, Lease & Purchase, Pipeline Statuses, Personnel, Status Updates, Buildout
- Page wires click-through from portfolio-card.tsx and portfolio-list.tsx (via Next.js <Link>; respects board drag-pan via the existing [data-portfolio-card] exclusion)
- Card atom (card-atoms.tsx) with collapsible header + localStorage persistence per card-id
- Matterport row links directly to the public viewer (https://my.matterport.com/show/?m=<modelId>) using Rhodes' matterportModelId — no upstream API call
### Lease & Purchase card (AERIE-186 spec update 2026-04-24)
- Dedicated card after Fact Sheet for lease/purchase detail — surfaces LOI signed + Lease signed (both from Rhodes today) and Link / Start / End placeholders for the data not yet upstream
- Lease document URL exists in REBL3 today but isn't exposed via Rhodes' listSitesForAerie — tracked in AERIE-223
- Lease start / end don't exist in any upstream system — tracked in AERIE-224
### Operator status updates feed (siteStatusUpdates)
- New chat-local Convex table + 4 mutations/queries (create / update / remove / listForSite)
- Per-author edit + delete enforced server-side; UI gates Edit/Delete on isOwn
- 16 unit tests covering auth, author-only edit/delete, listForSite ordering + scoping + isOwn flag, body length bounds
### AI Status Summary action
- New "use node" Convex action siteSummary.generateSiteStatusSummary, modeled after expenseInsights.ts
- Server-side Rhodes fetch (the action accepts ONLY siteSlug from the client; never trusts client-supplied facts in the prompt)
- Cache row per (siteSlug, audienceUserId) — invalidates on either 30-min TTL OR an input fingerprint change (status updates, Rhodes facts, last-visit timestamp)
- Per-(user, slug) 60s cooldown on forceRefresh to bound Anthropic spend
- Untrusted-data delimiters around all operator-authored content + system instruction telling the model to treat delimited content as data, never instructions
- Falls back to a deterministic local summary when ANTHROPIC_API_KEY is missing or Claude errors
- Configurable model via SITE_SUMMARY_MODEL Convex env var (defaults to claude-sonnet-4-5-20250929 matching the PMO summary route precedent)
- Discriminated result type so the UI can render an actionable message per failure mode (auth-missing vs. Rhodes-not-configured vs. Rhodes-fetch-failed vs. slug-not-in-Rhodes)
- "Discuss in chat →" handoff seeds an Aerie chat conversation with the summary as the first assistant message and routes to /c/[id]
### Site visit tracking (siteVisits)
- New chat-local Convex table per (userId, siteSlug)
- Recorded on page UNMOUNT (not mount) so the next visit's "since you last looked" framing references the previous session, not the current
- Read by both the StatusSummaryCard (subtitle) and the AI summary action (prompt context)
### Wider PortfolioSiteRow audit
- Extended PortfolioSiteRow + derivePortfolioSiteRow with 14 fields the previous contract dropped: marketingName, loiSignedDate, leaseSignedDate, projectedOpenDate, actualOpenDate, netFloorArea, occupancyLoad, numberOfClassrooms, gradeRange, tuition, schoolEmail, schoolPhone, zoning, matterportModelId
- Plus PortfolioMilestones struct + PORTFOLIO_MILESTONE_DEFINITIONS for M1–M6 display
- Updates portfolio-enrollment.test.ts fixture for the wider shape
### ISP→Rhodes matterportModelId discovery orchestrator (replaces Wrike LiDAR Vendor)
- New sync/src/upstream/rhodes/matterport-from-isp-sync.ts — daily-cadence orchestrator
- Reads completed ISP analyses (via existing fetchAllLatestIspAnalyses), extracts wrike_site.wrike_id, joins to Rhodes via wrikeFolderId, writes via setMatterportModelId
- Conflict detection: tracks duplicate wrikeFolderId writes within a batch (lex-first wins, second logged + counted) and flags cross-orchestrator overrides of existing values
- 13 unit tests covering happy path + every failure mode
- Wired into analytics-worker/index.ts after the existing Wrike orchestrator (which it deprecates)
- Cadence registered in refresh-cadence.ts as rhodesMatterportFromIspBackfill
- IspAnalyzeResponseSubset (in @bran/contracts) extended with optional wrike_site so the orchestrator reads it without casting; subset-compat.test.ts extended for drift detection
### Chat-side polish
- Pipeline Statuses LOI/Acquisition gates show "Done · date not in Rhodes" when phase-derived without a real ISO date (consistent with what Fact Sheet shows)
- Buildout card uses loiSignedDate ?? actualOpenDate for "Project started" anchor (operating sites get open date; pre-operating get LOI)
- RagDot has role="img" + aria-label for screen-reader announce when the dot stands alone
- Site name <h1> truncates with min-w-0 so long names don't push the slug off-screen
### Insight model alignment
- expenseInsights.ts model bumped from the 404'ing claude-3-haiku-20240307 to claude-haiku-4-5-20251001 (matches admissionsInsights.ts + attachment-lookup.ts)
- siteSummary.ts defaults to claude-sonnet-4-5-20250929 (matches pmo-summary route)
### Infra
- docker-compose.yml: chat service now passes RHODES_CONVEX_SITE_URL + RHODES_API_KEY and exposes the chat container on ${CHAT_PORT:-3000} for local Docker dev
## Field coverage (125 prod Rhodes sites — verified live 2026-04-24)
What the page surfaces today vs. what Rhodes has populated:
| Tier | Fields |
|---|---|
| 100% populated | address, market, milestones |
| 70–90% | Buildout DRI (83%), wrikeFolderId (72%) |
| 20–50% | Operating DRI (24%), zoning (42%), LOI date (40%), capacity (35%), tuition (23%), lease date (23%) |
| <20% | grade range (18%), marketing name (14%), occupancy (6%), classrooms (5%), email/phone (2%), projected open (2%) |
| 0% | matterportModelId, netFloorArea (will populate once the new ISP→Rhodes orchestrator runs on the analytics worker daily cadence post-merge) |
Page renders maximum signal today: every populated field surfaces correctly, every empty field is visible as — or "Not yet in Rhodes" (actionable, not hidden behind defaults).
## Data discipline
Site facts flow through Rhodes (via /api/portfolio-sites). Cards do NOT bypass to chat-local REBL3 / HubSpot / Wrike caches. One narrowly-scoped exception:
- Status Updates is a chat-local Convex feature — operator notes are app UX state that doesn't belong in Rhodes.
The Matterport row in Fact Sheet is built directly from Rhodes' matterportModelId — no upstream API call. The AI Status Summary action consumes only Rhodes facts (server-fetched, not client-supplied) + the chat-local status updates feed. It does NOT read Wrike-projected qualityBars or any other bypass cache.
An earlier iteration of this PR pulled chat-local qualityBars into a Buildout "Operating Health" subsection as a stopgap; that violated the data discipline above and was reverted in 3e60ec3. AERIE-216 tracks ingesting Operating Health into Rhodes proper, at which point the card switches over.
## Linear follow-ups filed
- AERIE-212 — auto-provision Rhodes rows for every REBL3 site (URL coverage parity)
- AERIE-213 — Rhodes schema: add brand
- AERIE-214 — Rhodes schema: add Personnel slots beyond p1/p2 DRI
- AERIE-215 — Rhodes Aerie API: expose CO + capacity-study artefact links via listSitesForAerie
- AERIE-216 — Rhodes schema: add Greenlight + Operating Health gates
- AERIE-217 — Audit Rhodes for REBL3-derived fields not yet surfaced via listSitesForAerie
- AERIE-222 — Site Detail "Discuss in chat" handoff: chat agent has no built-in awareness of Aerie's data layer (see Known limitations below)
- AERIE-223 — Rhodes: expose REBL3 lease_url via listSitesForAerie (filed against AERIE-186 spec update)
- AERIE-224 — Lease term extraction: Lease Start / Lease End for Site Detail (filed against AERIE-186 spec update)
## Known limitations
### "Discuss in chat →" agent does not understand Aerie data-layer terminology
The chat agent has no built-in awareness of Aerie's data layer (Rhodes, ISP, Quality Bars, the chat-local Convex tables). When operators ask follow-ups that name those systems directly — e.g. *"are there any operational updates in rhodes?"* — the agent runs file searches trying to locate "Rhodes" in the codebase, then asks the user to disambiguate ("do you mean Rhombus security cameras?").
This is out of scope for this PR. The chat agent / system-prompt territory belongs to a separate area; this PR just hands off the briefing as an assistant message. Tracked in AERIE-222.
Operators who phrase follow-ups in natural language ("how's the buildout going?", "what's changed since last week?", "any recent notes on this site?") will get useful answers from the briefing context the agent already has. The clarification round only triggers when an Aerie data-layer name appears in the prompt.
A stop-gap that injected an inline data-layer glossary into the seed message was tried and reverted — it cluttered the operator-facing briefing without solving the underlying agent gap.
### Lease & Purchase card has placeholder rows for upstream-blocked data
Per Benji's AERIE-186 spec update, the Lease & Purchase card surfaces Link / Start / End. Today:
- Lease document link — exists in REBL3 (details.lease_url on the system: "lease" task) but isn't exposed via Rhodes' listSitesForAerie. Renders "Not yet in Rhodes" placeholder. AERIE-223 tracks the upstream merger extension; once it lands, the row is a one-line client-side change to wire to a real <ExternalLink>.
- Lease start / end — don't exist in any upstream Aerie consumes today. Renders "Not yet in Rhodes" placeholders. AERIE-224 tracks the upstream decision (manual entry vs LLM PDF extraction vs hybrid).
Empty fields are intentional and visible — they motivate the upstream fixes rather than getting hidden behind defaults.
### Matterport walkthrough links not yet visible on any site
The Matterport row renders View walkthrough only when matterportModelId is populated on the Rhodes record. Live audit 2026-04-24 confirmed coverage at 0/125 sites. Every site shows a muted dash today.
The new ISP→Rhodes discovery orchestrator on this PR (sync/src/upstream/rhodes/matterport-from-isp-sync.ts) backfills this from the existing ISP analyses on a daily cadence — coverage will rise to wherever ISP has analyses keyed to Wrike folder ids (substantial portion of the portfolio) within ~24 hours of merge + analytics-worker deploy.
To verify the link rendering before merge, manually populate matterportModelId on a single Rhodes site row from the Convex dashboard; the Fact Sheet row on that site will switch from — to View walkthrough. Not blocking.
## Out of scope (deferred follow-ups)
- Pillar cards beyond Buildout (Finance, Operating Health) — whiteboard explicitly marks future
- AI-generated status updates (manual entries only in v1)
- Workspace / role / site-membership ACL on siteStatusUpdates (M7) — fine for current all-internal-staff phase
- Per-card error boundary (M10) — single shared error boundary handles everything today; nice-to-have
- Single-site portfolio API route (M6) — page fetches the full list and finds by slug; cheap at 125-site scale
- Tests for the "use node" siteSummary action itself — convex-test doesn't cleanly handle "use node" actions and the existing insight actions (expenseInsights, admissionsInsights) ship without action tests; siteSummaryStorage internals + siteVisits + siteStatusUpdates ARE all tested
- Chat agent system-prompt extension for Aerie data-layer awareness — see "Known limitations"; tracked in AERIE-222
- Lease document link wiring — needs AERIE-223 upstream first
- Lease start/end fields — needs AERIE-224 product decision first
## Internal review pass — addressed
Internal deep review identified 6 highs + 12 mediums + 7 lows + 3 nits. Addressed:
- H1 — sync worker test failures: 3 tests had timeouts / assertion failures. Threaded _refreshMatterportFromIspFn: noopRefreshMatterportFromIsp through the missing configs. Note: those 3 tests fail on origin/main too with the same symptoms (verified by stashing this PR's changes and running on stock main); the failures pre-date this PR.
- H2 — prompt injection: untrusted-data delimiters around operator notes + system instruction telling the model to treat delimited content as data only; closing-delimiter sequences are escaped to prevent envelope breakouts.
- H3 — client-controlled prompt facts: action signature reduced to { siteSlug, forceRefresh? }; canonical Rhodes facts are server-fetched per call.
- H4 — matterport conflict guard: detect duplicate wrikeFolderId writes within a batch (lex-first wins, conflictsSkipped counted) AND log + count when ISP overrides an existing Rhodes value (overrodeExisting). Two new tests cover both cases.
- H5 — scope creep undocumented: this description.
- H6 — cache fingerprint: siteSummaries.fingerprint field added; cache hit requires both TTL AND fingerprint match. New status update / Rhodes change → invalidates without waiting for TTL.
- M1 — layout comment fixed
- M2 — data-discipline docstring rewritten to enumerate the narrow exceptions
- M3 — per-(user, slug) 60s cooldown on forceRefresh
- M4 / M5 — listForSite and getLastVisit now trim slug to mirror writers
- M7 — ACL TODO comment added on listForSite
- M9 — edit composer reuses the shared <CharCounter> component
- M11 — subset-compat.test.ts extended with wrike_site.wrike_id + wrike_site.title field-path checks
- M12 — siteVisits + siteSummaryStorage now have unit-test coverage (19 new tests)
- L1 — site name <h1> truncates with min-w-0
- L4 — Buildout "Project started" semantics: uses loiSignedDate ?? actualOpenDate
- L6 — last-write-wins comment on siteStatusUpdates.update
- L7 — RagDot a11y: role="img" + aria-label
- N1 — Personnel uses shared MutedDash
- N2 — expenseInsights model bump split into a dedicated commit and called out above
- N3 — isp/fetcher.ts header documents both consumers (Rhodes merger + matterport-from-isp orchestrator)
Plus a regression caught and fixed during local testing:
- The siteSummary action returned null for three different failure modes (auth-missing, Rhodes-not-configured, slug-not-found), and the UI collapsed all three into "Sign in required to generate summary." Refactored to a discriminated union so the UI can render an actionable message for each — most importantly "Site facts unavailable: Rhodes is not configured on this Convex deployment. Set RHODES_CONVEX_SITE_URL and RHODES_API_KEY (npx convex env set …)." for the operator-fixable case.
Deferred (low-impact / out of scope today):
- M6 — single-site API route (perf optimisation)
- M8 — .collect() boundary on QB query — moot now that operatingHealth.ts is deleted; siteSummaryStorage already uses .take
- M10 — per-card error boundary
- L2 — floorplan failure differentiation — moot now that IspFloorplanLink is removed in favour of the direct Matterport viewer link
- L3 — collapse-state hydration cosmetic
- L5 — siteSummary action returns null vs throws — defensible; matches Convex query convention
## Testing
- pnpm tsc --noEmit clean
- Chat suite: 27 portfolio + 16 status updates + 19 visits/storage = 62/62 of the new tests; full chat suite 2689/2691 pass (2 pre-existing Windows-path failures in lib/__tests__/agent.test.ts)
- Sync: matterport-from-isp 13/13 + cadence 11/11. Worker tests 3/9 pre-existing failures (same symptom on origin/main per H1 note above)
- npx biome check clean on all changed files
## Test plan — verifiable today
Author has confirmed everything in this list against live prod Rhodes (125 sites, 2026-04-24).
Setup (one-time):
- [x] Chat container env: RHODES_CONVEX_SITE_URL=https://valiant-marlin-770.convex.site + RHODES_API_KEY=<key> (already in docker-compose.yml)
- [x] Convex env (for AI summary action): npx convex env set ANTHROPIC_API_KEY <value> + npx convex env set RHODES_CONVEX_SITE_URL https://valiant-marlin-770.convex.site + npx convex env set RHODES_API_KEY <value>
Navigation + routing:
- [x] Click a card on Portfolio board view → routes to /dashboards/portfolio/{slug}, breadcrumb says "Dashboards > Portfolio > {school name}"
- [x] Click a row in Portfolio list view → same destination
- [x] Cmd/ctrl-click opens detail in a new tab; browser back returns to Portfolio with view mode preserved; deep-link by URL works
- [x] Drag-pan on board background still pans (no accidental nav)
- [x] Unknown slug renders an explicit 404-style "Site not found" state with a back-link, not a hard error
Fact Sheet (Rhodes-sourced fields):
- [x] On any operating site (e.g. Spyglass), Capacity / Tuition / Zoning / Year opened / Buildout DRI render real values; Pipeline Statuses shows real M1–M6 completion dates
- [x] On an early-pipeline site, fields not yet captured upstream show muted — ("empty fields are the feature"); Brand row shows "Not yet in Rhodes" tag (Rhodes schema gap, AERIE-213)
- [x] Sq. Ft. row currently shows — on every site (Rhodes netFloorArea at 0/125 today; will populate once AERIE-XXX surfaces ISP building.total_main_sqft to Rhodes)
- [x] Matterport row currently shows — on every site (Rhodes matterportModelId at 0/125 today; new ISP→Rhodes orchestrator on this PR backfills it daily post-merge — see Known limitations)
Lease & Purchase card:
- [x] LOI signed + Lease signed render real dates on the ~40% / ~23% of sites that have them in Rhodes
- [x] On sites that don't, both rows show muted —
- [x] Lease document / Lease start / Lease end render muted — with "Not yet in Rhodes" tag on every site (AERIE-223 + AERIE-224 track the upstream work)
AI Status Summary card:
- [x] Renders Claude-generated prose with "Generated X ago · Claude" footer on first load
- [x] Refresh button regenerates; second click within 60s silently demotes to a cached read (visible in Convex logs as [siteSummary] forceRefresh demoted ...)
- [x] "Discuss in chat →" creates a conversation in /c/[id] with the summary as the first assistant message
- [x] When Convex RHODES_API_KEY is unset, the card shows the actionable config message ("Site facts unavailable: Rhodes is not configured ..."), NOT the misleading "Sign in required" the earlier draft regressed to
- [x] When ANTHROPIC_API_KEY is unset, the card falls back to a deterministic local summary with a yellow "Fallback" badge (rather than erroring)
Status Updates card:
- [x] Post a new update; it appears at the top of the feed with author + relative timestamp
- [x] Edit own update — char counter visible during edit; over-limit pastes disable Save
- [x] Delete own update — gone after refresh
- [x] Cannot edit / delete updates authored by another user (Edit / Delete buttons hidden, server rejects if attempted directly)
Layout + ergonomics:
- [x] Cards collapse + reopen via the chevron; state persists across page reloads (per-card-id localStorage)
- [x] lg+: main column and right rail scroll independently; mobile: single-column page scroll
- [x] Long site names truncate with ellipsis instead of pushing the slug off-screen
## Test plan — blocked on upstream coverage (not testable in this PR)
These will start passing automatically as the corresponding upstream work lands. Not blocking merge.
- [ ] Matterport row shows a View walkthrough link clicking through to https://my.matterport.com/show/?m=<modelId> — blocked until matterportModelId coverage > 0% (will rise via this PR's ISP→Rhodes orchestrator on its first daily run post-deploy, OR via manual Convex-dashboard population for spot-check). To verify rendering correctness today: pick any site and manually set matterportModelId on its Rhodes row → page swaps — for the link.
- [ ] Lease document row in Lease & Purchase shows a real Drive link — blocked on AERIE-223 (Rhodes merger extension to expose REBL3's details.lease_url via listSitesForAerie)
- [ ] Lease start / Lease end rows show real dates — blocked on AERIE-224 (product decision: manual entry vs LLM extraction vs hybrid)
- [ ] Sq. Ft. row in Fact Sheet shows real square footage — blocked on Rhodes surfacing ISP-derived building.total_main_sqft (likely a sub-task of AERIE-217 audit)