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