4be489d6d3
Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie) instead of RPE (6-10) as their effort field; strength keeps RPE. An exercise counts as cardio when its equipment type is "cardio" or it carries the "cardio" muscle group (isCardioExercise in lib/exerciseOptions), so the Assault Bike (type "assault bike") qualifies. New nullable SetLog.gear column added by the boot-time guarded ALTER in docker_entrypoint.sh (additive, idempotent); plumbed through all 5 set-write paths, the summary/edit views, and CSV/JSON import-export. Existing rpe data is untouched and still displays. Program/AI target-RPE is unaffected.
298 lines
8.3 KiB
TypeScript
298 lines
8.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { prisma } from "@/lib/prisma";
|
|
import {
|
|
parseCSV,
|
|
parseFloatMaybe,
|
|
parseIntMaybe,
|
|
getVariationNote,
|
|
resolveExerciseName,
|
|
parseDate,
|
|
} from "@/lib/csvParser";
|
|
|
|
interface ParsedSet {
|
|
setNumber: number;
|
|
weight?: number;
|
|
weightUnit: string;
|
|
reps?: number;
|
|
durationSeconds?: number;
|
|
distance?: number;
|
|
distanceUnit?: string;
|
|
calories?: number;
|
|
watts?: number;
|
|
rpe?: number;
|
|
gear?: number;
|
|
customMetrics?: Record<string, string>;
|
|
notes?: string;
|
|
}
|
|
|
|
interface ParsedExercise {
|
|
exerciseId: string;
|
|
exerciseName: string;
|
|
sourceName?: string;
|
|
unmapped?: boolean;
|
|
sets: ParsedSet[];
|
|
}
|
|
|
|
interface ParsedWorkout {
|
|
date: string;
|
|
exercises: ParsedExercise[];
|
|
}
|
|
|
|
interface ParseResponse {
|
|
workouts: ParsedWorkout[];
|
|
unmapped: string[];
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const user = await getCurrentUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const file = formData.get("file") as File;
|
|
|
|
if (!file) {
|
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
}
|
|
|
|
if (!file.name.endsWith(".csv")) {
|
|
return NextResponse.json(
|
|
{ error: "File must be a CSV file" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const content = await file.text();
|
|
const rows = parseCSV(content);
|
|
|
|
if (rows.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: "CSV is empty or invalid format" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Get all user exercises for matching
|
|
const exercises = await prisma.exercise.findMany({
|
|
where: { userId: user.id },
|
|
select: { id: true, name: true },
|
|
});
|
|
|
|
// Build case-insensitive lookup map
|
|
const exerciseMap = new Map<string, string>();
|
|
for (const ex of exercises) {
|
|
exerciseMap.set(ex.name.toLowerCase(), ex.id);
|
|
}
|
|
|
|
// Group rows by date
|
|
const workoutsByDate = new Map<string, Array<Record<string, string>>>();
|
|
|
|
const unmappedExercises = new Set<string>();
|
|
|
|
for (const row of rows) {
|
|
const date = row.date || row.date_str || row.workout_date || "";
|
|
const exerciseName = row.exercise || row.exercise_name || "";
|
|
|
|
if (!date || !exerciseName) {
|
|
continue;
|
|
}
|
|
|
|
if (!workoutsByDate.has(date)) {
|
|
workoutsByDate.set(date, []);
|
|
}
|
|
|
|
workoutsByDate.get(date)!.push(row);
|
|
|
|
// Check if exercise can be resolved
|
|
const resolvedName = resolveExerciseName(exerciseName);
|
|
const isKnown = exerciseMap.has(resolvedName.toLowerCase());
|
|
if (!isKnown) {
|
|
unmappedExercises.add(exerciseName);
|
|
}
|
|
}
|
|
|
|
// Build parsed workouts
|
|
const parsedWorkouts: ParsedWorkout[] = [];
|
|
const knownColumns = new Set([
|
|
"date",
|
|
"date_str",
|
|
"workout_date",
|
|
"exercise",
|
|
"exercise_name",
|
|
"set",
|
|
"set_number",
|
|
"weight",
|
|
"weight_unit",
|
|
"weightunit",
|
|
"reps",
|
|
"duration",
|
|
"duration_seconds",
|
|
"duration_minutes",
|
|
"time",
|
|
"time_seconds",
|
|
"time_minutes",
|
|
"distance",
|
|
"distance_unit",
|
|
"distanceunit",
|
|
"calories",
|
|
"watts",
|
|
"rpe",
|
|
"gear",
|
|
"notes",
|
|
"custom_metrics_json",
|
|
"custommetricsjson",
|
|
]);
|
|
|
|
for (const [date, rowsForDate] of workoutsByDate) {
|
|
const exercisesMap = new Map<
|
|
string,
|
|
{
|
|
exerciseId: string;
|
|
exerciseName: string;
|
|
sourceName?: string;
|
|
unmapped?: boolean;
|
|
sets: ParsedSet[];
|
|
}
|
|
>();
|
|
|
|
for (const row of rowsForDate) {
|
|
const csvExerciseName = row.exercise || row.exercise_name || "";
|
|
const resolvedName = resolveExerciseName(csvExerciseName);
|
|
const exerciseId = exerciseMap.get(resolvedName.toLowerCase()) || "";
|
|
const isUnmapped = !exerciseId;
|
|
const exerciseKey = isUnmapped
|
|
? `unmapped:${csvExerciseName.toLowerCase()}`
|
|
: exerciseId;
|
|
|
|
if (!exercisesMap.has(exerciseKey)) {
|
|
exercisesMap.set(exerciseKey, {
|
|
exerciseId: exerciseId || "",
|
|
exerciseName: isUnmapped ? csvExerciseName : resolvedName,
|
|
sourceName: csvExerciseName,
|
|
unmapped: isUnmapped,
|
|
sets: [],
|
|
});
|
|
}
|
|
|
|
const exerciseData = exercisesMap.get(exerciseKey)!;
|
|
const weight = parseFloatMaybe(row.weight);
|
|
const reps = parseIntMaybe(row.reps);
|
|
let notes = row.notes || "";
|
|
|
|
// Detect weight unit from notes
|
|
let weightUnit = row.weight_unit || row.weightunit || "lbs";
|
|
if (notes.toLowerCase().includes("kg")) {
|
|
weightUnit = "kg";
|
|
}
|
|
|
|
const durationSeconds =
|
|
parseIntMaybe(row.duration_seconds) ??
|
|
parseIntMaybe(row.time_seconds) ??
|
|
(parseFloatMaybe(row.duration_minutes) !== undefined
|
|
? Math.round((parseFloatMaybe(row.duration_minutes) || 0) * 60)
|
|
: undefined) ??
|
|
(parseFloatMaybe(row.time_minutes) !== undefined
|
|
? Math.round((parseFloatMaybe(row.time_minutes) || 0) * 60)
|
|
: undefined) ??
|
|
parseIntMaybe(row.duration) ??
|
|
parseIntMaybe(row.time);
|
|
|
|
const distance = parseFloatMaybe(row.distance);
|
|
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
|
|
const calories = parseIntMaybe(row.calories);
|
|
const watts = parseIntMaybe(row.watts);
|
|
const rpe = parseIntMaybe(row.rpe);
|
|
const gear = parseIntMaybe(row.gear);
|
|
|
|
const customMetrics: Record<string, string> = {};
|
|
const customJson = row.custom_metrics_json || row.custommetricsjson;
|
|
if (customJson) {
|
|
try {
|
|
const parsed = JSON.parse(customJson);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
if (v !== null && v !== undefined && String(v).trim() !== "") {
|
|
customMetrics[String(k)] = String(v);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore malformed JSON field.
|
|
}
|
|
}
|
|
|
|
for (const [col, val] of Object.entries(row)) {
|
|
if (!val) continue;
|
|
const normalizedCol = col.toLowerCase().trim();
|
|
if (knownColumns.has(normalizedCol)) continue;
|
|
if (normalizedCol.startsWith("custom_")) {
|
|
const key = normalizedCol.replace(/^custom_/, "");
|
|
if (key) customMetrics[key] = val;
|
|
continue;
|
|
}
|
|
// Treat extra columns as custom metrics too.
|
|
customMetrics[normalizedCol] = val;
|
|
}
|
|
|
|
// Add variation note if applicable
|
|
const variationNote = getVariationNote(csvExerciseName);
|
|
if (variationNote) {
|
|
notes = notes
|
|
? `${notes} (${variationNote})`
|
|
: `(${variationNote})`;
|
|
}
|
|
|
|
const setNumber =
|
|
parseIntMaybe(row.set_number) ??
|
|
parseIntMaybe(row.set) ??
|
|
exerciseData.sets.length + 1;
|
|
|
|
exerciseData.sets.push({
|
|
setNumber,
|
|
weight,
|
|
weightUnit,
|
|
reps,
|
|
durationSeconds,
|
|
distance,
|
|
distanceUnit,
|
|
calories,
|
|
watts,
|
|
rpe,
|
|
gear,
|
|
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
|
|
notes: notes || undefined,
|
|
});
|
|
}
|
|
|
|
const workoutExercises = Array.from(exercisesMap.values());
|
|
if (workoutExercises.length > 0) {
|
|
parsedWorkouts.push({
|
|
date: parseDate(date),
|
|
exercises: workoutExercises,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by date ascending (oldest first)
|
|
parsedWorkouts.sort(
|
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
);
|
|
|
|
const response: ParseResponse = {
|
|
workouts: parsedWorkouts,
|
|
unmapped: Array.from(unmappedExercises),
|
|
};
|
|
|
|
return NextResponse.json(response);
|
|
} catch (error) {
|
|
console.error("CSV parsing error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Failed to parse CSV file" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|