SkateSpots/src/lib/routeCache.ts
SkateSpots e016ee20bd fix: tier 3 — medium bugs & hardening
- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS
  guard EventSponsors already uses.
- Official SkateSpots badge only shows when the NIP-05 actually verifies
  (NIP19Page), not on a self-asserted handle.
- Community stats reads are null-guarded on persisted-cache fields
  (monthly/cumulativeSpots/topMappers) — no more crash on an old cache.
- Overpass response no longer assumes .elements (food layer).
- user-listings query key is memoized (was Date.now() → refetch storm).
- Pro grant requires the l=pro label (was accepted with none).
- Verify card RSVP maps accepted/tentative (was going/interested → showed
  'No RSVP' on a valid ticket).
- Expired listings can't be bought via a stale/shared URL.
- Batch spot photo/status hooks include the relay suffix and use
  querySkateContent (relay-policy parity with useSpotReports).
- Offline queue requeues rows stuck in 'publishing' after a crash.
- querySkateContent/queryCanonical dedupe replaceable events by author+kind
  (not id) so a stale profile/relay list can't win.
- Route offline cache key includes the origin (was destination+mode only).
- Internal links use <Link> (MarketplacePage); EventCountdown reacts to
  language changes.
2026-10-02 22:26:46 +07:00

115 lines
3.9 KiB
TypeScript

import type { RouteResult, TranslateFn } from '@/lib/routing';
/**
* Tiny localStorage cache of the last few routes.
*
* Why: routing needs the network. If you lose signal mid-skate (or the routing
* server is briefly down), Directions would show nothing at all. Keeping the
* last route per destination+mode means we can still show the line, the time and
* the steps — clearly labelled as cached — instead of an error.
*
* The full OSRM geometry can be thousands of points, so the line is
* DOWNSAMPLED before storing (a route line at this zoom doesn't need them all).
* Steps are kept verbatim — they're what you actually read.
*/
const KEY = 'skatespots:route-cache:v1';
const MAX_ENTRIES = 8;
const MAX_POINTS = 250;
interface StoredRoute {
at: number;
distanceM: number;
durationS: number;
steps?: RouteResult['steps'];
coordinates: Array<[number, number]>;
}
type Store = Record<string, StoredRoute>;
function keyFor(from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: string): string {
// Origin is part of the key: a route saved from a different start point is not
// a valid fallback for the one the user chose (the "Saved route" label would lie).
return `${from.lat.toFixed(4)},${from.lng.toFixed(4)}>${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
}
function read(): Store {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Store) : {};
} catch {
return {};
}
}
function write(store: Store): void {
try {
localStorage.setItem(KEY, JSON.stringify(store));
} catch {
// quota / private mode — a cache is a nicety, never an error
}
}
/** Keep every Nth point so the stored line stays small. */
function downsample(coords: Array<[number, number]>, max = MAX_POINTS): Array<[number, number]> {
if (coords.length <= max) return coords;
const step = Math.ceil(coords.length / max);
const out: Array<[number, number]> = [];
for (let i = 0; i < coords.length; i += step) out.push(coords[i]);
const last = coords[coords.length - 1];
if (out[out.length - 1] !== last) out.push(last);
return out;
}
/** Remember a freshly-fetched route (best effort). */
export function saveRoute(
from: { lat: number; lng: number },
to: { lat: number; lng: number },
mode: string,
route: RouteResult,
): void {
if (!route.coordinates?.length) return;
const store = read();
store[keyFor(from, to, mode)] = {
at: Date.now(),
distanceM: route.distanceM,
durationS: route.durationS,
steps: route.steps,
coordinates: downsample(route.coordinates),
};
// Keep only the newest few destinations so this can never grow unbounded.
const newest = Object.entries(store)
.sort((a, b) => b[1].at - a[1].at)
.slice(0, MAX_ENTRIES);
write(Object.fromEntries(newest));
}
/** Read a cached route for this destination+mode, with how old it is. */
export function loadRoute(
from: { lat: number; lng: number },
to: { lat: number; lng: number },
mode: string,
): { route: RouteResult; ageMs: number } | null {
const stored = read()[keyFor(from, to, mode)];
if (!stored || !Array.isArray(stored.coordinates) || stored.coordinates.length < 2) return null;
return {
route: {
coordinates: stored.coordinates,
distanceM: stored.distanceM,
durationS: stored.durationS,
steps: stored.steps,
mode: mode as RouteResult['mode'],
approximate: false,
},
ageMs: Date.now() - stored.at,
};
}
/** "just now" | "12 min ago" | "2 h ago" — for the cached-route badge. */
export function formatAge(ageMs: number, t: TranslateFn): string {
const mins = Math.round(ageMs / 60000);
if (mins < 1) return t('routeUi.ageJustNow');
if (mins < 60) return t('routeUi.ageMinAgo', { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t('routeUi.ageHourAgo', { count: hours });
return t('routeUi.ageDayAgo', { count: Math.round(hours / 24) });
}