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,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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user