feat(hunt): track walking distance via GPS, show live locations on map
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s

Add foreground-only GPS distance tracking for hunts
This commit is contained in:
2026-09-27 20:55:58 +02:00
parent a0fc836c05
commit 61cc516f47
43 changed files with 2313 additions and 24 deletions
+16 -1
View File
@@ -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
};
+31 -2
View File
@@ -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);
+137
View File
@@ -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 };
});