Files
proof-of-work/workout-planner/app/api/import/parse/route.ts
T
2026-02-28 09:27:26 -06:00

282 lines
7.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Exercise name mapping - CSV shorthand to DB names
const NAME_MAP: Record<string, string> = {
"Ab Wheel": "Ab Wheel Rollout",
"BB Upright Row": "Upright Row",
"Ball Situp": "Exercise Ball Situp",
"Bench": "Bench Press",
"DB Lateral Raise": "Lateral Raise",
"Dip": "Dips (Chest)",
"Face Pull": "Face Pulls",
"SA Lat Pulldown": "Lat Pulldown",
"SL Calf Raise": "Calf Raise",
"BB Row": "Barbell Row",
"DB Row": "Dumbbell Row",
"GHD": "Glute ham developer",
"Hamstring DL": "Hamstring deadlift",
"BB Curl": "Barbell Curl",
"BB Hip Bridge": "Hip Thrust",
"Cable Trap": "Rear delt",
"Chinup (Narrow)": "Chinup",
"Chinup Negatives": "Chinup",
"Squat (Foot Elevated)": "Squat",
"Ball Bicep Curl": "Dumbbell Curl",
"KB Hip Flexor": "Hip Flexor",
"Hamstring Deadlift": "Hamstring deadlift",
"Shoulder Press": "Overhead Press",
"CoC": "Captains of Crush",
"Hex DL": "Hex Bar Deadlift",
"KB Extension": "Kettlebell Leg Extension",
"Ski": "SkiErg",
};
interface ParsedSet {
setNumber: number;
weight?: number;
weightUnit: string;
reps?: number;
notes?: string;
}
interface ParsedExercise {
exerciseId: string;
exerciseName: string;
sets: ParsedSet[];
}
interface ParsedWorkout {
date: string;
exercises: ParsedExercise[];
}
interface ParseResponse {
workouts: ParsedWorkout[];
unmapped: string[];
}
function parseCSV(content: string): Array<Record<string, string>> {
const lines = content.trim().split("\n");
if (lines.length === 0) return [];
// Parse header
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
const rows = [];
// Parse data rows
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = line.split(",").map((v) => v.trim());
const row: Record<string, string> = {};
header.forEach((col, idx) => {
if (values[idx]) {
row[col] = values[idx];
}
});
rows.push(row);
}
return rows;
}
function getVariationNote(originalName: string): string | null {
if (originalName.includes("Narrow")) return "narrow";
if (originalName.includes("Negatives")) return "negatives";
if (originalName.includes("Foot Elevated")) return "foot elevated";
return null;
}
function resolveExerciseName(csvName: string): string {
// Check if it's in the name map
if (NAME_MAP[csvName]) {
return NAME_MAP[csvName];
}
// Return as-is for direct lookup
return csvName;
}
// Parse dates like "1/27/2026" or "2026-01-27" into ISO date string
function parseDate(dateStr: string): string {
// Try M/D/YYYY format
const mdyMatch = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (mdyMatch) {
const month = mdyMatch[1].padStart(2, "0");
const day = mdyMatch[2].padStart(2, "0");
const year = mdyMatch[3];
return `${year}-${month}-${day}T12:00:00.000Z`;
}
// Try ISO format
if (dateStr.includes("-")) {
return new Date(dateStr + "T12:00:00.000Z").toISOString();
}
// Fallback
return new Date(dateStr).toISOString();
}
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[] = [];
for (const [date, rowsForDate] of workoutsByDate) {
const exercisesMap = new Map<
string,
{
exerciseId: string;
exerciseName: string;
sets: ParsedSet[];
}
>();
for (const row of rowsForDate) {
const csvExerciseName = row.exercise || row.exercise_name || "";
const resolvedName = resolveExerciseName(csvExerciseName);
const exerciseId =
exerciseMap.get(resolvedName.toLowerCase()) || "";
if (!exerciseId) {
unmappedExercises.add(csvExerciseName);
continue;
}
if (!exercisesMap.has(exerciseId)) {
exercisesMap.set(exerciseId, {
exerciseId,
exerciseName: resolvedName,
sets: [],
});
}
const exerciseData = exercisesMap.get(exerciseId)!;
const weight = row.weight ? parseFloat(row.weight) : undefined;
const reps = row.reps ? parseInt(row.reps, 10) : undefined;
let notes = row.notes || "";
// Detect weight unit from notes
let weightUnit = "lbs";
if (notes.toLowerCase().includes("kg")) {
weightUnit = "kg";
}
// Add variation note if applicable
const variationNote = getVariationNote(csvExerciseName);
if (variationNote) {
notes = notes
? `${notes} (${variationNote})`
: `(${variationNote})`;
}
const setNumber = exerciseData.sets.length + 1;
exerciseData.sets.push({
setNumber,
weight,
weightUnit,
reps,
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 }
);
}
}