diff --git a/TODO.md b/TODO.md index 9efc465..4739c48 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/app/components/TrackDistanceCard.vue b/app/components/TrackDistanceCard.vue new file mode 100644 index 0000000..aa78eb9 --- /dev/null +++ b/app/components/TrackDistanceCard.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/components/admin/Team.vue b/app/components/admin/Team.vue index 30e4bce..75a83eb 100644 --- a/app/components/admin/Team.vue +++ b/app/components/admin/Team.vue @@ -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() {

@@ -171,6 +172,9 @@ async function deleteTeam() { by {{ team.owner.name }} ({{ team.owner.email }})

{{ team._count.answers }} answers

+

+ {{ t('page.common.distance') }}: {{ formatDistance(team.distance) }} +

+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; + 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(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(); + for (const team of props.data.teams) m.set(team.id, team.name); + return m; +}); +const userName = computed(() => { + const m = new Map(); + 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(); + 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: ` + + + `, + 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 + ? `` + : ''; + return `
+ ${title}
+ ${t('page.map.team')}: ${team}
+ ${t('page.map.member')}: ${member}
+ ${t('page.map.time')}: ${time} + ${thumb} + ${t('page.map.open_quest_admin')} +
`; +} + +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 + ? `
${t('page.map.accuracy')}: ${Math.round(p.accuracy)} m` + : ''; + return `
+ ${name}
+ ${t('page.map.team')}: ${team}
+ ${t('page.map.last_fix', { ago: relTime(p.recordedAt) })}${acc} +
`; +} + +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 }); + + + + + diff --git a/app/components/showcase/MemberCard.vue b/app/components/showcase/MemberCard.vue new file mode 100644 index 0000000..1d1d793 --- /dev/null +++ b/app/components/showcase/MemberCard.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/app/components/showcase/NumberCard.vue b/app/components/showcase/NumberCard.vue index 6eaf3f9..74b6ab9 100644 --- a/app/components/showcase/NumberCard.vue +++ b/app/components/showcase/NumberCard.vue @@ -1,6 +1,7 @@ + + + + diff --git a/app/pages/hunt/[huntSlug]/showcase.vue b/app/pages/hunt/[huntSlug]/showcase.vue index 298557b..ba95bc9 100644 --- a/app/pages/hunt/[huntSlug]/showcase.vue +++ b/app/pages/hunt/[huntSlug]/showcase.vue @@ -1,6 +1,7 @@ @@ -76,9 +90,9 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[2].answers" icon="question_mark" /> + +
@@ -210,6 +230,12 @@ const columns = leaderboardColumns.map((c) => :count="data.leaderboard[1].answers" icon="question_mark" /> + +

@@ -236,6 +262,11 @@ const columns = leaderboardColumns.map((c) => :count="data.leaderboard[0].answers" icon="question_mark" /> + @@ -258,6 +289,12 @@ const columns = leaderboardColumns.map((c) => :count="data.leaderboard[0].answers" icon="question_mark" /> + + diff --git a/app/pages/profile.vue b/app/pages/profile.vue index 9089900..d3d1035 100644 --- a/app/pages/profile.vue +++ b/app/pages/profile.vue @@ -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() { {{ t('page.profile.no_pictures') }} + + {{ t('page.profile.distances.title') }} + +

+ {{ + t('page.profile.distances.total', { + distance: formatDistance(data.totalDistance) + }) + }} +

+
    +
  • + + {{ tKey(entry, 'name') }} + + {{ + formatDistance(entry.distanceMeters) + }} +
  • +
+
+ {{ + t('page.profile.distances.no_distances') + }} +
diff --git a/app/stores/hunt.ts b/app/stores/hunt.ts index 328f160..e3c2876 100644 --- a/app/stores/hunt.ts +++ b/app/stores/hunt.ts @@ -1,13 +1,19 @@ export const useHuntStore = defineStore('hunt', () => { const huntId = ref(); 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)); diff --git a/app/stores/settings.ts b/app/stores/settings.ts index f39624d..b47cb54 100644 --- a/app/stores/settings.ts +++ b/app/stores/settings.ts @@ -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>({}); - 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 } ); diff --git a/bun.lock b/bun.lock index bd4c248..8dbc1d9 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index c5b1045..d46ced4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260914195720_add_distance_tracking/migration.sql b/prisma/migrations/20260914195720_add_distance_tracking/migration.sql new file mode 100644 index 0000000..7699035 --- /dev/null +++ b/prisma/migrations/20260914195720_add_distance_tracking/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e6037a..746963a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/server/api/admin/hunt/[huntId]/index.get.ts b/server/api/admin/hunt/[huntId]/index.get.ts index 1710a9a..db7c787 100644 --- a/server/api/admin/hunt/[huntId]/index.get.ts +++ b/server/api/admin/hunt/[huntId]/index.get.ts @@ -35,6 +35,7 @@ export default defineEventHandler(async (event) => { allowJoin: true, revealQuests: true, revealAnswers: true, + trackDistance: true, updatedAt: true, picture: { select: { diff --git a/server/api/admin/hunt/[huntId]/index.post.ts b/server/api/admin/hunt/[huntId]/index.post.ts index 8efc8cc..c3742c0 100644 --- a/server/api/admin/hunt/[huntId]/index.post.ts +++ b/server/api/admin/hunt/[huntId]/index.post.ts @@ -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( diff --git a/server/api/admin/hunt/[huntId]/map.get.ts b/server/api/admin/hunt/[huntId]/map.get.ts new file mode 100644 index 0000000..4638b9b --- /dev/null +++ b/server/api/admin/hunt/[huntId]/map.get.ts @@ -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 => { + 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(); + 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 + })) + }; +}); diff --git a/server/api/admin/hunt/[huntId]/teams.get.ts b/server/api/admin/hunt/[huntId]/teams.get.ts index 5ee48a4..109b238 100644 --- a/server/api/admin/hunt/[huntId]/teams.get.ts +++ b/server/api/admin/hunt/[huntId]/teams.get.ts @@ -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 diff --git a/server/api/admin/hunt/[huntId]/track/[userId].get.ts b/server/api/admin/hunt/[huntId]/track/[userId].get.ts new file mode 100644 index 0000000..f04f477 --- /dev/null +++ b/server/api/admin/hunt/[huntId]/track/[userId].get.ts @@ -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 => { + 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() + })) + }; +}); diff --git a/server/api/admin/hunt/index.post.ts b/server/api/admin/hunt/index.post.ts index c4ffce0..0f70415 100644 --- a/server/api/admin/hunt/index.post.ts +++ b/server/api/admin/hunt/index.post.ts @@ -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) }) ); diff --git a/server/api/auth/profile.get.ts b/server/api/auth/profile.get.ts index 9d213d5..dd45586 100644 --- a/server/api/auth/profile.get.ts +++ b/server/api/auth/profile.get.ts @@ -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 }; }); diff --git a/server/api/hunt/[huntId]/index.get.ts b/server/api/hunt/[huntId]/index.get.ts index 92a9f1d..883c87b 100644 --- a/server/api/hunt/[huntId]/index.get.ts +++ b/server/api/hunt/[huntId]/index.get.ts @@ -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 }; diff --git a/server/api/hunt/[huntId]/showcase.get.ts b/server/api/hunt/[huntId]/showcase.get.ts index 1e8bcf4..58ebbb9 100644 --- a/server/api/hunt/[huntId]/showcase.get.ts +++ b/server/api/hunt/[huntId]/showcase.get.ts @@ -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); diff --git a/server/api/hunt/[huntId]/track.post.ts b/server/api/hunt/[huntId]/track.post.ts new file mode 100644 index 0000000..bab0a29 --- /dev/null +++ b/server/api/hunt/[huntId]/track.post.ts @@ -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; + +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( + (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 }; +}); diff --git a/server/api/quest/[questId]/index.get.ts b/server/api/quest/[questId]/index.get.ts index b69d999..640bd84 100644 --- a/server/api/quest/[questId]/index.get.ts +++ b/server/api/quest/[questId]/index.get.ts @@ -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 diff --git a/server/api/quest/[questId]/submit.post.ts b/server/api/quest/[questId]/submit.post.ts index 9b37782..01be53a 100644 --- a/server/api/quest/[questId]/submit.post.ts +++ b/server/api/quest/[questId]/submit.post.ts @@ -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 } }); } diff --git a/server/utils/distance.ts b/server/utils/distance.ts new file mode 100644 index 0000000..baf6fef --- /dev/null +++ b/server/utils/distance.ts @@ -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 { + 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); +} diff --git a/shared/types/map.ts b/shared/types/map.ts new file mode 100644 index 0000000..691a30b --- /dev/null +++ b/shared/types/map.ts @@ -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[]; +}; diff --git a/shared/utils/geo.test.ts b/shared/utils/geo.test.ts new file mode 100644 index 0000000..730b203 --- /dev/null +++ b/shared/utils/geo.test.ts @@ -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(); + }); +}); diff --git a/shared/utils/geo.ts b/shared/utils/geo.ts new file mode 100644 index 0000000..95d73c5 --- /dev/null +++ b/shared/utils/geo.ts @@ -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; + +/** 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(); + 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); +} diff --git a/tests/integration/submit.itest.ts b/tests/integration/submit.itest.ts index 99536ed..d4239d9 100644 --- a/tests/integration/submit.itest.ts +++ b/tests/integration/submit.itest.ts @@ -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); + }); }); diff --git a/tests/integration/track.itest.ts b/tests/integration/track.itest.ts new file mode 100644 index 0000000..f938543 --- /dev/null +++ b/tests/integration/track.itest.ts @@ -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[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 + ); + }); +});