262 lines
7.1 KiB
TypeScript
262 lines
7.1 KiB
TypeScript
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
|
|
};
|
|
}
|