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.
135 lines
3.2 KiB
TypeScript
135 lines
3.2 KiB
TypeScript
import { getCurrentUser } from "@/lib/auth";
|
|
import { getTimestampFileSuffix } from "@/lib/db-file";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { NextResponse } from "next/server";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function csvCell(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
const text = String(value);
|
|
if (text.includes(",") || text.includes("\"") || text.includes("\n")) {
|
|
return `"${text.replace(/"/g, "\"\"")}"`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* GET /api/settings/export-csv
|
|
* Exports workout + set-level rows to CSV for offline backup.
|
|
*/
|
|
export async function GET() {
|
|
try {
|
|
const user = await getCurrentUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const setLogs = await prisma.setLog.findMany({
|
|
where: {
|
|
workout: {
|
|
userId: user.id,
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
exercise: true,
|
|
workout: {
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
name: true,
|
|
notes: true,
|
|
durationMinutes: true,
|
|
difficulty: true,
|
|
caloriesBurned: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ workout: { date: "desc" } },
|
|
{ setNumber: "asc" },
|
|
],
|
|
});
|
|
|
|
const header = [
|
|
"workoutId",
|
|
"workoutDate",
|
|
"workoutName",
|
|
"workoutNotes",
|
|
"workoutDurationMinutes",
|
|
"workoutDifficulty",
|
|
"workoutCaloriesBurned",
|
|
"exerciseId",
|
|
"exerciseName",
|
|
"setNumber",
|
|
"reps",
|
|
"weight",
|
|
"weightUnit",
|
|
"durationMinutes",
|
|
"distance",
|
|
"distanceUnit",
|
|
"setCalories",
|
|
"setWatts",
|
|
"rpe",
|
|
"setGear",
|
|
"setNotes",
|
|
"customMetricsJson",
|
|
];
|
|
|
|
const lines = [header.join(",")];
|
|
|
|
for (const set of setLogs) {
|
|
const durationMinutes =
|
|
typeof set.durationSeconds === "number"
|
|
? (set.durationSeconds / 60).toFixed(2)
|
|
: "";
|
|
|
|
const row = [
|
|
set.workout.id,
|
|
set.workout.date.toISOString(),
|
|
set.workout.name ?? "",
|
|
set.workout.notes ?? "",
|
|
set.workout.durationMinutes ?? "",
|
|
set.workout.difficulty ?? "",
|
|
set.workout.caloriesBurned ?? "",
|
|
set.exerciseId,
|
|
set.exercise.name,
|
|
set.setNumber,
|
|
set.reps ?? "",
|
|
set.weight ?? "",
|
|
set.weightUnit ?? "",
|
|
durationMinutes,
|
|
set.distance ?? "",
|
|
set.distanceUnit ?? "",
|
|
set.calories ?? "",
|
|
set.watts ?? "",
|
|
set.rpe ?? "",
|
|
set.gear ?? "",
|
|
set.notes ?? "",
|
|
set.customMetrics ?? "",
|
|
];
|
|
|
|
lines.push(row.map(csvCell).join(","));
|
|
}
|
|
|
|
const csv = lines.join("\n");
|
|
const fileName = `proof-of-work-export-${getTimestampFileSuffix()}.csv`;
|
|
|
|
return new NextResponse(csv, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/csv; charset=utf-8",
|
|
"Content-Disposition": `attachment; filename="${fileName}"`,
|
|
"Cache-Control": "no-store",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("CSV export error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Failed to export CSV" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|