From a984084464737415a8cf8cd5b1f743db82679cc8 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 9 Sep 2026 21:58:22 +0700 Subject: [PATCH] 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 --- package-lock.json | 29 +++++-- package.json | 5 +- src/App.tsx | 31 ++++++- src/components/MapSkeletonPins.tsx | 87 +++++++++++++++++++ src/index.css | 18 ++++ src/lib/geolocation.ts | 11 ++- src/lib/lastLocation.ts | 67 +++++++++++++++ src/lib/queryPersister.ts | 134 +++++++++++++++++++++++++++++ src/pages/MapPage.tsx | 87 ++++++++++++++++--- 9 files changed, 445 insertions(+), 24 deletions(-) create mode 100644 src/components/MapSkeletonPins.tsx create mode 100644 src/lib/lastLocation.ts create mode 100644 src/lib/queryPersister.ts diff --git a/package-lock.json b/package-lock.json index f74dd62..2cc98c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,9 @@ "@radix-ui/react-tooltip": "^1.1.4", "@scure/bip32": "^2.0.1", "@scure/bip39": "1.6.0", + "@tanstack/query-core": "^5.102.8", "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query-persist-client": "^5.102.8", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", "bitcoinjs-lib": "^7.0.0", @@ -76,6 +78,7 @@ "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", "idb": "^8.0.3", + "idb-keyval": "^6.3.0", "input-otp": "^1.2.4", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", @@ -3520,13 +3523,13 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.75.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.0.tgz", - "integrity": "sha512-rk8KQuCdhoRkzjRVF3QxLgAfFUyS0k7+GCQjlGEpEGco+qazJ0eMH6aO1DjDjibH7/ik383nnztua3BG+lOnwg==", + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", "license": "MIT", "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" + "url": "https://github.com/sponsors/tannerlinsley", + "type": "github" } }, "node_modules/@tanstack/react-query": { @@ -3545,6 +3548,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz", + "integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/tannerlinsley", + "type": "github" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -5993,6 +6006,12 @@ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", "license": "ISC" }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 00253d7..c52c543 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,9 @@ "@radix-ui/react-tooltip": "^1.1.4", "@scure/bip32": "^2.0.1", "@scure/bip39": "1.6.0", - "@tanstack/react-query": "^5.56.2", + "@tanstack/query-core": "^5.102.8", + "@tanstack/react-query": "5.102.8", + "@tanstack/react-query-persist-client": "5.102.8", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", "bitcoinjs-lib": "^7.0.0", @@ -83,6 +85,7 @@ "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", "idb": "^8.0.3", + "idb-keyval": "^6.3.0", "input-otp": "^1.2.4", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", diff --git a/src/App.tsx b/src/App.tsx index afb2218..2a2ccfc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,17 @@ // NOTE: This file should normally not be modified unless you are adding a new provider. // To add new routes, edit the AppRouter.tsx file. -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { createHead, UnheadProvider } from '@unhead/react/client'; import { InferSeoMetaPlugin } from '@unhead/addons'; import { Suspense } from 'react'; +import { + createIDBPersister, + shouldDehydrateQuery, + PERSIST_BUSTER, + PERSIST_MAX_AGE, +} from '@/lib/queryPersister'; import NostrProvider from '@/components/NostrProvider'; import { NostrSync } from '@/components/NostrSync'; import { Toaster } from "@/components/ui/toaster"; @@ -36,6 +43,10 @@ const queryClient = new QueryClient({ }, }); +// IndexedDB-backed persistence so the app rehydrates the last-seen spots, +// shops, events and profiles instantly on a cold start (see lib/queryPersister). +const persister = createIDBPersister(); + const defaultConfig: AppConfig = { theme: "dark", relayMetadata: { @@ -56,7 +67,21 @@ export function App() { - + @@ -78,7 +103,7 @@ export function App() { - + diff --git a/src/components/MapSkeletonPins.tsx b/src/components/MapSkeletonPins.tsx new file mode 100644 index 0000000..0c36826 --- /dev/null +++ b/src/components/MapSkeletonPins.tsx @@ -0,0 +1,87 @@ +/** + * MapSkeletonPins + * ───────────────────────────────────────────────────────────────────────────── + * A tasteful "spots are loading" overlay shown ONLY on a genuinely cold, empty + * map (no cached spots yet + still fetching). Instead of a blank map that reads + * as broken, we scatter a few softly-pulsing ghost pins in the SkateSpots pin + * shape, so the moment reads as "finding spots near you…". + * + * It's purely decorative and non-interactive (`pointer-events-none`), sits above + * the tiles but below every real control, and fully respects reduced-motion. + * The instant real spots arrive the parent unmounts this — no layout shift. + */ + +interface Ghost { + /** left/top as viewport percentages so it scales on any screen. */ + left: number; + top: number; + /** teardrop scale + animation delay for an organic, staggered feel. */ + scale: number; + delay: number; +} + +// Hand-placed so they feel scattered-but-balanced (not a rigid grid). +const GHOSTS: Ghost[] = [ + { left: 22, top: 34, scale: 1.0, delay: 0 }, + { left: 48, top: 22, scale: 0.82, delay: 0.35 }, + { left: 71, top: 38, scale: 1.1, delay: 0.15 }, + { left: 34, top: 58, scale: 0.9, delay: 0.6 }, + { left: 62, top: 62, scale: 1.0, delay: 0.45 }, + { left: 82, top: 66, scale: 0.78, delay: 0.8 }, +]; + +export function MapSkeletonPins() { + return ( +