import { computeTrackDistance, type GeoPoint, haversine } from '#shared/utils/geo'; import { useSettingsStore } from '~/stores/settings'; export type TrackableHunt = { id: number; trackDistance: boolean; virtual: boolean; start?: Date | string | null; end?: Date | string | null; }; /** Flush cadence and the key prefix for the offline retry queue. */ const FLUSH_INTERVAL_MS = 30_000; const QUEUE_PREFIX = 'pichunt:track:queue:'; const MIN_DISTANCE = 0.9; /** * Foreground GPS distance tracking for a single hunt. * * Independent paths by design: * - the watch loop buffers fixes and flushes them to `POST /api/hunt/:id/track` * via `navigator.sendBeacon` (tracking); * - {@link getPosition} is a one-shot read used by the answer submit form. * * Consent is client-only and persisted per hunt in the settings store; the * server enforces the hunt flag, window and membership, never consent. */ export function useGeoTracking(hunt: TrackableHunt, seedDistance = 0) { const settings = useSettingsStore(); const consented = computed(() => settings.hasTrackConsent(hunt.id)); // Re-evaluated on the flush tick so the window stays fresh while the page // is open. Plain Date.now() keeps this SSR-safe (no timers on the server). const nowTs = ref(Date.now()); const withinWindow = computed(() => { const start = hunt.start ? new Date(hunt.start).getTime() : null; const end = hunt.end ? new Date(hunt.end).getTime() : null; if (start !== null && nowTs.value < start) return false; if (end !== null && nowTs.value > end) return false; return true; }); /** The tracking card may be shown (independent of consent). */ const eligible = computed( () => hunt.trackDistance && !hunt.virtual && withinWindow.value ); /** Tracking is active only when eligible and the user consented. */ const gate = computed(() => eligible.value && consented.value); const { isSupported, coords, locatedAt, resume, pause } = useGeolocation({ enableHighAccuracy: true, immediate: false }); const tracking = ref(false); const userPaused = ref(false); const hidden = ref(false); const paused = computed(() => userPaused.value || hidden.value); const lastFix = ref(null); const baseDistance = ref(seedDistance); const sessionFixes = ref([]); const distance = computed( () => baseDistance.value + computeTrackDistance(sessionFixes.value) ); // --- offline queue ------------------------------------------------------- const queueKey = `${QUEUE_PREFIX}${hunt.id}`; let pending: GeoPoint[] = loadQueue(); function loadQueue(): GeoPoint[] { if (!import.meta.client) return []; try { const raw = localStorage.getItem(queueKey); return raw ? (JSON.parse(raw) as GeoPoint[]) : []; } catch { return []; } } function persistQueue() { if (!import.meta.client) return; try { if (pending.length > 0) { localStorage.setItem(queueKey, JSON.stringify(pending)); } else { localStorage.removeItem(queueKey); } } catch { // Ignore quota/serialization errors; the in-memory buffer still flushes. } } /** Send the buffered fixes; re-queue them if the beacon could not be sent. */ function flush() { if (!import.meta.client || pending.length === 0) return; const batch = pending; pending = []; persistQueue(); let queued = false; try { queued = 'sendBeacon' in navigator && navigator.sendBeacon( `/api/hunt/${hunt.id}/track`, new Blob([JSON.stringify(batch)], { type: 'application/json' }) ); } catch { queued = false; } if (!queued) { // Network/beacon failure: keep the batch for the next flush or 'online'. pending = [...batch, ...pending]; persistQueue(); } } // --- watch loop ---------------------------------------------------------- function onFix() { const at = locatedAt.value; const c = coords.value; if (at == null || !c) return; const recordedAt = new Date(at); const fix: GeoPoint = { lat: c.latitude, lon: c.longitude, accuracy: c.accuracy ?? null, recordedAt }; const last = sessionFixes.value.at(-1); const distToLast = last ? haversine(last, fix) : Number.MAX_VALUE; if (distToLast < MIN_DISTANCE) { return; } sessionFixes.value = [...sessionFixes.value, fix]; lastFix.value = recordedAt; pending.push(fix); } function beginWatch() { if (!import.meta.client || !isSupported.value || tracking.value) return; resume(); tracking.value = true; } function endWatch() { if (!tracking.value) return; pause(); tracking.value = false; } function start() { userPaused.value = false; if (gate.value) beginWatch(); } function stop() { userPaused.value = true; endWatch(); flush(); } function toggleConsent() { const next = !consented.value; settings.setTrackConsent(hunt.id, next); if (next) start(); else stop(); } function onVisibilityChange() { if (!import.meta.client) return; if (document.visibilityState === 'hidden') { hidden.value = true; flush(); endWatch(); } else { hidden.value = false; if (gate.value && !userPaused.value) beginWatch(); } } /** One-shot position read for the submit form; independent of the watch. */ function getPosition(timeoutMs = 10_000): Promise<{ lat: number; lon: number; accuracy: number | null; }> { return new Promise((resolve, reject) => { if (!import.meta.client || !('geolocation' in navigator)) { reject(new Error('geolocation unsupported')); return; } navigator.geolocation.getCurrentPosition( (pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude, accuracy: pos.coords.accuracy ?? null }), (err) => reject(err), { enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 } ); }); } watch(locatedAt, onFix); watch(gate, (open) => { if (open && !userPaused.value && !hidden.value) beginWatch(); else if (!open) endWatch(); }); let timer: ReturnType | undefined; onMounted(() => { if (!import.meta.client) return; window.addEventListener('online', flush); document.addEventListener('visibilitychange', onVisibilityChange); window.addEventListener('pagehide', flush); timer = setInterval(() => { nowTs.value = Date.now(); flush(); }, FLUSH_INTERVAL_MS); if (gate.value && !userPaused.value) beginWatch(); }); onScopeDispose(() => { flush(); endWatch(); if (timer !== undefined) clearInterval(timer); if (import.meta.client) { window.removeEventListener('online', flush); document.removeEventListener('visibilitychange', onVisibilityChange); window.removeEventListener('pagehide', flush); } }); return { distance, lastFix, tracking, paused, consented, eligible, gate, isSupported, start, stop, toggleConsent, getPosition }; }