Files
proof-of-work/proof-of-work/app/api/import/parse/route.ts
T
Keysat 4be489d6d3 v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the effort field for cardio
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.
2026-06-16 14:49:15 -05:00

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 }
);
}
}