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:
@@ -37,12 +37,12 @@
|
||||
- [x] Leave/Remove/Edit team
|
||||
- [ ] Support chat
|
||||
- [ ] Copy team from different hunt
|
||||
- [ ] Get Geo-Location
|
||||
- [x] Get Geo-Location
|
||||
- [-] View other answers
|
||||
- [ ] View other reviews and scores
|
||||
- [x] Process image uploads (resize, remove EXIF, etc.)
|
||||
- [ ] Review other answers (Fan-Favorite)
|
||||
- [ ] Track walking distance
|
||||
- [x] Track walking distance
|
||||
- [ ] Notifications
|
||||
- **General**
|
||||
- [x] Pretty slugified urls
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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: '© 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Locale-aware walking-distance formatting.
|
||||
*
|
||||
* Distances are stored as Int meters. Under 1000 m they render as whole
|
||||
* meters, above that as kilometers with one decimal (e.g. "1.2 km"). Reused by
|
||||
* the tracking card, the team leaderboards and the profile distances.
|
||||
*/
|
||||
export function useFormatDistance() {
|
||||
const { locale } = useI18n();
|
||||
|
||||
function formatDistance(meters: number | null | undefined): string {
|
||||
const value = meters ?? 0;
|
||||
const loc = locale.value || 'en';
|
||||
|
||||
if (value < 1000) {
|
||||
return new Intl.NumberFormat(loc, {
|
||||
style: 'unit',
|
||||
unit: 'meter',
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.round(value));
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(loc, {
|
||||
style: 'unit',
|
||||
unit: 'kilometer',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value / 1000);
|
||||
}
|
||||
|
||||
return { formatDistance };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
computeTrackDistance,
|
||||
type GeoPoint,
|
||||
haversine
|
||||
} from '#shared/utils/geo';
|
||||
import { useSettingsStore } from '~/stores/settings';
|
||||
|
||||
export type TrackableHunt = {
|
||||
id: number;
|
||||
trackDistance: boolean;
|
||||
virtual: boolean;
|
||||
start?: Date | string | null;
|
||||
end?: Date | string | null;
|
||||
};
|
||||
|
||||
/** Flush cadence and the key prefix for the offline retry queue. */
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
const QUEUE_PREFIX = 'pichunt:track:queue:';
|
||||
const MIN_DISTANCE = 0.9;
|
||||
|
||||
/**
|
||||
* Foreground GPS distance tracking for a single hunt.
|
||||
*
|
||||
* Independent paths by design:
|
||||
* - the watch loop buffers fixes and flushes them to `POST /api/hunt/:id/track`
|
||||
* via `navigator.sendBeacon` (tracking);
|
||||
* - {@link getPosition} is a one-shot read used by the answer submit form.
|
||||
*
|
||||
* Consent is client-only and persisted per hunt in the settings store; the
|
||||
* server enforces the hunt flag, window and membership, never consent.
|
||||
*/
|
||||
export function useGeoTracking(hunt: TrackableHunt, seedDistance = 0) {
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const consented = computed(() => settings.hasTrackConsent(hunt.id));
|
||||
|
||||
// Re-evaluated on the flush tick so the window stays fresh while the page
|
||||
// is open. Plain Date.now() keeps this SSR-safe (no timers on the server).
|
||||
const nowTs = ref(Date.now());
|
||||
|
||||
const withinWindow = computed(() => {
|
||||
const start = hunt.start ? new Date(hunt.start).getTime() : null;
|
||||
const end = hunt.end ? new Date(hunt.end).getTime() : null;
|
||||
if (start !== null && nowTs.value < start) return false;
|
||||
if (end !== null && nowTs.value > end) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/** The tracking card may be shown (independent of consent). */
|
||||
const eligible = computed(
|
||||
() => hunt.trackDistance && !hunt.virtual && withinWindow.value
|
||||
);
|
||||
|
||||
/** Tracking is active only when eligible and the user consented. */
|
||||
const gate = computed(() => eligible.value && consented.value);
|
||||
|
||||
const { isSupported, coords, locatedAt, resume, pause } = useGeolocation({
|
||||
enableHighAccuracy: true,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const tracking = ref(false);
|
||||
const userPaused = ref(false);
|
||||
const hidden = ref(false);
|
||||
const paused = computed(() => userPaused.value || hidden.value);
|
||||
|
||||
const lastFix = ref<Date | null>(null);
|
||||
const baseDistance = ref(seedDistance);
|
||||
const sessionFixes = ref<GeoPoint[]>([]);
|
||||
|
||||
const distance = computed(
|
||||
() => baseDistance.value + computeTrackDistance(sessionFixes.value)
|
||||
);
|
||||
|
||||
// --- offline queue -------------------------------------------------------
|
||||
const queueKey = `${QUEUE_PREFIX}${hunt.id}`;
|
||||
let pending: GeoPoint[] = loadQueue();
|
||||
|
||||
function loadQueue(): GeoPoint[] {
|
||||
if (!import.meta.client) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(queueKey);
|
||||
return raw ? (JSON.parse(raw) as GeoPoint[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistQueue() {
|
||||
if (!import.meta.client) return;
|
||||
try {
|
||||
if (pending.length > 0) {
|
||||
localStorage.setItem(queueKey, JSON.stringify(pending));
|
||||
} else {
|
||||
localStorage.removeItem(queueKey);
|
||||
}
|
||||
} catch {
|
||||
// Ignore quota/serialization errors; the in-memory buffer still flushes.
|
||||
}
|
||||
}
|
||||
|
||||
/** Send the buffered fixes; re-queue them if the beacon could not be sent. */
|
||||
function flush() {
|
||||
if (!import.meta.client || pending.length === 0) return;
|
||||
const batch = pending;
|
||||
pending = [];
|
||||
persistQueue();
|
||||
|
||||
let queued = false;
|
||||
try {
|
||||
queued =
|
||||
'sendBeacon' in navigator &&
|
||||
navigator.sendBeacon(
|
||||
`/api/hunt/${hunt.id}/track`,
|
||||
new Blob([JSON.stringify(batch)], { type: 'application/json' })
|
||||
);
|
||||
} catch {
|
||||
queued = false;
|
||||
}
|
||||
|
||||
if (!queued) {
|
||||
// Network/beacon failure: keep the batch for the next flush or 'online'.
|
||||
pending = [...batch, ...pending];
|
||||
persistQueue();
|
||||
}
|
||||
}
|
||||
|
||||
// --- watch loop ----------------------------------------------------------
|
||||
function onFix() {
|
||||
const at = locatedAt.value;
|
||||
const c = coords.value;
|
||||
if (at == null || !c) return;
|
||||
const recordedAt = new Date(at);
|
||||
const fix: GeoPoint = {
|
||||
lat: c.latitude,
|
||||
lon: c.longitude,
|
||||
accuracy: c.accuracy ?? null,
|
||||
recordedAt
|
||||
};
|
||||
const last = sessionFixes.value.at(-1);
|
||||
const distToLast = last ? haversine(last, fix) : Number.MAX_VALUE;
|
||||
if (distToLast < MIN_DISTANCE) {
|
||||
return;
|
||||
}
|
||||
sessionFixes.value = [...sessionFixes.value, fix];
|
||||
lastFix.value = recordedAt;
|
||||
pending.push(fix);
|
||||
}
|
||||
|
||||
function beginWatch() {
|
||||
if (!import.meta.client || !isSupported.value || tracking.value) return;
|
||||
resume();
|
||||
tracking.value = true;
|
||||
}
|
||||
|
||||
function endWatch() {
|
||||
if (!tracking.value) return;
|
||||
pause();
|
||||
tracking.value = false;
|
||||
}
|
||||
|
||||
function start() {
|
||||
userPaused.value = false;
|
||||
if (gate.value) beginWatch();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
userPaused.value = true;
|
||||
endWatch();
|
||||
flush();
|
||||
}
|
||||
|
||||
function toggleConsent() {
|
||||
const next = !consented.value;
|
||||
settings.setTrackConsent(hunt.id, next);
|
||||
if (next) start();
|
||||
else stop();
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!import.meta.client) return;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hidden.value = true;
|
||||
flush();
|
||||
endWatch();
|
||||
} else {
|
||||
hidden.value = false;
|
||||
if (gate.value && !userPaused.value) beginWatch();
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot position read for the submit form; independent of the watch. */
|
||||
function getPosition(timeoutMs = 10_000): Promise<{
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!import.meta.client || !('geolocation' in navigator)) {
|
||||
reject(new Error('geolocation unsupported'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({
|
||||
lat: pos.coords.latitude,
|
||||
lon: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy ?? null
|
||||
}),
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
watch(locatedAt, onFix);
|
||||
watch(gate, (open) => {
|
||||
if (open && !userPaused.value && !hidden.value) beginWatch();
|
||||
else if (!open) endWatch();
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return;
|
||||
window.addEventListener('online', flush);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('pagehide', flush);
|
||||
timer = setInterval(() => {
|
||||
nowTs.value = Date.now();
|
||||
flush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
if (gate.value && !userPaused.value) beginWatch();
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
flush();
|
||||
endWatch();
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
if (import.meta.client) {
|
||||
window.removeEventListener('online', flush);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('pagehide', flush);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
distance,
|
||||
lastFix,
|
||||
tracking,
|
||||
paused,
|
||||
consented,
|
||||
eligible,
|
||||
gate,
|
||||
isSupported,
|
||||
start,
|
||||
stop,
|
||||
toggleConsent,
|
||||
getPosition
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Client-only team color assignment for the live map.
|
||||
*
|
||||
* A fixed 20-color high-contrast palette is shuffled once per page-load and
|
||||
* assigned to teams by their stable order (the order returned by the map
|
||||
* endpoint). Colors are team-shared but NOT persisted, so a refresh may
|
||||
* reassign them. Every member of a team resolves to the same color.
|
||||
*/
|
||||
|
||||
const PALETTE = [
|
||||
'#e6194b',
|
||||
'#3cb44b',
|
||||
'#4363d8',
|
||||
'#f58231',
|
||||
'#911eb4',
|
||||
'#46f0f0',
|
||||
'#f032e6',
|
||||
'#bcf60c',
|
||||
'#fabebe',
|
||||
'#008080',
|
||||
'#e6beff',
|
||||
'#9a6324',
|
||||
'#fffac8',
|
||||
'#800000',
|
||||
'#aaffc3',
|
||||
'#808000',
|
||||
'#ffd8b1',
|
||||
'#000075',
|
||||
'#808080',
|
||||
'#ff4d00'
|
||||
] as const;
|
||||
|
||||
function shuffled<T>(input: readonly T[]): T[] {
|
||||
const arr = [...input];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Darken a hex color by a factor (0..1) for trail lines. */
|
||||
export function darken(hex: string, factor = 0.75): string {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = Math.round(((n >> 16) & 255) * factor);
|
||||
const g = Math.round(((n >> 8) & 255) * factor);
|
||||
const b = Math.round((n & 255) * factor);
|
||||
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
export function useTeamColors() {
|
||||
// Shuffled once per composable instance (i.e. per page-load).
|
||||
const colors = shuffled(PALETTE);
|
||||
|
||||
/** Build a teamId -> color map from an ordered list of team ids. */
|
||||
function assign(orderedTeamIds: number[]): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
orderedTeamIds.forEach((id, i) => {
|
||||
map.set(id, colors[i % colors.length]!);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
return { assign, colors, darken };
|
||||
}
|
||||
@@ -90,6 +90,7 @@
|
||||
"back_home": "Zurück zur Startseite",
|
||||
"back_to_hunt": "Zurück zum Hunt",
|
||||
"cancel": "Abbrechen",
|
||||
"distance": "Strecke",
|
||||
"join": "Beitreten",
|
||||
"login_first": "Erst anmelden",
|
||||
"no_pictures": "Es wurden noch keine Bilder abgeschickt :(",
|
||||
@@ -118,6 +119,7 @@
|
||||
"start": "Start",
|
||||
"success": "Hunt erfolgreich erstellt!",
|
||||
"title": "Neuen Hunt erstellen",
|
||||
"track_distance": "Gelaufene Strecke aufzeichnen",
|
||||
"virtual": "Virtueller Hunt"
|
||||
},
|
||||
"diashow": {
|
||||
@@ -166,6 +168,17 @@
|
||||
"teams": "Teams",
|
||||
"title": "Hunts",
|
||||
"total_points": "Punktestand: {score}",
|
||||
"tracking": {
|
||||
"consent": "Meine gelaufene Strecke aufzeichnen",
|
||||
"consent_explain": "Solange dies aktiv ist, wird dein Standort im Hintergrund aufgezeichnet, während diese Hunt-Seite geöffnet ist, und daraus die gelaufene Strecke berechnet. Wenn der Tab ausgeblendet ist oder du pausierst, wird nichts aufgezeichnet. Dies ermöglicht uns zu sehen, wo alle Teams unterwegs sind.",
|
||||
"last_fix": "letzter Punkt {ago}",
|
||||
"pause": "Pause",
|
||||
"resume": "Fortsetzen",
|
||||
"status_paused": "Pausiert",
|
||||
"status_tracking": "Aufzeichnung läuft",
|
||||
"title": "Gelaufene Strecke",
|
||||
"unsupported": "Die Standortbestimmung wird von deinem Browser nicht unterstützt."
|
||||
},
|
||||
"your_team": "Dein Team:"
|
||||
},
|
||||
"imprint": {
|
||||
@@ -188,6 +201,21 @@
|
||||
"step_win_text": "Sammle Punkte, klettere die Rangliste hinauf und feiere in der Showcase.",
|
||||
"step_win_title": "Punkte gewinnen"
|
||||
},
|
||||
"map": {
|
||||
"accuracy": "Genauigkeit",
|
||||
"last_fix": "letzte Position {ago}",
|
||||
"last_updated": "Zuletzt aktualisiert: {time}",
|
||||
"layer_answers": "Antworten",
|
||||
"layer_positions": "Live-Positionen",
|
||||
"member": "Mitglied",
|
||||
"nav": "Live-Karte",
|
||||
"no_teams": "Noch keine Teams.",
|
||||
"open_quest_admin": "Quest-Administration öffnen",
|
||||
"roster": "Teams & Spieler",
|
||||
"team": "Team",
|
||||
"time": "Zeit",
|
||||
"title": "Live-Karte"
|
||||
},
|
||||
"pictures": {
|
||||
"filter_quest": "Filtere Bilder nach Quests",
|
||||
"filter_team": "Filtere Bilder nach Teams",
|
||||
@@ -197,6 +225,11 @@
|
||||
},
|
||||
"profile": {
|
||||
"admins_and_creators": "Admins & Ersteller",
|
||||
"distances": {
|
||||
"no_distances": "Noch keine Strecken aufgezeichnet.",
|
||||
"title": "Deine Strecken",
|
||||
"total": "Gesamt: {distance}"
|
||||
},
|
||||
"new_password": "Neues Passwort",
|
||||
"no_pictures": "Noch keine Bilder hochgeladen.",
|
||||
"old_password": "Altes Passwort",
|
||||
@@ -220,12 +253,15 @@
|
||||
"finalize": "Antwort finalisieren",
|
||||
"finalize_explain": "Dies gibt den Admins die Möglichkeit deine Antwort zu bewerten und dir Punkte zu geben. Du kannst deine Antwort anschließend nicht mehr ändern!",
|
||||
"last_update": "Letztes Update: {date} von {name}",
|
||||
"location_unavailable": "Dein Standort konnte nicht ermittelt werden, die Antwort wird ohne ihn übermittelt.",
|
||||
"private_image": "Privates Bild",
|
||||
"private_image_explain": "Private Bilder werden in der öffentlichen Galerie, Diashow und im Showcase ausgeblendet. Admins können sie weiterhin zur Bewertung sehen.",
|
||||
"required_answer": "Erforderliche Antwort",
|
||||
"review": "Bewertung",
|
||||
"reviewed_by": "von {name} am {date}",
|
||||
"score": "Punkte:",
|
||||
"send_location": "Aktuellen Standort senden",
|
||||
"send_location_explain": "Hängt deine aktuelle GPS-Position an diese Antwort an.",
|
||||
"submission": "Einreichung",
|
||||
"upload_compress": "Bild wird komprimiert...",
|
||||
"upload_title": "Antwort wird hochgeladen...",
|
||||
@@ -270,6 +306,7 @@
|
||||
"showcase": {
|
||||
"answers": "Antworten",
|
||||
"crew": "Crew",
|
||||
"distance": "Strecke",
|
||||
"first_place": "Erster Platz",
|
||||
"leaderboard": "Rangliste",
|
||||
"pictures": "Bilder",
|
||||
@@ -280,6 +317,7 @@
|
||||
"table": {
|
||||
"answers": "Antworten",
|
||||
"description": "Beschreibung",
|
||||
"distance": "Strecke",
|
||||
"name": "Name",
|
||||
"place": "Platz",
|
||||
"score": "Punkte"
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"back_home": "Back home",
|
||||
"back_to_hunt": "Back to Hunt",
|
||||
"cancel": "Cancel",
|
||||
"distance": "Distance",
|
||||
"join": "Join",
|
||||
"login_first": "Login first",
|
||||
"no_pictures": "No pictures has been submitted yet :(",
|
||||
@@ -118,6 +119,7 @@
|
||||
"start": "Start",
|
||||
"success": "Hunt created successfully!",
|
||||
"title": "Create a new Hunt",
|
||||
"track_distance": "Track walking distance",
|
||||
"virtual": "Virtual hunt"
|
||||
},
|
||||
"diashow": {
|
||||
@@ -166,6 +168,17 @@
|
||||
"teams": "Teams",
|
||||
"title": "Hunts",
|
||||
"total_points": "Total points: {score}",
|
||||
"tracking": {
|
||||
"consent": "Record my walking distance",
|
||||
"consent_explain": "While this is on, your location is recorded in the background while this hunt page is open and used to calculate the distance you walked. Nothing is recorded when the tab is hidden or you pause. This allows us to monitor where the teams are.",
|
||||
"last_fix": "last fix {ago}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"status_paused": "Paused",
|
||||
"status_tracking": "Tracking",
|
||||
"title": "Walking distance",
|
||||
"unsupported": "Geolocation is not supported by your browser."
|
||||
},
|
||||
"your_team": "Your team:"
|
||||
},
|
||||
"imprint": {
|
||||
@@ -188,6 +201,21 @@
|
||||
"step_win_text": "Collect points, climb the leaderboard and celebrate in the showcase.",
|
||||
"step_win_title": "Win points"
|
||||
},
|
||||
"map": {
|
||||
"accuracy": "Accuracy",
|
||||
"last_fix": "last fix {ago}",
|
||||
"last_updated": "Last updated: {time}",
|
||||
"layer_answers": "Answers",
|
||||
"layer_positions": "Live positions",
|
||||
"member": "Member",
|
||||
"nav": "Live Map",
|
||||
"no_teams": "No teams yet.",
|
||||
"open_quest_admin": "Open quest admin",
|
||||
"roster": "Teams & users",
|
||||
"team": "Team",
|
||||
"time": "Time",
|
||||
"title": "Live Map"
|
||||
},
|
||||
"pictures": {
|
||||
"filter_quest": "Filter pictures by quests",
|
||||
"filter_team": "Filter pictures by teams",
|
||||
@@ -197,6 +225,11 @@
|
||||
},
|
||||
"profile": {
|
||||
"admins_and_creators": "Admins & Creators",
|
||||
"distances": {
|
||||
"no_distances": "No distances recorded yet.",
|
||||
"title": "Your distances",
|
||||
"total": "Total: {distance}"
|
||||
},
|
||||
"new_password": "New password",
|
||||
"no_pictures": "No pictures uploaded yet.",
|
||||
"old_password": "Old password",
|
||||
@@ -220,12 +253,15 @@
|
||||
"finalize": "Finalize answer",
|
||||
"finalize_explain": "This allows the admins to review your answer and give you points. You cannot change your answer afterwards!",
|
||||
"last_update": "Last update: {date} by {name}",
|
||||
"location_unavailable": "Your location could not be determined, the answer is submitted without it.",
|
||||
"private_image": "Private image",
|
||||
"private_image_explain": "Private images are hidden from the public gallery, diashow and showcase. Admins can still see them for review.",
|
||||
"required_answer": "Required Answer",
|
||||
"review": "Review",
|
||||
"reviewed_by": "by {name} at {date}",
|
||||
"score": "Score:",
|
||||
"send_location": "Send current location",
|
||||
"send_location_explain": "Attaches your current GPS position to this answer.",
|
||||
"submission": "Submission",
|
||||
"upload_compress": "Compressing image...",
|
||||
"upload_title": "Uploading answer...",
|
||||
@@ -270,6 +306,7 @@
|
||||
"showcase": {
|
||||
"answers": "Answers",
|
||||
"crew": "Crew",
|
||||
"distance": "Distance",
|
||||
"first_place": "First place",
|
||||
"leaderboard": "Leaderboard",
|
||||
"pictures": "Pictures",
|
||||
@@ -280,6 +317,7 @@
|
||||
"table": {
|
||||
"answers": "Answers",
|
||||
"description": "Description",
|
||||
"distance": "Distance",
|
||||
"name": "Name",
|
||||
"place": "Place",
|
||||
"score": "Score"
|
||||
|
||||
@@ -127,6 +127,22 @@ const isHuntAdmin = computed(
|
||||
<VaSidebarItemTitle>Hunt Teams</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && isHuntAdmin && huntStore.trackDistance"
|
||||
text-color="danger"
|
||||
hover-color="danger"
|
||||
active-color="onDanger"
|
||||
:to="localePath(`/hunt/${sluggy(slugs.huntSlug)}/live-map`)"
|
||||
:active="
|
||||
localePath(`/hunt/${sluggy(slugs.huntSlug)}/live-map`) ===
|
||||
route.path
|
||||
"
|
||||
>
|
||||
<VaSidebarItemContent>
|
||||
<VaIcon name="map" />
|
||||
<VaSidebarItemTitle>{{ t('page.map.nav') }}</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && slugs?.questSlug"
|
||||
:to="
|
||||
|
||||
@@ -22,6 +22,7 @@ const form = reactive({
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
virtual: false,
|
||||
trackDistance: false,
|
||||
start: null as Date | null,
|
||||
end: null as Date | null,
|
||||
allowJoin: false,
|
||||
@@ -114,6 +115,10 @@ async function submit() {
|
||||
v-model="form.virtual"
|
||||
:label="t('page.create_hunt.virtual')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.trackDistance"
|
||||
:label="t('page.create_hunt.track_distance')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.public"
|
||||
:label="t('page.create_hunt.public')"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ const { loggedIn, fetch } = useUserSession();
|
||||
const localePath = useLocalePath();
|
||||
const validation = useValidation();
|
||||
const { tKey, tSluggy } = useI18nKey();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
watch(loggedIn, (isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
router.push(localePath('/login'));
|
||||
@@ -18,6 +19,15 @@ watch(loggedIn, (isLoggedIn) => {
|
||||
});
|
||||
const { data, refresh, pending } = await useFetch('/api/auth/profile');
|
||||
|
||||
const distanceEntries = computed(() =>
|
||||
(data.value?.distances ?? []).map((entry) => ({
|
||||
id: entry.huntId,
|
||||
name_de: entry.huntName_de,
|
||||
name_en: entry.huntName_en,
|
||||
distanceMeters: entry.distanceMeters
|
||||
}))
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: t('layouts.title', {
|
||||
title: t('page.profile.title')
|
||||
@@ -271,6 +281,38 @@ async function submitPassword() {
|
||||
</VaCardContent>
|
||||
<VaCardContent v-else>{{ t('page.profile.no_pictures') }}</VaCardContent>
|
||||
</VaCard>
|
||||
<VaCard stripe stripe-color="info">
|
||||
<VaCardTitle>{{ t('page.profile.distances.title') }}</VaCardTitle>
|
||||
<VaCardContent v-if="data && data.distances.length > 0">
|
||||
<p class="mb-4 text-xl font-bold">
|
||||
{{
|
||||
t('page.profile.distances.total', {
|
||||
distance: formatDistance(data.totalDistance)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="entry in distanceEntries"
|
||||
:key="entry.id"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="localePath(`/hunt/${tSluggy(entry).value}`)"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ tKey(entry, 'name') }}
|
||||
</NuxtLink>
|
||||
<span class="font-mono">{{
|
||||
formatDistance(entry.distanceMeters)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</VaCardContent>
|
||||
<VaCardContent v-else>{{
|
||||
t('page.profile.distances.no_distances')
|
||||
}}</VaCardContent>
|
||||
</VaCard>
|
||||
<AdminUserRoles />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+7
-1
@@ -1,13 +1,19 @@
|
||||
export const useHuntStore = defineStore('hunt', () => {
|
||||
const huntId = ref<number>();
|
||||
const isMember = ref(false);
|
||||
const trackDistance = ref(false);
|
||||
|
||||
function setMembership(id: number, member: boolean) {
|
||||
huntId.value = id;
|
||||
isMember.value = member;
|
||||
}
|
||||
|
||||
return { huntId, isMember, setMembership };
|
||||
function setTrackDistance(id: number, value: boolean) {
|
||||
huntId.value = id;
|
||||
trackDistance.value = value;
|
||||
}
|
||||
|
||||
return { huntId, isMember, trackDistance, setMembership, setTrackDistance };
|
||||
});
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useHuntStore, import.meta.hot));
|
||||
|
||||
+18
-1
@@ -3,8 +3,25 @@ export const useSettingsStore = defineStore(
|
||||
() => {
|
||||
const hideAnswers = ref(true);
|
||||
const isSidebarVisible = ref(false);
|
||||
// Per-hunt, client-only consent for GPS distance tracking. Off by default;
|
||||
// the server cannot prove consent, so this lives only in the client store.
|
||||
const trackConsent = ref<Record<number, boolean>>({});
|
||||
|
||||
return { hideAnswers, isSidebarVisible };
|
||||
function hasTrackConsent(huntId: number): boolean {
|
||||
return trackConsent.value[huntId] ?? false;
|
||||
}
|
||||
|
||||
function setTrackConsent(huntId: number, consent: boolean) {
|
||||
trackConsent.value[huntId] = consent;
|
||||
}
|
||||
|
||||
return {
|
||||
hideAnswers,
|
||||
isSidebarVisible,
|
||||
trackConsent,
|
||||
hasTrackConsent,
|
||||
setTrackConsent
|
||||
};
|
||||
},
|
||||
{ persist: true }
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"compressorjs": "^1.2.1",
|
||||
"eslint": "^9.0.0",
|
||||
"h3-zod": "^0.5.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"minio": "^8.0.5",
|
||||
"nuxt": "^4.1.0",
|
||||
"nuxt-auth-utils": "^0.5.22",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@electric-sql/pglite-socket": "^0.2.8",
|
||||
"@nuxt/devtools": "^2.6.4",
|
||||
"@types/bun": "^1.2.20",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
@@ -644,8 +646,12 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
||||
@@ -1550,6 +1556,8 @@
|
||||
|
||||
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
|
||||
|
||||
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"compressorjs": "^1.2.1",
|
||||
"eslint": "^9.0.0",
|
||||
"h3-zod": "^0.5.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"minio": "^8.0.5",
|
||||
"nuxt": "^4.1.0",
|
||||
"nuxt-auth-utils": "^0.5.22",
|
||||
@@ -51,6 +52,7 @@
|
||||
"@electric-sql/pglite-socket": "^0.2.8",
|
||||
"@nuxt/devtools": "^2.6.4",
|
||||
"@types/bun": "^1.2.20",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Hunt" ADD COLUMN "trackDistance" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."TrackPoint" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"lat" DECIMAL(10,8) NOT NULL,
|
||||
"lon" DECIMAL(10,8) NOT NULL,
|
||||
"accuracy" DOUBLE PRECISION,
|
||||
"recordedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"huntId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "TrackPoint_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."UserTrack" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"distanceMeters" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastFixAt" TIMESTAMPTZ(3),
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"huntId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "UserTrack_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TrackPoint_userId_huntId_recordedAt_idx" ON "public"."TrackPoint"("userId", "huntId", "recordedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserTrack_userId_huntId_key" ON "public"."UserTrack"("userId", "huntId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."TrackPoint" ADD CONSTRAINT "TrackPoint_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."TrackPoint" ADD CONSTRAINT "TrackPoint_huntId_fkey" FOREIGN KEY ("huntId") REFERENCES "public"."Hunt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."UserTrack" ADD CONSTRAINT "UserTrack_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."UserTrack" ADD CONSTRAINT "UserTrack_huntId_fkey" FOREIGN KEY ("huntId") REFERENCES "public"."Hunt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -33,6 +33,9 @@ model User {
|
||||
reviews Review[]
|
||||
|
||||
uploadedFiles File[]
|
||||
|
||||
trackPoints TrackPoint[]
|
||||
userTracks UserTrack[]
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
@@ -80,6 +83,7 @@ model Hunt {
|
||||
revealQuests Boolean @default(false)
|
||||
revealAnswers Boolean @default(false)
|
||||
public Boolean @default(true)
|
||||
trackDistance Boolean @default(false)
|
||||
|
||||
creator User @relation(fields: [creatorId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
creatorId Int
|
||||
@@ -98,6 +102,9 @@ model Hunt {
|
||||
|
||||
oldFiles File[] @relation("OldHuntFiles")
|
||||
|
||||
trackPoints TrackPoint[]
|
||||
userTracks UserTrack[]
|
||||
|
||||
@@index([creatorId])
|
||||
}
|
||||
|
||||
@@ -255,3 +262,41 @@ model Review {
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
}
|
||||
|
||||
/// A single raw GPS fix streamed by the client while tracking is active.
|
||||
/// All points are retained (for future map features); distance is derived
|
||||
/// server-side and stored on UserTrack.
|
||||
model TrackPoint {
|
||||
id Int @id @default(autoincrement())
|
||||
lat Decimal @db.Decimal(10, 8)
|
||||
lon Decimal @db.Decimal(10, 8)
|
||||
/// Reported accuracy in meters, if the device provided one.
|
||||
accuracy Float?
|
||||
recordedAt DateTime @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
hunt Hunt @relation(fields: [huntId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
huntId Int
|
||||
|
||||
@@index([userId, huntId, recordedAt])
|
||||
}
|
||||
|
||||
/// Per-user, per-hunt aggregate of the derived walking distance.
|
||||
/// Recomputed idempotently from all of the user's TrackPoints on each batch.
|
||||
model UserTrack {
|
||||
id Int @id @default(autoincrement())
|
||||
distanceMeters Int @default(0)
|
||||
lastFixAt DateTime? @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
hunt Hunt @relation(fields: [huntId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
huntId Int
|
||||
|
||||
@@unique([userId, huntId])
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export default defineEventHandler(async (event) => {
|
||||
allowJoin: true,
|
||||
revealQuests: true,
|
||||
revealAnswers: true,
|
||||
trackDistance: true,
|
||||
updatedAt: true,
|
||||
picture: {
|
||||
select: {
|
||||
|
||||
@@ -26,7 +26,8 @@ export default defineEventHandler(async (event) => {
|
||||
end: z.iso.datetime().nullable(),
|
||||
allowJoin: z.boolean(),
|
||||
revealQuests: z.boolean(),
|
||||
revealAnswers: z.boolean()
|
||||
revealAnswers: z.boolean(),
|
||||
trackDistance: z.boolean()
|
||||
})
|
||||
.partial()
|
||||
.and(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||
import prisma from '~~/lib/prisma';
|
||||
import type { HuntMapData, MapPosition } from '#shared/types/map';
|
||||
|
||||
/**
|
||||
* Aggregate live-map payload for hunt organizers.
|
||||
*
|
||||
* Gated by HuntMember membership AND `hunt.trackDistance` (404 otherwise, so
|
||||
* non-members cannot probe the hunt). Returns plain JSON: `Date` fields as ISO
|
||||
* strings, Prisma `Decimal` coordinates coerced to numbers.
|
||||
*/
|
||||
export default defineEventHandler(async (event): Promise<HuntMapData> => {
|
||||
const user = await requireUserSession(event);
|
||||
const userId = user.user.id;
|
||||
|
||||
const { huntId } = await useValidatedParams(
|
||||
event,
|
||||
z.object({
|
||||
huntId: zh.numAsString
|
||||
})
|
||||
);
|
||||
|
||||
const hunt = await prisma.hunt.findUnique({
|
||||
where: {
|
||||
id: huntId,
|
||||
trackDistance: true,
|
||||
members: {
|
||||
some: {
|
||||
memberId: userId
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
teams: {
|
||||
orderBy: {
|
||||
createdAt: 'asc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
owner: {
|
||||
select: { id: true, name: true, email: true }
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
member: {
|
||||
select: { id: true, name: true, email: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hunt) {
|
||||
throw createError({ status: 404, statusText: 'Not found!' });
|
||||
}
|
||||
|
||||
const teams = hunt.teams.map(({ members, ...team }) => ({
|
||||
...team,
|
||||
members: members.map((m) => m.member)
|
||||
}));
|
||||
|
||||
// Latest TrackPoint per user for this hunt. Walk-scale volume, so fetch the
|
||||
// newest-first and keep the first fix seen per user.
|
||||
const points = await prisma.trackPoint.findMany({
|
||||
where: { huntId },
|
||||
orderBy: { recordedAt: 'desc' },
|
||||
select: { userId: true, lat: true, lon: true, accuracy: true, recordedAt: true }
|
||||
});
|
||||
|
||||
const seen = new Set<number>();
|
||||
const positions: MapPosition[] = [];
|
||||
for (const p of points) {
|
||||
if (seen.has(p.userId)) continue;
|
||||
seen.add(p.userId);
|
||||
positions.push({
|
||||
userId: p.userId,
|
||||
lat: Number(p.lat),
|
||||
lon: Number(p.lon),
|
||||
accuracy: p.accuracy,
|
||||
recordedAt: p.recordedAt.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const answers = await prisma.questAnswer.findMany({
|
||||
where: {
|
||||
quest: { huntId },
|
||||
lat: { not: null },
|
||||
lon: { not: null }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
createdAt: true,
|
||||
memberId: true,
|
||||
teamId: true,
|
||||
picture: { select: { url: true } },
|
||||
quest: { select: { id: true, title_de: true, title_en: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
teams,
|
||||
positions,
|
||||
answers: answers.map((a) => ({
|
||||
id: a.id,
|
||||
lat: Number(a.lat),
|
||||
lon: Number(a.lon),
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
memberId: a.memberId,
|
||||
teamId: a.teamId,
|
||||
pictureUrl: a.picture?.url ?? null,
|
||||
quest: a.quest
|
||||
}))
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||
import prisma from '~~/lib/prisma';
|
||||
import { teamAverageDistance } from '~~/server/utils/distance';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event);
|
||||
@@ -25,6 +26,7 @@ export default defineEventHandler(async (event) => {
|
||||
id: true,
|
||||
name_de: true,
|
||||
name_en: true,
|
||||
trackDistance: true,
|
||||
teams: {
|
||||
omit: {
|
||||
huntId: true,
|
||||
@@ -84,11 +86,21 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const { teams, ...hunt } = _hunt;
|
||||
|
||||
const tracks = await prisma.userTrack.findMany({
|
||||
where: { huntId },
|
||||
select: { userId: true, distanceMeters: true }
|
||||
});
|
||||
const trackMap = new Map(tracks.map((t) => [t.userId, t.distanceMeters]));
|
||||
|
||||
return {
|
||||
teams: teams
|
||||
.map(({ answers, ...t }) => ({
|
||||
...t,
|
||||
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0)
|
||||
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0),
|
||||
distance: teamAverageDistance(
|
||||
[t.owner.id, ...t.members.map((m) => m.member.id)],
|
||||
trackMap
|
||||
)
|
||||
}))
|
||||
.toSorted((a, b) => b.score - a.score),
|
||||
...hunt
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||
import prisma from '~~/lib/prisma';
|
||||
import type { UserTrail } from '#shared/types/map';
|
||||
|
||||
/**
|
||||
* Full TrackPoint trail for a single user in a hunt, fetched lazily when an
|
||||
* organizer clicks a user on the live map.
|
||||
*
|
||||
* Gated by HuntMember membership AND `hunt.trackDistance` (404 otherwise).
|
||||
* Plain JSON: `recordedAt` as ISO strings, coordinates coerced to numbers.
|
||||
*/
|
||||
export default defineEventHandler(async (event): Promise<UserTrail> => {
|
||||
const user = await requireUserSession(event);
|
||||
const callerId = user.user.id;
|
||||
|
||||
const { huntId, userId } = await useValidatedParams(
|
||||
event,
|
||||
z.object({
|
||||
huntId: zh.numAsString,
|
||||
userId: zh.numAsString
|
||||
})
|
||||
);
|
||||
|
||||
const hunt = await prisma.hunt.findUnique({
|
||||
where: {
|
||||
id: huntId,
|
||||
trackDistance: true,
|
||||
members: {
|
||||
some: {
|
||||
memberId: callerId
|
||||
}
|
||||
}
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (!hunt) {
|
||||
throw createError({ status: 404, statusText: 'Not found!' });
|
||||
}
|
||||
|
||||
const points = await prisma.trackPoint.findMany({
|
||||
where: { huntId, userId },
|
||||
orderBy: { recordedAt: 'asc' },
|
||||
select: { lat: true, lon: true, accuracy: true, recordedAt: true }
|
||||
});
|
||||
|
||||
return {
|
||||
points: points.map((p) => ({
|
||||
lat: Number(p.lat),
|
||||
lon: Number(p.lon),
|
||||
accuracy: p.accuracy,
|
||||
recordedAt: p.recordedAt.toISOString()
|
||||
}))
|
||||
};
|
||||
});
|
||||
@@ -24,7 +24,8 @@ export default defineEventHandler(async (event) => {
|
||||
allowJoin: z.boolean().default(false),
|
||||
revealQuests: z.boolean().default(false),
|
||||
revealAnswers: z.boolean().default(false),
|
||||
public: z.boolean().default(true)
|
||||
public: z.boolean().default(true),
|
||||
trackDistance: z.boolean().default(false)
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import prisma from '~~/lib/prisma';
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event);
|
||||
|
||||
return prisma.user.findUnique({
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.user.id },
|
||||
select: {
|
||||
name: true,
|
||||
@@ -38,7 +38,44 @@ export default defineEventHandler(async (event) => {
|
||||
answerId: null
|
||||
}
|
||||
}
|
||||
},
|
||||
userTracks: {
|
||||
select: {
|
||||
distanceMeters: true,
|
||||
hunt: {
|
||||
select: {
|
||||
id: true,
|
||||
name_de: true,
|
||||
name_en: true
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
hunt: {
|
||||
deletedAt: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw createError({ status: 404, statusText: 'Not found!' });
|
||||
}
|
||||
|
||||
const { userTracks, ...rest } = profile;
|
||||
|
||||
const distances = userTracks.map((track) => ({
|
||||
huntId: track.hunt.id,
|
||||
huntName_de: track.hunt.name_de,
|
||||
huntName_en: track.hunt.name_en,
|
||||
distanceMeters: track.distanceMeters
|
||||
}));
|
||||
|
||||
const totalDistance = distances.reduce(
|
||||
(sum, entry) => sum + entry.distanceMeters,
|
||||
0
|
||||
);
|
||||
|
||||
return { ...rest, distances, totalDistance };
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ export default defineEventHandler(async (event) => {
|
||||
description_en: true,
|
||||
revealAnswers: true,
|
||||
revealQuests: true,
|
||||
virtual: true,
|
||||
trackDistance: true,
|
||||
picture: {
|
||||
select: {
|
||||
url: true
|
||||
@@ -79,6 +81,14 @@ export default defineEventHandler(async (event) => {
|
||||
]
|
||||
}
|
||||
},
|
||||
userTracks: {
|
||||
select: {
|
||||
distanceMeters: true
|
||||
},
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
},
|
||||
quests: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -182,10 +192,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (!_hunt) {
|
||||
throw createError({ status: 404, statusText: 'Not found!' });
|
||||
}
|
||||
const { teams, members, quests, ...hunt } = _hunt;
|
||||
const { teams, members, quests, userTracks, ...hunt } = _hunt;
|
||||
|
||||
const isMember = hunt.creator.id === userId || members.length > 0;
|
||||
const ownTeam = teams.length > 0 ? teams[0] : null;
|
||||
const ownDistance =
|
||||
hunt.trackDistance && isMember
|
||||
? (userTracks[0]?.distanceMeters ?? 0)
|
||||
: undefined;
|
||||
|
||||
const mappedQuests = quests.map(({ answers, _count, ...q }) => ({
|
||||
...q,
|
||||
@@ -203,6 +217,7 @@ export default defineEventHandler(async (event) => {
|
||||
ownTeam,
|
||||
loggedIn: !!user.user,
|
||||
totalScore: ownTeam !== null ? totalScore : undefined,
|
||||
ownDistance,
|
||||
quests: hunt.revealQuests || isMember ? mappedQuests : null,
|
||||
...hunt
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||
import prisma from '~~/lib/prisma';
|
||||
import { teamAverageDistance } from '~~/server/utils/distance';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { huntId } = await useValidatedParams(
|
||||
@@ -52,6 +53,7 @@ export default defineEventHandler(async (event) => {
|
||||
name_en: true,
|
||||
start: true,
|
||||
end: true,
|
||||
trackDistance: true,
|
||||
|
||||
_count: {
|
||||
select: {
|
||||
@@ -78,6 +80,22 @@ export default defineEventHandler(async (event) => {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
member: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
answers: {
|
||||
select: {
|
||||
review: {
|
||||
@@ -186,18 +204,29 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const { teams, ...hunt } = _hunt;
|
||||
|
||||
const tracks = await prisma.userTrack.findMany({
|
||||
where: { huntId },
|
||||
select: { userId: true, distanceMeters: true }
|
||||
});
|
||||
const trackMap = new Map(tracks.map((t) => [t.userId, t.distanceMeters]));
|
||||
|
||||
const userCount = teams.reduce(
|
||||
(sum, team) => sum + team._count.members + 1,
|
||||
0
|
||||
);
|
||||
|
||||
const leaderboard = teams
|
||||
.map(({ name, id, description, answers, _count }) => ({
|
||||
.map(({ name, id, description, answers, _count, owner, members }) => ({
|
||||
name,
|
||||
id,
|
||||
description,
|
||||
answers: _count.answers,
|
||||
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0)
|
||||
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0),
|
||||
members: [owner.name, ...members.map((m) => m.member.name)],
|
||||
distance: teamAverageDistance(
|
||||
[owner.id, ...members.map((m) => m.member.id)],
|
||||
trackMap
|
||||
)
|
||||
}))
|
||||
.toSorted((a, b) => b.score - a.score);
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { computeTrackDistance } from '#shared/utils/geo';
|
||||
import { useValidatedParams, z, zh } from 'h3-zod';
|
||||
import prisma from '~~/lib/prisma';
|
||||
|
||||
/**
|
||||
* Ingests raw GPS fixes for the walking-distance feature.
|
||||
*
|
||||
* IMPORTANT: this endpoint must NEVER answer 4xx for gating failures. The
|
||||
* client flushes batches with `navigator.sendBeacon` and retries on network
|
||||
* failure; a 4xx would make it storm. Every gate/parse failure is logged and
|
||||
* dropped with a 2xx (204) instead.
|
||||
*/
|
||||
|
||||
const MAX_BATCH = 500;
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const FUTURE_TOLERANCE_MS = 60 * 1000;
|
||||
|
||||
const pointSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lon: z.number().min(-180).max(180),
|
||||
accuracy: z.number().min(0).nullish(),
|
||||
recordedAt: z.iso.datetime()
|
||||
});
|
||||
|
||||
type Point = z.infer<typeof pointSchema>;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { huntId } = await useValidatedParams(
|
||||
event,
|
||||
z.object({
|
||||
huntId: zh.numAsString
|
||||
})
|
||||
);
|
||||
|
||||
const session = await getUserSession(event);
|
||||
const userId = session.user?.id;
|
||||
|
||||
const drop = (reason: string) => {
|
||||
console.warn(
|
||||
`[track] dropped (${reason}) hunt=${huntId} user=${userId ?? 'anon'}`
|
||||
);
|
||||
setResponseStatus(event, 204);
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!userId) {
|
||||
return drop('no session');
|
||||
}
|
||||
|
||||
const hunt = await prisma.hunt.findUnique({
|
||||
where: { id: huntId, deletedAt: null },
|
||||
select: {
|
||||
trackDistance: true,
|
||||
start: true,
|
||||
end: true,
|
||||
teams: {
|
||||
select: { id: true },
|
||||
where: {
|
||||
OR: [{ ownerId: userId }, { members: { some: { memberId: userId } } }]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hunt || !hunt.trackDistance) {
|
||||
return drop('hunt missing or tracking disabled');
|
||||
}
|
||||
if (hunt.teams.length === 0) {
|
||||
return drop('not a team member');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (hunt.start && now < hunt.start.getTime()) {
|
||||
return drop('before start');
|
||||
}
|
||||
if (hunt.end && now > hunt.end.getTime()) {
|
||||
return drop('after end');
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
const batch = z.array(z.unknown()).safeParse(body);
|
||||
if (!batch.success) {
|
||||
return drop('body is not an array');
|
||||
}
|
||||
|
||||
const validPoints: Point[] = [];
|
||||
for (const raw of batch.data.slice(0, MAX_BATCH)) {
|
||||
const parsed = pointSchema.safeParse(raw);
|
||||
if (!parsed.success) continue;
|
||||
const time = new Date(parsed.data.recordedAt).getTime();
|
||||
if (time > now + FUTURE_TOLERANCE_MS || time < now - MAX_AGE_MS) continue;
|
||||
validPoints.push(parsed.data);
|
||||
}
|
||||
|
||||
if (validPoints.length === 0) {
|
||||
return drop('no valid points');
|
||||
}
|
||||
|
||||
await prisma.trackPoint.createMany({
|
||||
data: validPoints.map((p) => ({
|
||||
userId,
|
||||
huntId,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
accuracy: p.accuracy ?? null,
|
||||
recordedAt: new Date(p.recordedAt)
|
||||
}))
|
||||
});
|
||||
|
||||
// Idempotent full recompute over every raw fix of this user for this hunt.
|
||||
const all = await prisma.trackPoint.findMany({
|
||||
where: { userId, huntId },
|
||||
select: { lat: true, lon: true, accuracy: true, recordedAt: true }
|
||||
});
|
||||
|
||||
const distanceMeters = computeTrackDistance(
|
||||
all.map((p) => ({
|
||||
lat: Number(p.lat),
|
||||
lon: Number(p.lon),
|
||||
accuracy: p.accuracy,
|
||||
recordedAt: p.recordedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const lastFixAt = all.reduce<Date | null>(
|
||||
(max, p) => (max === null || p.recordedAt > max ? p.recordedAt : max),
|
||||
null
|
||||
);
|
||||
|
||||
await prisma.userTrack.upsert({
|
||||
where: { userId_huntId: { userId, huntId } },
|
||||
create: { userId, huntId, distanceMeters, lastFixAt },
|
||||
update: { distanceMeters, lastFixAt }
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -45,6 +45,10 @@ export default defineEventHandler(async (event) => {
|
||||
name_de: true,
|
||||
name_en: true,
|
||||
revealQuests: true,
|
||||
virtual: true,
|
||||
trackDistance: true,
|
||||
start: true,
|
||||
end: true,
|
||||
teams: {
|
||||
select: {
|
||||
id: true
|
||||
|
||||
@@ -102,7 +102,9 @@ export default defineEventHandler(async (event) => {
|
||||
.max(10_000_000)
|
||||
.optional(),
|
||||
final: zh.boolAsString.optional(),
|
||||
private: zh.boolAsString.default(false)
|
||||
private: zh.boolAsString.default(false),
|
||||
lat: zh.numAsString.pipe(z.number().min(-90).max(90)).optional(),
|
||||
lon: zh.numAsString.pipe(z.number().min(-180).max(180)).optional()
|
||||
})
|
||||
.parse(formDataObj);
|
||||
|
||||
@@ -174,7 +176,9 @@ export default defineEventHandler(async (event) => {
|
||||
text: data.answer,
|
||||
pictureId: file.id,
|
||||
final: data.final,
|
||||
lang: data.lang
|
||||
lang: data.lang,
|
||||
lat: data.lat,
|
||||
lon: data.lon
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -185,7 +189,9 @@ export default defineEventHandler(async (event) => {
|
||||
questId: quest.id,
|
||||
text: data.answer,
|
||||
final: data.final,
|
||||
lang: data.lang
|
||||
lang: data.lang,
|
||||
lat: data.lat,
|
||||
lon: data.lon
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
@@ -211,7 +217,9 @@ export default defineEventHandler(async (event) => {
|
||||
memberId: userId,
|
||||
text: data.answer,
|
||||
final: data.final,
|
||||
lang: data.lang
|
||||
lang: data.lang,
|
||||
lat: data.lat,
|
||||
lon: data.lon
|
||||
}
|
||||
});
|
||||
if (prevAnswer.picture) {
|
||||
@@ -232,7 +240,9 @@ export default defineEventHandler(async (event) => {
|
||||
questId: quest.id,
|
||||
text: data.answer,
|
||||
final: data.final,
|
||||
lang: data.lang
|
||||
lang: data.lang,
|
||||
lat: data.lat,
|
||||
lon: data.lon
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Team walking-distance aggregation.
|
||||
*
|
||||
* A team's distance is the average of the `UserTrack.distanceMeters` of its
|
||||
* _current_ participants (the team owner plus its team members) that actually
|
||||
* recorded a distance. Participants without a recorded track are excluded
|
||||
* rather than diluting the average with zeros.
|
||||
*/
|
||||
export function teamAverageDistance(
|
||||
participantIds: number[],
|
||||
tracks: Map<number, number>
|
||||
): number {
|
||||
const unique = [...new Set(participantIds)];
|
||||
const distances = unique
|
||||
.map((id) => tracks.get(id))
|
||||
.filter((d): d is number => d !== undefined);
|
||||
|
||||
if (distances.length === 0) return 0;
|
||||
|
||||
const sum = distances.reduce((acc, d) => acc + d, 0);
|
||||
return Math.round(sum / distances.length);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shared DTO shapes for the organizer live map. Both endpoints return plain
|
||||
* JSON (no SuperJSON) with `Date` fields serialized as ISO strings and Prisma
|
||||
* `Decimal` coordinates coerced to numbers.
|
||||
*/
|
||||
|
||||
export type MapUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type MapTeam = {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: MapUser;
|
||||
members: MapUser[];
|
||||
};
|
||||
|
||||
export type MapPosition = {
|
||||
userId: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number | null;
|
||||
recordedAt: string;
|
||||
};
|
||||
|
||||
export type MapAnswer = {
|
||||
id: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
createdAt: string;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
pictureUrl: string | null;
|
||||
quest: {
|
||||
id: number;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HuntMapData = {
|
||||
teams: MapTeam[];
|
||||
positions: MapPosition[];
|
||||
answers: MapAnswer[];
|
||||
};
|
||||
|
||||
export type TrailPoint = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number | null;
|
||||
recordedAt: string;
|
||||
};
|
||||
|
||||
export type UserTrail = {
|
||||
points: TrailPoint[];
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { computeTrackDistance, haversine, type GeoPoint } from './geo';
|
||||
|
||||
// One degree of latitude is ~111_195 m, so these small deltas give
|
||||
// predictable segment lengths: 0.0001° ≈ 11.12 m, 0.00001° ≈ 1.11 m.
|
||||
const DEG = 111_195;
|
||||
|
||||
function pt(
|
||||
lat: number,
|
||||
lon: number,
|
||||
seconds: number,
|
||||
accuracy?: number | null
|
||||
): GeoPoint {
|
||||
return { lat, lon, accuracy, recordedAt: new Date(seconds * 1000) };
|
||||
}
|
||||
|
||||
describe('haversine', () => {
|
||||
it('returns 0 for identical points', () => {
|
||||
expect(haversine(pt(0, 0, 0), pt(0, 0, 0))).toBe(0);
|
||||
});
|
||||
|
||||
it('measures one degree of latitude along a meridian', () => {
|
||||
const d = haversine({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
|
||||
expect(Math.abs(d - DEG)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('measures one degree of longitude along the equator', () => {
|
||||
const d = haversine({ lat: 0, lon: 0 }, { lat: 0, lon: 1 });
|
||||
expect(Math.abs(d - DEG)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('is symmetric', () => {
|
||||
const a = { lat: 52.5, lon: 13.4 };
|
||||
const b = { lat: 52.6, lon: 13.5 };
|
||||
expect(haversine(a, b)).toBeCloseTo(haversine(b, a), 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTrackDistance', () => {
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(computeTrackDistance([])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for a single point', () => {
|
||||
expect(computeTrackDistance([pt(0, 0, 0)])).toBe(0);
|
||||
});
|
||||
|
||||
it('sums a normal multi-point walk', () => {
|
||||
const points = [
|
||||
pt(0, 0, 0),
|
||||
pt(0.0001, 0, 10),
|
||||
pt(0.0002, 0, 20),
|
||||
pt(0.0003, 0, 30)
|
||||
];
|
||||
// three ~11.12 m segments
|
||||
const d = computeTrackDistance(points);
|
||||
expect(d).toBeGreaterThan(32);
|
||||
expect(d).toBeLessThan(35);
|
||||
});
|
||||
|
||||
it('excludes fixes with accuracy worse than 50 m', () => {
|
||||
const points = [
|
||||
pt(0, 0, 0, 5),
|
||||
// far off path, bad accuracy → dropped
|
||||
pt(0, 0.001, 10, 100),
|
||||
pt(0.0001, 0, 20, 5)
|
||||
];
|
||||
const d = computeTrackDistance(points);
|
||||
// only the ~11 m A→C segment counts, not the ~222 m detour via B
|
||||
expect(d).toBeGreaterThan(10);
|
||||
expect(d).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('keeps accuracy of exactly 50 m', () => {
|
||||
const points = [pt(0, 0, 0, 50), pt(0.0001, 0, 10, 50)];
|
||||
const d = computeTrackDistance(points);
|
||||
expect(d).toBeGreaterThan(10);
|
||||
expect(d).toBeLessThan(13);
|
||||
});
|
||||
|
||||
it('rejects teleport candidates and preserves the anchor', () => {
|
||||
const points = [
|
||||
pt(0, 0, 0),
|
||||
// ~1112 m in 1 s → far above 7 m/s → rejected, anchor stays at A
|
||||
pt(0.01, 0, 1),
|
||||
// ~11 m from A over 2 s → 5.6 m/s → accepted
|
||||
pt(0.0001, 0, 2)
|
||||
];
|
||||
const d = computeTrackDistance(points);
|
||||
expect(d).toBeGreaterThan(10);
|
||||
expect(d).toBeLessThan(13);
|
||||
});
|
||||
|
||||
it('ignores stationary jitter below 5 m', () => {
|
||||
const jitter = [
|
||||
pt(0, 0, 0),
|
||||
pt(0.00001, 0, 10), // ~1.1 m
|
||||
pt(0.00002, 0, 20) // ~2.2 m from anchor
|
||||
];
|
||||
expect(computeTrackDistance(jitter)).toBe(0);
|
||||
|
||||
// a real move after the jitter is still measured from the original anchor
|
||||
const withMove = [...jitter, pt(0.0002, 0, 30)];
|
||||
const d = computeTrackDistance(withMove);
|
||||
expect(d).toBeGreaterThan(21);
|
||||
expect(d).toBeLessThan(24);
|
||||
});
|
||||
|
||||
it('dedupes and sorts out-of-order points', () => {
|
||||
const a = pt(0, 0, 0);
|
||||
const b = pt(0.0001, 0, 10);
|
||||
const c = pt(0.0002, 0, 20);
|
||||
const shuffled = [c, a, b, a, c, b];
|
||||
const d = computeTrackDistance(shuffled);
|
||||
// two ~11.12 m segments after dedupe + sort
|
||||
expect(d).toBeGreaterThan(21);
|
||||
expect(d).toBeLessThan(24);
|
||||
});
|
||||
|
||||
it('accepts string recordedAt values', () => {
|
||||
const points: GeoPoint[] = [
|
||||
{ lat: 0, lon: 0, recordedAt: new Date(0).toISOString() },
|
||||
{ lat: 0.0001, lon: 0, recordedAt: new Date(10_000).toISOString() }
|
||||
];
|
||||
const d = computeTrackDistance(points);
|
||||
expect(d).toBeGreaterThan(10);
|
||||
expect(d).toBeLessThan(13);
|
||||
});
|
||||
|
||||
it('returns an integer number of meters', () => {
|
||||
const points = [pt(0, 0, 0), pt(0.0001, 0, 10), pt(0.0002, 0, 20)];
|
||||
expect(Number.isInteger(computeTrackDistance(points))).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared, pure geo math used by both the client accumulator and the
|
||||
* server-side distance recompute. Distances are in meters; the aggregate
|
||||
* is stored as an Int (rounded) to avoid float drift in the database.
|
||||
*/
|
||||
|
||||
/** Mean earth radius in meters (WGS-84 volumetric mean). */
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
|
||||
/** Fixes reporting an accuracy worse than this are dropped (anchor unchanged). */
|
||||
export const MAX_ACCURACY_M = 50;
|
||||
/** A candidate implying a speed above this is a teleport (anchor unchanged). */
|
||||
export const MAX_SPEED_MPS = 7;
|
||||
/** Segments shorter than this are treated as stationary jitter (not added). */
|
||||
export const MIN_SEGMENT_M = 5;
|
||||
|
||||
export type GeoPoint = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy?: number | null;
|
||||
recordedAt: Date | string;
|
||||
};
|
||||
|
||||
export type GeoCoord = Pick<GeoPoint, 'lat' | 'lon'>;
|
||||
|
||||
/** Great-circle distance between two coordinates in meters. */
|
||||
export function haversine(a: GeoCoord, b: GeoCoord): number {
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
function toTime(recordedAt: Date | string): number {
|
||||
return new Date(recordedAt).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the walked distance (Int meters) from a set of raw fixes.
|
||||
*
|
||||
* Points are deduped (by `recordedAt` + coords) and sorted by `recordedAt`,
|
||||
* then walked with an "anchor". For each candidate:
|
||||
* - accuracy worse than {@link MAX_ACCURACY_M} → dropped, anchor unchanged;
|
||||
* - implied speed above {@link MAX_SPEED_MPS} → teleport, anchor unchanged;
|
||||
* - segment shorter than {@link MIN_SEGMENT_M} → jitter, anchor unchanged;
|
||||
* - otherwise the segment is added and the anchor advances.
|
||||
*/
|
||||
export function computeTrackDistance(points: GeoPoint[]): number {
|
||||
const seen = new Set<string>();
|
||||
const normalized: (GeoPoint & { time: number })[] = [];
|
||||
|
||||
for (const p of points) {
|
||||
const time = toTime(p.recordedAt);
|
||||
if (Number.isNaN(time)) continue;
|
||||
const key = `${time}|${p.lat}|${p.lon}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push({ ...p, time });
|
||||
}
|
||||
|
||||
normalized.sort((a, b) => a.time - b.time);
|
||||
|
||||
let total = 0;
|
||||
let anchor: { lat: number; lon: number; time: number } | null = null;
|
||||
|
||||
for (const p of normalized) {
|
||||
if (p.accuracy != null && p.accuracy > MAX_ACCURACY_M) continue;
|
||||
|
||||
if (anchor === null) {
|
||||
anchor = { lat: p.lat, lon: p.lon, time: p.time };
|
||||
continue;
|
||||
}
|
||||
|
||||
const dtSeconds = (p.time - anchor.time) / 1000;
|
||||
// Non-positive time delta is ambiguous (duplicate timestamps) → skip.
|
||||
if (dtSeconds <= 0) continue;
|
||||
|
||||
const distance = haversine(anchor, p);
|
||||
if (distance / dtSeconds > MAX_SPEED_MPS) continue; // teleport
|
||||
if (distance < MIN_SEGMENT_M) continue; // stationary jitter
|
||||
|
||||
total += distance;
|
||||
anchor = { lat: p.lat, lon: p.lon, time: p.time };
|
||||
}
|
||||
|
||||
return Math.round(total);
|
||||
}
|
||||
@@ -192,4 +192,52 @@ describe('submit answer (POST /api/quest/[questId]/submit)', () => {
|
||||
);
|
||||
expect(res.status).toBe(418);
|
||||
});
|
||||
|
||||
it('persists lat/lon when submitted with the answer', async () => {
|
||||
const { quest, team } = await seedPlayground('owner@test.dev');
|
||||
|
||||
const client = newClient();
|
||||
await client.login('owner@test.dev', TEST_PASSWORD);
|
||||
const res = await client.post(
|
||||
`/api/quest/${quest.id}/submit`,
|
||||
form({ answer: 'here', lat: '52.520008', lon: '13.404954' })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const answer = await prisma.questAnswer.findFirstOrThrow({
|
||||
where: { teamId: team.id, questId: quest.id }
|
||||
});
|
||||
expect(Number(answer.lat)).toBeCloseTo(52.520008, 5);
|
||||
expect(Number(answer.lon)).toBeCloseTo(13.404954, 5);
|
||||
});
|
||||
|
||||
it('leaves lat/lon null when not submitted', async () => {
|
||||
const { quest, team } = await seedPlayground('owner@test.dev');
|
||||
|
||||
const client = newClient();
|
||||
await client.login('owner@test.dev', TEST_PASSWORD);
|
||||
await client.post(
|
||||
`/api/quest/${quest.id}/submit`,
|
||||
form({ answer: 'no location' })
|
||||
);
|
||||
|
||||
const answer = await prisma.questAnswer.findFirstOrThrow({
|
||||
where: { teamId: team.id, questId: quest.id }
|
||||
});
|
||||
expect(answer.lat).toBeNull();
|
||||
expect(answer.lon).toBeNull();
|
||||
});
|
||||
|
||||
it('never creates a TrackPoint from a submission', async () => {
|
||||
const { quest } = await seedPlayground('owner@test.dev');
|
||||
|
||||
const client = newClient();
|
||||
await client.login('owner@test.dev', TEST_PASSWORD);
|
||||
await client.post(
|
||||
`/api/quest/${quest.id}/submit`,
|
||||
form({ answer: 'here', lat: '52.520008', lon: '13.404954' })
|
||||
);
|
||||
|
||||
expect(await prisma.trackPoint.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { computeTrackDistance, type GeoPoint } from '#shared/utils/geo';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { newClient } from '../helpers/api';
|
||||
import { resetDatabase } from '../helpers/database';
|
||||
import {
|
||||
createHunt,
|
||||
createTeam,
|
||||
createUser,
|
||||
TEST_PASSWORD
|
||||
} from '../helpers/factories';
|
||||
import { prisma } from './setup';
|
||||
|
||||
beforeEach(resetDatabase);
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function point(lat: number, lon: number, recordedAt: Date, accuracy?: number) {
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
recordedAt: recordedAt.toISOString(),
|
||||
...(accuracy !== undefined ? { accuracy } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/** A small walk north along a meridian, one fix every 10 s (~11 m apart). */
|
||||
function walk(count: number, startMs = Date.now() - count * 10_000) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
point(i * 0.0001, 0, new Date(startMs + i * 10_000), 5)
|
||||
);
|
||||
}
|
||||
|
||||
async function seedTrackingHunt(
|
||||
ownerEmail: string,
|
||||
overrides: Parameters<typeof createHunt>[1] = {}
|
||||
) {
|
||||
const creator = await createUser();
|
||||
const hunt = await createHunt(creator.id, {
|
||||
trackDistance: true,
|
||||
revealQuests: true,
|
||||
...overrides
|
||||
});
|
||||
const owner = await createUser({ email: ownerEmail });
|
||||
const team = await createTeam(hunt.id, owner.id);
|
||||
return { hunt, owner, team };
|
||||
}
|
||||
|
||||
async function loggedInClient(email: string) {
|
||||
const client = newClient();
|
||||
await client.login(email, TEST_PASSWORD);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe('track (POST /api/hunt/[huntId]/track)', () => {
|
||||
it('drops points without a session (2xx, nothing persisted)', async () => {
|
||||
const { hunt } = await seedTrackingHunt('owner@test.dev');
|
||||
|
||||
const res = await newClient().post(`/api/hunt/${hunt.id}/track`, walk(3));
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(await prisma.trackPoint.count()).toBe(0);
|
||||
expect(await prisma.userTrack.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('drops points from an authenticated non-member (2xx, nothing persisted)', async () => {
|
||||
const { hunt } = await seedTrackingHunt('owner@test.dev');
|
||||
const loner = await createUser({ email: 'loner@test.dev' });
|
||||
|
||||
const client = await loggedInClient('loner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, walk(3));
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(await prisma.trackPoint.count({ where: { userId: loner.id } })).toBe(
|
||||
0
|
||||
);
|
||||
expect(await prisma.userTrack.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('drops points when hunt.trackDistance is false', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev', {
|
||||
trackDistance: false
|
||||
});
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, walk(3));
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(await prisma.trackPoint.count({ where: { userId: owner.id } })).toBe(
|
||||
0
|
||||
);
|
||||
expect(await prisma.userTrack.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('drops points outside the time window', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev', {
|
||||
start: new Date(Date.now() - 2 * DAY),
|
||||
end: new Date(Date.now() - DAY)
|
||||
});
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, walk(3));
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(await prisma.trackPoint.count({ where: { userId: owner.id } })).toBe(
|
||||
0
|
||||
);
|
||||
expect(await prisma.userTrack.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('persists a valid batch and derives the distance', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev');
|
||||
const batch = walk(4);
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, batch);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const points = await prisma.trackPoint.findMany({
|
||||
where: { userId: owner.id, huntId: hunt.id }
|
||||
});
|
||||
expect(points).toHaveLength(4);
|
||||
|
||||
const track = await prisma.userTrack.findUniqueOrThrow({
|
||||
where: { userId_huntId: { userId: owner.id, huntId: hunt.id } }
|
||||
});
|
||||
const expected = computeTrackDistance(batch as GeoPoint[]);
|
||||
expect(expected).toBeGreaterThan(0);
|
||||
expect(track.distanceMeters).toBe(expected);
|
||||
expect(track.lastFixAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent when the same batch is re-sent', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev');
|
||||
const batch = walk(4);
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
await client.post(`/api/hunt/${hunt.id}/track`, batch);
|
||||
const first = await prisma.userTrack.findUniqueOrThrow({
|
||||
where: { userId_huntId: { userId: owner.id, huntId: hunt.id } }
|
||||
});
|
||||
|
||||
await client.post(`/api/hunt/${hunt.id}/track`, batch);
|
||||
const second = await prisma.userTrack.findUniqueOrThrow({
|
||||
where: { userId_huntId: { userId: owner.id, huntId: hunt.id } }
|
||||
});
|
||||
|
||||
expect(second.distanceMeters).toBe(first.distanceMeters);
|
||||
});
|
||||
|
||||
it('truncates oversize batches but stays 2xx', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev');
|
||||
const batch = walk(600);
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, batch);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
const count = await prisma.trackPoint.count({
|
||||
where: { userId: owner.id, huntId: hunt.id }
|
||||
});
|
||||
expect(count).toBeLessThanOrEqual(500);
|
||||
expect(count).toBe(500);
|
||||
});
|
||||
|
||||
it('filters invalid points but keeps the valid ones', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev');
|
||||
const now = Date.now();
|
||||
const batch = [
|
||||
point(0, 0, new Date(now - 30_000), 5), // valid
|
||||
point(999, 0, new Date(now - 25_000), 5), // lat out of range
|
||||
point(0, 999, new Date(now - 24_000), 5), // lon out of range
|
||||
point(0.0001, 0, new Date(now + DAY), 5), // recordedAt in the future
|
||||
point(0.0002, 0, new Date(now - 2 * DAY - MINUTE), 5), // too old
|
||||
point(0.0003, 0, new Date(now - 22_000), -5), // negative accuracy
|
||||
point(0.0001, 0, new Date(now - 20_000), 5) // valid
|
||||
];
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, batch);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
const points = await prisma.trackPoint.findMany({
|
||||
where: { userId: owner.id, huntId: hunt.id }
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('drops a malformed (non-array) body silently', async () => {
|
||||
const { hunt, owner } = await seedTrackingHunt('owner@test.dev');
|
||||
|
||||
const client = await loggedInClient('owner@test.dev');
|
||||
const res = await client.post(`/api/hunt/${hunt.id}/track`, {
|
||||
not: 'an array'
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(await prisma.trackPoint.count({ where: { userId: owner.id } })).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user