← All docs

Architecture Overview

Last updated: 2026-04-28 · Stack: Next.js 16 · React 19 · TypeScript · Prisma 7 · PostgreSQL (Neon) · PostGIS · Tailwind v4

Component diagrams

Current state — post-refactor (mock phase)

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
    

Future state — live data

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
    

Sequence diagrams

Both diagrams trace the same event: the user changes the catchment radius in RefineBar. This is the primary interactive trigger on the dashboard.

Current state — filter change

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
    

Future state — filter change

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
    

Application layers

Browser Next.js 16 App Router
app/ ├── page.tsx ← Homepage — onboarding, build-type picker └── dashboard/ └── [market]/ ├── page.tsx ← async RSC — ~130 lines, layout + generateMetadata ├── error.tsx ← React class ErrorBoundary — route-level fallback ├── types.ts ← Mode, Catchment, PropertyType + modeConfig ├── supply-data.ts ← Pure data — PARCELS constant (no JSX, no "use client") ├── supply-concepts.tsx ← "use client" — TerrainMap · SupplyBanner · SupplyDensitySection ├── supply-parcel-panel.tsx ← "use client" — MiniCard · RightPanel · moat layouts A/B/C ├── moat-concepts.tsx ← "use client" — MoatCoverageSection (receives moats prop) ├── refine-bar.tsx ← "use client" — useSearchParams filter bar · exports REFINE_BAR_ID ├── section-nav.tsx ← "use client" — scroll-linked nav · imports TOPBAR_ID + REFINE_BAR_ID └── sections/ ├── s01-market-intelligence.tsx ← async RSC — awaits getConvergenceSnapshot + getDemandSnapshot ├── s02-supply-density.tsx ← RSC wrapper — renders supply-concepts client subtree ├── s03-moat-coverage.tsx ← async RSC — awaits getMoatDimensions, passes moats prop ├── s04-named-territory.tsx ├── s05-intent.tsx ├── s06-pricing.tsx ├── s07-strategy.tsx └── s08-diagnosis.tsx components/ └── dashboard/ ├── topbar.tsx ← Sticky top bar + LogoMark · exports TOPBAR_ID as const ├── logo-mark.tsx ← Conic-gradient brand mark ├── section-head.tsx ← Eyebrow + h2 + description block ├── card.tsx ← Base white card primitive └── section-banner.tsx ← Dark signal card (S02–S07) lib/ ├── moat-data.ts ← MOATS · SAT_COLOR (CSS tokens) · SAT_COLOR_HEX (SVG hex) · SAT_LABEL ├── market-data.ts ← CATCHMENT_STATS · RING_DATA · RING_COLORS (CSS tokens) · RING_COLORS_HEX · SUPPLY_* └── queries/ ← Data-access layer — all accept (_marketSlug, …) · stubs → Prisma ├── market.ts ← getMarketStats ├── supply.ts ← getParcels ├── moats.ts ← getMoatDimensions └── intelligence.ts ← getConvergenceSnapshot · getDemandSnapshot
ORM Prisma 7 with Neon adapter

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

Database Neon PostgreSQL (serverless) + PostGIS

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 structure

RouteFileTypePurpose
/app/page.tsxRSCHomepage — build-type picker chips, onboarding entry point
/scopeapp/scope/page.tsxRSCAct 02 — geography / market picker
/scope/[market]/youapp/scope/[market]/you/page.tsxRSCAct 03 — operator identity questions
/scope/[market]/contextapp/scope/[market]/context/page.tsxRSCAct 04 — budget, constraints, thesis
/scope/[market]/buildingapp/scope/[market]/building/page.tsxRSCAct 05 — dark probing screen with live counters
/scope/[market]/readyapp/scope/[market]/ready/page.tsxRSCAct 06 — handoff / paywall (not yet built)
/dashboard/[market]app/dashboard/[market]/page.tsxasync RSC8-section market intelligence dashboard · generateMetadata for page title
/api/tiles/parcels/[z]/[x]/[y]app/api/tiles/parcels/[z]/[x]/[y]/route.tsRoute HandlerMapbox Vector Tile (MVT) API — PostGIS ST_AsMVT · Clallam parcels ≥ 0.5ac · z≥11 only

