fix(stats): don't crash on a persisted cache from the old data shape

/stats slammed with 'Cannot read properties of undefined' for anyone whose
IndexedDB held a growth-stats cache saved before the new fields existed —
stats.topCountries.length threw.

- Bump PERSIST_BUSTER v1 -> v2 (the documented way to invalidate a shape change;
  it is wired into App.tsx).
- Read the new fields defensively (stats?.x ?? []) so a future field can't do
  this again.
- AGENTS.md: note that the smoke test cannot catch this (clean profile, no
  persisted cache).
This commit is contained in:
SkateSpots 2026-10-01 13:02:21 +07:00
parent d32df04c79
commit 863b6a115d
3 changed files with 26 additions and 10 deletions

View file

@ -829,6 +829,15 @@ session with **no other memory of this project**. So:
`hidden lg:contents`, so children stay real grid items) with a "Show all 13 `hidden lg:contents`, so children stay real grid items) with a "Show all 13
crafts" toggle; container widened `max-w-6xl` → `max-w-7xl`; `StatCard` gained crafts" toggle; container widened `max-w-6xl` → `max-w-7xl`; `StatCard` gained
a `compact` prop (`+compactNumber`) so big sats don't overflow `text-4xl`. a `compact` prop (`+compactNumber`) so big sats don't overflow `text-4xl`.
- ⚠️ **Adding a field to a PERSISTED query is a crash risk (2026-10-01).**
`growth-stats` is in the `queryPersister` allow-list, so a cache saved before
a new field existed lacks it — and `stats.topCountries.length` throws
(`Cannot read properties of undefined`), slamming `/stats` for anyone with an
old cache. Fix: **bump `PERSIST_BUSTER`** (`src/lib/queryPersister.ts`, wired
in `App.tsx`) **AND** read defensively (`stats?.x ?? []`). ⚠️ **The smoke test
cannot catch this** — it boots a clean profile with no persisted cache, so it
passed while real users crashed. Any new field on an allow-listed query needs
both.
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out - **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
free handles so a new skater has a human name from minute one. The registry is free handles so a new skater has a human name from minute one. The registry is
the Netlify Function **`netlify/functions/nip05.mjs`** the Netlify Function **`netlify/functions/nip05.mjs`**

View file

@ -40,7 +40,7 @@ import type { Query } from '@tanstack/react-query';
const IDB_KEY = 'skatespots:react-query-cache'; const IDB_KEY = 'skatespots:react-query-cache';
/** Bump when the persisted data shape changes to invalidate old caches. */ /** Bump when the persisted data shape changes to invalidate old caches. */
export const PERSIST_BUSTER = 'v1'; export const PERSIST_BUSTER = 'v2';
/** Discard any persisted cache older than this (ms). */ /** Discard any persisted cache older than this (ms). */
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h

View file

@ -206,6 +206,13 @@ export default function CommunityStatsPage() {
// Total across ALL Lab creator/service crafts (matches what /lab lists) // Total across ALL Lab creator/service crafts (matches what /lab lists)
const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]); const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]);
// Defensive reads: a persisted cache saved before these fields existed would
// lack them, and `stats.topCountries.length` would throw. (The persist buster
// was bumped too, but never trust a cache blindly.)
const tradesByMethod = stats?.tradesByMethod ?? [];
const topCountries = stats?.topCountries ?? [];
const topCities = stats?.topCities ?? [];
return ( return (
<> <>
<div className="container max-w-7xl px-4 py-10 md:py-14"> <div className="container max-w-7xl px-4 py-10 md:py-14">
@ -333,9 +340,9 @@ export default function CommunityStatsPage() {
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact /> <StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact />
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} /> <StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
</div> </div>
{stats && stats.tradesByMethod.length > 0 && ( {tradesByMethod.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{stats.tradesByMethod.map((m) => ( {tradesByMethod.map((m) => (
<span <span
key={m.method} key={m.method}
className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-gray-300" className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-gray-300"
@ -353,25 +360,25 @@ export default function CommunityStatsPage() {
</section> </section>
{/* ── Section 1e: Regions ── */} {/* ── Section 1e: Regions ── */}
{stats && (stats.topCountries.length > 0 || stats.topCities.length > 0) && ( {(topCountries.length > 0 || topCities.length > 0) && (
<section className="mb-12"> <section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2"> <h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Where Skaters Are <Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Where Skaters Are
</h2> </h2>
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{stats.topCountries.length > 0 && ( {topCountries.length > 0 && (
<Card className="border-white/8"> <Card className="border-white/8">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle> <CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{stats.topCountries.map((c) => ( {topCountries.map((c) => (
<div key={c.name} className="flex items-center gap-3"> <div key={c.name} className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span> <span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div <div
className="h-full rounded-full bg-[#4ade80]" className="h-full rounded-full bg-[#4ade80]"
style={{ width: `${Math.max(4, (c.spots / stats.topCountries[0].spots) * 100)}%` }} style={{ width: `${Math.max(4, (c.spots / topCountries[0].spots) * 100)}%` }}
/> />
</div> </div>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span> <span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
@ -380,19 +387,19 @@ export default function CommunityStatsPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{stats.topCities.length > 0 && ( {topCities.length > 0 && (
<Card className="border-white/8"> <Card className="border-white/8">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle> <CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{stats.topCities.map((c) => ( {topCities.map((c) => (
<div key={c.name} className="flex items-center gap-3"> <div key={c.name} className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span> <span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div <div
className="h-full rounded-full bg-[#38bdf8]" className="h-full rounded-full bg-[#38bdf8]"
style={{ width: `${Math.max(4, (c.spots / stats.topCities[0].spots) * 100)}%` }} style={{ width: `${Math.max(4, (c.spots / topCities[0].spots) * 100)}%` }}
/> />
</div> </div>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span> <span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>