← back to Stayclaim
src/app/stars/page.tsx
188 lines
/**
* /stars — Map of the Stars (historical residences)
*
* Curated, source-cited historical celebrity residences in LA / BH.
* Per PLAN.md privacy guardrail: no current addresses, only past residences
* with date_range + source_tier + source_url. Living/recently-deceased people
* are blocked at the DB level via the entity_place_association privacy gate.
*
* Each star pin → click → /address/[slug] for the full record.
*/
import Link from 'next/link';
import type { Metadata } from 'next';
import { pool } from '@/lib/db';
import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
export const metadata: Metadata = {
title: 'Map of the Stars',
description:
'A map of historical celebrity residences in Los Angeles and Beverly Hills, anchored to verifiable public records. Date-ranged, source-cited, never current.',
alternates: { canonical: '/stars' },
};
type StarRow = {
listing_id: string;
listing_slug: string;
listing_title: string;
city: string | null;
lat: number | null;
lng: number | null;
year_built: number | null;
person_slug: string;
person_name: string;
person_kind: string;
birth_year: number | null;
death_year: number | null;
date_from: string | null;
date_to: string | null;
source_label: string | null;
source_url: string | null;
};
async function getStars(): Promise<StarRow[]> {
try {
// DISTINCT ON (entity_id, listing_id) — collapses dup associations
// (legacy seed + new seed both inserted) into one row per person+address.
const { rows } = await pool.query<StarRow>(`
SELECT DISTINCT ON (epa.entity_id, epa.listing_id)
l.id AS listing_id, l.slug AS listing_slug, l.title AS listing_title, l.city,
l.latitude::float AS lat, l.longitude::float AS lng,
p.year_built,
e.slug AS person_slug, e.display_name AS person_name, e.kind AS person_kind,
e.birth_year, e.death_year,
epa.date_from::text, epa.date_to::text,
epa.source_notes AS source_label,
COALESCE(epa.source_urls[1], e.wiki_url) AS source_url
FROM entity_place_association epa
JOIN entity e ON e.id = epa.entity_id
JOIN listing l ON l.id = epa.listing_id
LEFT JOIN la_parcel p ON p.listing_id = l.id
WHERE epa.public_visible = true
AND l.is_public = true
AND l.latitude IS NOT NULL AND l.longitude IS NOT NULL
ORDER BY epa.entity_id, epa.listing_id, epa.date_from ASC NULLS LAST
LIMIT 1500
`);
return rows;
} catch (e) {
// Log DB errors so a schema drift / outage doesn't masquerade as a healthy
// empty result. Returning [] still keeps the page rendering, but at least
// the failure is observable in server logs.
console.error('stars: getStars query failed:', e instanceof Error ? e.message : e);
return [];
}
}
/**
* Format a date range for a historical residence. The page's privacy rule is
* "we don't publish current addresses" — so a null `date_to` means "end date
* unknown," NOT "still lives there." Never render "present."
*/
function formatRange(dateFrom: string | null, dateTo: string | null): string {
const from = yearOnly(dateFrom);
const to = yearOnly(dateTo);
if (from && to) return `${from}–${to}`;
if (from && !to) return `${from}–?`;
if (!from && to) return `?–${to}`;
return 'date unknown';
}
function yearOnly(d: string | null): string | null {
if (!d) return null;
const m = d.match(/^(\d{4})/);
return m ? m[1] : null;
}
export default async function StarsPage() {
const stars = await getStars();
const pins: MapPin[] = stars
.filter(s => s.lat != null && s.lng != null)
.map(s => ({
slug: s.listing_slug,
title: s.person_name,
subtitle: s.listing_title,
lat: Number(s.lat),
lng: Number(s.lng),
}));
return (
<>
<div className="max-w-6xl mx-auto px-6 pt-6">
<BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Map of the stars' }]} />
</div>
<header 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/55 mb-3 font-mono">
Historical residences only
</p>
<h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
Map of the stars.
</h1>
<p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
Where they really lived — anchored to LA County public records, dated,
and sourced. We don’t publish current addresses.
</p>
<p className="mt-3 text-sm text-ink/55 max-w-prose">
{stars.length} historical residence{stars.length === 1 ? '' : 's'} on the map.
Each pin links to the full address record: assessor data, films shot there,
permits filed, and the documented historical association.
</p>
</header>
{pins.length > 0 && (
<section className="max-w-6xl mx-auto px-6 py-8">
<InteractiveMapMount pins={pins} height={520} variant="positron" />
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
Pins anchored to LA County GIS centroids · click any pin for the full record
</p>
</section>
)}
<section className="max-w-3xl mx-auto px-6 py-10">
<ol className="divide-y divide-ink/10">
{stars.map(s => (
<li key={`${s.person_slug}-${s.listing_slug}`}>
<Link
href={`/address/${s.listing_slug}`}
className="group grid grid-cols-[1fr_auto] gap-4 py-5 items-baseline hover:bg-[#fdf9ee] -mx-3 px-3 transition"
>
<div>
<h2 className="font-display text-3xl text-ink tracking-[-0.01em] group-hover:text-coral transition">
{s.person_name}
</h2>
<p className="mt-1 text-sm text-ink/70">
{s.listing_title}{s.city ? `, ${s.city}` : ''}
{s.year_built && <span className="text-ink/45"> · built {s.year_built}</span>}
</p>
{s.source_label && (
<p className="mt-1 text-[11px] font-mono text-ink/50 italic">
{s.source_label}
</p>
)}
</div>
<div className="text-right">
<p className="font-mono text-xs text-ink/60 tabular-nums">
{formatRange(s.date_from, s.date_to)}
</p>
{(s.birth_year || s.death_year) && (
<p className="font-mono text-[10px] text-ink/40 mt-1">
{s.birth_year ?? '?'}{s.death_year ? `–${s.death_year}` : ''}
</p>
)}
</div>
</Link>
</li>
))}
</ol>
{stars.length === 0 && (
<p className="font-display italic text-2xl text-ink/55 text-center py-12">
The map is loading. Stars are pinned as the archive grows.
</p>
)}
</section>
</>
);
}