Spec — Comp Analysis page (NEW)

"Five comparables + ARV + 70%-rule offer, in ~3 seconds." The PropStream replacement. Prototype: panel-comps in reference/closer/public/v2/index.html.

UX flow

  1. User enters a subject address → clicks Run comps →.
  2. Status line streams: Geocoding address…Fetching candidates…AI selecting comps… → done.
  3. Results render:
    • ARV bar: low/high band (e.g. $385K–$442K), 5 comp dots plotted by sale price, a 70%-rule max-offer marker ($268K), confidence 4/5 bars, footnote n=5 · 0.4mi · 97d avg age.
    • AI "Deal read" prose summary (streamed, --accent styling).
    • Selected comparables grid (5 of N candidates): each card → address, beds/baths, sqft, distance (mi), sold price, sold age (23d ago), and an adjustment delta (+$3.4K / −$11K, bull/bear colored).
  4. Right sidebar: Subject property stats, This month usage (comps run, avg time 2.8s, avg API cost $0.11, saved-to-leads), Recent searches.

Backend pipeline (Cloudflare Worker)

geocode → fetch sold comps (ATTOM / external) → AI selects best 5 + writes deal-read (Anthropic) → compute ARV band + 70%/80% offer → persist + cache. Graceful degrade to mock data if an external call fails or times out. Track api_cost_cents and generation_time_ms per query.

ARV math (server): median of adjusted comp $/sqft × subject sqft → band = [median×~0.93, median×~1.04]; offer_70pct = arv_median × 0.70.

D1 schema (new migration)

  • comp_queries(id, user_id, input_address, geocoded_lat, geocoded_lng, status[pending|geocoded|candidates_fetched|ai_selected|complete|failed], error_message, total_candidates, selected_count, api_cost_cents, generation_time_ms, created_at) — idx (user_id, created_at DESC).
  • comparables(id, subject_lead_id?, comp_query_id, comp_address, comp_city, comp_state, comp_zip, comp_property_type, beds, baths, sqft, year_built, lot_size_sqft, sold_price_cents, sold_date, days_since_sold, distance_miles, adjustment_cents, data_source, external_mls_id, created_at) — idx (comp_query_id), (subject_lead_id, sold_date DESC).
  • arv_estimates(id, subject_lead_id?, comp_query_id, arv_low_cents, arv_high_cents, arv_median_cents, confidence_score[0-100], confidence_breakdown(JSON), offer_70pct_cents, offer_80pct_cents, rehab_estimate_cents, market_summary, comps_used_count, radius_miles, created_at, expires_at) — TTL ~30d.
  • leads augment: comps_run_count INT DEFAULT 0, last_arv_estimate_id TEXT, last_comp_query_date TEXT.

API (/api/v1)

  • POST /comps/run{address, radius_miles?=0.5, comp_count?=5, min_quality_score?}{query_id, subject{…}, comps[…], arv{low,high,median}, offer_70pct_cents, confidence, market_summary, api_cost_cents, generation_time_ms}. Auth required.
  • GET /comps/:queryId — re-fetch a completed analysis (cache hit, no re-query).
  • GET /leads/:leadId/comps — history for a lead (paginated).
  • GET /user/comps/history?limit=10 — recent searches (sidebar).
  • GET /user/comps/stats — month/YTD usage (sidebar), 1h cache.
  • POST /comps/:queryId/save{lead_id?, notes?} → links to lead, bumps comps_run_count.

Secrets / env

ATTOM_API_KEY (or chosen comps source), ANTHROPIC_API_KEY — into .dev.vars locally. Prototype referenced an external https://biglead.velli.cc/api/comps; the real impl should run inside the soldi worker so cost/latency are tracked.

Build notes

  • Slice 1 can ship against a deterministic mock provider (seeded comps) so the full UI + persistence is verifiable offline; swap in the live ATTOM+AI provider behind the same POST /comps/run contract in a follow-up slice.
  • Stream the AI deal-read via chunked response or poll GET /comps/:queryId.