Files
proof-of-work/proof-of-work/app/main/workouts/[id]/page.tsx
T
Keysat 390aaf556e v1.2.0:4 — make avg. watts a first-class SetLog field
Average watts (assault bike, rower, ski erg) was a free-text entry stuffed
into the per-set customMetrics JSON blob. Promote it to a real nullable
column, SetLog.watts, written through every set path (create / PATCH /
add-sets / import-save / account-import) and shown everywhere as
"Avg. watts" with a proper numeric input.

The column is added by the boot-time guarded ALTER in docker_entrypoint.sh
(additive, idempotent), so the version migration stays empty. Existing data
is untouched: legacy watts values remain readable from customMetrics and
migrate to the column the next time a set is saved.
2026-06-16 12:52:59 -05:00

263 lines
9.6 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import {
ChevronLeft,
Trash2,
Loader,
Pencil,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { WorkoutWithSets } from "@/types";
import { formatSetsSummary } from "@/lib/formatSets";
function buildSetSummary(set: {
weight?: number | null;
weightUnit?: string | null;
reps?: number | null;
rpe?: number | null;
notes?: string | null;
durationSeconds?: number | null;
distance?: number | null;
calories?: number | null;
watts?: number | null;
customMetrics?: string | null;
}) {
const parts: string[] = [];
if (set.weight) parts.push(`${set.weight} ${set.weightUnit === "kg" ? "kg" : "lbs"}`);
if (set.reps) parts.push(`${set.reps} reps`);
if ((set as any).durationSeconds) {
const minutes = (set as any).durationSeconds / 60;
const rounded = Math.round(minutes * 10) / 10;
parts.push(`${Number.isInteger(rounded) ? Math.trunc(rounded) : rounded} min`);
}
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
if ((set as any).watts) parts.push(`${(set as any).watts} W`);
if ((set as any).customMetrics) {
try {
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
for (const [k, v] of Object.entries(custom)) {
if (!v) continue;
// Watts is now a first-class column. Legacy sets still carry it under
// customMetrics — render it the same way (and skip if the column
// already supplied it) so old and new sets read identically.
if (k === "watts") {
if (!(set as any).watts) parts.push(`${v} W`);
continue;
}
parts.push(`${k}: ${v}`);
}
} catch {}
}
if (set.rpe) parts.push(`RPE ${set.rpe}`);
if (set.notes) parts.push(set.notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
}
export default function WorkoutDetailPage() {
const params = useParams();
const router = useRouter();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [expandedExercise, setExpandedExercise] = useState<string | null>(null);
const workoutId = params.id as string;
useEffect(() => {
const fetchWorkout = async () => {
try {
const response = await fetch(`/api/workouts/${workoutId}`);
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
setWorkout(data);
} catch (error) {
console.error("Error fetching workout:", error);
} finally {
setLoading(false);
}
};
fetchWorkout();
}, [workoutId]);
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this workout?")) return;
setDeleting(true);
try {
const response = await fetch(`/api/workouts/${workoutId}`, { method: "DELETE" });
if (!response.ok) throw new Error("Failed to delete");
router.push("/main/workouts");
} catch (error) {
console.error("Error deleting workout:", error);
alert("Failed to delete workout");
setDeleting(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
<Loader className="w-8 h-8 animate-spin text-white" />
</div>
);
}
if (!workout) {
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-6">
<Link href="/main/workouts" className="inline-flex items-center gap-2 text-white hover:text-gray-200 mb-4">
<ChevronLeft className="w-5 h-5" /> Back
</Link>
<p className="text-zinc-400">Workout not found</p>
</div>
</div>
);
}
// Format date
const date = new Date(workout.date);
const formattedDate = date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
// Group sets by exercise (preserving order)
const exerciseGroups: Array<{ exerciseId: string; exerciseName: string; sets: typeof workout.setLogs }> = [];
const seen = new Set<string>();
for (const set of workout.setLogs) {
if (!seen.has(set.exercise.id)) {
seen.add(set.exercise.id);
exerciseGroups.push({
exerciseId: set.exercise.id,
exerciseName: set.exercise.name,
sets: workout.setLogs.filter((s) => s.exercise.id === set.exercise.id),
});
}
}
// Build post-workout stats
const stats: string[] = [];
if (workout.durationMinutes) stats.push(`${workout.durationMinutes} min`);
if (workout.difficulty) stats.push(`Difficulty ${workout.difficulty}/10`);
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/main/workouts" className="p-2 hover:bg-zinc-800 rounded-lg -ml-2" aria-label="Back">
<ChevronLeft className="w-6 h-6 text-white" />
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold text-white truncate">
{workout.name || "Unnamed Workout"}
</h1>
<p className="text-sm text-zinc-400">{formattedDate}</p>
</div>
<Link
href={`/main/workouts/new?edit=${workoutId}`}
className="p-2 hover:bg-zinc-800 text-zinc-400 hover:text-white rounded-lg transition-colors"
aria-label="Edit workout"
>
<Pencil className="w-5 h-5" />
</Link>
<button
onClick={handleDelete}
disabled={deleting}
className="p-2 hover:bg-zinc-800 text-red-500 rounded-lg -mr-2 disabled:opacity-50"
aria-label="Delete workout"
>
{deleting ? <Loader className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-4 space-y-2">
{/* Exercises — compact collapsible cards */}
{exerciseGroups.map((group) => {
const isExpanded = expandedExercise === group.exerciseId;
const setsWithData = group.sets.filter((s) => s.reps || s.weight);
const summary = formatSetsSummary(setsWithData) ||
`${group.sets.length} set${group.sets.length !== 1 ? "s" : ""}`;
return (
<div key={group.exerciseId} className="border border-zinc-800 rounded-lg bg-zinc-900">
{/* Exercise header */}
<button
type="button"
onClick={() => setExpandedExercise(isExpanded ? null : group.exerciseId)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-zinc-800/50 transition-colors rounded-lg"
>
<div className="text-left flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<h4 className="font-semibold text-white text-sm truncate">
{group.exerciseName}
</h4>
<span className="text-xs text-zinc-500 flex-shrink-0">
{group.sets.length} set{group.sets.length !== 1 ? "s" : ""}
</span>
</div>
{!isExpanded && setsWithData.length > 0 && (
<p className="text-xs text-zinc-400 mt-0.5 truncate">{summary}</p>
)}
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
) : (
<ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
)}
</button>
{/* Expanded sets — same style as locked SetRow */}
{isExpanded && (
<div className="border-t border-zinc-800 p-3 space-y-2">
{group.sets.map((set) => (
<div key={set.id} className="flex items-center gap-1.5">
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
{set.setNumber}
</div>
<p className="text-sm text-zinc-300 truncate flex-1">
{buildSetSummary(set)}
</p>
</div>
))}
</div>
)}
</div>
);
})}
{/* Post-workout info (notes, duration, difficulty) */}
{(workout.notes || stats.length > 0) && (
<div className="border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2.5 space-y-1.5">
{stats.length > 0 && (
<p className="text-xs text-zinc-400">{stats.join(" · ")}</p>
)}
{workout.notes && (
<p className="text-sm text-zinc-300">{workout.notes}</p>
)}
</div>
)}
{/* Edit button at bottom */}
<div className="pt-4 pb-8">
<Link
href={`/main/workouts/new?edit=${workoutId}`}
className="w-full py-3 border border-zinc-700 text-white font-semibold rounded-lg hover:bg-zinc-800 flex items-center justify-center gap-2 transition"
>
<Pencil className="w-5 h-5" />
Edit Workout
</Link>
</div>
</div>
</div>
);
}