[object Object]

← back to Homesonspec

feat(search): Amazon-style collapsed facet rail — 11 data points with live counts

fdacc4265127d594f1e29c6733b5b4a2ae87f8cc · 2026-07-30 08:34:52 -0700 · Steve

Backend (packages/search runFacets): expand faceting from 5 → 11 groups —
add baths, stories, garage, city (top 25), and price/sqft range bands
(one indexed count() per band, each computed with its own band excluded so
counts reflect the other active filters).

Frontend (SearchClient): every data-point section is now a collapsed
accordion panel (per-panel open state persisted to localStorage), with a
teal active-selection badge visible while collapsed, an option-count in the
header, and Expand all / Collapse all. New panels: Home type, Baths, Sq ft
bands, Stories, Garage, City. Price gains quick range-band quick-picks
above the custom min/max inputs.

Author: steve@designerwallcoverings.com

Files touched

Diff

commit fdacc4265127d594f1e29c6733b5b4a2ae87f8cc
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 08:34:52 2026 -0700

    feat(search): Amazon-style collapsed facet rail — 11 data points with live counts
    
    Backend (packages/search runFacets): expand faceting from 5 → 11 groups —
    add baths, stories, garage, city (top 25), and price/sqft range bands
    (one indexed count() per band, each computed with its own band excluded so
    counts reflect the other active filters).
    
    Frontend (SearchClient): every data-point section is now a collapsed
    accordion panel (per-panel open state persisted to localStorage), with a
    teal active-selection badge visible while collapsed, an option-count in the
    header, and Expand all / Collapse all. New panels: Home type, Baths, Sq ft
    bands, Stories, Garage, City. Price gains quick range-band quick-picks
    above the custom min/max inputs.
    
    Author: steve@designerwallcoverings.com
---
 apps/web/src/app/search/SearchClient.tsx | 334 ++++++++++++++++++++++++-------
 packages/search/src/index.ts             | 112 +++++++----
 2 files changed, 335 insertions(+), 111 deletions(-)

diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 46ba66dc..560c3427 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -40,12 +40,25 @@ interface SearchResponse {
   location: { label: string; lat: number; lon: number } | null;
 }
 
+interface Band {
+  label: string;
+  min: number | null;
+  max: number | null;
+  count: number;
+}
+
 interface Facets {
   constructionStatus: { value: string; count: number }[];
   builder: { value: string; label: string; count: number }[];
   beds: { value: number; count: number }[];
+  baths: { value: number; count: number }[];
   homeType: { value: string; count: number }[];
   state: { value: string; count: number }[];
+  stories: { value: number; count: number }[];
+  garage: { value: number; count: number }[];
+  city: { value: string; count: number }[];
+  priceBands: Band[];
+  sqftBands: Band[];
 }
 
 const STATE_NAMES: Record<string, string> = {
@@ -74,6 +87,14 @@ const STATUS_LABELS: Record<string, string> = {
   PLANNED: "Planned",
 };
 
+const HOME_TYPE_LABELS: Record<string, string> = {
+  SINGLE_FAMILY: "Single-family",
+  TOWNHOME: "Townhome",
+  CONDO: "Condo",
+  DUPLEX: "Duplex",
+  OTHER: "Other",
+};
+
 // national-site card upgrades: colored urgency badges + an estimated monthly payment.
 const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
   MOVE_IN_READY: { label: "Move-in ready", cls: "bg-amber-100 text-amber-800 ring-1 ring-amber-200" },
@@ -89,6 +110,13 @@ function estMonthly(price: string | number | null): number | null {
   return Math.round(pi + (p * 0.0125) / 12);
 }
 
+// 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 = [
+  "state", "price", "beds", "baths", "sqft", "homeType",
+  "status", "stories", "garage", "city", "builder", "more",
+] as const;
+
 export default function SearchClient({ initialQuery }: { initialQuery: string }) {
   const [q, setQ] = useState(initialQuery);
   const [filters, setFilters] = useState<Record<string, string | string[]>>({});
@@ -100,6 +128,8 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
   const [loading, setLoading] = useState(true);
   const [page, setPage] = useState(1);
   const bboxRef = useRef<string | null>(null);
+  // Per-panel collapsed/expanded state — persisted so the rail remembers what you opened.
+  const [open, setOpen] = useState<Record<string, boolean>>({});
   // Favorites (national-site staple) — persisted client-side.
   const [favs, setFavs] = useState<Set<string>>(new Set());
   useEffect(() => { try { setFavs(new Set(JSON.parse(localStorage.getItem("homesonspec.favs") || "[]"))); } catch {} }, []);
@@ -113,6 +143,11 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
     const savedCols = localStorage.getItem("homesonspec.cols");
     if (savedSort) setSort(savedSort);
     if (savedCols) setCols(Number(savedCols));
+    try {
+      const savedOpen = JSON.parse(localStorage.getItem("homesonspec.facetOpen") || "null");
+      // First visit → everything collapsed except State (so the rail isn't a blank wall).
+      setOpen(savedOpen ?? { state: true });
+    } catch { setOpen({ state: true }); }
   }, []);
 
   const setSortPersist = (value: string) => {
@@ -123,6 +158,12 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
     setCols(value);
     localStorage.setItem("homesonspec.cols", String(value));
   };
