← All docs

Code Review

Reviewed: 2026-04-28 · Scope: web/app/ — all dashboard + home components · Status: pre-data mock phase

The codebase is in good shape for a fast-moving mock phase. Visual quality is high and component logic is clear. The main issues are a monolith file, duplicate data definitions, dead code from design explorations and a few TypeScript gaps that will matter once live data is wired. None of these are blocking — but the data duplication and monolith should be addressed before S03+ components are built out.

🔴 High — fix before live data
🟡 Medium — fix before beta
⚪ Low — polish
01 page.tsx is a 1 350-line monolith 🔴 High ✓ Completed
app/dashboard/[market]/page.tsx

The dashboard page contains 12+ component definitions inline — Topbar, SectionHead, Card, OpportunityBanner, ConvergenceIndexSection, DemandPressureSection, OpportunityMatrix, SectionBanner, MoatSection, WhitespaceSection, IntentSection, PricingSection, StrategySection and DiagnosisSection — plus the page itself. Individual sections cannot be tested in isolation and the file is difficult to navigate.

Suggested restructure:

app/dashboard/[market]/
  page.tsx                       ← ~100 lines — layout + section composition only
  sections/
    s01-market-intelligence.tsx
    s03-moat-coverage.tsx
    s04-named-territory.tsx
    s05-intent.tsx
    s06-pricing.tsx
    s07-strategy.tsx
    s08-diagnosis.tsx
components/dashboard/
  section-banner.tsx             ← generic dark-card signal banner (used S02–S07)
  section-head.tsx
  card.tsx
  logo-mark.tsx
02 Dead code from design explorations 🟡 Medium ✓ Completed
app/dashboard/[market]/page.tsx

OpportunityMatrix defines three sub-components — OptionA, OptionB, OptionC — but only renders Option B. Options A and C are fully implemented but never reached. Separately, DemandPressureSection contains Options G and H inside a display: none div (the comment confirms "design preview removed").

Fix: Delete OptionA, OptionC and the hidden G/H block. Keep only what renders.

03 Moat data is duplicated across two files 🔴 High ✓ Completed

page.tsx defines MOATS — an array of 15 objects with code, label, group, status, count and note. supply-concepts.tsx defines the same data as five parallel arrays: MOAT_CODES, MOAT_LABELS, MOAT_SAT, MOAT_COUNTS, MOAT_DEFS. These must be manually kept in sync and will diverge when live moat scores are wired.

Fix: Create lib/moat-data.ts as a single source of truth. Both files import from there.

// lib/moat-data.ts
export const MOATS = [...] as const;
export const MOAT_CODES  = MOATS.map(m => m.code);
export const MOAT_LABELS = MOATS.map(m => m.label);
export const MOAT_SAT    = MOATS.map(m => m.status);
export const MOAT_COUNTS = MOATS.map(m => m.count);
export const MOAT_DEFS   = MOATS.map(m => m.def);
04 Catchment data is duplicated 🟡 Medium ✓ Completed

page.tsx defines catchmentDesc (includes parcels count). supply-concepts.tsx defines ALL_DESCS (same numeric data, no parcels). SUPPLY_INSIGHT and SUPPLY_HEADLINE also cover the same catchment narrative from different directions.

Fix: Create lib/market-data.ts with catchment constants. Both files import from it.

05 Next.js 15+ params / searchParams must be awaited 🔴 High ✓ Completed
app/dashboard/[market]/page.tsx

Next.js 15 changed params and searchParams from synchronous objects to Promise<...>. The current synchronous access pattern will throw warnings in strict mode and fail in production builds.

