feat(hunt): track walking distance via GPS, show live locations on map
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s

Add foreground-only GPS distance tracking for hunts
This commit is contained in:
2026-09-27 20:55:58 +02:00
parent a0fc836c05
commit 61cc516f47
43 changed files with 2313 additions and 24 deletions
+261
View File
@@ -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
};
}