+  const persistOpen = (next: Record<string, boolean>) => {
+    setOpen(next);
+    localStorage.setItem("homesonspec.facetOpen", JSON.stringify(next));
+  };
+  const toggleOpen = (key: string) => persistOpen({ ...open, [key]: !open[key] });
+  const setAllOpen = (v: boolean) => persistOpen(Object.fromEntries(FACET_KEYS.map((k) => [k, v])));
 
   const queryString = useMemo(() => {
     const params = new URLSearchParams();
@@ -167,6 +208,43 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
     setPage(1);
     setFilters((prev) => ({ ...prev, [key]: value }));
   };
+  // Range bands set a min+max pair together; clicking the active band clears both.
+  const bandActive = (minKey: string, maxKey: string, b: Band) =>
+    (filters[minKey] ?? "") === (b.min === null ? "" : String(b.min)) &&
+    (filters[maxKey] ?? "") === (b.max === null ? "" : String(b.max));
+  const toggleBand = (minKey: string, maxKey: string, b: Band) => {
+    setPage(1);
+    setFilters((prev) => {
+      const next = { ...prev };
+      if (bandActive(minKey, maxKey, b)) { delete next[minKey]; delete next[maxKey]; return next; }
+      if (b.min === null) delete next[minKey]; else next[minKey] = String(b.min);
+      if (b.max === null) delete next[maxKey]; else next[maxKey] = String(b.max);
+      return next;
+    });
+  };
+
+  // How many active selections live inside each panel — drives the teal header badge
+  // so you can see where your filters are even while a panel is collapsed.
+  const activeCount = (key: string): number => {
+    const arr = (k: string) => (Array.isArray(filters[k]) ? (filters[k] as string[]).length : 0);
+    const one = (k: string) => (filters[k] && filters[k] !== "" ? 1 : 0);
+    switch (key) {
+      case "state": return arr("st");
+      case "builder": return arr("builder");
+      case "status": return arr("status");
+      case "homeType": return arr("homeType");
+      case "beds": return one("bedsMin");
+      case "baths": return one("bathsMin");
+      case "stories": return one("stories");
+      case "garage": return one("garageMin");
+      case "city": return one("city");
+      case "price": return one("priceMin") + one("priceMax");
+      case "sqft": return one("sqftMin") + one("sqftMax");
+      case "more": return one("incentives") + one("ageRestricted") + one("moveInByMonths");
+      default: return 0;
+    }
+  };
+  const totalActive = FACET_KEYS.reduce((n, k) => n + activeCount(k), 0);
 
   const markers = (data?.homes ?? [])
     .filter((h) => h.lat !== null && h.lon !== null)
@@ -213,104 +291,154 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
       </form>
 
       <div className="mt-6 grid gap-6 lg:grid-cols-[280px_1fr]">
-        {/* Amazon-style faceted rail — every dimension a checkable filter with live counts. */}
+        {/* Amazon-style faceted rail — every data point a collapsed panel with live counts. */}
         <aside className="text-sm lg:sticky lg:top-[68px] lg:max-h-[calc(100vh-84px)] lg:overflow-y-auto lg:pr-1" data-testid="filters">
-          <div className="mb-3 flex items-center justify-between">
-            <h2 className="font-display text-base font-semibold text-brand-900">Filter homes</h2>
-            {Object.keys(filters).length > 0 && (
+          <div className="mb-2 flex items-center justify-between">
+            <h2 className="font-display text-base font-semibold text-brand-900">
+              Filter homes
+              {totalActive > 0 && (
+                <span className="ml-2 rounded-full bg-teal-100 px-1.5 py-0.5 align-middle text-[11px] font-bold text-teal-700 ring-1 ring-teal-200">
+                  {totalActive}
+                </span>
+              )}
+            </h2>
+            {totalActive > 0 && (
               <button type="button" className="text-xs font-semibold text-brand-700 hover:text-brand-800"
-                onClick={() => { setPage(1); setFilters({}); }}>
+                onClick={() => { setPage(1); setFilters({}); }} data-testid="clear-all">
                 Clear all
               </button>
             )}
           </div>
+          {/* Expand / collapse every panel at once. */}
+          <div className="mb-1 flex gap-3 text-[11px] font-semibold text-neutral-500">
+            <button type="button" className="hover:text-brand-700" onClick={() => setAllOpen(true)} data-testid="expand-all">Expand all</button>
+            <span className="text-neutral-300">·</span>
+            <button type="button" className="hover:text-brand-700" onClick={() => setAllOpen(false)} data-testid="collapse-all">Collapse all</button>
+          </div>
 
           {/* State — multi-select, top 12 + overflow. */}
-          <FacetSection title="State">
+          <FacetPanel id="state" title="State" optionCount={facets?.state.length} active={activeCount("state")} open={!!open.state} onToggle={() => toggleOpen("state")}>
             <ChecklistOverflow
               items={(facets?.state ?? []).map((s) => ({ value: s.value, label: STATE_NAMES[s.value] ?? s.value, count: s.count }))}
               selected={Array.isArray(filters.st) ? (filters.st as string[]) : []}
               onToggle={(v) => toggleMulti("st", v)}
               testid="facet-state"
             />
-          </FacetSection>
-
-          {/* Builder / firm — multi-select. */}
-          <FacetSection title="Builder">
-            <ChecklistOverflow
-              items={(facets?.builder ?? []).map((b) => ({ value: b.value, label: b.label, count: b.count }))}
-              selected={Array.isArray(filters.builder) ? (filters.builder as string[]) : []}
-              onToggle={(v) => toggleMulti("builder", v)}
-              testid="facet-builder"
-            />
-          </FacetSection>
-
-          {/* Min Sq Ft — single-select chips. */}
-          <FacetSection title="Min sq ft">
-            <div className="flex flex-wrap gap-1.5" data-testid="facet-sqft">
-              {[1000, 1500, 2000, 2500, 3000].map((n) => (
-                <button key={n} type="button"
-                  onClick={() => setSingle("sqftMin", filters.sqftMin === String(n) ? "" : String(n))}
-                  className={`rounded-full border px-3 py-1 text-xs transition ${filters.sqftMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
-                  {n.toLocaleString()}+
-                </button>
+          </FacetPanel>
+
+          {/* Price — quick range bands (counts) + custom min/max. */}
+          <FacetPanel id="price" title="Price" optionCount={facets?.priceBands.length} active={activeCount("price")} open={!!open.price} onToggle={() => toggleOpen("price")}>
+            <div className="space-y-0.5" data-testid="facet-price">
+              {(facets?.priceBands ?? []).map((b) => (
+                <FacetCheck key={b.label} label={b.label} count={b.count}
+                  checked={bandActive("priceMin", "priceMax", b)} onToggle={() => toggleBand("priceMin", "priceMax", b)} />
               ))}
             </div>
-          </FacetSection>
-
-          <FacetSection title="Price">
-            <div className="flex gap-2">
-              <input type="number" placeholder="Min $" className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
+            <div className="mt-2 flex gap-2">
+              <input type="number" placeholder="Min $" value={(filters.priceMin as string) ?? ""} className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
                 onChange={(e) => setSingle("priceMin", e.target.value)} data-testid="price-min" />
-              <input type="number" placeholder="Max $" className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
+              <input type="number" placeholder="Max $" value={(filters.priceMax as string) ?? ""} className="w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
                 onChange={(e) => setSingle("priceMax", e.target.value)} data-testid="price-max" />
             </div>
-          </FacetSection>
-
-          <FacetSection title="Beds (min)">
-            <div className="flex gap-1.5">
-              {[1, 2, 3, 4, 5].map((n) => (
-                <button key={n} type="button" data-testid={`beds-${n}`}
-                  onClick={() => setSingle("bedsMin", filters.bedsMin === String(n) ? "" : String(n))}
-                  className={`rounded-full border px-3 py-1 text-xs transition ${filters.bedsMin === String(n) ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
-                  {n}+
-                </button>
+          </FacetPanel>
+
+          {/* Beds — single-select "N+" min, with live counts. */}
+          <FacetPanel id="beds" title="Beds (min)" optionCount={facets?.beds.length} active={activeCount("beds")} open={!!open.beds} onToggle={() => toggleOpen("beds")}>
+            <MinChips values={(facets?.beds ?? []).map((b) => b.value)} suffix="+" field="bedsMin" filters={filters} onPick={setSingle} testid="facet-beds" />
+          </FacetPanel>
+
+          {/* Baths — single-select "N+" min, with live counts. */}
+          <FacetPanel id="baths" title="Baths (min)" optionCount={facets?.baths.length} active={activeCount("baths")} open={!!open.baths} onToggle={() => toggleOpen("baths")}>
+            <MinChips values={(facets?.baths ?? []).map((b) => b.value)} suffix="+" field="bathsMin" filters={filters} onPick={setSingle} testid="facet-baths" />
+          </FacetPanel>
+
+          {/* Sq ft — quick range bands (counts). */}
+          <FacetPanel id="sqft" title="Sq ft" optionCount={facets?.sqftBands.length} active={activeCount("sqft")} open={!!open.sqft} onToggle={() => toggleOpen("sqft")}>
+            <div className="space-y-0.5" data-testid="facet-sqft">
+              {(facets?.sqftBands ?? []).map((b) => (
+                <FacetCheck key={b.label} label={b.label} count={b.count}
+                  checked={bandActive("sqftMin", "sqftMax", b)} onToggle={() => toggleBand("sqftMin", "sqftMax", b)} />
               ))}
             </div>
-          </FacetSection>
+          </FacetPanel>
+
+          {/* Home type — multi-select. */}
+          <FacetPanel id="homeType" title="Home type" optionCount={facets?.homeType.length} active={activeCount("homeType")} open={!!open.homeType} onToggle={() => toggleOpen("homeType")}>
+            <div className="space-y-0.5" data-testid="facet-hometype">
+              {(facets?.homeType ?? []).map((t) => (
+                <FacetCheck key={t.value} label={HOME_TYPE_LABELS[t.value] ?? t.value} count={t.count}
+                  checked={Array.isArray(filters.homeType) && (filters.homeType as string[]).includes(t.value)}
+                  onToggle={() => toggleMulti("homeType", t.value)} />
+              ))}
+            </div>
+          </FacetPanel>
 
-          <FacetSection title="Construction status">
-            <div className="space-y-0.5">
-              {facets?.constructionStatus.map((facet) => (
+          {/* Construction status — multi-select. */}
+          <FacetPanel id="status" title="Construction status" optionCount={facets?.constructionStatus.length} active={activeCount("status")} open={!!open.status} onToggle={() => toggleOpen("status")}>
+            <div className="space-y-0.5" data-testid="facet-status">
+              {(facets?.constructionStatus ?? []).map((facet) => (
                 <FacetCheck key={facet.value}
                   label={STATUS_LABELS[facet.value] ?? facet.value} count={facet.count}
                   checked={Array.isArray(filters.status) && (filters.status as string[]).includes(facet.value)}
                   onToggle={() => toggleMulti("status", facet.value)} />
               ))}
             </div>
-          </FacetSection>
-
-          <FacetSection title="More">
-            <label className="flex cursor-pointer items-center gap-2 py-0.5">
-              <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.incentives === "1"}
-                onChange={() => setSingle("incentives", filters.incentives === "1" ? "" : "1")} />
-              Has incentives
-            </label>
-            <label className="flex cursor-pointer items-center gap-2 py-0.5">
-              <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.ageRestricted === "1"}
-                onChange={() => setSingle("ageRestricted", filters.ageRestricted === "1" ? "" : "1")} />
-              Age-restricted community
-            </label>
-            <select className="mt-2 w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
-              value={(filters.moveInByMonths as string) ?? ""}
-              onChange={(e) => setSingle("moveInByMonths", e.target.value)}>
-              <option value="">Any move-in timeframe</option>
-              <option value="0">Ready now</option>
-              <option value="3">Within 3 months</option>
-              <option value="6">Within 6 months</option>
-              <option value="12">Within 12 months</option>
-            </select>
-          </FacetSection>
+          </FacetPanel>
+
+          {/* Stories — single-select exact, with live counts. */}
+          <FacetPanel id="stories" title="Stories" optionCount={facets?.stories.length} active={activeCount("stories")} open={!!open.stories} onToggle={() => toggleOpen("stories")}>
+            <MinChips values={(facets?.stories ?? []).map((s) => s.value)} suffix="" field="stories" filters={filters} onPick={setSingle} testid="facet-stories" />
+          </FacetPanel>
+
+          {/* Garage — single-select "N+" min, with live counts. */}
+          <FacetPanel id="garage" title="Garage (min)" optionCount={facets?.garage.length} active={activeCount("garage")} open={!!open.garage} onToggle={() => toggleOpen("garage")}>
+            <MinChips values={(facets?.garage ?? []).map((g) => g.value)} suffix="+" field="garageMin" filters={filters} onPick={setSingle} testid="facet-garage" />
+          </FacetPanel>
+
+          {/* City — single-select, top 25 + overflow. */}
+          <FacetPanel id="city" title="City" optionCount={facets?.city.length} active={activeCount("city")} open={!!open.city} onToggle={() => toggleOpen("city")}>
+            <ChecklistOverflow
+              items={(facets?.city ?? []).map((c) => ({ value: c.value, label: c.value, count: c.count }))}
+              selected={filters.city ? [filters.city as string] : []}
+              onToggle={(v) => setSingle("city", filters.city === v ? "" : v)}
+              testid="facet-city"
+            />
+          </FacetPanel>
+
+          {/* Builder / firm — multi-select. */}
+          <FacetPanel id="builder" title="Builder" optionCount={facets?.builder.length} active={activeCount("builder")} open={!!open.builder} onToggle={() => toggleOpen("builder")}>
+            <ChecklistOverflow
+              items={(facets?.builder ?? []).map((b) => ({ value: b.value, label: b.label, count: b.count }))}
+              selected={Array.isArray(filters.builder) ? (filters.builder as string[]) : []}
+              onToggle={(v) => toggleMulti("builder", v)}
+              testid="facet-builder"
+            />
+          </FacetPanel>
+
+          {/* More — incentives / age-restricted / move-in timeframe. */}
+          <FacetPanel id="more" title="More" active={activeCount("more")} open={!!open.more} onToggle={() => toggleOpen("more")}>
+            <div data-testid="facet-more">
+              <label className="flex cursor-pointer items-center gap-2 py-0.5">
+                <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.incentives === "1"}
+                  onChange={() => setSingle("incentives", filters.incentives === "1" ? "" : "1")} />
+                Has incentives
+              </label>
+              <label className="flex cursor-pointer items-center gap-2 py-0.5">
+                <input type="checkbox" className="h-4 w-4 rounded accent-brand-700" checked={filters.ageRestricted === "1"}
+                  onChange={() => setSingle("ageRestricted", filters.ageRestricted === "1" ? "" : "1")} />
+                Age-restricted community
+              </label>
+              <select className="mt-2 w-full rounded-lg border-0 px-3 py-1.5 shadow-sm ring-1 ring-inset ring-neutral-200 focus:ring-2 focus:ring-brand-400"
+                value={(filters.moveInByMonths as string) ?? ""}
+                onChange={(e) => setSingle("moveInByMonths", e.target.value)}>
+                <option value="">Any move-in timeframe</option>
+                <option value="0">Ready now</option>
+                <option value="3">Within 3 months</option>
+                <option value="6">Within 6 months</option>
+                <option value="12">Within 12 months</option>
+              </select>
+            </div>
+          </FacetPanel>
         </aside>
 
         {/* Results */}
@@ -412,11 +540,67 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
 }
 
 // ── Faceted-rail primitives ────────────────────────────────────────────────
-function FacetSection({ title, children }: { title: string; children: React.ReactNode }) {
+
+// A collapsed accordion panel for one data point. Header shows the option count and a
+// teal badge with the # of active selections inside (visible even while collapsed).
+function FacetPanel({
+  id, title, optionCount, active, open, onToggle, children,
+}: {
+  id: string;
+  title: string;
+  optionCount?: number;
+  active: number;
+  open: boolean;
+  onToggle: () => void;
+  children: React.ReactNode;
+}) {
+  return (
+    <div className="border-t border-neutral-100 first:border-t-0">
+      <button type="button" onClick={onToggle} aria-expanded={open}
+        className="flex w-full items-center justify-between gap-2 py-2.5 text-left"
+        data-testid={`facet-head-${id}`}>
+        <span className="flex items-center gap-2">
+          <span className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-600">{title}</span>
+          {active > 0 && (
+            <span className="rounded-full bg-teal-100 px-1.5 py-0.5 text-[10px] font-bold text-teal-700 ring-1 ring-teal-200">{active}</span>
+          )}
+        </span>
+        <span className="flex items-center gap-2 text-neutral-400">
+          {optionCount != null && optionCount > 0 && <span className="text-[11px]">{optionCount}</span>}
+          <svg className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`} viewBox="0 0 12 12" fill="none" aria-hidden="true">
+            <path d="M2.5 4.5L6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+          </svg>
+        </span>
+      </button>
+      {open && <div className="pb-3">{children}</div>}
+    </div>
+  );
+}
+
+// Single-select "N+" (or exact) numeric chips, backed by live facet counts.
+function MinChips({
+  values, suffix, field, filters, onPick, testid,
+}: {
+  values: number[];
+  suffix: string;
+  field: string;
+  filters: Record<string, string | string[]>;
+  onPick: (key: string, value: string) => void;
+  testid?: string;
+}) {
+  if (values.length === 0) return <p className="px-1 text-xs text-neutral-400">No data</p>;
   return (
-    <div className="border-t border-neutral-100 py-3 first:border-t-0">
-      <h3 className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-600">{title}</h3>
-      {children}
+    <div className="flex flex-wrap gap-1.5" data-testid={testid}>
+      {values.map((n) => {
+        const active = filters[field] === String(n);
+        return (
+          <button key={n} type="button"
+            onClick={() => onPick(field, active ? "" : String(n))}
+            className={`rounded-full border px-3 py-1 text-xs transition ${active ? "border-brand-600 bg-brand-50 font-semibold text-brand-800" : "border-neutral-300 hover:border-brand-300"}`}>
+            {n % 1 === 0 ? n : n.toFixed(1)}{suffix}
+          </button>
+        );
+      })}
     </div>
   );
 }
@@ -436,7 +620,7 @@ function FacetCheck({
 }
 
 // Shows the first `cap` options; the rest live inside a native <details> so the
-// rail stays short with long lists (all 50 states, many builders).
+// rail stays short with long lists (all 50 states, many builders, top cities).
 function ChecklistOverflow({
   items, selected, onToggle, cap = 12, testid,
 }: {
diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts
index c14fea19..5d858ace 100644
--- a/packages/search/src/index.ts
+++ b/packages/search/src/index.ts
@@ -252,34 +252,59 @@ export async function runFacets(params: SearchParams) {
 
   const without = (key: keyof SearchParams): Prisma.InventoryHomeWhereInput =>
     buildWhere({ ...params, [key]: undefined }, bbox);
+  // Range facets drop BOTH ends of their own band so each band's count reflects the
+  // OTHER active filters (Amazon behavior — a facet never zeroes out its own options).
+  const whereNoPrice = buildWhere({ ...params, priceMin: undefined, priceMax: undefined }, bbox);
+  const whereNoSqft = buildWhere({ ...params, sqftMin: undefined, sqftMax: undefined }, bbox);
 
-  const [byStatus, byBuilder, byBeds, byType, byState] = await Promise.all([
-    prisma.inventoryHome.groupBy({
-      by: ["constructionStatus"],
-      where: without("statuses"),
-      _count: { _all: true },
-    }),
-    prisma.inventoryHome.groupBy({
-      by: ["builderId"],
-      where: without("builderSlugs"),
-      _count: { _all: true },
-    }),
-    prisma.inventoryHome.groupBy({
-      by: ["beds"],
-      where: without("bedsMin"),
-      _count: { _all: true },
-    }),
-    prisma.inventoryHome.groupBy({
-      by: ["homeType"],
-      where: without("homeTypes"),
-      _count: { _all: true },
-    }),
-    prisma.inventoryHome.groupBy({
-      by: ["state"],
-      where: without("states"),
-      _count: { _all: true },
-    }),
-  ]);
+  // [label, min-inclusive, max-exclusive]; null = open-ended.
+  const PRICE_BANDS: [string, number | null, number | null][] = [
+    ["Under $300k", null, 300000],
+    ["$300k–$400k", 300000, 400000],
+    ["$400k–$500k", 400000, 500000],
+    ["$500k–$600k", 500000, 600000],
+    ["$600k–$750k", 600000, 750000],
+    ["$750k–$1M", 750000, 1000000],
+    ["$1M+", 1000000, null],
+  ];
+  const SQFT_BANDS: [string, number | null, number | null][] = [
+    ["Under 1,500", null, 1500],
+    ["1,500–2,000", 1500, 2000],
+    ["2,000–2,500", 2000, 2500],
+    ["2,500–3,000", 2500, 3000],
+    ["3,000–4,000", 3000, 4000],
+    ["4,000+", 4000, null],
+  ];
+  const bandCount = (
+    base: Prisma.InventoryHomeWhereInput,
+    field: "price" | "sqft",
+    min: number | null,
+    max: number | null,
+  ) =>
+    prisma.inventoryHome.count({
+      where: { ...base, [field]: { ...(min !== null ? { gte: min } : {}), ...(max !== null ? { lt: max } : {}) } },
+    });
+
+  const [byStatus, byBuilder, byBeds, byType, byState, byBaths, byStories, byGarage, byCity, priceBands, sqftBands] =
+    await Promise.all([
+      prisma.inventoryHome.groupBy({ by: ["constructionStatus"], where: without("statuses"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["builderId"], where: without("builderSlugs"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["beds"], where: without("bedsMin"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["homeType"], where: without("homeTypes"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["state"], where: without("states"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["bathsTotal"], where: without("bathsMin"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["stories"], where: without("storiesEq"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({ by: ["garageSpaces"], where: without("garageMin"), _count: { _all: true } }),
+      prisma.inventoryHome.groupBy({
+        by: ["city"],
+        where: without("city"),
+        _count: { _all: true },
+        orderBy: { _count: { city: "desc" } },
+        take: 25,
+      }),
+      Promise.all(PRICE_BANDS.map(async ([label, min, max]) => ({ label, min, max, count: await bandCount(whereNoPrice, "price", min, max) }))),
+      Promise.all(SQFT_BANDS.map(async ([label, min, max]) => ({ label, min, max, count: await bandCount(whereNoSqft, "sqft", min, max) }))),
+    ]);
 
   const builders = await prisma.builder.findMany({
     where: { id: { in: byBuilder.map((b) => b.builderId) } },
@@ -289,18 +314,33 @@ export async function runFacets(params: SearchParams) {
 
   return {
     constructionStatus: byStatus.map((s) => ({ value: s.constructionStatus, count: s._count._all })),
-    builder: byBuilder.map((b) => ({
-      value: builderById.get(b.builderId)?.slug ?? b.builderId,
-      label: builderById.get(b.builderId)?.name ?? b.builderId,
-      count: b._count._all,
-    })),
+    builder: byBuilder
+      .map((b) => ({
+        value: builderById.get(b.builderId)?.slug ?? b.builderId,
+        label: builderById.get(b.builderId)?.name ?? b.builderId,
+        count: b._count._all,
+      }))
+      .sort((a, b) => b.count - a.count),
     beds: byBeds
       .filter((b) => b.beds !== null)
       .sort((a, b) => (a.beds ?? 0) - (b.beds ?? 0))
       .map((b) => ({ value: b.beds, count: b._count._all })),
-    homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })),
-    state: byState
-      .map((s) => ({ value: s.state, count: s._count._all }))
-      .sort((a, b) => b.count - a.count),
+    baths: byBaths
+      .filter((b) => b.bathsTotal !== null)
+      .map((b) => ({ value: Number(b.bathsTotal), count: b._count._all }))
+      .sort((a, b) => a.value - b.value),
+    homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })).sort((a, b) => b.count - a.count),
+    state: byState.map((s) => ({ value: s.state, count: s._count._all })).sort((a, b) => b.count - a.count),
+    stories: byStories
+      .filter((s) => s.stories !== null)
+      .map((s) => ({ value: s.stories as number, count: s._count._all }))
+      .sort((a, b) => a.value - b.value),
+    garage: byGarage
+      .filter((g) => g.garageSpaces !== null)
+      .map((g) => ({ value: g.garageSpaces as number, count: g._count._all }))
+      .sort((a, b) => a.value - b.value),
+    city: byCity.map((c) => ({ value: c.city, count: c._count._all })),
+    priceBands: priceBands.filter((b) => b.count > 0),
+    sqftBands: sqftBands.filter((b) => b.count > 0),
   };
 }

← 41e2509b workers: register K. Hovnanian adapter (khovnanian-site)  ·  back to Homesonspec  ·  auto-save: 2026-07-30T08:46:20 (1 files) — pnpm-lock.yaml 891f7980 →