import { fail } from '@sveltejs/kit'; import { db, peaksNearBounds, recordAscent } from '$lib/server/db'; import { matchPeaks, parseGpx } from '$lib/server/gpx'; import type { Actions } from './$types'; /** A "name" that is really just a date or timestamp, e.g. "2023-10-03 17:39:35". */ const DATE_ONLY_NAME = /^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T](\d{1,2}[:.]\d{2}([:.]\d{2})?|\d{4,6}))?Z?$/; /** Infer an activity type from average moving speed when the GPX doesn't say. */ function inferType(gpx: { distance_m: number; moving_s: number | null }): string | null { if (!gpx.moving_s || gpx.moving_s < 60) return null; const kmh = (gpx.distance_m / gpx.moving_s) * 3.6; if (kmh >= 13) return 'ride'; if (kmh >= 7) return 'run'; return 'hike'; } /** "Morning Hike", "Evening Run", or "Hike on 3 Oct 2023" when there's no start time. */ function generateName(type: string, start: string | null, date: string | null): string { const capitalized = type.charAt(0).toUpperCase() + type.slice(1); if (start) { const hour = new Date(start).getUTCHours(); const period = hour >= 4 && hour < 11 ? 'Morning' : hour >= 11 && hour < 14 ? 'Lunch' : hour >= 14 && hour < 18 ? 'Afternoon' : hour >= 18 && hour < 22 ? 'Evening' : 'Night'; return `${period} ${capitalized}`; } if (date) { const nice = new Date(date + 'T12:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); return `${capitalized} on ${nice}`; } return capitalized; } /** * Parse export-style filenames like * "2018-01-11 141322 - Run - Afternoon Run.gpx" (Date - Type - Name). */ function parseFilename( filename: string ): { date: string; type: string; name: string } | null { const base = filename.replace(/\.gpx$/i, ''); const parts = base.split(' - '); if (parts.length < 3) return null; const dateMatch = parts[0].match(/^(\d{4}-\d{2}-\d{2})(\s+\d{4,6})?$/); if (!dateMatch) return null; return { date: dateMatch[1], type: parts[1].trim().toLowerCase(), name: parts.slice(2).join(' - ').trim() }; } export const actions: Actions = { default: async ({ request, locals }) => { const userId = locals.user!.id; const form = await request.formData(); const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0); if (files.length === 0) return fail(400, { error: 'No GPX files selected.' }); const uploaded: { id: number; name: string; newPeaks: string[] }[] = []; const errors: string[] = []; const insert = db.prepare(` INSERT INTO activities (user_id, name, type, date, distance_m, duration_s, moving_s, elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, points, bounds) VALUES (@user_id, @name, @type, @date, @distance_m, @duration_s, @moving_s, @elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds) `); for (const file of files) { try { const gpx = parseGpx(await file.text()); const parsed = parseFilename(file.name); // a missing, "outdoor", or numeric (old Strava code) type gets inferred from pace let type = parsed?.type ?? gpx.type ?? ''; if (!type || type === 'outdoor' || /^\d+$/.test(type)) { type = inferType(gpx) ?? 'outdoor'; } // a name that is just a date/timestamp gets a friendly generated one let name = parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, ''); if (!name.trim() || DATE_ONLY_NAME.test(name.trim())) { name = generateName(type, gpx.start, gpx.date ?? parsed?.date ?? null); } const result = insert.run({ user_id: userId, name, type, date: gpx.date ?? parsed?.date ?? null, distance_m: gpx.distance_m, duration_s: gpx.duration_s, moving_s: gpx.moving_s, elev_gain_m: gpx.elev_gain_m, elev_loss_m: gpx.elev_loss_m, elev_min_m: gpx.elev_min_m, elev_max_m: gpx.elev_max_m, points: JSON.stringify(gpx.points), bounds: JSON.stringify(gpx.bounds) }); const activityId = Number(result.lastInsertRowid); const nearby = peaksNearBounds(gpx.bounds); const summited = matchPeaks(gpx.points, nearby); const newPeaks: string[] = []; for (const peak of summited) { if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name); } uploaded.push({ id: activityId, name, newPeaks }); } catch (err) { errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`); } } if (uploaded.length === 0) return fail(422, { error: errors.join(' ') }); return { uploaded, errors }; } };