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
crafts" toggle; container widened `max-w-6xl` → `max-w-7xl`; `StatCard` gained
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
free handles so a new skater has a human name from minute one. The registry is
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';
/** 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). */
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)
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 (
<>
<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?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
</div>
{stats && stats.tradesByMethod.length > 0 && (
{tradesByMethod.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{stats.tradesByMethod.map((m) => (
{tradesByMethod.map((m) => (
<span
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"
@ -353,25 +360,25 @@ export default function CommunityStatsPage() {
</section>
{/* ── Section 1e: Regions ── */}
{stats && (stats.topCountries.length > 0 || stats.topCities.length > 0) && (
{(topCountries.length > 0 || topCities.length > 0) && (
<section className="mb-12">
<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
</h2>
<div className="grid md:grid-cols-2 gap-4">
{stats.topCountries.length > 0 && (
{topCountries.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{stats.topCountries.map((c) => (
{topCountries.map((c) => (
<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>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
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>
<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>
</Card>
)}
{stats.topCities.length > 0 && (
{topCities.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{stats.topCities.map((c) => (
{topCities.map((c) => (
<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>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
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>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>