[object Object]

← back to Homesonspec

HomesOnSpec: property detail parcel/public-records block + href-drill atoms on listings grid (TK-10482)

4d9cd041570f9db3d62cb05989173296ebae97d1 · 2026-08-12 08:28:56 -0700 · Steve

- Detail page (/homes/[id]): new Parcel & public records section — lot number,
  county, and a public-records/assessor parcel-search link (curated county
  assessor map + generic county-scoped fallback). No APN fabricated. Core facts
  (status/beds/baths/sqft/stories/garage/city/state/community) now drill to their
  URL-addressable filtered views.
- Listings grid (SearchClient): every card data point is now a drill href — price
  (band), city, state, beds, baths, sqft (band), status, builder→/builders,
  community→/communities. Card uses a stretched overlay link (no nested anchors).
- Filters are URL-addressable: SearchClient hydrates from the URL query and syncs
  filter/sort changes back via replaceState, so drill links deep-link into a
  pre-filtered grid.
- lib/parcel.ts: county→assessor public-records resolver (compliant: link OUT to
  the county's own public search; source facts from public records).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 4d9cd041570f9db3d62cb05989173296ebae97d1
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 08:28:56 2026 -0700

    HomesOnSpec: property detail parcel/public-records block + href-drill atoms on listings grid (TK-10482)
    
    - Detail page (/homes/[id]): new Parcel & public records section — lot number,
      county, and a public-records/assessor parcel-search link (curated county
      assessor map + generic county-scoped fallback). No APN fabricated. Core facts
      (status/beds/baths/sqft/stories/garage/city/state/community) now drill to their
      URL-addressable filtered views.
    - Listings grid (SearchClient): every card data point is now a drill href — price
      (band), city, state, beds, baths, sqft (band), status, builder→/builders,
      community→/communities. Card uses a stretched overlay link (no nested anchors).
    - Filters are URL-addressable: SearchClient hydrates from the URL query and syncs
      filter/sort changes back via replaceState, so drill links deep-link into a
      pre-filtered grid.
    - lib/parcel.ts: county→assessor public-records resolver (compliant: link OUT to
      the county's own public search; source facts from public records).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apps/web/src/app/homes/[id]/page.tsx     |  84 ++++++++++++++---
 apps/web/src/app/search/SearchClient.tsx | 149 +++++++++++++++++++++++++++----
 apps/web/src/app/search/page.tsx         |  15 +++-
 apps/web/src/lib/parcel.ts               |  95 ++++++++++++++++++++
 4 files changed, 315 insertions(+), 28 deletions(-)

diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index d26b1950..c171083b 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -4,6 +4,7 @@ import { prisma } from "@homesonspec/database";
 import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
 import CorrectionForm from "./CorrectionForm";
 import LeadForm from "./LeadForm";
+import { parcelLink } from "../../../lib/parcel";
 
 export const dynamic = "force-dynamic";
 
@@ -71,14 +72,24 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
     }, {}),
   ).sort((a, b) => a.field.localeCompare(b.field));
 
