SkateSpots/src/lib/lastLocation.ts
shakespeare.diy a984084464 Instant cold-start: query persistence, optimistic geolocation, skeleton pins
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>
2026-09-09 21:58:22 +07:00

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;
}
}