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

View file

@ -65,6 +65,9 @@ export async function action({ params, request }: Route.ActionArgs) {
const workoutId = formData.get("workoutId") as string;
const workoutName = formData.get("workoutName") 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
let tokens = {
@ -77,18 +80,9 @@ export async function action({ params, request }: Route.ActionArgs) {
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;
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);
gpx = await provider.convertToGpx(fileBuffer);
}
@ -96,6 +90,9 @@ export async function action({ params, request }: Route.ActionArgs) {
const activityId = await createActivity(user.id, {
name: workoutName || `${provider.name} workout`,
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);
@ -151,6 +148,9 @@ function WorkoutRow({
<input type="hidden" name="workoutId" value={workout.id} />
<input type="hidden" name="workoutName" value={workout.name} />
<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
type="submit"
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) {
const { provider, workouts, page, totalPages } = loaderData;
const { t } = useTranslation("journal");
@ -192,11 +203,7 @@ export default function SyncImportPage({ loaderData }: Route.ComponentProps) {
}
const w = importAllRef.current.workouts[nextIndex]!;
const formData = new FormData();
formData.set("workoutId", w.id);
formData.set("workoutName", w.name);
formData.set("fileUrl", w.fileUrl ?? "");
importAllFetcher.submit(formData, { method: "post" });
importAllFetcher.submit(workoutFormData(w), { method: "post" });
}, [importAllActive, importAllFetcher.state, importAllFetcher.data]);
const startImportAll = useCallback(() => {
@ -205,12 +212,7 @@ export default function SyncImportPage({ loaderData }: Route.ComponentProps) {
setImportAllIndex(0);
setImportAllActive(true);
const w = importableWorkouts[0]!;
const formData = new FormData();
formData.set("workoutId", w.id);
formData.set("workoutName", w.name);
formData.set("fileUrl", w.fileUrl ?? "");
importAllFetcher.submit(formData, { method: "post" });
importAllFetcher.submit(workoutFormData(importableWorkouts[0]!), { method: "post" });
}, [importableWorkouts, importAllFetcher]);
return (