Eight section components in sections/. Five shared primitives in components/dashboard/. page.tsx is a ~130-line async RSC that generates metadata, awaits params and search params, and composes sections. S01 and S03 are async RSC wrappers that call query stubs. The supply layer is split across three files: supply-data.ts (pure data), supply-parcel-panel.tsx ("use client" — MiniCard, RightPanel), and supply-concepts.tsx ("use client" — TerrainMap, SupplyBanner). All data served from in-memory constants in lib/ — zero DB calls.
flowchart TB
Browser(["Browser"])
Browser -->|"GET /dashboard/[market]?..."| page
subgraph App["Next.js 16 · App Router"]
page["page.tsx — async RSC\n~130 lines · generateMetadata · layout composition"]
subgraph CC["Client components"]
refine["refine-bar.tsx\nuseSearchParams · router.push\nexports REFINE_BAR_ID"]
nav["section-nav.tsx\nIntersectionObserver + ResizeObserver\nimports TOPBAR_ID · REFINE_BAR_ID"]
end
subgraph Sec["sections/ — RSC wrappers"]
s01["s01-market-intelligence.tsx async RSC\nawaits getConvergenceSnapshot + getDemandSnapshot"]
s03["s03-moat-coverage.tsx async RSC\nawaits getMoatDimensions → passes moats prop"]
sc["supply-concepts.tsx use client\nTerrainMap · SupplyBanner · SupplyDensitySection"]
spp["supply-parcel-panel.tsx use client\nMiniCard · RightPanel · MoatGridA/B/C"]
mc["moat-concepts.tsx use client\nMoatCoverageSection (receives moats prop)"]
s4x["s04 → s08 RSC stubs"]
end
subgraph Q["lib/queries/ — stubs → Prisma"]
qi["intelligence.ts · moats.ts\nmarket.ts · supply.ts"]
end
subgraph CD["components/dashboard/ — shared primitives"]
ui["Topbar (exports TOPBAR_ID) · LogoMark\nSectionHead · Card · SectionBanner"]
end
subgraph Lib["lib/ — in-memory constants · zero DB calls"]
moat["moat-data.ts\nMOATS · SAT_COLOR · SAT_COLOR_HEX · SAT_LABEL"]
mkt["market-data.ts\nCATCHMENT_STATS · RING_COLORS · RING_COLORS_HEX"]
sd["supply-data.ts\nPARCELS constant"]
end
end
page --> refine
page --> nav
page --> Sec
Sec --> Q
Q --> Lib
Sec --> CD
sc --> sd
spp --> sd
s01 --> qi
s03 --> qi
page.tsx stays async. Filter params drive lib/queries/ stubs → Prisma → Neon. TerrainMap (SVG) is replaced by react-map-gl with real isochrone rings and parcel polygons from PostGIS. Client components are otherwise unchanged.
flowchart TB
Browser(["Browser"])
Browser -->|"GET /dashboard/[market]?..."| page
subgraph App["Next.js 16 · App Router"]
page["page.tsx — async RSC\nawait params · await searchParams"]
subgraph CC["Client components — unchanged"]
refine["refine-bar.tsx"]
nav["section-nav.tsx"]
end
subgraph Q["lib/queries/"]
qf["market.ts · supply.ts\nmoats.ts · intelligence.ts"]
end
subgraph Sec["sections/ — 8 RSC wrappers"]
sc["supply-concepts.tsx\nuse client · react-map-gl\ndrive rings + parcel polygons"]
sO["s01 · s03 → s08 RSC"]
end
subgraph CD["components/dashboard/ — shared primitives"]
ui["Topbar · LogoMark\nSectionHead · Card · SectionBanner"]
end
end
subgraph DL["Data layer"]
prisma["Prisma Client\n@prisma/adapter-neon"]
neon[("Neon PostgreSQL\nPostGIS · 25 tables")]
end
page --> refine
page --> nav
page --> Q
Q --> prisma
prisma --> neon
page --> Sec
Sec --> CD
Both diagrams trace the same event: the user changes the catchment radius in RefineBar. This is the primary interactive trigger on the dashboard.
Query stubs pass through to in-memory constants. The round-trip cost is a single server render with no I/O. The stub signatures are already wired (_marketSlug, catchment), so swapping to Prisma requires only replacing the function bodies.
%%{init: {'theme': 'default'}}%%
sequenceDiagram
actor User
participant RB as RefineBar (use client)
participant Browser
participant Page as page.tsx (async RSC)
participant Q as lib/queries/ stubs
participant Lib as lib/ constants
User->>RB: select catchment = 90
RB->>Browser: router.push(?catchment=90)
Browser->>Page: GET /dashboard/[market]?catchment=90
Page->>Page: await params / searchParams
Page->>Q: getMarketStats(slug, "90")
Q->>Lib: CATCHMENT_STATS["90"] (~0ms)
Lib-->>Q: in-memory
Q-->>Page: market stats
Page-->>Browser: HTML
Browser-->>RB: hydrate
RB-->>User: render
Note over Q,Lib: No I/O — stubs read in-memory constants
page.tsx becomes async. Filter params drive Prisma queries against Neon. Two queries run for a typical filter change — market stats and parcel set — both filtered server-side.
%%{init: {'theme': 'default'}}%%
sequenceDiagram
actor User
participant RB as RefineBar (use client)
participant Browser
participant Page as page.tsx (async RSC)
participant Q as lib/queries/
participant DB as Neon PostgreSQL
User->>RB: select catchment = 90
RB->>Browser: router.push(?catchment=90)
Browser->>Page: GET /dashboard/[market]?catchment=90
Page->>Page: await params / searchParams
Page->>Q: getMarketStats(slug, "90")
Q->>DB: SELECT WHERE isochrone = 90
DB-->>Q: rows
Q-->>Page: market stats
Page->>Q: getParcels(catchment=90, acres=10-19, budget=1.5-6)
Q->>DB: ST_Within + WHERE filters
DB-->>Q: rows
Q-->>Page: parcels
Page-->>Browser: HTML
Browser-->>RB: hydrate
RB-->>User: render
Note over Q,DB: 2 Neon queries · ~15–40ms
@prisma/adapter-neon connects Prisma Client to the Neon serverless driver.
Schema at prisma/schema.prisma. Config at prisma/prisma.config.ts (loads .env for CLI usage).
Two migrations live: 20260427015013_init (25 tables) and 20260427034432_gist_indexes (PostGIS GiST indexes).
25 tables · 27 enums. Connection string in .env as DATABASE_URL (gitignored).
PostGIS extension enabled — Geography columns on Market, Parcel, Anchor and Isochrone.
GiST indexes on all Geography fields for spatial query performance.
| Route | File | Type | Purpose |
|---|---|---|---|
| / | app/page.tsx | RSC | Homepage — build-type picker chips, onboarding entry point |
| /scope | app/scope/page.tsx | RSC | Act 02 — geography / market picker |
| /scope/[market]/you | app/scope/[market]/you/page.tsx | RSC | Act 03 — operator identity questions |
| /scope/[market]/context | app/scope/[market]/context/page.tsx | RSC | Act 04 — budget, constraints, thesis |
| /scope/[market]/building | app/scope/[market]/building/page.tsx | RSC | Act 05 — dark probing screen with live counters |
| /scope/[market]/ready | app/scope/[market]/ready/page.tsx | RSC | Act 06 — handoff / paywall (not yet built) |
| /dashboard/[market] | app/dashboard/[market]/page.tsx | async RSC | 8-section market intelligence dashboard · generateMetadata for page title |
| /api/tiles/parcels/[z]/[x]/[y] | app/api/tiles/parcels/[z]/[x]/[y]/route.ts | Route Handler | Mapbox Vector Tile (MVT) API — PostGIS ST_AsMVT · Clallam parcels ≥ 0.5ac · z≥11 only |
| Param | Values | Default |
|---|---|---|
mode | scoping · site · ops | scoping |
catchment | 30 · 60 · 90 | 60 |
acres | 0-10 · 10-19 · 20+ | 10-19 |
budget | 0.5-1.5 · 1.5-6 · 6+ | 1.5-6 |
propertyType | str · bnb · boutique-hotel · glamping · lodge · resort | lodge |
All color usage follows a two-constant pattern in lib/. var() is not supported in SVG presentation attributes — always use the _HEX variant there.
| Constant | Values | Use in |
|---|---|---|
SAT_COLOR | var(--ocean), var(--sage), etc. | CSS style={{ color, background }} props |
SAT_COLOR_HEX | #5789A8, #8A9A7B, etc. | SVG fill= / stroke= / stopColor= attributes |
RING_COLORS | var(--rust), var(--brass), etc. | CSS style props |
RING_COLORS_HEX | #B85A3E, #B99B6A, etc. | SVG presentation attributes |
| Index | Formula | Storage |
|---|---|---|
| PCI | Composite of keyword overlap, positioning frequency, amenity similarity, price dispersion, review-axis overlap (0–100) | ConvergenceSnapshot.pciScore |
| DPI | Visitor arrivals ÷ available keys · displayed ×100 (0–100+ scale) | DemandPressureSnapshot.dpiIndex |
| Moat score | Per-dimension per-property score (0–100) | MoatScore.score |
| ADR ceiling | 85th percentile realized ADR among comparable operators in catchment | Comp + CompSnapshot aggregation |
| File | Purpose |
|---|---|
web/prisma/schema.prisma | Full DB schema — 25 models, 27 enums |
web/prisma/prisma.config.ts | Loads .env for CLI; configures Neon adapter |
web/next.config.ts | Minimal Next.js config (defaults) |
web/components.json | shadcn/ui — Radix Nova style, RSC enabled, Lucide icons |
web/postcss.config.mjs | Tailwind v4 PostCSS plugin |
web/.env | DATABASE_URL — Neon connection string · NEXT_PUBLIC_MAPBOX_TOKEN (gitignored) |
The .env file is gitignored and never committed. Any variable needed at build or runtime must be added manually in the Vercel dashboard: Project → Settings → Environment Variables. Set all variables for Production, Preview and Development.
| Variable | Value | Notes |
|---|---|---|
NEXT_PUBLIC_MAPBOX_TOKEN |
pk.eyJ1IjoiamFuaWNlLXdpbHNvbiIsImEiOiJjbW9obmNsNWowMHY3MnFvbW9icXM5MmprIn0.u4CkU98k6Z3KrUjb0hygcg |
Janice's Mapbox account. Required for the S02 map to render. Without it the map container stays blank. |
DATABASE_URL |
See Neon console → territori project → Connection string | Neon Postgres (PostGIS). Required for all server-side data queries. |
After adding or changing any variable, trigger a fresh deploy (Deployments → Redeploy) — Next.js bakes NEXT_PUBLIC_ variables into the client bundle at build time.
| Target | Platform | Status |
|---|---|---|
| Web app | Vercel (Next.js native) | Live — territori.io |
| Database | Neon PostgreSQL (serverless) | Live — 25 tables · 54,512 Clallam parcels + Jefferson County GeoJSON loaded |
| Internal docs | Vercel (internal.territori.io) — territori-io/internal-docs private repo | Setup in progress |