Files
Pascal 61cc516f47
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
feat(hunt): track walking distance via GPS, show live locations on map
Add foreground-only GPS distance tracking for hunts
2026-09-27 20:55:58 +02:00

201 lines
5.7 KiB
Vue

<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>