← back to Homesonspec

packages/shared/src/canonical.ts

75 lines

import { createHash } from "node:crypto";

/**
 * Canonical inventory identity. The same physical home may surface on a
 * builder's national site, a division site, a community page, or a feed —
 * this key collapses them to one record. Components are normalized before
 * hashing so cosmetic differences ("123 Oak St." vs "123 oak street")
 * don't fork identities.
 */
export interface CanonicalHints {
  builderSlug: string;
  communityName?: string | null;
  address?: string | null;
  lotNumber?: string | null;
  builderInventoryId?: string | null;
  lat?: number | null;
  lon?: number | null;
  planName?: string | null;
}

const STREET_ABBREVIATIONS: Record<string, string> = {
  street: "st", "st.": "st",
  avenue: "ave", "ave.": "ave", av: "ave",
  boulevard: "blvd", "blvd.": "blvd",
  drive: "dr", "dr.": "dr",
  lane: "ln", "ln.": "ln",
  road: "rd", "rd.": "rd",
  court: "ct", "ct.": "ct",
  circle: "cir", "cir.": "cir",
  place: "pl", "pl.": "pl",
  trail: "trl", "trl.": "trl",
  parkway: "pkwy", "pkwy.": "pkwy",
  terrace: "ter", "ter.": "ter",
  way: "way",
  north: "n", south: "s", east: "e", west: "w",
  "n.": "n", "s.": "s", "e.": "e", "w.": "w",
};

/** Lowercase, strip punctuation, collapse whitespace, abbreviate street words. */
export function normalizeAddress(address: string | null | undefined): string {
  if (!address) return "";
  return address
    .toLowerCase()
    .replace(/[#,.]/g, " ")
    .split(/\s+/)
    .filter(Boolean)
    .map((word) => STREET_ABBREVIATIONS[word] ?? word)
    .join(" ")
    .trim();
}

function norm(value: string | null | undefined): string {
  return (value ?? "").toLowerCase().replace(/\s+/g, " ").trim();
}

/** Round to 5 decimals (~1.1m) so GPS jitter doesn't fork identities. */
function latlon5(value: number | null | undefined): string {
  if (value === null || value === undefined || Number.isNaN(value)) return "";
  return value.toFixed(5);
}

export function canonicalKey(hints: CanonicalHints): string {
  const parts = [
    norm(hints.builderSlug),
    norm(hints.communityName),
    normalizeAddress(hints.address),
    norm(hints.lotNumber),
    norm(hints.builderInventoryId),
    latlon5(hints.lat),
    latlon5(hints.lon),
    norm(hints.planName),
  ];
  return createHash("sha256").update(parts.join("|")).digest("hex");
}