-  const facts: [string, string][] = [
+  // href-drill: builds drill URLs into the URL-addressable search grid, so every
+  // fact on this detail page routes to its deeper, filtered view.
+  const drill = (kv: Record<string, string>): string => {
+    const params = new URLSearchParams();
+    for (const [k, v] of Object.entries(kv)) if (v) params.set(k, v);
+    return `/search?${params.toString()}`;
+  };
+
+  // Each fact is [label, value, href?] — href makes the fact a drill link into
+  // the URL-addressable grid (href-drill rule: no dead-end data points).
+  const facts: [string, string, string?][] = [
     ["Price", fmtPrice(home.price === null ? null : Number(home.price))],
-    ["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus],
-    ["Beds", home.beds?.toString() ?? "Not published"],
-    ["Baths", home.bathsTotal?.toString() ?? "Not published"],
-    ["Square feet", home.sqft?.toLocaleString() ?? "Not published"],
-    ["Stories", home.stories?.toString() ?? "Not published"],
-    ["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published"],
+    ["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus, drill({ status: home.constructionStatus })],
+    ["Beds", home.beds?.toString() ?? "Not published", home.beds != null ? drill({ bedsMin: String(home.beds) }) : undefined],
+    ["Baths", home.bathsTotal?.toString() ?? "Not published", home.bathsTotal != null ? drill({ bathsMin: String(home.bathsTotal) }) : undefined],
+    ["Square feet", home.sqft?.toLocaleString() ?? "Not published", home.sqft != null ? drill({ sqftMin: String(Math.max(0, Math.floor((home.sqft * 0.8) / 100) * 100)), sqftMax: String(Math.ceil((home.sqft * 1.2) / 100) * 100) }) : undefined],
+    ["Stories", home.stories?.toString() ?? "Not published", home.stories != null ? drill({ stories: String(home.stories) }) : undefined],
+    ["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published", home.garageSpaces ? drill({ garageMin: String(home.garageSpaces) }) : undefined],
     ["Lot", home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"],
     [
       "Est. completion",
@@ -117,6 +128,11 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
     });
   const purchaseUrl = typeof rawSpecs.purchaseUrl === "string" ? rawSpecs.purchaseUrl : null;
 
+  // Parcel / public-records drill. We have no APN column (never fabricated) — we
+  // surface the honest identifiers we DO have (lot number + county) and link OUT
+  // to the county's public parcel-search surface (public-records backbone).
+  const parcel = parcelLink(home.community.county, home.state, home.city);
+
   return (
     <div className="mx-auto max-w-5xl px-4 py-8">
       <nav className="text-sm text-neutral-500">
@@ -147,7 +163,9 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
                 {home.street ?? "Address available from builder"}
               </h1>
               <p className="mt-1 text-sm text-brand-100">
-                {home.city}, {home.state} {home.zip} · {home.community.name}
+                <Link href={drill({ city: home.city, st: home.state })} className="hover:text-white hover:underline">{home.city}</Link>,{" "}
+                <Link href={drill({ st: home.state })} className="hover:text-white hover:underline">{home.state}</Link> {home.zip} ·{" "}
+                <Link href={`/communities/${home.community.slug}`} className="hover:text-white hover:underline">{home.community.name}</Link>
               </p>
             </div>
             <div className="text-right">
@@ -199,10 +217,19 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
         <div>
           <h2 className="font-display text-xl font-semibold text-brand-900">Home facts</h2>
           <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
-            {facts.map(([label, value]) => (
+            {facts.map(([label, value, href]) => (
               <div key={label} className="border-b border-neutral-100 py-1.5">
                 <dt className="text-neutral-500">{label}</dt>
-                <dd className="font-medium">{value}</dd>
+                <dd className="font-medium">
+                  {href ? (
+                    <Link href={href} className="text-brand-800 hover:text-brand-600 hover:underline decoration-brand-300 underline-offset-2"
+                      data-testid={`fact-drill-${label.toLowerCase().replace(/\s+/g, "-")}`}>
+                      {value}
+                    </Link>
+                  ) : (
+                    value
+                  )}
+                </dd>
               </div>
             ))}
           </dl>
@@ -210,6 +237,43 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
             “Not published” means the source did not state this fact. HomesOnSpec never fills in missing values.
           </p>
 
+          {/* Parcel & public records — the lot/parcel identifier plus a link OUT to the
+              county's public assessor / property-records search. We surface only the
+              identifiers we honestly hold (lot number + county); we never fabricate an
+              APN. The assessor link is public-records backbone (compliant). */}
+          <section className="mt-8" data-testid="parcel-section">
+            <h2 className="font-display text-xl font-semibold text-brand-900">Parcel &amp; public records</h2>
+            <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
+              <div className="border-b border-neutral-100 py-1.5">
+                <dt className="text-neutral-500">Lot number</dt>
+                <dd className="font-medium">{home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"}</dd>
+              </div>
+              <div className="border-b border-neutral-100 py-1.5">
+                <dt className="text-neutral-500">County</dt>
+                <dd className="font-medium">{home.community.county ?? "Not published"}</dd>
+              </div>
+              <div className="border-b border-neutral-100 py-1.5">
+                <dt className="text-neutral-500">City / State</dt>
+                <dd className="font-medium">
+                  <Link href={drill({ city: home.city, st: home.state })} className="text-brand-800 hover:text-brand-600 hover:underline">
+                    {home.city}, {home.state}
+                  </Link>
+                </dd>
+              </div>
+            </dl>
+            {parcel ? (
+              <a href={parcel.url} target="_blank" rel="nofollow noopener noreferrer"
+                className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-accent-600 hover:text-accent-700"
+                data-testid="assessor-link">
+                🔎 Look up this parcel on {parcel.authority} ↗
+              </a>
+            ) : null}
+            <p className="mt-2 text-xs text-neutral-400">
+              HomesOnSpec does not publish an APN. Use the county&rsquo;s public property-records
+              search above to look up the official parcel record by address or lot.
+            </p>
+          </section>
+
           {(specFacts.length > 0 || purchaseUrl) && (
             <section className="mt-8">
               <h2 className="font-display text-xl font-semibold text-brand-900">Additional details</h2>
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 560c3427..528918ce 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -5,6 +5,30 @@ import dynamic from "next/dynamic";
 import Link from "next/link";
 import { fmtPrice, VerificationBadge } from "@homesonspec/shared-ui";
 
+// Which URL params seed the filter state on load (href-drill: a link like
+// /search?city=Austin&status=MOVE_IN_READY deep-links into a pre-filtered grid).
+// Multi-value params (repeatable) vs single-value params are split so hydration
+// rebuilds the exact filter shape the rail expects.
+const MULTI_FILTER_KEYS = ["st", "builder", "status", "homeType"] as const;
+const SINGLE_FILTER_KEYS = [
+  "priceMin", "priceMax", "bedsMin", "bathsMin", "sqftMin", "sqftMax",
+  "stories", "garageMin", "city", "incentives", "ageRestricted", "moveInByMonths",
+] as const;
+
+function hydrateFilters(qs: string): Record<string, string | string[]> {
+  const sp = new URLSearchParams(qs);
+  const out: Record<string, string | string[]> = {};
+  for (const k of MULTI_FILTER_KEYS) {
+    const vals = sp.getAll(k);
+    if (vals.length) out[k] = vals;
+  }
+  for (const k of SINGLE_FILTER_KEYS) {
+    const v = sp.get(k);
+    if (v) out[k] = v;
+  }
+  return out;
+}
+
 // Leaflet needs window — client-only load, direct file import (not the barrel).
 const MapView = dynamic(() => import("@homesonspec/shared-ui/src/MapView").then((m) => m.MapView), { ssr: false });
 
@@ -110,6 +134,23 @@ function estMonthly(price: string | number | null): number | null {
   return Math.round(pi + (p * 0.0125) / 12);
 }
 
+// Price/sqft are continuous, so their card atoms drill to a sensible BAND around
+// the home's value (±15% price, ±20% sqft) rather than an exact match that would
+// usually return just the one home. Rounds to clean numbers for a shareable URL.
+function priceBandDrill(price: string | number | null, drill: (kv: Record<string, string>) => string): string {
+  const p = Number(price);
+  if (!Number.isFinite(p) || p <= 0) return drill({});
+  const lo = Math.max(0, Math.floor((p * 0.85) / 10000) * 10000);
+  const hi = Math.ceil((p * 1.15) / 10000) * 10000;
+  return drill({ priceMin: String(lo), priceMax: String(hi) });
+}
+function sqftBandDrill(sqft: number, drill: (kv: Record<string, string>) => string): string {
+  if (!Number.isFinite(sqft) || sqft <= 0) return drill({});
+  const lo = Math.max(0, Math.floor((sqft * 0.8) / 100) * 100);
+  const hi = Math.ceil((sqft * 1.2) / 100) * 100;
+  return drill({ sqftMin: String(lo), sqftMax: String(hi) });
+}
+
 // The rail's data-point panels, in display order. Each is a collapsed accordion whose
 // open/closed state persists to localStorage (standing "one collapsed tab per data field" rule).
 const FACET_KEYS = [
@@ -117,11 +158,24 @@ const FACET_KEYS = [
   "status", "stories", "garage", "city", "builder", "more",
 ] as const;
 
-export default function SearchClient({ initialQuery }: { initialQuery: string }) {
+export default function SearchClient({
+  initialQuery,
+  initialParams = "",
+}: {
+  initialQuery: string;
+  initialParams?: string;
+}) {
   const [q, setQ] = useState(initialQuery);
-  const [filters, setFilters] = useState<Record<string, string | string[]>>({});
-  // Sort + density persist across reloads (standing grid rule).
-  const [sort, setSort] = useState<string>("newest");
+  // Seed filters from the URL so drill links (?city=…&status=…) land pre-filtered.
+  const [filters, setFilters] = useState<Record<string, string | string[]>>(() =>
+    hydrateFilters(initialParams),
+  );
+  // Sort + density persist across reloads (standing grid rule). Sort can also be
+  // seeded from the URL (a drill link may pin a sort).
+  const [sort, setSort] = useState<string>(() => {
+    const s = new URLSearchParams(initialParams).get("sort");
+    return s ?? "newest";
+  });
   const [cols, setCols] = useState<number>(3);
   const [data, setData] = useState<SearchResponse | null>(null);
   const [facets, setFacets] = useState<Facets | null>(null);
@@ -178,6 +232,22 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
     return params.toString();
   }, [q, filters, sort, page]);
 
+  // Keep the browser URL in sync with the active filters + sort + query so the
+  // filtered grid is URL-addressable and shareable (href-drill rule). bbox/page
+  // are intentionally excluded to keep the shareable URL stable and clean.
+  useEffect(() => {
+    const params = new URLSearchParams();
+    if (q) params.set("q", q);
+    for (const [key, value] of Object.entries(filters)) {
+      if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
+      else if (value !== "") params.set(key, value);
+    }
+    if (sort !== "newest") params.set("sort", sort);
+    const qs = params.toString();
+    const url = qs ? `/search?${qs}` : "/search";
+    window.history.replaceState(null, "", url);
+  }, [q, filters, sort]);
+
   const fetchResults = useCallback(async () => {
     setLoading(true);
     try {
@@ -266,6 +336,14 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
 
   const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
 
+  // Drill-href builder: every displayed data point on a card links to its deeper,
+  // URL-addressable filtered view (href-drill rule — no dead-end data points).
+  const drill = (kv: Record<string, string>): string => {
+    const params = new URLSearchParams();
+    for (const [k, v] of Object.entries(kv)) if (v) params.set(k, v);
+    return `/search?${params.toString()}`;
+  };
+
   return (
     <div className="mx-auto max-w-7xl px-4 py-6">
       {/* Search bar */}
@@ -469,9 +547,15 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
           <div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
             <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
               {(data?.homes ?? []).map((home) => (
-                <Link key={home.id} href={`/homes/${home.id}`}
-                  className="card-interactive relative overflow-hidden"
+                // href-drill: the card is a container with a STRETCHED overlay link to the
+                // detail page, and every data point below is its own real drill link sitting
+                // ABOVE the overlay (z-10). No nested anchors — clicking blank card area opens
+                // the detail; clicking a data atom drills to that atom's deeper view.
+                <div key={home.id}
+                  className="card-interactive group relative overflow-hidden"
                   data-testid="home-card">
+                  <Link href={`/homes/${home.id}`} aria-label={`View ${home.street ?? "home"} in ${home.city}, ${home.state}`}
+                    className="absolute inset-0 z-0" data-testid="home-card-overlay" tabIndex={-1} />
                   {/* Photo header — the primary scan signal for a real-estate grid. Falls back
                       to a branded gradient when a home has no photo (image-less builders) or when
                       the media-rights gate blanks images upstream, so the grid stays uniform. */}
@@ -483,35 +567,70 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
                     ) : null}
                     <button type="button" aria-label="Save home" data-testid="fav-btn"
                       onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(home.id); }}
-                      className="absolute right-3 top-3 z-10 text-lg leading-none drop-shadow transition hover:scale-110">
+                      className="absolute right-3 top-3 z-20 text-lg leading-none drop-shadow transition hover:scale-110">
                       <span className={favs.has(home.id) ? "text-rose-500" : "text-white/90"}>{favs.has(home.id) ? "♥" : "♡"}</span>
                     </button>
+                    {/* Status badge drills to the same construction status. */}
                     {STATUS_BADGE[home.constructionStatus] && (
-                      <span className={`absolute left-3 top-3 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium shadow-sm ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
+                      <Link href={drill({ status: home.constructionStatus })} data-testid="drill-status"
+                        onClick={(e) => e.stopPropagation()}
+                        className={`absolute left-3 top-3 z-20 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium shadow-sm transition hover:brightness-95 ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
                         {STATUS_BADGE[home.constructionStatus]!.label}
-                      </span>
+                      </Link>
                     )}
                   </div>
-                  <div className="p-4">
+                  <div className="relative z-10 p-4">
                     <div className="flex items-baseline gap-2">
-                      <span className="font-display text-xl font-semibold text-brand-900">{fmtPrice(home.price)}</span>
+                      {/* Price drills to a price band around this home's price (±15%). */}
+                      <Link href={priceBandDrill(home.price, drill)} data-testid="drill-price"
+                        className="font-display text-xl font-semibold text-brand-900 hover:text-brand-700 hover:underline decoration-brand-300 underline-offset-2">
+                        {fmtPrice(home.price)}
+                      </Link>
                       {estMonthly(home.price) !== null && (
                         <span className="text-xs text-neutral-500">~${estMonthly(home.price)!.toLocaleString()}/mo</span>
                       )}
                     </div>
                     <div className="mt-0.5 truncate text-sm text-neutral-700">
-                      {home.street ?? "Address on request"} · {home.city}, {home.state}
+                      {/* Detail link for the street, then city + state each drill to their scope. */}
+                      <Link href={`/homes/${home.id}`} className="hover:underline">
+                        {home.street ?? "Address on request"}
+                      </Link>{" · "}
+                      <Link href={drill({ city: home.city, st: home.state })} data-testid="drill-city"
+                        className="hover:text-brand-700 hover:underline">{home.city}</Link>{", "}
+                      <Link href={drill({ st: home.state })} data-testid="drill-state"
+                        className="hover:text-brand-700 hover:underline">{home.state}</Link>
                     </div>
                     <div className="mt-1 text-sm text-neutral-500">
-                      {home.beds ?? "—"} bd · {home.bathsTotal ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
+                      {/* Beds / baths / sqft each drill to the matching min filter. */}
+                      {home.beds != null ? (
+                        <Link href={drill({ bedsMin: String(home.beds) })} data-testid="drill-beds"
+                          className="hover:text-brand-700 hover:underline">{home.beds} bd</Link>
+                      ) : "— bd"}{" · "}
+                      {home.bathsTotal != null ? (
+                        <Link href={drill({ bathsMin: String(home.bathsTotal) })} data-testid="drill-baths"
+                          className="hover:text-brand-700 hover:underline">{home.bathsTotal} ba</Link>
+                      ) : "— ba"}{" · "}
+                      {home.sqft != null ? (
+                        <Link href={sqftBandDrill(home.sqft, drill)} data-testid="drill-sqft"
+                          className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()} sqft</Link>
+                      ) : "— sqft"}
                       {home.estCompletionDate ? ` · ready ${new Date(home.estCompletionDate).toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""}
                     </div>
                     <div className="mt-2 flex items-center justify-between">
-                      <span className="text-xs text-neutral-500">{home.builder.name}</span>
+                      {/* Builder drills to the builder's own page (existing route). */}
+                      <Link href={`/builders/${home.builder.slug}`} data-testid="drill-builder"
+                        className="truncate text-xs text-neutral-500 hover:text-brand-700 hover:underline">
+                        {home.builder.name}
+                      </Link>
                       <VerificationBadge label={home.verificationLabel} />
                     </div>
+                    {/* Community drill — the "project" this home belongs to. */}
+                    <Link href={`/communities/${home.community.slug}`} data-testid="drill-community"
+                      className="mt-1 block truncate text-xs text-neutral-400 hover:text-brand-700 hover:underline">
+                      {home.community.name}
+                    </Link>
                   </div>
-                </Link>
+                </div>
               ))}
               {!loading && (data?.homes.length ?? 0) === 0 && (
                 <p className="col-span-full py-10 text-center text-neutral-500">
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx
index e66b2960..98a0836f 100644
--- a/apps/web/src/app/search/page.tsx
+++ b/apps/web/src/app/search/page.tsx
@@ -2,11 +2,20 @@ import SearchClient from "./SearchClient";
 
 export const dynamic = "force-dynamic";
 
+// Pass the FULL query string through so the grid's drill atoms are URL-addressable:
+// /search?city=Austin&status=MOVE_IN_READY&st=TX deep-links straight into a
+// pre-filtered grid (href-drill rule: URL-addressable filters).
 export default async function SearchPage({
   searchParams,
 }: {
-  searchParams: Promise<{ q?: string }>;
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
 }) {
-  const { q } = await searchParams;
-  return <SearchClient initialQuery={q ?? ""} />;
+  const sp = await searchParams;
+  const params = new URLSearchParams();
+  for (const [k, v] of Object.entries(sp)) {
+    if (Array.isArray(v)) v.forEach((x) => params.append(k, x));
+    else if (v !== undefined) params.set(k, v);
+  }
+  const q = typeof sp.q === "string" ? sp.q : "";
+  return <SearchClient initialQuery={q} initialParams={params.toString()} />;
 }
diff --git a/apps/web/src/lib/parcel.ts b/apps/web/src/lib/parcel.ts
new file mode 100644
index 00000000..ae53346e
--- /dev/null
+++ b/apps/web/src/lib/parcel.ts
@@ -0,0 +1,95 @@
+// Parcel / public-records drill helpers.
+//
+// SOURCING-COMPLIANCE: we source parcel facts (lot, county) that the ingest
+// pipeline already legitimately captured, and we link OUT to the county's own
+// public-records / assessor parcel-search surface. We NEVER fabricate an APN —
+// the data model has no APN column, so we surface what we honestly have (lot
+// number + county) and hand the user a public-records search scoped to the
+// county so they can look the parcel up on the authoritative source themselves.
+//
+// A small curated map of county → assessor parcel-search URL covers the highest
+// -volume counties in our data with a known, stable public search page. For any
+// county not in the map we fall back to a generic public-records search that is
+// still scoped to the county + state, so there is never a dead-end data point.
+
+interface CountyAssessor {
+  /** Display name of the authority the link points to. */
+  authority: string;
+  /** Landing/search URL on the county's own public parcel-search surface. */
+  url: (city: string, state: string) => string;
+}
+
+// Normalize "Solano County" / "solano" → "solano" for keying.
+export function normCounty(county: string | null | undefined): string {
+  if (!county) return "";
+  return county
+    .toLowerCase()
+    .replace(/\s+county$/i, "")
+    .trim();
+}
+
+// state-scoped county key so identical county names in different states don't collide.
+function key(county: string | null | undefined, state: string): string {
+  return `${state.toUpperCase()}:${normCounty(county)}`;
+}
+
+// Curated public parcel-search surfaces. URLs point at the county's OWN public
+// search landing page (public-records backbone) — the user runs the lookup.
+const ASSESSOR_MAP: Record<string, CountyAssessor> = {
+  // California
+  "CA:solano": { authority: "Solano County Assessor", url: () => "https://services.solanocounty.com/apps/PropertyInformation/" },
+  "CA:san diego": { authority: "San Diego County Assessor", url: () => "https://arcc.sdcounty.ca.gov/Pages/PropertySearch.aspx" },
+  "CA:kern": { authority: "Kern County Assessor", url: () => "https://recorderonline.co.kern.ca.us/" },
+  "CA:riverside": { authority: "Riverside County Assessor", url: () => "https://ca-riverside-acr.publicaccessnow.com/" },
+  "CA:sacramento": { authority: "Sacramento County Assessor", url: () => "https://assessorparcelviewer.saccounty.gov/" },
+  // Texas
+  "TX:harris": { authority: "Harris County Appraisal District", url: () => "https://hcad.org/property-search/" },
+  "TX:fort bend": { authority: "Fort Bend Central Appraisal District", url: () => "https://www.fbcad.org/property-search/" },
+  "TX:comal": { authority: "Comal Appraisal District", url: () => "https://esearch.comalad.org/" },
+  "TX:kendall": { authority: "Kendall Appraisal District", url: () => "https://esearch.kendallad.org/" },
+  "TX:travis": { authority: "Travis Central Appraisal District", url: () => "https://traviscad.org/property-search/" },
+  // North Carolina
+  "NC:wake": { authority: "Wake County Real Estate", url: () => "https://services.wake.gov/realestate/" },
+  "NC:forsyth": { authority: "Forsyth County Tax Assessor", url: () => "https://www.forsyth.cc/tax/property_records.aspx" },
+  "NC:cherokee": { authority: "Cherokee County Tax Office", url: () => "https://cherokeecounty-nc.gov/213/Tax-Assessor" },
+  // Delaware
+  "DE:sussex": { authority: "Sussex County Assessment", url: () => "https://sussexcountyde.gov/property-information" },
+  // Virginia
+  "VA:albemarle": { authority: "Albemarle County Real Estate", url: () => "https://www.albemarle.org/government/finance-budget/real-estate-tax-assessment" },
+  // Georgia
+  "GA:forsyth": { authority: "Forsyth County GA Assessor", url: () => "https://qpublic.schneidercorp.com/Application.aspx?App=ForsythCountyGA" },
+};
+
+export interface ParcelLink {
+  /** Human label for the authority the link points at. */
+  authority: string;
+  /** URL to the county's public parcel/records search. */
+  url: string;
+  /** True when we matched a curated county assessor; false = generic fallback. */
+  curated: boolean;
+}
+
+// Resolve a public-records drill target for a home's county+state. Always returns
+// something — a curated assessor when known, else a generic county-scoped
+// public-records web search — so a county/parcel data point is never a dead end.
+export function parcelLink(
+  county: string | null | undefined,
+  state: string | null | undefined,
+  city: string | null | undefined,
+): ParcelLink | null {
+  if (!state) return null;
+  const st = state.toUpperCase();
+  const hit = county ? ASSESSOR_MAP[key(county, st)] : undefined;
+  if (hit) {
+    return { authority: hit.authority, url: hit.url(city ?? "", st), curated: true };
+  }
+  // Generic, honest fallback: a web search scoped to the county assessor. No
+  // fabricated parcel id — just routes the user to the authoritative source.
+  const label = county ? `${normCounty(county).replace(/\b\w/g, (c) => c.toUpperCase())} County` : st;
+  const q = encodeURIComponent(`${label} ${st} county assessor parcel property records search`);
+  return {
+    authority: county ? `${label} public records` : `${st} public records`,
+    url: `https://www.google.com/search?q=${q}`,
+    curated: false,
+  };
+}

← be9e4af2 TK-10001: deploy-kamatera.sh frees :9975/:9976 from orphaned  ·  back to Homesonspec  ·  homesonspec search: drill() merges active rail filters (unio 62b2c3cb →