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
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import {
type TrackableHunt,
useGeoTracking
} from '~/composables/useGeoTracking';
const props = defineProps<{
hunt: TrackableHunt;
seedDistance?: number;
}>();
const { t, locale } = useI18n();
const { formatDistance } = useFormatDistance();
const {
distance,
lastFix,
paused,
consented,
eligible,
gate,
isSupported,
start,
stop,
toggleConsent
} = useGeoTracking(props.hunt, props.seedDistance ?? 0);
// Ticking clock so the "last fix … ago" label stays live while the card is open.
const nowTs = ref(Date.now());
let clock: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
clock = setInterval(() => (nowTs.value = Date.now()), 1000);
});
onScopeDispose(() => {
if (clock !== undefined) clearInterval(clock);
});
const lastFixAgo = computed(() => {
if (!lastFix.value) return '';
const seconds = Math.max(
0,
Math.round((nowTs.value - lastFix.value.getTime()) / 1000)
);
return new Intl.RelativeTimeFormat(locale.value || 'en', {
numeric: 'auto'
}).format(-seconds, 'second');
});
</script>
<template>
<VaCard
v-if="eligible"
stripe
:stripe-color="gate && !paused ? 'success' : 'primary'"
>
<VaCardTitle>{{ t('page.hunt.tracking.title') }}</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-3">
<VaAlert v-if="!isSupported" color="warning">
{{ t('page.hunt.tracking.unsupported') }}
</VaAlert>
<VaSwitch
:model-value="consented"
:disabled="!isSupported"
:label="t('page.hunt.tracking.consent')"
@update:model-value="toggleConsent"
/>
<p class="text-sm opacity-70">
{{ t('page.hunt.tracking.consent_explain') }}
</p>
<template v-if="consented">
<div class="grid grid-cols-2 gap-2 text-lg md:grid-cols-3">
<VaChip :color="paused ? 'secondary' : 'success'">
{{
t(
paused
? 'page.hunt.tracking.status_paused'
: 'page.hunt.tracking.status_tracking'
)
}}
</VaChip>
<span class="font-bold">{{ formatDistance(distance) }}</span>
<span
v-if="lastFixAgo"
class="col-span-2 text-sm opacity-70 md:col-span-1"
>
{{ t('page.hunt.tracking.last_fix', { ago: lastFixAgo }) }}
</span>
</div>
<VaButton
:disabled="!isSupported"
:icon="paused ? 'play_arrow' : 'pause'"
@click="paused ? start() : stop()"
>
{{
t(
paused
? 'page.hunt.tracking.resume'
: 'page.hunt.tracking.pause'
)
}}
</VaButton>
</template>
</div>
</VaCardContent>
</VaCard>
</template>
+5 -1
View File
@@ -5,6 +5,7 @@ import { fullDate } from '~~/server/utils/date';
const { confirm } = useModal();
const { init } = useToast();
const { t, d } = useI18n();
const { formatDistance } = useFormatDistance();
const props = defineProps<{
team: Team;
@@ -151,7 +152,7 @@ async function deleteTeam() {
<VaCollapse
:key="team.id"
color="info"
:header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points`"
:header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points — ${formatDistance(team.distance)}`"
>
<div class="flex flex-col gap-2">
<h2 class="text-xl">
@@ -171,6 +172,9 @@ async function deleteTeam() {
by {{ team.owner.name }} ({{ team.owner.email }})
</p>
<p>{{ team._count.answers }} answers</p>
<p v-if="team.distance > 0">
{{ t('page.common.distance') }}: {{ formatDistance(team.distance) }}
</p>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3 lg:grid-cols-5">
<VaButton
v-for="member in team.members"
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import type { HuntMapData } from '#shared/types/map';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useI18nKey } from '~/composables/useI18nKey';
import { darken } from '~/composables/useTeamColors';
import { fullDate } from '~~/server/utils/date';
const props = defineProps<{
huntId: number;
huntSlug: string;
data: HuntMapData;
colors: Map<number, string>;
showAnswers: boolean;
showPositions: boolean;
selectedUserId: number | null;
}>();
const emit = defineEmits<{
(e: 'select-user', userId: number | null): void;
}>();
const { t, d, locale } = useI18n();
const { tSluggy } = useI18nKey();
const localePath = useLocalePath();
const container = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let tile: L.TileLayer | null = null;
let answerLayer: L.LayerGroup | null = null;
let positionLayer: L.LayerGroup | null = null;
let trailLayer: L.LayerGroup | null = null;
let fitted = false;
// Lookup maps rebuilt whenever data changes.
const teamName = computed(() => {
const m = new Map<number, string>();
for (const team of props.data.teams) m.set(team.id, team.name);
return m;
});
const userName = computed(() => {
const m = new Map<number, string>();
for (const team of props.data.teams) {
m.set(team.owner.id, team.owner.name);
for (const mem of team.members) m.set(mem.id, mem.name);
}
return m;
});
const userTeam = computed(() => {
const m = new Map<number, number>();
for (const team of props.data.teams) {
m.set(team.owner.id, team.id);
for (const mem of team.members) m.set(mem.id, team.id);
}
return m;
});
function colorForTeam(teamId: number): string {
return props.colors.get(teamId) ?? '#808080';
}
function pinIcon(color: string): L.DivIcon {
return L.divIcon({
className: '',
html: `<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
<path d="M14 0C6.3 0 0 6.3 0 14c0 10.5 14 26 14 26s14-15.5 14-26C28 6.3 21.7 0 14 0z" fill="${color}" stroke="#fff" stroke-width="1.5"/>
<circle cx="14" cy="14" r="5" fill="#fff"/>
</svg>`,
iconSize: [28, 40],
iconAnchor: [14, 40],
popupAnchor: [0, -38]
});
}
function relTime(iso: string): string {
const seconds = Math.max(
0,
Math.round((Date.now() - new Date(iso).getTime()) / 1000)
);
return new Intl.RelativeTimeFormat(locale.value || 'en', {
numeric: 'auto'
}).format(-seconds, 'second');
}
function questAdminHref(quest: {
id: number;
title_de: string;
title_en: string;
}) {
return localePath(`/hunt/${props.huntSlug}/${tSluggy(quest).value}/admin`);
}
function answerPopup(a: HuntMapData['answers'][number]): string {
const title =
locale.value === 'en'
? a.quest.title_en
: (a.quest.title_de ?? a.quest.title_en);
const team = teamName.value.get(a.teamId) ?? '';
const member = userName.value.get(a.memberId) ?? '';
const time = d(new Date(a.createdAt), fullDate);
const thumb = a.pictureUrl
? `<img src="${a.pictureUrl}" alt="" style="max-width:160px;max-height:120px;border-radius:4px;margin:4px 0;" />`
: '';
return `<div style="min-width:180px">
<strong>${title}</strong><br/>
<span>${t('page.map.team')}: ${team}</span><br/>
<span>${t('page.map.member')}: ${member}</span><br/>
<span>${t('page.map.time')}: ${time}</span>
${thumb}
<a href="${questAdminHref(a.quest)}" target="_blank">${t('page.map.open_quest_admin')}</a>
</div>`;
}
function positionPopup(p: HuntMapData['positions'][number]): string {
const teamId = userTeam.value.get(p.userId);
const team = teamId != null ? (teamName.value.get(teamId) ?? '') : '';
const name = userName.value.get(p.userId) ?? '';
const acc =
p.accuracy != null
? `<br/><span>${t('page.map.accuracy')}: ${Math.round(p.accuracy)} m</span>`
: '';
return `<div style="min-width:160px">
<strong>${name}</strong><br/>
<span>${t('page.map.team')}: ${team}</span><br/>
<span>${t('page.map.last_fix', { ago: relTime(p.recordedAt) })}</span>${acc}
</div>`;
}
function renderAnswers() {
if (!map || !answerLayer) return;
answerLayer.clearLayers();
if (!props.showAnswers) return;
for (const a of props.data.answers) {
const marker = L.marker([a.lat, a.lon], {
icon: pinIcon(colorForTeam(a.teamId))
}).bindPopup(answerPopup(a));
answerLayer.addLayer(marker);
}
}
function renderPositions() {
if (!map || !positionLayer) return;
positionLayer.clearLayers();
if (!props.showPositions) return;
for (const p of props.data.positions) {
const teamId = userTeam.value.get(p.userId);
const color = teamId != null ? colorForTeam(teamId) : '#808080';
const dot = L.circleMarker([p.lat, p.lon], {
radius: 7,
color: '#fff',
weight: 2,
fillColor: color,
fillOpacity: 1
}).bindPopup(positionPopup(p));
dot.on('click', () => {
emit('select-user', props.selectedUserId === p.userId ? null : p.userId);
});
positionLayer.addLayer(dot);
}
}
function fitOnce() {
if (!map || fitted) return;
const pts: L.LatLngExpression[] = [
...(props.showAnswers
? props.data.answers.map((a) => [a.lat, a.lon] as L.LatLngExpression)
: []),
...(props.showPositions
? props.data.positions.map((p) => [p.lat, p.lon] as L.LatLngExpression)
: [])
];
if (pts.length === 0) return;
const bounds = L.latLngBounds(pts);
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 17 });
fitted = true;
}
async function renderTrail() {
if (!map || !trailLayer) return;
trailLayer.clearLayers();
const uid = props.selectedUserId;
if (uid == null) return;
const { points } = await $fetch<{
points: {
lat: number;
lon: number;
accuracy: number | null;
recordedAt: string;
}[];
}>(`/api/admin/hunt/${props.huntId}/track/${uid}`);
if (points.length === 0) return;
const teamId = userTeam.value.get(uid);
const base = teamId != null ? colorForTeam(teamId) : '#808080';
const latlngs = points.map((p) => [p.lat, p.lon] as L.LatLngExpression);
const line = L.polyline(latlngs, {
color: darken(base, 0.75),
weight: 4,
opacity: 0.9
});
trailLayer.addLayer(line);
// Start + end markers for clarity.
const start = L.circleMarker(latlngs[0]!, {
radius: 5,
color: '#fff',
weight: 2,
fillColor: base,
fillOpacity: 1
});
trailLayer.addLayer(start);
map.fitBounds(line.getBounds(), { padding: [50, 50], maxZoom: 18 });
}
function initMap() {
if (!container.value || map) return;
map = L.map(container.value, { zoomControl: true }).setView([51.0, 10.0], 6);
tile = L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap-Mitwirkende',
maxZoom: 19
});
map.addLayer(tile);
trailLayer = L.layerGroup().addTo(map);
answerLayer = L.layerGroup().addTo(map);
positionLayer = L.layerGroup().addTo(map);
renderAnswers();
renderPositions();
fitOnce();
}
onMounted(() => {
initMap();
});
onBeforeUnmount(() => {
if (map) {
map.remove();
map = null;
}
});
// Re-render markers on data/toggle changes (trail preserved separately).
watch(
() => props.data,
() => {
renderAnswers();
renderPositions();
fitOnce();
},
{ deep: true }
);
watch(() => props.showAnswers, renderAnswers);
watch(() => props.showPositions, renderPositions);
watch(() => props.selectedUserId, renderTrail, { immediate: true });
</script>
<template>
<div ref="container" class="livemap" />
</template>
<style scoped>
.livemap {
width: 100%;
height: 100%;
min-height: 400px;
z-index: 0;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
defineProps<{
members: string[];
}>();
</script>
<template>
<div
class="m-4 flex flex-col justify-center gap-2 rounded-lg p-4 text-4xl shadow-lg"
>
<span v-for="(member, i) in members" :key="i">
{{ member }}
</span>
</div>
</template>
<style scoped></style>
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
defineProps<{
count: number | string;
value?: string;
count?: number | string;
title: string;
icon: string;
}>();
@@ -8,7 +9,7 @@ defineProps<{
<template>
<div class="card">
<h3>{{ Number(count).toLocaleString('de') }}</h3>
<h3>{{ value ? value : Number(count).toLocaleString('de') }}</h3>
<VaIcon :name="icon" size="10rem" />
<p>{{ title }}</p>
</div>