282 lines
7.5 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|