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:
@@ -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"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user