feat(hunt): track walking distance via GPS, show live locations on map
Add foreground-only GPS distance tracking for hunts
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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<Date | null>(null);
|
||||
const baseDistance = ref(seedDistance);
|
||||
const sessionFixes = ref<GeoPoint[]>([]);
|
||||
|
||||
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<typeof setInterval> | 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user