Initial commit for Start9 packaging
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { formatSetsSummary } from "@/lib/formatSets";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Loader,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Dumbbell,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { Exercise as PrismaExercise } from "@prisma/client";
|
||||
|
||||
// Extended type until Prisma client is regenerated with new fields
|
||||
type Exercise = PrismaExercise & {
|
||||
inputFields?: string;
|
||||
defaultWeightUnit?: string | null;
|
||||
};
|
||||
|
||||
const EXERCISE_TYPES = [
|
||||
"barbell",
|
||||
"dumbbell",
|
||||
"machine",
|
||||
"cable",
|
||||
"bodyweight",
|
||||
"cardio",
|
||||
"kettlebell",
|
||||
"other",
|
||||
];
|
||||
|
||||
const MUSCLE_GROUPS = [
|
||||
"chest",
|
||||
"back",
|
||||
"shoulders",
|
||||
"quads",
|
||||
"hamstrings",
|
||||
"glutes",
|
||||
"biceps",
|
||||
"triceps",
|
||||
"forearms",
|
||||
"core",
|
||||
"calves",
|
||||
"full body",
|
||||
"cardio",
|
||||
];
|
||||
|
||||
const INPUT_FIELD_OPTIONS = [
|
||||
{ value: "sets", label: "Sets" },
|
||||
{ value: "reps", label: "Reps" },
|
||||
{ value: "weight", label: "Weight" },
|
||||
{ value: "duration", label: "Duration" },
|
||||
{ value: "distance", label: "Distance" },
|
||||
{ value: "calories", label: "Calories" },
|
||||
{ value: "notes", label: "Notes" },
|
||||
];
|
||||
|
||||
interface WorkoutHistory {
|
||||
workout: { id: string; date: string; name: string | null };
|
||||
sets: Array<{
|
||||
id: string;
|
||||
setNumber: number;
|
||||
reps: number | null;
|
||||
weight: number | null;
|
||||
weightUnit: string;
|
||||
rpe: number | null;
|
||||
durationSeconds: number | null;
|
||||
distance: number | null;
|
||||
calories: number | null;
|
||||
notes: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ExerciseDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const exerciseId = params.id as string;
|
||||
|
||||
const [exercise, setExercise] = useState<Exercise | null>(null);
|
||||
const [history, setHistory] = useState<WorkoutHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editType, setEditType] = useState("");
|
||||
const [editMuscleGroups, setEditMuscleGroups] = useState<string[]>([]);
|
||||
const [editInputFields, setEditInputFields] = useState<string[]>([]);
|
||||
const [editDefaultUnit, setEditDefaultUnit] = useState<string | null>(null);
|
||||
|
||||
// Custom "+" add state
|
||||
const [addingType, setAddingType] = useState(false);
|
||||
const [newTypeText, setNewTypeText] = useState("");
|
||||
const [addingMuscle, setAddingMuscle] = useState(false);
|
||||
const [newMuscleText, setNewMuscleText] = useState("");
|
||||
const [addingField, setAddingField] = useState(false);
|
||||
const [newFieldText, setNewFieldText] = useState("");
|
||||
const [customTypes, setCustomTypes] = useState<string[]>([]);
|
||||
const [customMuscles, setCustomMuscles] = useState<string[]>([]);
|
||||
const [customFields, setCustomFields] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExercise();
|
||||
}, [exerciseId]);
|
||||
|
||||
const fetchExercise = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`);
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
const data = await res.json();
|
||||
setExercise(data.exercise);
|
||||
setHistory(data.history || []);
|
||||
|
||||
// Populate edit form
|
||||
setEditName(data.exercise.name);
|
||||
setEditType(data.exercise.type);
|
||||
const mg = JSON.parse(data.exercise.muscleGroups || "[]") as string[];
|
||||
setEditMuscleGroups(mg);
|
||||
const ifs = JSON.parse(data.exercise.inputFields || '["sets","reps","weight"]') as string[];
|
||||
setEditInputFields(ifs);
|
||||
setEditDefaultUnit(data.exercise.defaultWeightUnit);
|
||||
|
||||
// Detect custom values not in default lists
|
||||
const knownTypes = EXERCISE_TYPES;
|
||||
if (!knownTypes.includes(data.exercise.type)) {
|
||||
setCustomTypes((prev) => prev.includes(data.exercise.type) ? prev : [...prev, data.exercise.type]);
|
||||
}
|
||||
const knownMuscles = MUSCLE_GROUPS;
|
||||
mg.forEach((m: string) => {
|
||||
if (!knownMuscles.includes(m)) {
|
||||
setCustomMuscles((prev) => prev.includes(m) ? prev : [...prev, m]);
|
||||
}
|
||||
});
|
||||
const knownFields = INPUT_FIELD_OPTIONS.map((f) => f.value);
|
||||
ifs.forEach((f: string) => {
|
||||
if (!knownFields.includes(f)) {
|
||||
setCustomFields((prev) =>
|
||||
prev.some((cf) => cf.value === f)
|
||||
? prev
|
||||
: [...prev, { value: f, label: f.charAt(0).toUpperCase() + f.slice(1) }]
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editName,
|
||||
type: editType,
|
||||
muscleGroups: editMuscleGroups,
|
||||
inputFields: editInputFields,
|
||||
defaultWeightUnit: editDefaultUnit,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
const updated = await res.json();
|
||||
setExercise(updated);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to save changes");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Delete this exercise and all its history?")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
router.push("/main/exercises");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to delete exercise");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMuscleGroup = (group: string) => {
|
||||
setEditMuscleGroups((prev) =>
|
||||
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleInputField = (field: string) => {
|
||||
setEditInputFields((prev) =>
|
||||
prev.includes(field)
|
||||
? prev.filter((f) => f !== field)
|
||||
: [...prev, field]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
|
||||
<Loader className="w-8 h-8 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exercise) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] p-6">
|
||||
<Link
|
||||
href="/main/exercises"
|
||||
className="text-zinc-400 hover:text-white flex items-center gap-1 mb-4"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
Back
|
||||
</Link>
|
||||
<p className="text-zinc-500">Exercise not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const muscleGroups = JSON.parse(exercise.muscleGroups || "[]") as string[];
|
||||
const inputFields = JSON.parse(
|
||||
exercise.inputFields || '["sets","reps","weight"]'
|
||||
) as string[];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 px-4 py-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center gap-4">
|
||||
<Link
|
||||
href="/main/exercises"
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-white flex-1 truncate">
|
||||
{exercise.name}
|
||||
</h1>
|
||||
{!editing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-red-500 hover:text-red-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||
{/* Edit Mode */}
|
||||
{editing ? (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Equipment
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...EXERCISE_TYPES, ...customTypes].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setEditType(type)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editType === type
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{addingType ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newTypeText.trim().toLowerCase();
|
||||
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
|
||||
setCustomTypes((p) => [...p, val]);
|
||||
setEditType(val);
|
||||
}
|
||||
setNewTypeText("");
|
||||
setAddingType(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={newTypeText}
|
||||
onChange={(e) => setNewTypeText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newTypeText.trim().toLowerCase();
|
||||
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
|
||||
setCustomTypes((p) => [...p, val]);
|
||||
setEditType(val);
|
||||
}
|
||||
setNewTypeText("");
|
||||
setAddingType(false);
|
||||
}}
|
||||
placeholder="New type"
|
||||
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingType(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Muscle Groups
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => toggleMuscleGroup(group)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editMuscleGroups.includes(group)
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{group.charAt(0).toUpperCase() + group.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{addingMuscle ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newMuscleText.trim().toLowerCase();
|
||||
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
|
||||
setCustomMuscles((p) => [...p, val]);
|
||||
}
|
||||
if (val && !editMuscleGroups.includes(val)) {
|
||||
setEditMuscleGroups((p) => [...p, val]);
|
||||
}
|
||||
setNewMuscleText("");
|
||||
setAddingMuscle(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={newMuscleText}
|
||||
onChange={(e) => setNewMuscleText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newMuscleText.trim().toLowerCase();
|
||||
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
|
||||
setCustomMuscles((p) => [...p, val]);
|
||||
}
|
||||
if (val && !editMuscleGroups.includes(val)) {
|
||||
setEditMuscleGroups((p) => [...p, val]);
|
||||
}
|
||||
setNewMuscleText("");
|
||||
setAddingMuscle(false);
|
||||
}}
|
||||
placeholder="New group"
|
||||
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingMuscle(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Input Fields
|
||||
</label>
|
||||
<p className="text-xs text-zinc-600 mb-2">
|
||||
Choose which fields are relevant when logging this exercise
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...INPUT_FIELD_OPTIONS, ...customFields].map((field) => (
|
||||
<button
|
||||
key={field.value}
|
||||
onClick={() => toggleInputField(field.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editInputFields.includes(field.value)
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
</button>
|
||||
))}
|
||||
{addingField ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newFieldText.trim().toLowerCase();
|
||||
if (
|
||||
val &&
|
||||
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
|
||||
!customFields.some((f) => f.value === val)
|
||||
) {
|
||||
setCustomFields((p) => [
|
||||
...p,
|
||||
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
|
||||
]);
|
||||
}
|
||||
if (val && !editInputFields.includes(val)) {
|
||||
setEditInputFields((p) => [...p, val]);
|
||||
}
|
||||
setNewFieldText("");
|
||||
setAddingField(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={newFieldText}
|
||||
onChange={(e) => setNewFieldText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newFieldText.trim().toLowerCase();
|
||||
if (
|
||||
val &&
|
||||
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
|
||||
!customFields.some((f) => f.value === val)
|
||||
) {
|
||||
setCustomFields((p) => [
|
||||
...p,
|
||||
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
|
||||
]);
|
||||
}
|
||||
if (val && !editInputFields.includes(val)) {
|
||||
setEditInputFields((p) => [...p, val]);
|
||||
}
|
||||
setNewFieldText("");
|
||||
setAddingField(false);
|
||||
}}
|
||||
placeholder="New field"
|
||||
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingField(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Default Weight Unit
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: null, label: "User Default" },
|
||||
{ value: "lbs", label: "Pounds" },
|
||||
{ value: "kg", label: "Kilograms" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => setEditDefaultUnit(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editDefaultUnit === opt.value
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 bg-white text-black font-bold rounded-lg hover:bg-zinc-200 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-5 h-5" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="flex-1 py-3 bg-zinc-800 text-white font-medium rounded-lg hover:bg-zinc-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View Mode */
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="bg-zinc-800 p-3 rounded-lg">
|
||||
<Dumbbell className="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{exercise.name}
|
||||
</h2>
|
||||
<span className="inline-block mt-1 px-2 py-0.5 bg-zinc-800 text-zinc-300 rounded text-xs font-medium">
|
||||
{exercise.type.charAt(0).toUpperCase() +
|
||||
exercise.type.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{muscleGroups.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{muscleGroups.map((group) => (
|
||||
<span
|
||||
key={group}
|
||||
className="px-2 py-1 bg-zinc-800 text-zinc-400 rounded text-xs"
|
||||
>
|
||||
{group.charAt(0).toUpperCase() + group.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-zinc-800 pt-4 mt-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">
|
||||
Tracked Fields
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inputFields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="px-2 py-1 bg-zinc-800 text-zinc-300 rounded text-xs font-medium"
|
||||
>
|
||||
{field.charAt(0).toUpperCase() + field.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exercise.defaultWeightUnit && (
|
||||
<p className="text-xs text-zinc-500 mt-3">
|
||||
Default unit:{" "}
|
||||
<span className="text-zinc-300">
|
||||
{exercise.defaultWeightUnit}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-zinc-400" />
|
||||
History
|
||||
</h3>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center">
|
||||
<TrendingUp className="w-10 h-10 text-zinc-700 mx-auto mb-3" />
|
||||
<p className="text-zinc-500">No history yet</p>
|
||||
<p className="text-zinc-600 text-sm mt-1">
|
||||
Start logging this exercise to see your progress
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{history.map((entry) => {
|
||||
const summary = formatSetsSummary(
|
||||
entry.sets.map((s: any) => ({ weight: s.weight, reps: s.reps, weightUnit: s.weightUnit }))
|
||||
);
|
||||
const dateStr = new Date(entry.workout.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric" }
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={entry.workout.id}
|
||||
href={`/main/workouts/${entry.workout.id}`}
|
||||
className="flex items-baseline gap-2 px-3 py-1.5 rounded-md hover:bg-zinc-800/60 transition"
|
||||
>
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0 tabular-nums">
|
||||
{dateStr}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0">
|
||||
{entry.sets.length} {entry.sets.length === 1 ? "set" : "sets"}
|
||||
</span>
|
||||
{summary && (
|
||||
<>
|
||||
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
|
||||
<span className="text-sm text-zinc-300 truncate">
|
||||
{summary}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user