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:
parent
d32df04c79
commit
863b6a115d
3 changed files with 26 additions and 10 deletions
|
|
@ -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`**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue