← back to Stayclaim

src/components/AddressNarrative.tsx

211 lines

/**
 * AddressNarrative — weaves the structured facts about a listing into 2-3
 * paragraphs of editorial prose. Sits at the top of every address page so
 * the user reads a story first, then sees the data tables underneath.
 *
 * Storytelling pivot — Steve's note: "make it seem like storytelling".
 * The data doesn't change; the framing does. Address pages used to lead
 * with property facts in a sidebar; now they lead with a narrative voice
 * that says what we know AS a story.
 *
 * Future: replace the templater with Claude API per /claude-api skill,
 * cached per listing_id. For now the templater handles the common shapes
 * (year built / style / film count / permit count) gracefully.
 */
import type { Listing, Structure, FilmingLocation, PlaceEvent } from '@/lib/db';
import type { LAParcel } from '@/lib/la-arcgis';
import type { LAPermit } from '@/lib/la-permits';

type Productions = (FilmingLocation & {
  production_slug: string;
  production_title: string;
  production_kind: string;
  production_year: number | null;
})[];

function ord(n: number): string {
  const s = ['th', 'st', 'nd', 'rd'];
  const v = n % 100;
  return n + (s[(v - 20) % 10] || s[v] || s[0]);
}

function articleFor(word: string): 'A' | 'An' {
  return /^[aeiou]/i.test(word) ? 'An' : 'A';
}

function listAnd(arr: string[]): string {
  if (arr.length === 0) return '';
  if (arr.length === 1) return arr[0];
  if (arr.length === 2) return arr.join(' and ');
  return arr.slice(0, -1).join(', ') + ', and ' + arr[arr.length - 1];
}

function buildOpening(listing: Listing, structure: Structure | undefined, parcel: LAParcel | null): string {
  const parts: string[] = [];
  // Prefer real assessor data (parcel) over local listing/structure data when available.
  // Real data has the freshest year_built / sqft / use_description from LA County roll.
  const built = parcel?.year_built ?? structure?.year_built;
  const style = structure?.style?.toLowerCase();
  const type =
    parcel?.use_description?.toLowerCase() ??
    structure?.property_type?.toLowerCase().replace(/^residential[,-]?\s*/, '');
  const city = listing.city ?? parcel?.city ?? null;
  const sqft = parcel?.sqft_main ?? structure?.building_area_sqft;
  const lot = structure?.lot_size_sqft;

  if (built && style) {
    parts.push(`The ${style} at ${listing.title}${city ? ` in ${city}` : ''} was built in ${built}`);
  } else if (built && type) {
    parts.push(`The ${type} at ${listing.title}${city ? ` in ${city}` : ''} dates to ${built}`);
  } else if (built) {
    parts.push(`${listing.title}${city ? `, in ${city},` : ''} was built in ${built}`);
  } else if (style || type) {
    parts.push(`${listing.title}${city ? ` in ${city}` : ''} is ${articleFor(style ?? type ?? '').toLowerCase()} ${style ?? type}`);
  } else {
    parts.push(`${listing.title}${city ? ` is in ${city}` : ''}`);
  }

  // Append size
  const size: string[] = [];
  if (sqft) size.push(`${sqft.toLocaleString()} square feet`);
  if (lot) size.push(`a ${lot.toLocaleString()}-square-foot lot`);
  if (size.length) {
    parts[0] += `, ${size.length === 2 ? `set on ${size[1]} of land and ${size[0]} of living space` : `with ${size[0]}`}`;
  }

  return parts[0] + '.';
}

function buildOwnership(events: PlaceEvent[]): string | null {
  const sales = events.filter(e => e.kind === 'sale');
  if (sales.length === 0) return null;
  const first = sales[0];
  const last = sales[sales.length - 1];
  const firstYear = first?.valid_from ? new Date(first.valid_from).getFullYear() : null;
  const lastYear = last?.valid_from ? new Date(last.valid_from).getFullYear() : null;

  if (sales.length === 1 && firstYear) {
    return `The recorded chain of title shows a single transfer, in ${firstYear}.`;
  }
  if (firstYear && lastYear && firstYear !== lastYear) {
    return `Title has changed hands ${sales.length} times in the public record, the earliest entry in ${firstYear} and the most recent in ${lastYear}.`;
  }
  if (sales.length > 1) {
    return `The recorded chain of title includes ${sales.length} transfers.`;
  }
  return null;
}

