Three UX wins so the app feels "already loaded" the moment it opens:
1. IndexedDB React Query persistence (lib/queryPersister.ts)
- PersistQueryClientProvider rehydrates the last-seen spots, shops,
events, tours, crews, coaches, listings and author profiles from
IndexedDB on cold start, so returning users land on a populated map
instead of a blank one; fresh data refetches quietly in the
background.
- Strict allow-list / deny-list: only public, JSON-serializable,
cold-start-relevant queries are persisted. Private/encrypted data
(stash, DMs, contacts, wallet/nwc/cashu/bitcoin, signer) is NEVER
written to disk. 24h maxAge + version buster keep it fresh & safe.
- Pinned @tanstack/react-query + query-core + persist-client to a
single 5.102.8 to avoid a query-core mismatch (partialMatchKey).
2. Optimistic geolocation (lib/lastLocation.ts)
- getCurrentPosition now caches every successful fix.
- The map opens ALREADY centered on the user's last-known area
(city zoom) instead of the world view; "you are here" + proximity
sort seed from the cache instantly.
- "Near Me" flies to the cached location immediately on tap, then
refines to the fresh GPS fix. Silent background warm-up on web map
mount so a later tap resolves instantly.
3. Cold-start skeleton (components/MapSkeletonPins.tsx)
- When the map is genuinely empty + loading, show elegant pulsing
ghost pins + a "Finding spots" pill instead of a blank map; they
vanish the instant real (or cached) spots render. Splash now
signals content-ready when spots actually resolve.
Respects prefers-reduced-motion throughout. Build + runtime verified
clean (no console errors).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
67 lines
2.2 KiB
TypeScript
67 lines
2.2 KiB
TypeScript
/**
|
|
* Last-known-location cache.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Getting a fresh GPS fix takes time. To make the map feel instant we remember
|
|
* the user's last successful location in localStorage and use it to:
|
|
* 1. Open the map ALREADY centered on their area (optimistic initial view).
|
|
* 2. Give "Near Me" something to fly to immediately while the fresh fix
|
|
* resolves in the background.
|
|
*
|
|
* This is a coarse convenience cache, not a source of truth — a fresh fix always
|
|
* supersedes it. We never store accuracy or timestamps beyond what's needed and
|
|
* it lives only in this browser/app.
|
|
*/
|
|
|
|
export interface LastLocation {
|
|
lat: number;
|
|
lng: number;
|
|
/** Epoch ms when this fix was captured. */
|
|
at: number;
|
|
}
|
|
|
|
const KEY = 'skatespots:last-location';
|
|
|
|
/** Consider a cached location "usable for centering" for 30 days. */
|
|
const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
/** Persist a successful location fix. Never throws. */
|
|
export function saveLastLocation(lat: number, lng: number): void {
|
|
try {
|
|
if (
|
|
typeof lat !== 'number' ||
|
|
typeof lng !== 'number' ||
|
|
Number.isNaN(lat) ||
|
|
Number.isNaN(lng) ||
|
|
(lat === 0 && lng === 0)
|
|
) {
|
|
return;
|
|
}
|
|
const payload: LastLocation = { lat, lng, at: Date.now() };
|
|
localStorage.setItem(KEY, JSON.stringify(payload));
|
|
} catch {
|
|
// ignore private-mode / quota failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the cached location if present and not expired. Returns null otherwise.
|
|
* Never throws.
|
|
*/
|
|
export function getLastLocation(): LastLocation | null {
|
|
try {
|
|
const raw = localStorage.getItem(KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as Partial<LastLocation>;
|
|
if (
|
|
typeof parsed?.lat !== 'number' ||
|
|
typeof parsed?.lng !== 'number' ||
|
|
typeof parsed?.at !== 'number'
|
|
) {
|
|
return null;
|
|
}
|
|
if (Date.now() - parsed.at > MAX_AGE) return null;
|
|
return { lat: parsed.lat, lng: parsed.lng, at: parsed.at };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|