- 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.
115 lines
3.9 KiB
TypeScript
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) });
|
|
}
|