function buildPermits(events: PlaceEvent[]): string | null {
  const permits = events.filter(e => e.kind === 'permit' || e.kind === 'remodel');
  if (permits.length === 0) return null;
  const years = permits.map(p => p.valid_from ? new Date(p.valid_from).getFullYear() : null).filter((y): y is number => y != null);
  if (years.length === 0) {
    return `${permits.length} building permit${permits.length === 1 ? '' : 's'} ${permits.length === 1 ? 'is' : 'are'} on file with the city.`;
  }
  const earliest = Math.min(...years);
  const latest = Math.max(...years);
  if (earliest === latest) {
    return `The address pulled ${permits.length} permit${permits.length === 1 ? '' : 's'} in ${earliest}.`;
  }
  return `${permits.length} building permit${permits.length === 1 ? '' : 's'} are on file, the earliest from ${earliest} and the latest from ${latest}.`;
}

function buildLaPermits(permits: LAPermit[] | undefined): string | null {
  if (!permits || permits.length === 0) return null;
  const dated = permits.filter(p => p.issue_date);
  if (dated.length === 0) {
    return `${permits.length} City of LA permit${permits.length === 1 ? '' : 's'} on file.`;
  }
  const years = dated.map(p => Number(p.issue_date!.slice(0, 4))).filter(Number.isFinite);
  const earliest = Math.min(...years);
  const latest = Math.max(...years);
  if (earliest === latest) {
    return `${permits.length} City of LA building permit${permits.length === 1 ? '' : 's'} pulled in ${earliest}.`;
  }
  return `${permits.length} City of LA building permits in the public record, the earliest from ${earliest} and the latest from ${latest}.`;
}

function buildAssessor(parcel: LAParcel | null): string | null {
  if (!parcel) return null;
  const bits: string[] = [];
  if (parcel.apn) bits.push(`APN ${parcel.apn}`);
  if (parcel.roll_year && parcel.total_value) {
    bits.push(`assessed at $${(parcel.total_value / 1000).toFixed(0)}K (${parcel.roll_year} roll)`);
  }
  if (parcel.homeowner_exempt) bits.push('homeowner exemption on file');
  if (bits.length === 0) return null;
  return `LA County records: ${bits.join('; ')}.`;
}

function buildFilming(productions: Productions): string | null {
  if (productions.length === 0) return null;
  const titles = productions.slice(0, 3).map(p => `${p.production_title}${p.production_year ? ` (${p.production_year})` : ''}`);
  if (productions.length === 1) {
    return `Filming records show one production permitted at this address: ${titles[0]}.`;
  }
  if (productions.length <= 3) {
    return `Production permits at this address include ${listAnd(titles)}.`;
  }
  return `${productions.length} productions have pulled film permits here, including ${listAnd(titles.slice(0, 3))}, and others.`;
}

export function AddressNarrative({
  listing,
  structures,
  events,
  productions,
  parcel,
  laPermits,
}: {
  listing: Listing;
  structures: Structure[];
  events: PlaceEvent[];
  productions: Productions;
  parcel?: LAParcel | null;
  laPermits?: LAPermit[];
}) {
  const structure = structures[0];
  const opening    = buildOpening(listing, structure, parcel ?? null);
  const ownership  = buildOwnership(events);
  const permits    = buildPermits(events);
  const filming    = buildFilming(productions);
  const assessor   = buildAssessor(parcel ?? null);
  const ladbs      = buildLaPermits(laPermits);

  // Stitch into 2-3 paragraphs
  const para1 = opening;
  const para2Parts = [ownership, permits, ladbs, assessor].filter(Boolean) as string[];
  const para3Parts = [filming].filter(Boolean) as string[];

  if (para2Parts.length === 0 && para3Parts.length === 0) {
    // Sparse listing — single sentence fine
    return (
      <section className="max-w-3xl mx-auto px-6 py-10 border-b border-ink/10">
        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/45 mb-3 font-mono">The story</p>
        <p className="font-display text-2xl md:text-3xl text-ink leading-[1.35] tracking-[-0.005em]">
          {para1}
        </p>
        <p className="mt-4 text-sm font-mono text-ink/50 italic">
          What we know so far. Records continue to be added.
        </p>
      </section>
    );
  }

  return (
    <section className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
      <p className="text-[10px] uppercase tracking-[0.18em] text-ink/45 mb-4 font-mono">The story</p>
      <div className="font-display text-xl md:text-2xl text-ink leading-[1.5] tracking-[-0.005em] space-y-5">
        <p>{para1}</p>
        {para2Parts.length > 0 && <p>{para2Parts.join(' ')}</p>}
        {para3Parts.length > 0 && <p>{para3Parts.join(' ')}</p>}
      </div>
      <p className="mt-6 text-[11px] font-mono text-ink/45 italic">
        Drawn from the assessor record, building permits, sale history, and FilmLA permits.
        Every claim is a public-record citation, not an inference.
      </p>
    </section>
  );
}