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