[object Object]

← back to Homesonspec

feat(specs): dr-horton capture proving slice — extract->validate->publish->render

7c3609ccc9682177c54896be41fbff35f3748fed · 2026-08-02 23:26:34 -0700 · Steve Abrams

Reference-builder end-to-end: schemas declares optional specs field; dr-horton
extractor emits a pruned specs map (planCode/multiGen/originalPrice — nulls &
false-flags dropped, verified vs a real snapshot); detail page renders an
'Additional details' block + builder-listing link. All 4 packages typecheck
clean. Monthly-payment/was-price live deeper in DRH's tree (follow-up noted).

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

Files touched

Diff

commit 7c3609ccc9682177c54896be41fbff35f3748fed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 2 23:26:34 2026 -0700

    feat(specs): dr-horton capture proving slice — extract->validate->publish->render
    
    Reference-builder end-to-end: schemas declares optional specs field; dr-horton
    extractor emits a pruned specs map (planCode/multiGen/originalPrice — nulls &
    false-flags dropped, verified vs a real snapshot); detail page renders an
    'Additional details' block + builder-listing link. All 4 packages typecheck
    clean. Monthly-payment/was-price live deeper in DRH's tree (follow-up noted).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apps/web/src/app/homes/[id]/page.tsx | 53 ++++++++++++++++++++++++++++++++++++
 collectors/SPECS-AUDIT.md            | 10 +++++++
 collectors/dr-horton/src/index.ts    | 29 ++++++++++++++++++++
 packages/schemas/src/index.ts        |  4 +++
 4 files changed, 96 insertions(+)

diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
index 74b4c396..3935168d 100644
--- a/apps/web/src/app/homes/[id]/page.tsx
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -90,6 +90,33 @@ export default async function HomeDetailPage({ params }: { params: Promise<{ id:
     ],
   ];
 
+  // Long-tail specs captured into the extensible `specs` map (per builder). Rendered
+  // in their own "Additional details" block, separate from the core facts. Only
+  // known keys with a label surface; unknown keys are ignored (never fabricated).
+  const SPEC_LABELS: Record<string, string> = {
+    estMonthlyPayment: "Est. monthly payment",
+    originalPrice: "Original price",
+    totalSalePrice: "Sale price",
+    planCode: "Plan code",
+    multiGen: "Multi-gen layout",
+    allowOffer: "Accepts offers",
+    qmiAvailable: "Quick move-in",
+    requiresPromo: "Promo required",
+  };
+  const rawSpecs =
+    home.specs && typeof home.specs === "object" && !Array.isArray(home.specs)
+      ? (home.specs as Record<string, unknown>)
+      : {};
+  const specFacts: [string, string][] = Object.entries(rawSpecs)
+    .filter(([k]) => SPEC_LABELS[k])
+    .map(([k, v]): [string, string] => {
+      if (typeof v === "boolean") return [SPEC_LABELS[k]!, v ? "Yes" : "No"];
+      if (k === "estMonthlyPayment") return [SPEC_LABELS[k]!, `${fmtPrice(Number(v))}/mo`];
+      if (k === "originalPrice" || k === "totalSalePrice") return [SPEC_LABELS[k]!, fmtPrice(Number(v))];
+      return [SPEC_LABELS[k]!, String(v)];
+    });
+  const purchaseUrl = typeof rawSpecs.purchaseUrl === "string" ? rawSpecs.purchaseUrl : null;
+
   return (
     <div className="mx-auto max-w-5xl px-4 py-8">
       <nav className="text-sm text-neutral-500">
@@ -183,6 +210,32 @@ 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>
 
+          {(specFacts.length > 0 || purchaseUrl) && (
+            <section className="mt-8">
+              <h2 className="font-display text-xl font-semibold text-brand-900">Additional details</h2>
+              {specFacts.length > 0 && (
+                <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
+                  {specFacts.map(([label, value]) => (
+                    <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>
+                    </div>
+                  ))}
+                </dl>
+              )}
+              {purchaseUrl && (
+                <a
+                  href={purchaseUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="mt-3 inline-block text-sm font-medium text-accent-600 hover:text-accent-700"
+                >
+                  View this home on the builder&rsquo;s site →
+                </a>
+              )}
+            </section>
+          )}
+
           {incentives.length > 0 && (
             <section className="mt-8">
               <h2 className="font-display text-xl font-semibold text-brand-900">Incentives</h2>
diff --git a/collectors/SPECS-AUDIT.md b/collectors/SPECS-AUDIT.md
index d65b6cb7..9f4ad0c8 100644
--- a/collectors/SPECS-AUDIT.md
+++ b/collectors/SPECS-AUDIT.md
@@ -79,6 +79,16 @@ its structure). So they fold directly into the per-builder fan-out below —
 each worker reads that builder's extractor, enumerates the object it iterates,
 and adds the SPEC fields. No separate audit step for them.
 
+### Proving slice — dr-horton end-to-end (schema→extract→publish→render) — 2026-08-02
+DONE + typechecks clean across schemas/collector/publisher/web. Verified against a
+real snapshot: DRH home Items carry `PlanCode` (real, e.g. "X475"), `OriginalPrice`
+(often 0 sentinel → dropped), `MultiGen` (bool). Extractor emits a pruned `specs`
+map (nulls + false-flags dropped); publisher persists it; detail page shows an
+"Additional details" block + builder-listing link.
+**Follow-up:** `EstimatedMontlyPayment` / `TotalSalePrice` / `PurchaseUrl` are NOT on
+the home Items — they live elsewhere in DRH's `var model` tree (plan-level?). A
+second extraction pass is needed to reach them. Item-level specs work today.
+
 ### GATED — capture requires prod changes
 Beyond read-only auditing, actually CAPTURING specs needs: (a) `specs Json?`
 schema migration on the live Kamatera DB, (b) per-builder extractor edits, and
diff --git a/collectors/dr-horton/src/index.ts b/collectors/dr-horton/src/index.ts
index 9449c12d..2bb7665a 100644
--- a/collectors/dr-horton/src/index.ts
+++ b/collectors/dr-horton/src/index.ts
@@ -184,6 +184,29 @@ export const drHortonAdapter: SourceAdapter = {
         const lot = str(it.LotNumber) ?? str(it.ItemId) ?? str(it.Id);
         const planName = str(it.PlanName);
 
+        // Long-tail specs D.R. Horton exposes beyond the core columns (SPECS-AUDIT.md).
+        // Captured into the extensible `specs` map so buyers see monthly payment,
+        // was-price / price drops, plan code, multi-gen, and purchase link.
+        const bool = (v: unknown): boolean | null =>
+          typeof v === "boolean" ? v : v === "true" ? true : v === "false" ? false : null;
+        const purchaseRaw = str(it.PurchaseUrl);
+        const specs: Record<string, unknown> = {
+          originalPrice: num(it.OriginalPrice),
+          totalSalePrice: num(it.TotalSalePrice),
+          estMonthlyPayment: num(it.EstimatedMontlyPayment),
+          planCode: str(it.PlanCode),
+          multiGen: bool(it.MultiGen),
+          allowOffer: bool(it.AllowOffer),
+          qmiAvailable: bool(it.QmiAvailable),
+          requiresPromo: bool(it.RequiresPromo),
+          purchaseUrl: purchaseRaw
+            ? purchaseRaw.startsWith("http") ? purchaseRaw : ORIGIN + purchaseRaw
+            : null,
+        };
+        // Keep only real signal: drop nulls AND false feature-flags (a home that
+        // is NOT multi-gen shouldn't carry "multiGen: false" — that's noise, not a spec).
+        for (const k of Object.keys(specs)) if (specs[k] == null || specs[k] === false) delete specs[k];
+
         records.push({
           entityType: "inventory_home",
           canonicalHints: {
@@ -215,6 +238,12 @@ export const drHortonAdapter: SourceAdapter = {
             planName: fv(planName, planName, page.url),
             availabilityStatus: fv(status, status, page.url),
             images: fv<string[]>(image ? [image] : [], null, page.url, image ? "builder listing photo" : null),
+            specs: fv(
+              Object.keys(specs).length ? specs : null,
+              null,
+              page.url,
+              "dr-horton extra specs (monthly payment, was-price, plan code, multi-gen, lifecycle)",
+            ),
           },
         });
       }
diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts
index 7a7edc52..661849bc 100644
--- a/packages/schemas/src/index.ts
+++ b/packages/schemas/src/index.ts
@@ -80,6 +80,10 @@ export const inventoryHomePayloadSchema = z.object({
   // Builder-supplied listing photos (largest srcset variant per asset). Optional
   // so adapters that don't yet capture media still validate.
   images: fieldValueSchema(z.array(z.string())).optional(),
+  // Extensible long-tail specs (monthly payment, was-price, plan code, multi-gen,
+  // lifecycle flags, incentives, …) — an open object, per-builder. Optional so
+  // adapters that don't emit it still validate. See collectors/SPECS-AUDIT.md.
+  specs: fieldValueSchema(z.record(z.string(), z.unknown())).optional(),
 });
 export type InventoryHomePayload = z.infer<typeof inventoryHomePayloadSchema>;
 

← 26f91560 feat(specs): InventoryHome.specs Json foundation — schema +  ·  back to Homesonspec  ·  feat(specs): pulte specs capture — discount-off-list, monthl 1fd7b60a →