Dashboard query params

ParamValuesDefault
modescoping · site · opsscoping
catchment30 · 60 · 9060
acres0-10 · 10-19 · 20+10-19
budget0.5-1.5 · 1.5-6 · 6+1.5-6
propertyTypestr · bnb · boutique-hotel · glamping · lodge · resortlodge

Dashboard sections (S01–S08)

S01
Market Intelligence (s01-market-intelligence.tsx — async RSC)
OpportunityBanner · ConvergenceIndexSection · DemandPressureSection · OpportunityMatrix (Option B locked) — all receive typed props from query stubs
S02
Supply Density (supply-concepts.tsx + supply-parcel-panel.tsx + mapbox-map.tsx — "use client")
SupplyBanner · MapboxMap (raw mapbox-gl, dark-v11, isochrone rings + PostGIS MVT parcel tiles, anchor pins) · MiniCard · RightPanel (moat layouts A/B/C)
S03
Moat Coverage (s03-moat-coverage.tsx — async RSC · moat-concepts.tsx — "use client")
SectionBanner · MoatCoverageSection (15-cell grid) — receives moats: MoatDatum[] prop from async wrapper
S04
Named Unclaimed Territory
SectionBanner · WhitespaceSection (ranked plays, locked row for upgrade)
S05
Intent Intelligence
SectionBanner · IntentSection (SEO sparklines + AEO engine presence)
S06
Price & Positioning
SectionBanner · PricingSection (realized ADR by segment, sparkbars)
S07
Site Strategy
SectionBanner · StrategySection (parcel-fit matrix — plays × criteria)
S08
Editorial Diagnosis
DiagnosisSection (prose analysis + CTA strip)

Color token conventions

All color usage follows a two-constant pattern in lib/. var() is not supported in SVG presentation attributes — always use the _HEX variant there.

ConstantValuesUse in
SAT_COLORvar(--ocean), var(--sage), etc.CSS style={{ color, background }} props
SAT_COLOR_HEX#5789A8, #8A9A7B, etc.SVG fill= / stroke= / stopColor= attributes
RING_COLORSvar(--rust), var(--brass), etc.CSS style props
RING_COLORS_HEX#B85A3E, #B99B6A, etc.SVG presentation attributes

Data model (25 entities, 27 enums)

People & Access
User
Subscription
Geography
Market · Anchor · MarketAnchor
AnchorVisitorStat · Isochrone
County · MarketCounty
Study · GuestProfile
Operators & Supply
Operator · Property · Parcel
MarketParcel · Category
CategoryMoatDimension
Regulatory Signals
PermitEvent (property-level)
CupEvent (county-level)
Moat Intelligence
MoatDimension
MoatScore → Property
Support
Competitive Intelligence
Comp → Property
CompSnapshot
Intent & AI
IntentProbe
IntentProbeOperator
Narratives, Alerts & Time-Series
Narrative · Alert · AlertUser
ConvergenceSnapshot (PCI)
DemandPressureSnapshot (DPI)

Intelligence indices

IndexFormulaStorage
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

Configuration files

FilePurpose
web/prisma/schema.prismaFull DB schema — 25 models, 27 enums
web/prisma/prisma.config.tsLoads .env for CLI; configures Neon adapter
web/next.config.tsMinimal Next.js config (defaults)
web/components.jsonshadcn/ui — Radix Nova style, RSC enabled, Lucide icons
web/postcss.config.mjsTailwind v4 PostCSS plugin
web/.envDATABASE_URL — Neon connection string · NEXT_PUBLIC_MAPBOX_TOKEN (gitignored)

Vercel environment variables

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.

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

Deployment (planned)

TargetPlatformStatus
Web appVercel (Next.js native)Live — territori.io
DatabaseNeon PostgreSQL (serverless)Live — 25 tables · 54,512 Clallam parcels + Jefferson County GeoJSON loaded
Internal docsVercel (internal.territori.io) — territori-io/internal-docs private repoSetup in progress