[object Object]

← back to Homesonspec

admin: Business Analysis page — full analysis + live metrics, accessible in admin

72e78889a2ad42bd1e64297437f4eac02c6fdadc · 2026-07-28 10:30:20 -0700 · Steve

docs/product/BUSINESS-ANALYSIS.md (consolidated strategy + live data assets) rendered at
/business with live metric cards (homes/permits/geocoded/commercial/enriched). Nav link added.

Files touched

Diff

commit 72e78889a2ad42bd1e64297437f4eac02c6fdadc
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 28 10:30:20 2026 -0700

    admin: Business Analysis page — full analysis + live metrics, accessible in admin
    
    docs/product/BUSINESS-ANALYSIS.md (consolidated strategy + live data assets) rendered at
    /business with live metric cards (homes/permits/geocoded/commercial/enriched). Nav link added.
---
 apps/admin/src/app/business/page.tsx |  96 +++++++++++++++++++++++++++++
 apps/admin/src/app/layout.tsx        |   1 +
 docs/product/BUSINESS-ANALYSIS.md    | 115 +++++++++++++++++++++++++++++++++++
 3 files changed, 212 insertions(+)

diff --git a/apps/admin/src/app/business/page.tsx b/apps/admin/src/app/business/page.tsx
new file mode 100644
index 00000000..21655e9b
--- /dev/null
+++ b/apps/admin/src/app/business/page.tsx
@@ -0,0 +1,96 @@
+import { prisma } from "@homesonspec/database";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+
+export const dynamic = "force-dynamic";
+
+async function liveMetrics() {
+  const [homes, communities, enriched] = await Promise.all([
+    prisma.inventoryHome.count({ where: { status: "PUBLISHED" } }),
+    prisma.community.count(),
+    prisma.community.count({ where: { NOT: { amenities: { equals: null as never } } } }).catch(() => 0),
+  ]);
+  const builders = await prisma.inventoryHome.findMany({ where: { status: "PUBLISHED" }, select: { builderId: true }, distinct: ["builderId"] }).then((r) => r.length).catch(() => 0);
+  const perm = async (sql: string) => {
+    try { const r: Array<{ n: bigint }> = await prisma.$queryRawUnsafe(sql); return Number(r?.[0]?.n ?? 0); } catch { return 0; }
+  };
+  const permits = await perm(`select count(*)::bigint n from building_permits`);
+  const permitsGeo = await perm(`select count(*)::bigint n from building_permits where lat is not null`);
+  const commercial = await perm(`select count(*)::bigint n from building_permits where permit_type ilike '%commercial%' or permit_type ilike '%apartment%'`);
+  return { homes, builders, communities, enriched, permits, permitsGeo, commercial };
+}
+
+async function loadDoc() {
+  for (const p of [
+    path.join(process.cwd(), "../../docs/product/BUSINESS-ANALYSIS.md"),
+    path.join(process.cwd(), "docs/product/BUSINESS-ANALYSIS.md"),
+    "/Users/macstudio3/Projects/homesonspec/docs/product/BUSINESS-ANALYSIS.md",
+  ]) { try { return await readFile(p, "utf8"); } catch {} }
+  return "# Business Analysis\n\n_Document not found on disk._";
+}
+
+const inline = (s: string) =>
+  s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
+    .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
+    .replace(/`(.+?)`/g, '<code class="rounded bg-neutral-100 px-1 text-[0.85em]">$1</code>')
+    .replace(/\*(.+?)\*/g, "<em>$1</em>");
+
+function Markdown({ text }: { text: string }) {
+  const lines = text.split("\n");
+  const els: React.ReactNode[] = [];
+  let tbl: string[] = [], list: string[] = [];
+  const flushList = () => { if (list.length) { els.push(<ul key={els.length} className="my-2 ml-5 list-disc space-y-1 text-sm text-neutral-700">{list.map((l, i) => <li key={i} dangerouslySetInnerHTML={{ __html: inline(l) }} />)}</ul>); list = []; } };
+  const flushTbl = () => {
+    if (!tbl.length) return;
+    const rows = tbl.filter((r) => !/^\|[\s:-]+\|$/.test(r)).map((r) => r.split("|").slice(1, -1).map((c) => c.trim()));
+    const head = rows[0] ?? [];
+    const body = rows.slice(1);
+    els.push(
+      <div key={els.length} className="my-3 overflow-x-auto"><table className="w-full border-collapse text-sm">
+        <thead><tr>{head.map((h, i) => <th key={i} className="border-b border-neutral-300 bg-neutral-50 px-3 py-1.5 text-left font-semibold" dangerouslySetInnerHTML={{ __html: inline(h) }} />)}</tr></thead>
+        <tbody>{body.map((r, i) => <tr key={i} className="odd:bg-white even:bg-neutral-50">{r.map((c, j) => <td key={j} className="border-b border-neutral-200 px-3 py-1.5 align-top" dangerouslySetInnerHTML={{ __html: inline(c) }} />)}</tr>)}</tbody>
+      </table></div>);
+    tbl = [];
+  };
+  for (const l of lines) {
+    if (l.startsWith("|")) { flushList(); tbl.push(l); continue; } else flushTbl();
+    if (l.startsWith("- ")) { list.push(l.slice(2)); continue; } else flushList();
+    if (l.startsWith("### ")) els.push(<h3 key={els.length} className="mt-4 text-base font-semibold text-neutral-900" dangerouslySetInnerHTML={{ __html: inline(l.slice(4)) }} />);
+    else if (l.startsWith("## ")) els.push(<h2 key={els.length} className="mt-6 border-b border-neutral-200 pb-1 text-lg font-bold text-teal-800" dangerouslySetInnerHTML={{ __html: inline(l.slice(3)) }} />);
+    else if (l.startsWith("# ")) els.push(<h1 key={els.length} className="text-2xl font-bold" dangerouslySetInnerHTML={{ __html: inline(l.slice(2)) }} />);
+    else if (l.startsWith("---")) els.push(<hr key={els.length} className="my-4 border-neutral-200" />);
+    else if (l.trim() === "") els.push(<div key={els.length} className="h-1" />);
+    else els.push(<p key={els.length} className="my-1.5 text-sm leading-relaxed text-neutral-700" dangerouslySetInnerHTML={{ __html: inline(l) }} />);
+  }
+  flushTbl(); flushList();
+  return <div>{els}</div>;
+}
+
+export default async function BusinessPage() {
+  const [m, doc] = await Promise.all([liveMetrics(), loadDoc()]);
+  const fmt = (n: number) => n.toLocaleString();
+  const cards = [
+    ["New-home listings", fmt(m.homes)], ["Builders", fmt(m.builders)],
+    ["Building permits", fmt(m.permits)], ["Permits geocoded", fmt(m.permitsGeo)],
+    ["Commercial + multifamily", fmt(m.commercial)], ["Communities enriched", fmt(m.enriched)],
+  ] as const;
+  return (
+    <div>
+      <div className="flex items-baseline justify-between">
+        <h1 className="text-2xl font-bold">Business Analysis</h1>
+        <span className="text-xs text-neutral-400">live metrics · refreshed on load</span>
+      </div>
+      <div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+        {cards.map(([k, v]) => (
+          <div key={k} className="rounded-xl border border-neutral-200 bg-white p-3 shadow-sm">
+            <div className="text-[10px] uppercase tracking-wide text-neutral-500">{k}</div>
+            <div className="mt-0.5 text-xl font-extrabold text-neutral-900">{v}</div>
+          </div>
+        ))}
+      </div>
+      <article className="mt-6 rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
+        <Markdown text={doc} />
+      </article>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index 353cab79..f1ff8417 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -16,6 +16,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
             <Link href="/" className="text-lg font-bold text-neutral-900">
               HomesOnSpec <span className="text-teal-700">Admin</span>
             </Link>
+            <Link href="/business" className="font-semibold text-teal-700 hover:text-teal-900">Business</Link>
             <Link href="/ingestion" className="hover:text-teal-700">Live ingestion</Link>
             <Link href="/review" className="hover:text-teal-700">Review queue</Link>
             <Link href="/sources" className="hover:text-teal-700">Sources</Link>
diff --git a/docs/product/BUSINESS-ANALYSIS.md b/docs/product/BUSINESS-ANALYSIS.md
new file mode 100644
index 00000000..d8cae14e
--- /dev/null
+++ b/docs/product/BUSINESS-ANALYSIS.md
@@ -0,0 +1,115 @@
+# HomesOnSpec — Business Analysis
+
+*Consolidated from Steve's stated strategy (2026-07 build sessions) + the live data assets.
+Provenance note: no prior standalone analysis file was found on disk; this document captures
+the direction articulated in the build sessions and is the working source of truth — correct or
+extend it freely.*
+
+---
+
+## 1. Thesis
+
+HomesOnSpec is not a listings site. It is a **permission-aware, evidence-backed, national
+database of new-construction real estate — captured across its entire lifecycle**, from the
+moment a builder pulls a permit to the day the finished spec home is listed for sale. Owning
+that lifecycle turns one crawler into two products and a licensable data asset.
+
+**The core insight (Steve, 2026-07-28):** catch construction at the *permit and land* stage,
+not just when the finished home lists. The public-records paper trail —
+`land acquired → rezoning/entitlement → subdivision plat → grading permit → building permit →
+utility hookup → under construction → listed` — lets us see inventory **months before** anyone
+selling finished homes can. Competitors see the last box; we see the whole pipe.
+
+## 2. The gap (validated 2026-07-22)
+
+There is **no dedicated, trustworthy, one-stop search for builder spec inventory.** Builders
+each publish on their own site (D.R. Horton, Lennar, Pulte, Toll Brothers… 30+ national and
+hundreds of regional/local sites) and never meet on a neutral surface with verification and
+freshness guarantees. Resale portals treat new construction as an afterthought filter;
+lead-gen aggregators carry stale, partial inventory. On the commercial side, permit and
+market intelligence is locked behind expensive incumbents (CoStar-class) — not permit-first.
+
+## 3. Products (two, one data spine)
+
+**A. Consumer — new-home search.** *"Every new home. Every builder. One search. Verified from
+the source."* A neutral, national search + map of builder spec/quick-move-in inventory, with
+per-fact source evidence, verification labels, and public-records enrichment (nearby NCES
+schools, OSM places) on every geolocated community.
+
+**B. B2B — construction & market intelligence (the RENTV/partner data business).** The permit
++ parcel + broker datasets, licensed as feeds. The earliest, richest signal of where money is
+being spent on real estate — pure editorial and analytical fuel for commercial-real-estate
+media (RENTV) and market platforms (USRealEstate).
+
+## 4. Defensible moat
+
+- **Evidence-first, never overstated.** Every fact carries source evidence + retrieval
+  timestamp; verification labels derive from *how* data was collected and never exceed it;
+  stale inventory is labeled, not hidden.
+- **Permission-aware by construction.** robots.txt enforced in code, honest rate-limited UA,
+  facts-only (no scraped media without recorded rights). Sources upgrade crawl → feed →
+  partnership. This is a compliance posture a scraper-only competitor can't cheaply copy.
+- **Leading-indicator coverage.** Permits/parcels/zoning are public and free but *hard to
+  unify nationally* — each jurisdiction publishes differently (Socrata vs ArcGIS vs Accela,
+  geo vs address-only). The per-jurisdiction adapter library is the durable asset.
+- **Two-sided linkage.** A builder's permit → its eventual listing (address/parcel match), and
+  a commercial parcel → the broker/firm who'll lease it. Nobody else holds both halves.
+
+## 5. Live data assets (as built)
+
+| Asset | Volume | Notes |
+|---|---|---|
+| New-home listings | ~27k, 12 builders | nationwide, West-first; facts-only, robots-enforced |
+| Building permits | ~542k | all types; commercial + multifamily ~198k; LA alone ~539k |
+| Permits geocoded | 130k+ and climbing | free US Census batch, 94% hit on existing parcels |
+| LA CRE pipeline | **$17.4B** across 52,772 projects | commercial + multifamily, by valuation |
+| Brokers (usre) | ~2.0M | national broker directory |
+| Firms (usre) | ~214k | brokerages, agent counts |
+| Commercial parcels (usre) | ~154k | assessed values (Beverly Center $995M, etc.) |
+| Communities enriched | ~1.1k+ | NCES schools + OSM places, all $0 public data |
+
+All ingestion is **$0** (public/free sources, no LLM in the data path). The only metered cost
+is optional governance reasoning, run on local models.
+
+## 6. Monetization
+
+1. **Data licensing / feeds (nearest-term).** Read-only CORS feeds already live for partners:
+   HomesOnSpec construction pipeline (`:9799/feed/*`) and USRealEstate CRE market data
+   (`:9796/feed/*`), tailnet-reachable. RENTV is the first internal consumer; the same feeds
+   license to external CRE media, proptech, and analytics buyers.
+2. **Consumer lead-gen / builder partnerships.** Builder inquiries → qualified leads; sources
+   upgrade to paid partner feeds as relationships form.
+3. **CRE intelligence subscriptions.** Permit alerts ("a $168M development just filed at 525
+   Santa Fe"), pipeline dashboards by metro, broker/firm targeting.
+4. **Ads / sponsorship** on the consumer surface (new-home builders are motivated advertisers).
+
+## 7. Go-to-market
+
+**West Coast first, then fan out** (Steve, 2026-07-28). Start LA (home market, densest data),
+expand LA County → SoCal counties → CA → West Coast → nationwide. Builder adapters and permit
+jurisdictions are onboarded in the same westward order. The autonomous build loop runs this
+continuously on local models at $0.
+
+## 8. Competitive landscape
+
+- **Zillow / Redfin / Realtor.com** — resale-first; new construction is a weak filter, no
+  permit/pipeline layer, no verification-label discipline.
+- **CoStar / commercial incumbents** — expensive, licensed, *not* permit-first; slower signal.
+- **BuildZoom / Shovels / permit aggregators** — permits only, paid, no consumer surface and
+  no builder-listing linkage.
+- **HomesOnSpec** — the only surface unifying **builder spec inventory + the public permit
+  pipeline + the broker/parcel market layer**, evidence-backed, permission-aware, $0 to run.
+
+## 9. Traction (this build)
+
+Nationwide database live; 4 builder adapters running maxed-out parallel (60rpm, West-first) +
+2 regional adapters (Discovery/4 Seeno brands, David Weekley); construction-permit layer live
+(SF, Seattle, full LA); free Census geocoding; public-records enrichment; a live V8 "engine
+room" ops dashboard (:9798); a searchable permit map (:9977); and **both data feeds wired for
+RENTV over the tailnet.** Prod moved to Kamatera (self-hosted).
+
+## 10. Roadmap
+
+Permit-metro fan-out (LA County → SoCal → nationwide) · permit↔listing address matching (watch
+a home move down the pipeline) · add parcel/zoning/CFD signals · geocode the no-geo builders ·
+upgrade top sources crawl→feed→partnership · package the CRE feeds as a paid product.

← 1a7632dd build-loop: correct the misleading 'cheap no-op' comment (un  ·  back to Homesonspec  ·  admin/business: fully interactive — every data point hrefs t a5e1e41e →