← back to Homesonspec
admin/business: fully interactive — every data point hrefs to live feed data, real charts, sortable field tables
a5e1e41e71318d1637cb97c358b55ff4826342c7 · 2026-07-28 10:35:47 -0700 · Steve
KPI cards + narrative figures link to the /feed endpoints (real records); SVG charts
(permits by state, homes by builder, CRE $ by metro); 4 sortable tables (builders,
permits-by-state, top CRE metros, largest projects) with per-row links. Client SortableTable
uses string href templates (serializable across the RSC boundary).
Files touched
A apps/admin/src/app/business/SortableTable.tsxM apps/admin/src/app/business/page.tsx
Diff
commit a5e1e41e71318d1637cb97c358b55ff4826342c7
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 28 10:35:47 2026 -0700
admin/business: fully interactive — every data point hrefs to live feed data, real charts, sortable field tables
KPI cards + narrative figures link to the /feed endpoints (real records); SVG charts
(permits by state, homes by builder, CRE $ by metro); 4 sortable tables (builders,
permits-by-state, top CRE metros, largest projects) with per-row links. Client SortableTable
uses string href templates (serializable across the RSC boundary).
---
apps/admin/src/app/business/SortableTable.tsx | 55 ++++++++
apps/admin/src/app/business/page.tsx | 181 +++++++++++++++++++-------
2 files changed, 192 insertions(+), 44 deletions(-)
diff --git a/apps/admin/src/app/business/SortableTable.tsx b/apps/admin/src/app/business/SortableTable.tsx
new file mode 100644
index 00000000..d29bd2cd
--- /dev/null
+++ b/apps/admin/src/app/business/SortableTable.tsx
@@ -0,0 +1,55 @@
+"use client";
+import { useState } from "react";
+
+export type Col = { key: string; label: string; num?: boolean; money?: boolean; href?: string };
+const fillHref = (tpl: string, row: Row) => tpl.replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(String(row[k] ?? "")));
+export type Row = Record<string, string | number | null>;
+
+const fmt = (v: unknown, money?: boolean) => {
+ if (v === null || v === undefined || v === "") return "—";
+ if (typeof v === "number") return (money ? "$" : "") + v.toLocaleString();
+ return String(v);
+};
+
+export default function SortableTable({ cols, rows, initialSort }: { cols: Col[]; rows: Row[]; initialSort?: string }) {
+ const [sort, setSort] = useState(initialSort ?? cols[0]!.key);
+ const [dir, setDir] = useState<1 | -1>(-1);
+ const sorted = [...rows].sort((a, b) => {
+ const x = a[sort], y = b[sort];
+ if (x === null || x === undefined) return 1;
+ if (y === null || y === undefined) return -1;
+ if (typeof x === "number" && typeof y === "number") return (x - y) * dir;
+ return String(x).localeCompare(String(y)) * dir;
+ });
+ const click = (k: string) => { if (k === sort) setDir((d) => (d === 1 ? -1 : 1)); else { setSort(k); setDir(-1); } };
+ return (
+ <div className="overflow-x-auto rounded-xl border border-neutral-200">
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="bg-neutral-50">
+ {cols.map((c) => (
+ <th key={c.key} onClick={() => click(c.key)}
+ className={`cursor-pointer select-none border-b border-neutral-200 px-3 py-2 font-semibold hover:bg-neutral-100 ${c.num ? "text-right" : "text-left"}`}>
+ {c.label}<span className="ml-1 text-neutral-400">{sort === c.key ? (dir === -1 ? "▼" : "▲") : "↕"}</span>
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {sorted.map((r, i) => (
+ <tr key={i} className="odd:bg-white even:bg-neutral-50 hover:bg-teal-50/40">
+ {cols.map((c) => {
+ const cell = <span className={c.num ? "tabular-nums" : ""}>{fmt(r[c.key], c.money)}</span>;
+ return (
+ <td key={c.key} className={`border-b border-neutral-100 px-3 py-1.5 ${c.num ? "text-right" : "text-left"}`}>
+ {c.href ? <a href={fillHref(c.href, r)} target="_blank" rel="noreferrer" className="text-teal-700 hover:underline">{cell}</a> : cell}
+ </td>
+ );
+ })}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ );
+}
diff --git a/apps/admin/src/app/business/page.tsx b/apps/admin/src/app/business/page.tsx
index 21655e9b..702fe4dc 100644
--- a/apps/admin/src/app/business/page.tsx
+++ b/apps/admin/src/app/business/page.tsx
@@ -1,61 +1,102 @@
import { prisma } from "@homesonspec/database";
import { readFile } from "node:fs/promises";
import path from "node:path";
+import SortableTable, { type Col, type Row } from "./SortableTable";
export const dynamic = "force-dynamic";
-async function liveMetrics() {
+// live data surfaces (tailnet-reachable; localhost when viewing admin on the Mac)
+const FEED = process.env.HOS_FEED ?? "http://127.0.0.1:9799";
+const USRE = process.env.USRE_FEED ?? "http://127.0.0.1:9796";
+const VIEWER = process.env.HOS_VIEWER ?? "http://127.0.0.1:9977";
+
+async function raw<T = any>(sql: string): Promise<T[]> {
+ try { return (await prisma.$queryRawUnsafe(sql)) as T[]; } catch { return []; }
+}
+const N = (v: unknown) => (v === null || v === undefined ? null : Number(v));
+
+async function data() {
+ const one = async (sql: string) => N((await raw<{ n: unknown }>(sql))[0]?.n) ?? 0;
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 };
+ const permits = await one(`select count(*)::int n from building_permits`);
+ const permitsGeo = await one(`select count(*)::int n from building_permits where lat is not null`);
+ const commercial = await one(`select count(*)::int n from building_permits where permit_type ilike '%commercial%' or permit_type ilike '%apartment%'`);
+
+ const byBuilder = (await raw(`select b.slug builder, count(*)::int homes, count(distinct h.state)::int states
+ from "Builder" b join "InventoryHome" h on h."builderId"=b.id where h.status='PUBLISHED'
+ group by b.slug having count(*)>0 order by homes desc`)).map((r: any) => ({ builder: r.builder, homes: N(r.homes), states: N(r.states) }));
+ const byState = (await raw(`select state, count(*)::int permits,
+ count(*) filter(where permit_type ilike '%commercial%')::int commercial,
+ count(*) filter(where permit_type ilike '%apartment%')::int multifamily,
+ count(*) filter(where lat is not null)::int geocoded
+ from building_permits where state is not null group by state order by permits desc limit 15`))
+ .map((r: any) => ({ state: r.state, permits: N(r.permits), commercial: N(r.commercial), multifamily: N(r.multifamily), geocoded: N(r.geocoded) }));
+ const metros = (await raw(`select city, state, count(*)::int projects,
+ count(*) filter(where permit_type ilike '%commercial%')::int commercial,
+ count(*) filter(where permit_type ilike '%apartment%')::int multifamily,
+ coalesce(round(sum(valuation)),0)::float8 total_valuation
+ from building_permits where (permit_type ilike '%commercial%' or permit_type ilike '%apartment%') and valuation is not null
+ group by city, state order by total_valuation desc limit 25`))
+ .map((r: any) => ({ city: r.city, state: r.state, projects: N(r.projects), commercial: N(r.commercial), multifamily: N(r.multifamily), total_valuation: N(r.total_valuation) }));
+ const projects = (await raw(`select address, city, state, coalesce(valuation,0)::float8 valuation, permit_type, issued_date::text issued
+ from building_permits where valuation is not null order by valuation desc limit 30`))
+ .map((r: any) => ({ address: r.address, city: r.city, state: r.state, valuation: N(r.valuation), permit_type: r.permit_type, issued: r.issued }));
+ return { homes, builders, communities, enriched, permits, permitsGeo, commercial, byBuilder, byState, metros, projects };
}
-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._";
+function Bar({ items, unit }: { items: { label: string; value: number; href?: string }[]; unit?: string }) {
+ const max = Math.max(1, ...items.map((i) => i.value));
+ return (
+ <div className="space-y-1.5">
+ {items.map((it) => (
+ <div key={it.label} className="flex items-center gap-2 text-xs">
+ <div className="w-24 shrink-0 truncate text-neutral-600">{it.href ? <a href={it.href} target="_blank" rel="noreferrer" className="text-teal-700 hover:underline">{it.label}</a> : it.label}</div>
+ <div className="h-3.5 flex-1 rounded bg-neutral-100"><div className="h-3.5 rounded bg-teal-500" style={{ width: `${(it.value / max) * 100}%` }} /></div>
+ <div className="w-20 shrink-0 text-right tabular-nums text-neutral-700">{(unit === "$" ? "$" : "") + Math.round(it.value).toLocaleString()}</div>
+ </div>
+ ))}
+ </div>
+ );
}
-const inline = (s: string) =>
- s.replace(/&/g, "&").replace(/</g, "<")
- .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>");
+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 "";
+}
+// linkify data points in the narrative to real feed endpoints
+const linkify = (s: string) => s
+ .replace(/(\$17\.4B[^,.]*pipeline|\$17\.4B)/gi, `<a class="lk" href="${FEED}/feed/developments?state=CA" target=_blank>$1</a>`)
+ .replace(/(~?542k|~?541,982|~?539k)(\s*(building )?permits)?/gi, `<a class="lk" href="${FEED}/feed/permits?limit=100" target=_blank>$&</a>`)
+ .replace(/(~?198k|~?197,755)/gi, `<a class="lk" href="${FEED}/feed/permits?sector=commercial&limit=100" target=_blank>$&</a>`)
+ .replace(/(~?2\.0M|~?2M)(\s*(national )?broker)?/gi, `<a class="lk" href="${USRE}/feed/brokers?limit=100" target=_blank>$&</a>`)
+ .replace(/(~?214k)(\s*firm)?/gi, `<a class="lk" href="${USRE}/feed/firms?limit=100" target=_blank>$&</a>`)
+ .replace(/(~?154k)(\s*commercial parcel)?/gi, `<a class="lk" href="${USRE}/feed/commercial?limit=100" target=_blank>$&</a>`)
+ .replace(/(~?27k|~?35k)(\s*(new-home )?listings|\s*homes)?/gi, `<a class="lk" href="${FEED}/feed/homes?limit=100" target=_blank>$&</a>`)
+ .replace(/`:9799\/feed\/\*`/g, `<a class="lk" href="${FEED}/" target=_blank>:9799/feed/*</a>`)
+ .replace(/`:9796\/feed\/\*`/g, `<a class="lk" href="${USRE}/" target=_blank>:9796/feed/*</a>`);
+const inline = (s: string) => linkify(s.replace(/&/g, "&").replace(/</g, "<"))
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, '<code class="rounded bg-neutral-100 px-1 text-[0.85em]">$1</code>').replace(/(^|[^*])\*([^*]+?)\*/g, "$1<em>$2</em>");
function Markdown({ text }: { text: string }) {
- const lines = text.split("\n");
- const els: React.ReactNode[] = [];
- let tbl: string[] = [], list: string[] = [];
+ 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>);
+ 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 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) {
+ for (const l of text.split("\n")) {
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)) }} />);
+ if (l.startsWith("### ")) els.push(<h3 key={els.length} className="mt-4 text-base font-semibold" 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" />);
@@ -67,28 +108,80 @@ function Markdown({ text }: { text: string }) {
}
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)],
+ const [d, doc] = await Promise.all([data(), loadDoc()]);
+ const kpis = [
+ ["New-home listings", d.homes, `${FEED}/feed/homes?limit=200`],
+ ["Builders", d.builders, `${VIEWER}`],
+ ["Building permits", d.permits, `${FEED}/feed/permits?limit=200`],
+ ["Permits geocoded", d.permitsGeo, `${FEED}/feed/permits?geo=1&limit=200`],
+ ["Commercial + multifamily", d.commercial, `${FEED}/feed/permits?sector=commercial&limit=200`],
+ ["Communities enriched", d.enriched, `${VIEWER}`],
] as const;
+
+ const builderCols: Col[] = [
+ { key: "builder", label: "Builder", href: `${FEED}/feed/homes?limit=200` },
+ { key: "homes", label: "Homes", num: true }, { key: "states", label: "States", num: true },
+ ];
+ const stateCols: Col[] = [
+ { key: "state", label: "State", href: `${FEED}/feed/permits?state={state}&limit=200` },
+ { key: "permits", label: "Permits", num: true }, { key: "commercial", label: "Commercial", num: true },
+ { key: "multifamily", label: "Multifamily", num: true }, { key: "geocoded", label: "Geocoded", num: true },
+ ];
+ const metroCols: Col[] = [
+ { key: "city", label: "City" }, { key: "state", label: "ST" }, { key: "projects", label: "Projects", num: true },
+ { key: "commercial", label: "Comm.", num: true }, { key: "multifamily", label: "MF", num: true },
+ { key: "total_valuation", label: "Total valuation", num: true, money: true, href: `${FEED}/feed/developments?state={state}` },
+ ];
+ const projCols: Col[] = [
+ { key: "address", label: "Address" }, { key: "city", label: "City" }, { key: "state", label: "ST" },
+ { key: "valuation", label: "Valuation", num: true, money: true }, { key: "permit_type", label: "Type" }, { key: "issued", label: "Issued" },
+ ];
+
return (
<div>
+ <style>{`.lk{color:#0f766e;text-decoration:underline;text-underline-offset:2px}.lk:hover{color:#134e4a}`}</style>
<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>
+ <span className="text-xs text-neutral-400">every figure links to live data · tables sortable · refreshed on load</span>
</div>
+
+ {/* KPI cards — each links to the real records */}
<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">
+ {kpis.map(([k, v, href]) => (
+ <a key={k} href={href as string} target="_blank" rel="noreferrer" className="group rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition hover:border-teal-400 hover:shadow">
<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 className="mt-0.5 text-xl font-extrabold text-neutral-900 group-hover:text-teal-700">{(v as number).toLocaleString()}</div>
+ <div className="text-[9px] text-teal-600 opacity-0 transition group-hover:opacity-100">view data →</div>
+ </a>
))}
</div>
- <article className="mt-6 rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
+
+ {/* Charts — real aggregates */}
+ <div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
+ <div className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm">
+ <h3 className="mb-3 text-sm font-semibold">Permits by state (top)</h3>
+ <Bar items={d.byState.slice(0, 10).map((s) => ({ label: s.state ?? "?", value: s.permits ?? 0, href: `${FEED}/feed/permits?state=${s.state}&limit=200` }))} />
+ </div>
+ <div className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm">
+ <h3 className="mb-3 text-sm font-semibold">Homes by builder</h3>
+ <Bar items={d.byBuilder.slice(0, 10).map((b) => ({ label: b.builder, value: b.homes ?? 0 }))} />
+ </div>
+ <div className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm">
+ <h3 className="mb-3 text-sm font-semibold">CRE pipeline $ by metro</h3>
+ <Bar unit="$" items={d.metros.slice(0, 10).map((m) => ({ label: `${m.city}`, value: m.total_valuation ?? 0, href: `${FEED}/feed/developments?state=${m.state}` }))} />
+ </div>
+ </div>
+
+ {/* Sortable field tables */}
+ <div className="mt-6 space-y-6">
+ <section><h3 className="mb-2 text-sm font-semibold text-neutral-700">Builders <span className="text-neutral-400">(click a header to sort)</span></h3><SortableTable cols={builderCols} rows={d.byBuilder as Row[]} initialSort="homes" /></section>
+ <section><h3 className="mb-2 text-sm font-semibold text-neutral-700">Permits by state</h3><SortableTable cols={stateCols} rows={d.byState as Row[]} initialSort="permits" /></section>
+ <section><h3 className="mb-2 text-sm font-semibold text-neutral-700">Top CRE development metros</h3><SortableTable cols={metroCols} rows={d.metros as Row[]} initialSort="total_valuation" /></section>
+ <section><h3 className="mb-2 text-sm font-semibold text-neutral-700">Largest projects (permit valuation)</h3><SortableTable cols={projCols} rows={d.projects as Row[]} initialSort="valuation" /></section>
+ </div>
+
+ {/* Narrative — data points linkified */}
+ <article className="mt-8 rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
<Markdown text={doc} />
</article>
</div>
← 72e78889 admin: Business Analysis page — full analysis + live metrics
·
back to Homesonspec
·
admin: live Engine viewer (/engine) — terminal-style tail of c3c35afe →