← 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>
);
}