// Current — synchronous (will break)
interface PageProps {
  params: { market: string };
  searchParams: { mode?: string; ... };
}
export default function DashboardPage({ params, searchParams }: PageProps) {
  const market = decodeURIComponent(params.market);

// Fix — async with await
interface PageProps {
  params: Promise<{ market: string }>;
  searchParams: Promise<{ mode?: string; ... }>;
}
export default async function DashboardPage({ params, searchParams }: PageProps) {
  const { market } = await params;
  const sp = await searchParams;
06 Color values hardcoded throughout — CSS variables underused 🟡 Medium ✓ Completed

The codebase defines a complete brand token set in Tailwind (--brass, --sage, --rust, etc.) but inline style blocks repeatedly use raw hex values (#B99B6A, #2B2826, #D4A85C, etc.) instead of var(--brass), var(--ink), var(--gold). This makes dark mode support very difficult and color semantics invisible.

Fix: Enforce var(--token) in all inline styles. The SAT_COLOR map in supply-concepts.tsx is a good pattern — extend it to all color references.

07 Parcel type belongs in types.ts 🟡 Medium ✓ Completed
app/dashboard/[market]/supply-concepts.tsx

The Parcel and SupplyDesc types are defined inline in supply-concepts.tsx. As parcels will eventually come from the DB, these should live in types.ts alongside Mode, Catchment etc. — and eventually be derived from Prisma-generated types.

08 as const on CSS inline styles is a smell ⚪ Low ✓ Completed

Throughout both files there are patterns like textAlign: "center" as const and whiteSpace: "nowrap" as const. This is a workaround for TypeScript not narrowing string literals to CSS property value types. The cleaner fix is typed style objects or Tailwind utility classes.

// Instead of: textAlign: "center" as const
const centeredStyle: React.CSSProperties = { textAlign: "center" };
09 hexToRgb utility should be in lib/utils.ts ⚪ Low ✓ Completed
app/dashboard/[market]/supply-concepts.tsx

The hexToRgb() function is a pure utility. It belongs in lib/utils.ts alongside cn().

10 scrollMarginTop is a magic number 🟡 Medium ✓ Completed

Every section uses scrollMarginTop: "160px" hardcoded. This is the combined height of Topbar + RefineBar + SectionNav. If any of those change height, all sections break silently with no compile error.

Fix: Define a CSS custom property --scroll-offset: 160px or TypeScript constant SCROLL_OFFSET = 160 and reference it everywhere.

11 No data fetching layer — mock data is coupled to components 🔴 High ✓ Completed

All mock data (PARCELS, MOATS, catchmentDesc, pricing segments, SEO queries, etc.) is defined inline in component files. When live data is wired, every component will need to be touched simultaneously.

Fix: Introduce a thin query layer before wiring any live data. Components call query functions that today return mock data and later call Prisma.

lib/queries/
  market.ts       ← getMarket(slug), getMarketStats(slug, catchment)
  supply.ts       ← getParcels(marketId, filters)
  moats.ts        ← getMoatScores(marketId)
  intelligence.ts ← getPCI(marketId), getDPI(marketId)
12 Missing error boundaries 🟡 Medium ✓ Completed

No <ErrorBoundary> wraps any section. A crash in, say, SupplyDensitySection will blank the entire dashboard. Each section should be wrapped so failures are isolated. Next.js provides error.tsx at the route segment level for RSC errors; client component sections need explicit error boundary wrappers.

Recommended order of work

StepTaskEst. time
1Delete dead code — OptionA, OptionC, hidden G/H block30 min
2Extract moat data to lib/moat-data.ts45 min
3Extract catchment data to lib/market-data.ts20 min
4Fix params / searchParams typing for Next.js 1515 min
5Introduce data layer stubs in lib/queries/2 hr
6Split page.tsx into section files1.5 hr

Round 2 review — 2026-04-28

All 13 original findings resolved. The following emerged from a fresh read of every file after the refactor.

14 app/page.tsx missed the hex token sweep 🟡 Medium

The hex sweep (finding #6) targeted only app/dashboard/ and components/dashboard/. The home page was untouched and contains ~40 hardcoded hex values — #B99B6A, #D4A85C, #2B2826 etc. The pricing section cards are especially dense. The same hex_to_tokens.py script can be re-run with app/page.tsx added. Also note: #F5EBD4 (off-white pricing card text) and the button gradient #FDFAF6→#F5F0E8 have no matching token — they'll need new tokens or to be mapped to near-equivalents.

15 Section components don't consume the query layer 🔴 High

lib/queries/intelligence.ts exports getConvergenceSnapshot and getDemandSnapshot but nothing calls them. S01MarketIntelligence hardcodes PCI_SCORE = 72, DPI_INDEX = 70, KEYS = 287 and a full months array inline. The same problem exists for S03MoatCoveragemoat-concepts.tsx pulling from lib/moat-data.ts directly instead of lib/queries/moats.ts.

When live data is wired, this forces touching component internals instead of swapping at the query boundary — defeating the purpose of the data layer. Fix: thread the query functions as await calls at the section level before any Prisma work starts.

16 supply-concepts.tsx is a 901-line second monolith 🟡 Medium

The first monolith (page.tsx) was split in finding #1, but supply-concepts.tsx has grown to the same size. It contains five distinct concerns: SVG terrain map, parcel selection overlay, right-panel detail drawer, mini-card, and the legend. Suggested split into app/dashboard/[market]/supply/ with terrain-map.tsx, parcel-mini-card.tsx, parcel-detail.tsx, supply-legend.tsx, and supply-banner.tsx. Lower urgency than the original, but should be done before live parcel data turns the SVG map into a real data-driven component.

17 Page <title> is static — no per-market metadata 🟡 Medium

layout.tsx hardcodes title: "Territori". Every dashboard page renders the same browser tab. Fix with generateMetadata in app/dashboard/[market]/page.tsx to produce "Olympic National Park Gateway · Territori" from the market slug.

18 SectionNav couples to Topbar and RefineBar via magic DOM IDs 🟡 Medium

section-nav.tsx calls document.getElementById("page-topbar") and document.getElementById("page-refine-bar") to measure header height. These IDs are set as bare strings in topbar.tsx and refine-bar.tsx. If either is renamed, the nav silently miscalculates its offset — no TypeScript error. Fix: export typed ID constants from each component file and import them in section-nav.tsx.

19 CTA buttons on the home page are inert <button> elements 🟡 Medium

The "Take the seat" and "Commission the brief" pricing CTAs use <button> elements with no onClick handlers, no href, no form. They render as visible but completely inert chrome. The intent-card links (/scope, /defend, /reposition) also point to non-existent routes. Fix: wire to /sign-up?plan=annual / /sign-up?plan=brief with <Link>, or mark explicitly as stubs with href="#".

20 Color constants in lib/ still carry hardcoded hex 🟡 Medium

SAT_COLOR, MOAT_STYLES, RING_COLORS and RING_DATA[].color all use hardcoded hex. These can't be replaced blindly with var() because the values flow into both CSS style props (where var() works) and SVG presentation attributes like fill={color} (where var() does not). The right fix is a two-value approach: SAT_COLOR_HEX for SVG attributes and SAT_COLOR_CSS (var(--token)) for style props. Apply the same pattern to RING_COLORS and MOAT_STYLES.

21 RefineBar has dead re-exports 🟡 Medium

refine-bar.tsx re-exports Mode, Catchment, Acres, Budget, PropertyType and modeConfig from ./types with a comment suggesting page.tsx could use them. It doesn't — page.tsx imports directly from ./types. No other file consumes these re-exports. Delete both lines; if a single-import-point is ever wanted, establish it in types.ts itself.

22 Newsreader font uses a manual <link> — misses Next.js font optimization ⚪ Low

Inter and JetBrains Mono are loaded with next/font/google (subsetting, preloading, CSS variable injection, self-hosted). Newsreader is loaded via a manual Google Fonts <link> tag — no preload hint, no layout-shift prevention, external origin request on every load. Fix: add Newsreader to the next/font/google imports in layout.tsx, inject it as --font-newsreader, and update globals.css to reference var(--font-newsreader) instead of the bare string 'Newsreader'. Remove the manual <link> and preconnect tags.