Pass workout stats (distance, duration, startedAt) through on import

ActivityInput now accepts optional distance, duration, and startedAt.
GPX-derived stats take precedence when available, but provider stats
are used as fallback for workouts without GPS data.

The import form and Import All now pass these fields from the Wahoo
workout data so activities show correct metadata even without a FIT file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-05 19:59:54 +02:00
parent b331c2295f
commit 80291ccf6e
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
2 changed files with 33 additions and 27 deletions

View file

@ -10,25 +10,28 @@ export interface ActivityInput {
description?: string; description?: string;
gpx?: string; gpx?: string;
routeId?: string; routeId?: string;
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
} }
export async function createActivity(ownerId: string, input: ActivityInput) { export async function createActivity(ownerId: string, input: ActivityInput) {
const db = getDb(); const db = getDb();
const id = randomUUID(); const id = randomUUID();
let distance: number | null = null; let distance: number | null = input.distance ?? null;
let elevationGain: number | null = null; let elevationGain: number | null = null;
let elevationLoss: number | null = null; let elevationLoss: number | null = null;
let startedAt: Date | null = null; let startedAt: Date | null = input.startedAt ?? null;
let duration: number | null = input.duration ?? null;
if (input.gpx) { if (input.gpx) {
try { try {
const gpxData = await parseGpxAsync(input.gpx); const gpxData = await parseGpxAsync(input.gpx);
distance = gpxData.distance || null; distance = gpxData.distance || distance;
elevationGain = gpxData.elevation.gain; elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss; elevationLoss = gpxData.elevation.loss;
// Try to extract start time from first track point if (!startedAt && gpxData.tracks[0]?.[0]?.time) {
if (gpxData.tracks[0]?.[0]?.time) {
startedAt = new Date(gpxData.tracks[0][0].time); startedAt = new Date(gpxData.tracks[0][0].time);
} }
} catch { } catch {
@ -44,6 +47,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
description: input.description ?? "", description: input.description ?? "",
gpx: input.gpx, gpx: input.gpx,
distance, distance,
duration,
elevationGain, elevationGain,
elevationLoss, elevationLoss,
startedAt, startedAt,

View file

@ -65,6 +65,9 @@ export async function action({ params, request }: Route.ActionArgs) {
const workoutId = formData.get("workoutId") as string; const workoutId = formData.get("workoutId") as string;
const workoutName = formData.get("workoutName") as string; const workoutName = formData.get("workoutName") as string;
const fileUrl = formData.get("fileUrl") as string; const fileUrl = formData.get("fileUrl") as string;
const startedAt = formData.get("startedAt") as string;
const distance = formData.get("distance") as string;
const duration = formData.get("duration") as string;
// Refresh token if needed // Refresh token if needed
let tokens = { let tokens = {
@ -77,18 +80,9 @@ export async function action({ params, request }: Route.ActionArgs) {
await updateTokens(connection.id, tokens); await updateTokens(connection.id, tokens);
} }
const workout = {
id: workoutId,
name: workoutName,
type: "",
startedAt: "",
duration: null,
distance: null,
fileUrl: fileUrl || undefined,
};
let gpx: string | null = null; let gpx: string | null = null;
if (workout.fileUrl) { if (fileUrl) {
const workout = { id: workoutId, name: workoutName, type: "", startedAt: "", duration: null, distance: null, fileUrl };
const fileBuffer = await provider.downloadFile(tokens, workout); const fileBuffer = await provider.downloadFile(tokens, workout);
gpx = await provider.convertToGpx(fileBuffer); gpx = await provider.convertToGpx(fileBuffer);
} }
@ -96,6 +90,9 @@ export async function action({ params, request }: Route.ActionArgs) {
const activityId = await createActivity(user.id, { const activityId = await createActivity(user.id, {
name: workoutName || `${provider.name} workout`, name: workoutName || `${provider.name} workout`,
gpx: gpx ?? undefined, gpx: gpx ?? undefined,
distance: distance ? parseFloat(distance) : null,
duration: duration ? parseInt(duration) : null,
startedAt: startedAt ? new Date(startedAt) : null,
}); });
await recordImport(user.id, provider.id, workoutId, activityId); await recordImport(user.id, provider.id, workoutId, activityId);
@ -151,6 +148,9 @@ function WorkoutRow({
<input type="hidden" name="workoutId" value={workout.id} /> <input type="hidden" name="workoutId" value={workout.id} />
<input type="hidden" name="workoutName" value={workout.name} /> <input type="hidden" name="workoutName" value={workout.name} />
<input type="hidden" name="fileUrl" value={workout.fileUrl ?? ""} /> <input type="hidden" name="fileUrl" value={workout.fileUrl ?? ""} />
<input type="hidden" name="startedAt" value={workout.startedAt ?? ""} />
<input type="hidden" name="distance" value={workout.distance ?? ""} />
<input type="hidden" name="duration" value={workout.duration ?? ""} />
<button <button
type="submit" type="submit"
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700" className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
@ -163,6 +163,17 @@ function WorkoutRow({
); );
} }
function workoutFormData(w: { id: string; name: string; fileUrl?: string; startedAt: string; distance: number | null; duration: number | null }) {
const fd = new FormData();
fd.set("workoutId", w.id);
fd.set("workoutName", w.name);
fd.set("fileUrl", w.fileUrl ?? "");
fd.set("startedAt", w.startedAt ?? "");
fd.set("distance", w.distance != null ? String(w.distance) : "");
fd.set("duration", w.duration != null ? String(w.duration) : "");
return fd;
}
export default function SyncImportPage({ loaderData }: Route.ComponentProps) { export default function SyncImportPage({ loaderData }: Route.ComponentProps) {
const { provider, workouts, page, totalPages } = loaderData; const { provider, workouts, page, totalPages } = loaderData;
const { t } = useTranslation("journal"); const { t } = useTranslation("journal");
@ -192,11 +203,7 @@ export default function SyncImportPage({ loaderData }: Route.ComponentProps) {
} }
const w = importAllRef.current.workouts[nextIndex]!; const w = importAllRef.current.workouts[nextIndex]!;
const formData = new FormData(); importAllFetcher.submit(workoutFormData(w), { method: "post" });
formData.set("workoutId", w.id);
formData.set("workoutName", w.name);
formData.set("fileUrl", w.fileUrl ?? "");
importAllFetcher.submit(formData, { method: "post" });
}, [importAllActive, importAllFetcher.state, importAllFetcher.data]); }, [importAllActive, importAllFetcher.state, importAllFetcher.data]);
const startImportAll = useCallback(() => { const startImportAll = useCallback(() => {
@ -205,12 +212,7 @@ export default function SyncImportPage({ loaderData }: Route.ComponentProps) {
setImportAllIndex(0); setImportAllIndex(0);
setImportAllActive(true); setImportAllActive(true);
const w = importableWorkouts[0]!; importAllFetcher.submit(workoutFormData(importableWorkouts[0]!), { method: "post" });
const formData = new FormData();
formData.set("workoutId", w.id);
formData.set("workoutName", w.name);
formData.set("fileUrl", w.fileUrl ?? "");
importAllFetcher.submit(formData, { method: "post" });
}, [importableWorkouts, importAllFetcher]); }, [importableWorkouts, importAllFetcher]);
return ( return (