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
@@ -74,6 +74,14 @@ const answer = ref(data.value?.answer?.text);
const final = ref(data.value?.answer?.final || false);
const privateImage = ref(data.value?.answer?.picture?.private ?? false);
const trackableHunt = data.value?.hunt ?? null;
const geo = trackableHunt ? useGeoTracking(trackableHunt) : null;
// Submit-location slice: independent of the tracking watch loop. The checkbox
// is offered only when the user consented and the hunt is tracking-eligible.
const sendLocation = ref(unref(geo?.consented?.value ?? false));
const locationNotice = ref('');
const privateChanged = computed(
() => (data.value?.answer?.picture?.private ?? false) !== privateImage.value
);
@@ -105,6 +113,7 @@ useEventListener(window, 'beforeunload', (ev) => {
async function submit() {
uploading.value = true;
submitError.value = '';
locationNotice.value = '';
uploadProgress.value = 0;
const body = new FormData();
@@ -127,6 +136,16 @@ async function submit() {
compressing.value = false;
body.set('image', compressedImage);
}
if (sendLocation.value && geo) {
try {
const pos = await geo.getPosition();
body.set('lat', String(pos.lat));
body.set('lon', String(pos.lon));
} catch {
// Non-blocking: submit without the location and tell the user.
locationNotice.value = t('page.quest.location_unavailable');
}
}
const xhr = new XMLHttpRequest();
try {
@@ -149,6 +168,7 @@ async function submit() {
answer.value = data.value?.answer?.text;
privateImage.value = data.value?.answer?.picture?.private ?? false;
newImage.value = undefined;
sendLocation.value = false;
} catch (e) {
if (xhr.status === 418) {
submitError.value = t('page.quest.already_finalized');
@@ -353,7 +373,20 @@ async function submit() {
:label="t('page.quest.finalize')"
:messages="t('page.quest.finalize_explain')"
/>
<VaCheckbox
v-if="geo?.gate"
v-model="sendLocation"
:disabled="uploading || data.answer?.final"
:label="t('page.quest.send_location')"
:messages="t('page.quest.send_location_explain')"
/>
<VaAlert
v-if="locationNotice.length > 0"
color="warning"
class="w-full text-center"
>{{ locationNotice }}</VaAlert
>
<VaAlert
v-if="submitError.length > 0"
color="danger"
@@ -367,7 +400,8 @@ async function submit() {
newImage ||
data.answer?.text !== answer ||
data.answer?.final !== final ||
privateChanged
privateChanged ||
sendLocation
) || uploading
"
:loading="uploading"
+4
View File
@@ -286,6 +286,10 @@ async function deleteImage() {
v-model="formData.cloned.value.virtual"
:label="t('page.create_hunt.virtual')"
/>
<VaCheckbox
v-model="formData.cloned.value.trackDistance"
:label="t('page.create_hunt.track_distance')"
/>
<VaCheckbox
v-model="formData.cloned.value.public"
:label="t('page.create_hunt.public')"
+8
View File
@@ -25,6 +25,7 @@ const teamSettingsOpen = ref(false);
const { data, pending, refresh, error } = await useFetch(`/api/hunt/${id}`);
huntStore.setMembership(id, data.value?.isMember ?? false);
huntStore.setTrackDistance(id, data.value?.trackDistance ?? false);
const hasBingo = computed(
() => data.value?.quests?.some((q) => q.isBingo) ?? false
@@ -282,6 +283,13 @@ ${document.location.href}`
</VaCardContent>
</VaCard>
</div>
<ClientOnly v-if="data.ownTeam !== null">
<TrackDistanceCard
:hunt="data"
:seed-distance="data.ownDistance ?? 0"
class="my-4"
/>
</ClientOnly>
<VaDivider />
<VaCard
v-if="data.ownTeam === null && !data.isMember"
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { parseError, sluggy } from '#imports';
import type { HuntMapData } from '#shared/types/map';
import { useTeamColors } from '~/composables/useTeamColors';
import { useHuntStore } from '~/stores/hunt';
import { useTitleStore } from '~/stores/title';
definePageMeta({
middleware: ['auth']
});
const app = useNuxtApp();
const { loggedIn } = useUserSession();
const { t } = useI18n();
const localePath = useLocalePath();
const router = useRouter();
const { id } = app.$slugData.huntSlug!;
const huntSlug = sluggy(app.$slugData.huntSlug!);
const titleStore = useTitleStore();
const huntStore = useHuntStore();
const POLL_MS = 15_000;
const { data, error, refresh } = await useFetch<HuntMapData>(
`/api/admin/hunt/${id}/map`
);
// The map endpoint 404s for non-members / tracking-off hunts; reflect that in
// the store so the layout nav stays consistent.
huntStore.setMembership(id, !error.value);
huntStore.setTrackDistance(id, !error.value);
titleStore.title = t('page.map.title');
watch(loggedIn, (isLoggedIn) => {
if (!isLoggedIn) {
router.push(localePath('/login'));
}
});
// Team colors: shuffled once per page-load, assigned by stable team order.
const { assign } = useTeamColors();
const teamColors = computed(() =>
assign((data.value?.teams ?? []).map((team) => team.id))
);
// Layer toggles + trail selection.
const showAnswers = ref(true);
const showPositions = ref(true);
const selectedUserId = ref<number | null>(null);
function toggleUser(userId: number) {
selectedUserId.value = selectedUserId.value === userId ? null : userId;
}
// "Last updated" timestamp for silent-failure polling feedback.
const lastUpdated = ref<Date | null>(null);
watch(
data,
() => {
if (data.value) lastUpdated.value = new Date();
},
{ immediate: true }
);
// 15s polling, paused while the tab is hidden, silent on failure (refresh()
// keeps the previous data on error).
let timer: ReturnType<typeof setInterval> | undefined;
function startPolling() {
if (timer !== undefined) return;
timer = setInterval(() => {
if (import.meta.client && document.visibilityState === 'hidden') return;
void refresh();
}, POLL_MS);
}
function stopPolling() {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
}
function onVisibilityChange() {
if (!import.meta.client) return;
if (document.visibilityState === 'visible') {
void refresh();
startPolling();
}
}
onMounted(() => {
startPolling();
document.addEventListener('visibilitychange', onVisibilityChange);
});
onScopeDispose(() => {
stopPolling();
if (import.meta.client) {
document.removeEventListener('visibilitychange', onVisibilityChange);
}
});
// Roster: teams with owner + members flattened for display.
const roster = computed(() =>
(data.value?.teams ?? []).map((team) => ({
id: team.id,
name: team.name,
color: teamColors.value.get(team.id) ?? '#808080',
users: [team.owner, ...team.members.filter((m) => m.id !== team.owner.id)]
}))
);
</script>
<template>
<div>
<div v-if="error">
<VaAlert color="danger" class="my-4">
{{ t(parseError(error)) }}
</VaAlert>
<VaButton icon="arrow_back" :to="localePath(`/hunt/${huntSlug}`)">
{{ t('page.common.back_to_hunt') }}
</VaButton>
</div>
<template v-else>
<div class="mb-2 flex flex-col gap-3 md:flex-row md:items-center">
<h1 class="text-2xl">{{ t('page.map.title') }}</h1>
<VaSpacer />
<VaSwitch v-model="showAnswers" :label="t('page.map.layer_answers')" />
<VaSwitch
v-model="showPositions"
:label="t('page.map.layer_positions')"
/>
<VaButton icon="replay" :disabled="!data" @click="refresh">
{{ t('page.common.refresh') }}
</VaButton>
</div>
<p v-if="lastUpdated" class="mb-2 text-sm opacity-70">
{{
t('page.map.last_updated', {
time: lastUpdated.toLocaleTimeString()
})
}}
</p>
<div class="flex flex-col gap-4 md:flex-row">
<div class="h-[80vh] min-h-[400px] flex-grow">
<LazyMapLiveMap
v-if="data"
:hunt-id="id"
:hunt-slug="huntSlug"
:data="data"
:colors="teamColors"
:show-answers="showAnswers"
:show-positions="showPositions"
:selected-user-id="selectedUserId"
@select-user="selectedUserId = $event"
/>
</div>
<VaCard class="w-full md:max-w-xs">
<VaCardTitle>{{ t('page.map.roster') }}</VaCardTitle>
<VaCardContent>
<p v-if="roster.length === 0" class="text-sm opacity-70">
{{ t('page.map.no_teams') }}
</p>
<ul v-else class="flex flex-col gap-3">
<li v-for="team in roster" :key="team.id">
<div class="flex items-center gap-2 font-bold">
<span
class="inline-block h-4 w-4 rounded-full"
:style="{ backgroundColor: team.color }"
/>
{{ team.name }}
</div>
<ul class="ml-6 mt-1 flex flex-col gap-1">
<li v-for="u in team.users" :key="u.id">
<VaButton
size="small"
preset="secondary"
:color="selectedUserId === u.id ? 'primary' : 'secondary'"
@click="toggleUser(u.id)"
>
{{ u.name }}
</VaButton>
</li>
</ul>
</li>
</ul>
</VaCardContent>
</VaCard>
</div>
</template>
</div>
</template>
<style scoped></style>
+41 -4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { sluggy } from '#imports';
import type { DataTableColumnSource } from 'vuestic-ui';
import MemberCard from '~/components/showcase/MemberCard.vue';
import NumberCard from '~/components/showcase/NumberCard.vue';
import { fullDate } from '~~/server/utils/date';
@@ -10,6 +11,7 @@ definePageMeta({
const { t, d } = useI18n();
const localePath = useLocalePath();
const { tKey, hasKey, tSluggy } = useI18nKey();
const { formatDistance } = useFormatDistance();
const app = useNuxtApp();
const { name, id } = app.$slugData.huntSlug!;
@@ -32,11 +34,23 @@ const leaderboardColumns: DataTableColumnSource[] = [
key: 'score',
displayFormatFn: (data: number) => Number(data).toLocaleString('de')
},
'answers'
'answers',
...(data.value?.trackDistance
? ([
{
key: 'distance',
displayFormatFn: (value: number) => formatDistance(value)
}
] as DataTableColumnSource[])
: [])
];
const columns = leaderboardColumns.map((c) =>
typeof c === 'string' ? c : c.key
);
const totalDistance = computed(
() => data.value?.leaderboard?.reduce((ac, l) => ac + l.distance, 0) ?? 0
);
// TODO: colors
</script>
@@ -76,9 +90,9 @@ const columns = leaderboardColumns.map((c) =>
<section id="numbers">
<div class="grid grid-cols-3 gap-4 bg-red-100 p-4">
<NumberCard
:title="t('page.showcase.crew')"
:count="data._count.members + 1"
icon="support_agent"
:title="t('page.showcase.distance')"
:value="formatDistance(totalDistance)"
icon="route"
/>
<NumberCard
:title="t('page.showcase.quests')"
@@ -188,6 +202,12 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[2].answers"
icon="question_mark"
/>
<NumberCard
:title="t('page.showcase.distance')"
:value="formatDistance(data.leaderboard[2].distance)"
icon="route"
/>
<MemberCard :members="data.leaderboard[2].members" />
</div>
</div>
</section>
@@ -210,6 +230,12 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[1].answers"
icon="question_mark"
/>
<NumberCard
:title="t('page.showcase.distance')"
:value="formatDistance(data.leaderboard[1].distance)"
icon="route"
/>
<MemberCard :members="data.leaderboard[1].members" />
</div>
</div>
</section>
@@ -236,6 +262,11 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[0].answers"
icon="question_mark"
/>
<NumberCard
:title="t('page.showcase.distance')"
:value="formatDistance(data.leaderboard[0].distance)"
icon="route"
/>
</div>
</div>
</section>
@@ -258,6 +289,12 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[0].answers"
icon="question_mark"
/>
<NumberCard
:title="t('page.showcase.distance')"
:value="formatDistance(data.leaderboard[0].distance)"
icon="route"
/>
<MemberCard :members="data.leaderboard[0].members" />
</div>
</div>
</section>