[object Object]

← back to Homesonspec

HomesOnSpec search: add Grid/List/Table view modes + broadened server-side sort

1ee4dcd7e1cb5afe83bd6db046b268370a65dbe8 · 2026-08-13 08:29:15 -0700 · steve

- View-mode toggle (Grid | List | Table), persisted to localStorage (homesonspec.viewMode)
- ListView: compact one-row-per-home; TableView: dense all-columns spreadsheet with click-to-sort headers driving the same server-side sort param
- Broaden sort set (sqft asc, beds/baths desc, completion asc/desc, city/state A-Z, recently verified) wired through a validated whitelist (SORT_KEYS) + Prisma orderBy with nulls-last

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

Files touched

Diff

commit 1ee4dcd7e1cb5afe83bd6db046b268370a65dbe8
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:29:15 2026 -0700

    HomesOnSpec search: add Grid/List/Table view modes + broadened server-side sort
    
    - View-mode toggle (Grid | List | Table), persisted to localStorage (homesonspec.viewMode)
    - ListView: compact one-row-per-home; TableView: dense all-columns spreadsheet with click-to-sort headers driving the same server-side sort param
    - Broaden sort set (sqft asc, beds/baths desc, completion asc/desc, city/state A-Z, recently verified) wired through a validated whitelist (SORT_KEYS) + Prisma orderBy with nulls-last
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apps/web/src/app/search/SearchClient.tsx | 301 ++++++++++++++++++++++++++++++-
 apps/web/src/lib/parse-search.ts         |   4 +-
 packages/search/src/index.ts             |  71 +++++++-
 3 files changed, 360 insertions(+), 16 deletions(-)

diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
index 56c477f3..1b7686be 100644
--- a/apps/web/src/app/search/SearchClient.tsx
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -45,6 +45,7 @@ interface Home {
   sqft: number | null;
   stories: number | null;
   garageSpaces: number | null;
+  homeType: string | null;
   lat: string | number | null;
   lon: string | number | null;
   constructionStatus: string;
@@ -97,14 +98,60 @@ const STATE_NAMES: Record<string, string> = {
   WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming", DC: "Washington DC",
 };
 
+// Broadened sort set. Every option maps 1:1 to a whitelisted key in packages/search
+// (SORT_KEYS) and drives the server-side orderBy over the full result set. The Table
+// view's click-to-sort column headers set the SAME `sort` param.
 const SORTS = [
   { value: "newest", label: "Newest" },
   { value: "price_asc", label: "Price ↑" },
   { value: "price_desc", label: "Price ↓" },
+  { value: "sqft_asc", label: "Sq Ft ↑" },
   { value: "sqft_desc", label: "Sq Ft ↓" },
+  { value: "beds_desc", label: "Beds (most)" },
+  { value: "baths_desc", label: "Baths (most)" },
+  { value: "completion_asc", label: "Completion (soonest)" },
+  { value: "completion_desc", label: "Completion (latest)" },
+  { value: "city_asc", label: "City A→Z" },
+  { value: "state_asc", label: "State A→Z" },
+  { value: "last_verified", label: "Recently verified" },
   { value: "closest", label: "Closest" },
 ] as const;
 
+// View modes — mirrors the vanilla RE builds' shared grid engine: card Grid, compact
+// List, and dense all-columns Table. Persisted to localStorage (homesonspec.viewMode).
+type ViewMode = "grid" | "list" | "table";
+const VIEW_MODES: { value: ViewMode; label: string }[] = [
+  { value: "grid", label: "Grid" },
+  { value: "list", label: "List" },
+  { value: "table", label: "Table" },
+];
+
+// Table columns: [sort key asc, sort key desc] so a header click toggles direction.
+// null means the column isn't server-sortable (e.g. street address).
+type TableCol = {
+  key: string;
+  label: string;
+  asc: string | null;
+  desc: string | null;
+  align?: "right";
+};
+const TABLE_COLS: TableCol[] = [
+  { key: "address", label: "Address", asc: null, desc: null },
+  { key: "city", label: "City", asc: "city_asc", desc: "city_asc" },
+  { key: "state", label: "State", asc: "state_asc", desc: "state_asc" },
+  { key: "price", label: "Price", asc: "price_asc", desc: "price_desc", align: "right" },
+  { key: "beds", label: "Beds", asc: "beds_asc", desc: "beds_desc", align: "right" },
+  { key: "baths", label: "Baths", asc: "baths_asc", desc: "baths_desc", align: "right" },
+  { key: "sqft", label: "Sq Ft", asc: "sqft_asc", desc: "sqft_desc", align: "right" },
+  { key: "stories", label: "Stories", asc: "stories_desc", desc: "stories_desc", align: "right" },
+  { key: "garage", label: "Garage", asc: "garage_desc", desc: "garage_desc", align: "right" },
+  { key: "homeType", label: "Type", asc: null, desc: null },
+  { key: "status", label: "Status", asc: null, desc: null },
+  { key: "completion", label: "Completion", asc: "completion_asc", desc: "completion_desc" },
+  { key: "builder", label: "Builder", asc: null, desc: null },
+  { key: "verified", label: "Verified", asc: "last_verified", desc: "last_verified" },
+];
+
 const STATUS_LABELS: Record<string, string> = {
   MOVE_IN_READY: "Move-in ready",
   UNDER_CONSTRUCTION: "Under construction",
@@ -177,6 +224,8 @@ export default function SearchClient({
     return s ?? "newest";
   });
   const [cols, setCols] = useState<number>(3);
+  // View mode (grid | list | table), persisted across reloads.
+  const [viewMode, setViewMode] = useState<ViewMode>("grid");
   const [data, setData] = useState<SearchResponse | null>(null);
   const [facets, setFacets] = useState<Facets | null>(null);
   const [loading, setLoading] = useState(true);
@@ -195,8 +244,10 @@ export default function SearchClient({
   useEffect(() => {
     const savedSort = localStorage.getItem("homesonspec.sort");
     const savedCols = localStorage.getItem("homesonspec.cols");
+    const savedView = localStorage.getItem("homesonspec.viewMode");
     if (savedSort) setSort(savedSort);
     if (savedCols) setCols(Number(savedCols));
+    if (savedView === "grid" || savedView === "list" || savedView === "table") setViewMode(savedView);
     try {
       const savedOpen = JSON.parse(localStorage.getItem("homesonspec.facetOpen") || "null");
       // First visit → everything collapsed except State (so the rail isn't a blank wall).
@@ -212,6 +263,18 @@ export default function SearchClient({
     setCols(value);
     localStorage.setItem("homesonspec.cols", String(value));
   };
+  const setViewModePersist = (value: ViewMode) => {
+    setViewMode(value);
+    localStorage.setItem("homesonspec.viewMode", value);
+  };
+  // A Table header click sets the sort param (server-side) and toggles direction when
+  // the same column is clicked again. Resets to page 1 so the new order starts at top.
+  const onColumnSort = (col: TableCol) => {
+    if (!col.asc && !col.desc) return; // non-sortable column
+    setPage(1);
+    const next = sort === col.asc ? (col.desc ?? col.asc) : (col.asc ?? col.desc);
+    if (next) setSortPersist(next);
+  };
   const persistOpen = (next: Record<string, boolean>) => {
     setOpen(next);
     localStorage.setItem("homesonspec.facetOpen", JSON.stringify(next));
@@ -539,8 +602,20 @@ export default function SearchClient({
             <div className="text-sm text-neutral-600" data-testid="result-count">
               {loading ? "Searching…" : `${data?.total ?? 0} homes${data?.location ? ` near ${data.location.label}` : ""}`}
             </div>
-            {/* Mandatory grid controls: sort select + density slider, localStorage-persisted */}
+            {/* Mandatory grid controls: view-mode toggle + sort select + density slider, localStorage-persisted */}
             <div className="flex items-center gap-4">
+              {/* View mode: Grid | List | Table (matches the vanilla RE builds' shared grid engine). */}
+              <div className="inline-flex overflow-hidden rounded-lg text-sm shadow-sm ring-1 ring-inset ring-neutral-200" role="group" aria-label="View mode" data-testid="view-toggle">
+                {VIEW_MODES.map((v) => (
+                  <button key={v.value} type="button"
+                    onClick={() => setViewModePersist(v.value)}
+                    aria-pressed={viewMode === v.value}
+                    data-testid={`view-${v.value}`}
+                    className={`px-3 py-1 transition ${viewMode === v.value ? "bg-brand-700 text-white" : "bg-white text-neutral-600 hover:bg-brand-50"}`}>
+                    {v.label}
+                  </button>
+                ))}
+              </div>
               <label className="flex items-center gap-2 text-sm">
                 Sort
                 <select value={sort} onChange={(e) => setSortPersist(e.target.value)}
@@ -550,15 +625,40 @@ export default function SearchClient({
                   ))}
                 </select>
               </label>
-              <label className="flex items-center gap-2 text-sm">
-                Density
-                <input type="range" min={2} max={4} step={1} value={cols} className="accent-brand-700"
-                  onChange={(e) => setColsPersist(Number(e.target.value))} data-testid="density-slider" />
-              </label>
+              {/* Density controls the card grid columns — only meaningful in Grid view. */}
+              {viewMode === "grid" && (
+                <label className="flex items-center gap-2 text-sm">
+                  Density
+                  <input type="range" min={2} max={4} step={1} value={cols} className="accent-brand-700"
+                    onChange={(e) => setColsPersist(Number(e.target.value))} data-testid="density-slider" />
+                </label>
+              )}
             </div>
           </div>
 
+          {viewMode === "table" ? (
+            <TableView
+              homes={data?.homes ?? []}
+              loading={loading}
+              sort={sort}
+              onColumnSort={onColumnSort}
+              drill={drill}
+              priceBandDrill={priceBandDrill}
+              sqftBandDrill={sqftBandDrill}
+            />
+          ) : (
           <div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
+            {viewMode === "list" ? (
+              <ListView
+                homes={data?.homes ?? []}
+                loading={loading}
+                favs={favs}
+                toggleFav={toggleFav}
+                drill={drill}
+                priceBandDrill={priceBandDrill}
+                sqftBandDrill={sqftBandDrill}
+              />
+            ) : (
             <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
               {(data?.homes ?? []).map((home) => (
                 // href-drill: the card is a container with a STRETCHED overlay link to the
@@ -652,10 +752,12 @@ export default function SearchClient({
                 </p>
               )}
             </div>
+            )}
             <div className="h-[540px] overflow-hidden rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="map">
               <MapView markers={markers} onMoveEnd={onMapMove} />
             </div>
           </div>
+          )}
 
           {totalPages > 1 && (
             <div className="mt-6 flex items-center justify-center gap-3 text-sm">
@@ -787,3 +889,190 @@ function ChecklistOverflow({
     </div>
   );
 }
+
+// ── View modes ─────────────────────────────────────────────────────────────
+// Shared drill helper types so the view components can build the same href-drill
+// links the Grid uses (no dead-end data points).
+type Drill = (kv: Record<string, string>) => string;
+type BandDrill = (v: string | number | null, drill: Drill) => string;
+
+const fmtCompletion = (d: string | null) =>
+  d ? new Date(d).toLocaleDateString(undefined, { month: "short", year: "numeric" }) : "—";
+const fmtVerified = (d: string | null) =>
+  d ? new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "—";
+
+// ListView — one compact row per home: address/city + price + key stats inline.
+// Keeps every data point a drill link (href-drill rule), like the card grid.
+function ListView({
+  homes, loading, favs, toggleFav, drill, priceBandDrill, sqftBandDrill,
+}: {
+  homes: Home[];
+  loading: boolean;
+  favs: Set<string>;
+  toggleFav: (id: string) => void;
+  drill: Drill;
+  priceBandDrill: BandDrill;
+  sqftBandDrill: (sqft: number, drill: Drill) => string;
+}) {
+  if (!loading && homes.length === 0) {
+    return <p className="py-10 text-center text-neutral-500">No homes match these filters.</p>;
+  }
+  return (
+    <div className="divide-y divide-neutral-100 rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="results-list">
+      {homes.map((home) => (
+        <div key={home.id} className="group relative flex items-center gap-4 p-3 transition hover:bg-brand-50/40" data-testid="home-row">
+          <Link href={`/homes/${home.id}`} aria-label={`View ${home.street ?? "home"} in ${home.city}, ${home.state}`}
+            className="absolute inset-0 z-0" tabIndex={-1} />
+          {/* Thumbnail (or branded gradient fallback). */}
+          <div className="relative z-10 h-16 w-24 shrink-0 overflow-hidden rounded-lg bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500">
+            {home.images?.[0]?.startsWith("http") ? (
+              // eslint-disable-next-line @next/next/no-img-element
+              <img src={home.images[0]} alt="" loading="lazy" className="h-full w-full object-cover" />
+            ) : null}
+          </div>
+          <div className="relative z-10 min-w-0 flex-1">
+            <div className="flex items-baseline gap-2">
+              <Link href={priceBandDrill(home.price, drill)} data-testid="row-price"
+                className="font-display text-lg font-semibold text-brand-900 hover:text-brand-700 hover:underline">
+                {fmtPrice(home.price)}
+              </Link>
+              {STATUS_BADGE[home.constructionStatus] && (
+                <Link href={drill({ status: home.constructionStatus })} onClick={(e) => e.stopPropagation()}
+                  className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[home.constructionStatus]!.cls}`}>
+                  {STATUS_BADGE[home.constructionStatus]!.label}
+                </Link>
+              )}
+            </div>
+            <div className="mt-0.5 truncate text-sm text-neutral-700">
+              <Link href={`/homes/${home.id}`} className="hover:underline">{home.street ?? "Address on request"}</Link>{" · "}
+              <Link href={drill({ city: home.city, st: home.state })} className="hover:text-brand-700 hover:underline">{home.city}</Link>{", "}
+              <Link href={drill({ st: home.state })} className="hover:text-brand-700 hover:underline">{home.state}</Link>
+            </div>
+            <div className="mt-0.5 truncate text-sm text-neutral-500">
+              {home.beds != null ? (
+                <Link href={drill({ bedsMin: String(home.beds) })} className="hover:text-brand-700 hover:underline">{home.beds} bd</Link>
+              ) : "— bd"}{" · "}
+              {home.bathsTotal != null ? (
+                <Link href={drill({ bathsMin: String(home.bathsTotal) })} className="hover:text-brand-700 hover:underline">{home.bathsTotal} ba</Link>
+              ) : "— ba"}{" · "}
+              {home.sqft != null ? (
+                <Link href={sqftBandDrill(home.sqft, drill)} className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()} sqft</Link>
+              ) : "— sqft"}
+              {" · "}
+              <Link href={`/builders/${home.builder.slug}`} className="hover:text-brand-700 hover:underline">{home.builder.name}</Link>
+              {home.estCompletionDate ? ` · ready ${fmtCompletion(home.estCompletionDate)}` : ""}
+            </div>
+          </div>
+          <div className="relative z-10 flex shrink-0 items-center gap-3">
+            <VerificationBadge label={home.verificationLabel} />
+            <button type="button" aria-label="Save home"
+              onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(home.id); }}
+              className="text-lg leading-none transition hover:scale-110">
+              <span className={favs.has(home.id) ? "text-rose-500" : "text-neutral-300"}>{favs.has(home.id) ? "♥" : "♡"}</span>
+            </button>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+// TableView — dense spreadsheet, a column per meaningful field, click-to-sort headers.
+// A header click drives the SAME server-side `sort` param (over the full result set),
+// toggling asc/desc. Sortable cells stay drill links (href-drill rule).
+function TableView({
+  homes, loading, sort, onColumnSort, drill, priceBandDrill, sqftBandDrill,
+}: {
+  homes: Home[];
+  loading: boolean;
+  sort: string;
+  onColumnSort: (col: TableCol) => void;
+  drill: Drill;
+  priceBandDrill: BandDrill;
+  sqftBandDrill: (sqft: number, drill: Drill) => string;
+}) {
+  // Direction arrow shown on the active column's header.
+  const arrow = (col: TableCol): string => {
+    if (sort === col.asc && sort === col.desc) return "↕";
+    if (sort === col.asc) return "↑";
+    if (sort === col.desc) return "↓";
+    return "";
+  };
+  const isActive = (col: TableCol) => sort === col.asc || sort === col.desc;
+  return (
+    <div className="mt-4 overflow-x-auto rounded-2xl border border-neutral-200 shadow-[var(--shadow-card)]" data-testid="results-table">
+      <table className="w-full border-collapse text-sm">
+        <thead>
+          <tr className="border-b border-neutral-200 bg-neutral-50 text-left">
+            {TABLE_COLS.map((col) => {
+              const sortable = !!(col.asc || col.desc);
+              return (
+                <th key={col.key} scope="col"
+                  className={`whitespace-nowrap px-3 py-2 text-xs font-semibold uppercase tracking-wide ${col.align === "right" ? "text-right" : ""} ${isActive(col) ? "text-brand-800" : "text-neutral-500"}`}
+                  data-testid={`th-${col.key}`}
+                  aria-sort={isActive(col) ? (sort === col.desc ? "descending" : "ascending") : "none"}>
+                  {sortable ? (
+                    <button type="button" onClick={() => onColumnSort(col)}
+                      className="inline-flex items-center gap-1 hover:text-brand-700"
+                      data-testid={`sort-col-${col.key}`}>
+                      {col.label}<span className="text-[10px]">{arrow(col)}</span>
+                    </button>
+                  ) : col.label}
+                </th>
+              );
+            })}
+          </tr>
+        </thead>
+        <tbody className="divide-y divide-neutral-100">
+          {homes.map((home) => (
+            <tr key={home.id} className="transition hover:bg-brand-50/40" data-testid="table-row">
+              <td className="max-w-[220px] truncate px-3 py-2">
+                <Link href={`/homes/${home.id}`} className="text-brand-800 hover:underline">{home.street ?? "Address on request"}</Link>
+              </td>
+              <td className="whitespace-nowrap px-3 py-2">
+                <Link href={drill({ city: home.city, st: home.state })} className="hover:text-brand-700 hover:underline">{home.city}</Link>
+              </td>
+              <td className="whitespace-nowrap px-3 py-2">
+                <Link href={drill({ st: home.state })} className="hover:text-brand-700 hover:underline">{home.state}</Link>
+              </td>
+              <td className="whitespace-nowrap px-3 py-2 text-right font-medium">
+                <Link href={priceBandDrill(home.price, drill)} className="text-brand-900 hover:text-brand-700 hover:underline">{fmtPrice(home.price)}</Link>
+              </td>
+              <td className="px-3 py-2 text-right">
+                {home.beds != null ? (
+                  <Link href={drill({ bedsMin: String(home.beds) })} className="hover:text-brand-700 hover:underline">{home.beds}</Link>
+                ) : "—"}
+              </td>
+              <td className="px-3 py-2 text-right">
+                {home.bathsTotal != null ? (
+                  <Link href={drill({ bathsMin: String(home.bathsTotal) })} className="hover:text-brand-700 hover:underline">{home.bathsTotal}</Link>
+                ) : "—"}
+              </td>
+              <td className="whitespace-nowrap px-3 py-2 text-right">
+                {home.sqft != null ? (
+                  <Link href={sqftBandDrill(home.sqft, drill)} className="hover:text-brand-700 hover:underline">{home.sqft.toLocaleString()}</Link>
+                ) : "—"}
+              </td>
+              <td className="px-3 py-2 text-right">{home.stories ?? "—"}</td>
+              <td className="px-3 py-2 text-right">{home.garageSpaces ?? "—"}</td>
+              <td className="whitespace-nowrap px-3 py-2 text-neutral-600">{home.homeType ? (HOME_TYPE_LABELS[home.homeType] ?? home.homeType) : "—"}</td>
+              <td className="whitespace-nowrap px-3 py-2">
+                <Link href={drill({ status: home.constructionStatus })} className="hover:text-brand-700 hover:underline">
+                  {STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus}
+                </Link>
+              </td>
+              <td className="whitespace-nowrap px-3 py-2 text-neutral-600">{fmtCompletion(home.estCompletionDate)}</td>
+              <td className="max-w-[160px] truncate px-3 py-2">
+                <Link href={`/builders/${home.builder.slug}`} className="hover:text-brand-700 hover:underline">{home.builder.name}</Link>
+              </td>
+              <td className="whitespace-nowrap px-3 py-2 text-neutral-500" title={home.lastVerifiedAt ?? undefined}>{fmtVerified(home.lastVerifiedAt)}</td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+      {!loading && homes.length === 0 && (
+        <p className="py-10 text-center text-neutral-500">No homes match these filters.</p>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/lib/parse-search.ts b/apps/web/src/lib/parse-search.ts
index c13c2c99..6c3cd615 100644
--- a/apps/web/src/lib/parse-search.ts
+++ b/apps/web/src/lib/parse-search.ts
@@ -1,4 +1,5 @@
 import type { SearchParams } from "@homesonspec/search";
+import { isSortKey } from "@homesonspec/search";
 
 function num(v: string | null): number | undefined {
   if (v === null || v === "") return undefined;
@@ -39,7 +40,8 @@ export function parseSearchParams(searchParams: URLSearchParams): SearchParams {
     states: searchParams.getAll("st"),
     city: searchParams.get("city") ?? undefined,
     moveInByMonths: num(searchParams.get("moveInByMonths")),
-    sort: (searchParams.get("sort") as SearchParams["sort"]) ?? "newest",
+    // Validate against the whitelist so an arbitrary ?sort= can't reach the orderBy.
+    sort: (() => { const s = searchParams.get("sort"); return isSortKey(s) ? s : "newest"; })(),
     page: num(searchParams.get("page")),
     pageSize: num(searchParams.get("pageSize")),
   };
diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts
index 5d858ace..1345ad60 100644
--- a/packages/search/src/index.ts
+++ b/packages/search/src/index.ts
@@ -35,11 +35,39 @@ export interface SearchParams {
   states?: string[]; // multi-select state facet (Amazon-style rail)
   city?: string;
   moveInByMonths?: number; // move-in timeframe: ready or completing within N months
-  sort?: "newest" | "price_asc" | "price_desc" | "sqft_desc" | "closest";
+  sort?: SortKey;
   page?: number;
   pageSize?: number;
 }
 
+// The whitelist of server-side sort keys. Table column-header clicks and the Sort
+// <select> both drive this single param, so sorting is over the FULL result set
+// (server-side), not just the current page. parse-search validates against this.
+export const SORT_KEYS = [
+  "newest",
+  "price_asc",
+  "price_desc",
+  "sqft_asc",
+  "sqft_desc",
+  "beds_desc",
+  "beds_asc",
+  "baths_desc",
+  "baths_asc",
+  "stories_desc",
+  "garage_desc",
+  "completion_asc",
+  "completion_desc",
+  "city_asc",
+  "state_asc",
+  "last_verified",
+  "closest",
+] as const;
+export type SortKey = (typeof SORT_KEYS)[number];
+
+export function isSortKey(v: string | null | undefined): v is SortKey {
+  return v != null && (SORT_KEYS as readonly string[]).includes(v);
+}
+
 export interface ResolvedLocation {
   label: string;
   lat: number;
@@ -173,6 +201,38 @@ export interface SearchResult {
   location: ResolvedLocation | null;
 }
 
+/**
+ * Map a whitelisted sort key → a Prisma orderBy. Every column-sort from the Table
+ * view and every Sort <select> option resolves here, so sorting is server-side over
+ * the full filtered result set. All nullable columns sort nulls-last both directions
+ * so empty values never lead. A secondary publishedAt key gives a stable tiebreak.
+ * `closest` is handled separately (raw-SQL haversine) — it falls through to newest here.
+ */
+function orderByFor(sort?: SortKey): Prisma.InventoryHomeOrderByWithRelationInput[] {
+  const NEWEST: Prisma.InventoryHomeOrderByWithRelationInput[] = [{ publishedAt: "desc" }];
+  const tie: Prisma.InventoryHomeOrderByWithRelationInput = { publishedAt: "desc" };
+  switch (sort) {
+    case "price_asc": return [{ price: { sort: "asc", nulls: "last" } }, tie];
+    case "price_desc": return [{ price: { sort: "desc", nulls: "last" } }, tie];
+    case "sqft_asc": return [{ sqft: { sort: "asc", nulls: "last" } }, tie];
+    case "sqft_desc": return [{ sqft: { sort: "desc", nulls: "last" } }, tie];
+    case "beds_desc": return [{ beds: { sort: "desc", nulls: "last" } }, tie];
+    case "beds_asc": return [{ beds: { sort: "asc", nulls: "last" } }, tie];
+    case "baths_desc": return [{ bathsTotal: { sort: "desc", nulls: "last" } }, tie];
+    case "baths_asc": return [{ bathsTotal: { sort: "asc", nulls: "last" } }, tie];
+    case "stories_desc": return [{ stories: { sort: "desc", nulls: "last" } }, tie];
+    case "garage_desc": return [{ garageSpaces: { sort: "desc", nulls: "last" } }, tie];
+    case "completion_asc": return [{ estCompletionDate: { sort: "asc", nulls: "last" } }, tie];
+    case "completion_desc": return [{ estCompletionDate: { sort: "desc", nulls: "last" } }, tie];
+    case "city_asc": return [{ city: "asc" }, { state: "asc" }, tie];
+    case "state_asc": return [{ state: "asc" }, { city: "asc" }, tie];
+    case "last_verified": return [{ lastVerifiedAt: { sort: "desc", nulls: "last" } }, tie];
+    case "newest":
+    default:
+      return NEWEST; // organic default
+  }
+}
+
 export async function runSearch(params: SearchParams): Promise<SearchResult> {
   const page = Math.max(1, params.page ?? 1);
   const pageSize = Math.min(60, Math.max(1, params.pageSize ?? 24));
@@ -189,14 +249,7 @@ export async function runSearch(params: SearchParams): Promise<SearchResult> {
 
   const where = buildWhere(params, bbox);
 
-  const orderBy: Prisma.InventoryHomeOrderByWithRelationInput[] =
-    params.sort === "price_asc"
-      ? [{ price: { sort: "asc", nulls: "last" } }]
-      : params.sort === "price_desc"
-        ? [{ price: { sort: "desc", nulls: "last" } }]
-        : params.sort === "sqft_desc"
-          ? [{ sqft: { sort: "desc", nulls: "last" } }]
-          : [{ publishedAt: "desc" }]; // newest — the organic default
+  const orderBy = orderByFor(params.sort);
 
   // sort=closest is the one raw-SQL path (haversine); fall back to newest
   // when there is no center point to measure from.

← db6c10ce chore: HomesOnSpec safe full-web deploy runbook (build-gated  ·  back to Homesonspec  ·  HomesOnSpec search: make Table columns bidirectionally sorta d5513153 →