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
+31
View File
@@ -0,0 +1,31 @@
/**
* Locale-aware walking-distance formatting.
*
* Distances are stored as Int meters. Under 1000 m they render as whole
* meters, above that as kilometers with one decimal (e.g. "1.2 km"). Reused by
* the tracking card, the team leaderboards and the profile distances.
*/
export function useFormatDistance() {
const { locale } = useI18n();
function formatDistance(meters: number | null | undefined): string {
const value = meters ?? 0;
const loc = locale.value || 'en';
if (value < 1000) {
return new Intl.NumberFormat(loc, {
style: 'unit',
unit: 'meter',
maximumFractionDigits: 0
}).format(Math.round(value));
}
return new Intl.NumberFormat(loc, {
style: 'unit',
unit: 'kilometer',
maximumFractionDigits: 1
}).format(value / 1000);
}
return { formatDistance };
}
+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
};
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Client-only team color assignment for the live map.
*
* A fixed 20-color high-contrast palette is shuffled once per page-load and
* assigned to teams by their stable order (the order returned by the map
* endpoint). Colors are team-shared but NOT persisted, so a refresh may
* reassign them. Every member of a team resolves to the same color.
*/
const PALETTE = [
'#e6194b',
'#3cb44b',
'#4363d8',
'#f58231',
'#911eb4',
'#46f0f0',
'#f032e6',
'#bcf60c',
'#fabebe',
'#008080',
'#e6beff',
'#9a6324',
'#fffac8',
'#800000',
'#aaffc3',
'#808000',
'#ffd8b1',
'#000075',
'#808080',
'#ff4d00'
] as const;
function shuffled<T>(input: readonly T[]): T[] {
const arr = [...input];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
}
return arr;
}
/** Darken a hex color by a factor (0..1) for trail lines. */
export function darken(hex: string, factor = 0.75): string {
const n = parseInt(hex.slice(1), 16);
const r = Math.round(((n >> 16) & 255) * factor);
const g = Math.round(((n >> 8) & 255) * factor);
const b = Math.round((n & 255) * factor);
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
}
export function useTeamColors() {
// Shuffled once per composable instance (i.e. per page-load).
const colors = shuffled(PALETTE);
/** Build a teamId -> color map from an ordered list of team ids. */
function assign(orderedTeamIds: number[]): Map<number, string> {
const map = new Map<number, string>();
orderedTeamIds.forEach((id, i) => {
map.set(id, colors[i % colors.length]!);
});
return map;
}
return { assign, colors, darken };
}