Files
Trey t 1c61b80731 workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types
- Add quality gate retry loop in generate_single_workout()
- Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections)
- Wire WeeklySplitPattern DB records into _pick_weekly_split()
- Enforce movement patterns from WorkoutStructureRule in exercise selection
- Add straight-set strength support (single main lift, 4-6 rounds)
- Add modality consistency check for duration-dominant workout types
- Add InjuryStep component to onboarding and preferences
- Add sibling exercise exclusion in regenerate and preview_day endpoints
- Display generator warnings on dashboard
- Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo
- Add audit_exercise_data and check_rules_drift management commands
- Add Next.js frontend with dashboard, onboarding, preferences, history pages
- Add generator app with ML-powered workout generation pipeline
- 96 new tests across 7 test modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:07:40 -06:00

158 lines
5.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { Navbar } from "@/components/layout/Navbar";
import { BottomNav } from "@/components/layout/BottomNav";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Spinner } from "@/components/ui/Spinner";
import { api } from "@/lib/api";
import { DIFFICULTY_LABELS } from "@/lib/types";
import type { CompletedWorkout } from "@/lib/types";
function formatDuration(seconds: number | null): string {
if (!seconds) return "N/A";
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
if (hours > 0) {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
function formatTotalHours(seconds: number): string {
const hours = (seconds / 3600).toFixed(1);
return `${hours}h`;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
}
function getDifficultyVariant(
difficulty: number
): "default" | "success" | "warning" | "error" | "accent" {
if (difficulty <= 1) return "success";
if (difficulty <= 3) return "warning";
return "error";
}
export default function HistoryPage() {
const [workouts, setWorkouts] = useState<CompletedWorkout[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getCompletedWorkouts()
.then((data) => {
const sorted = [...data].sort(
(a, b) =>
new Date(b.workout_start_time).getTime() -
new Date(a.workout_start_time).getTime()
);
setWorkouts(sorted);
})
.catch((err) =>
console.error("Failed to fetch completed workouts:", err)
)
.finally(() => setLoading(false));
}, []);
const totalWorkouts = workouts.length;
const totalTime = workouts.reduce((sum, w) => sum + (w.total_time || 0), 0);
const avgDifficulty =
totalWorkouts > 0
? workouts.reduce((sum, w) => sum + w.difficulty, 0) / totalWorkouts
: 0;
return (
<AuthGuard>
<Navbar />
<BottomNav />
<main className="pt-20 pb-20 px-4 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-zinc-100 mb-6">History</h1>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : workouts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-zinc-400 text-lg text-center">
No completed workouts yet.
</p>
</div>
) : (
<div>
{/* Summary Stats */}
<div className="grid grid-cols-3 gap-3 mb-8">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[#39FF14]">
{totalWorkouts}
</p>
<p className="text-xs text-zinc-500 mt-1">Total Workouts</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[#39FF14]">
{avgDifficulty.toFixed(1)}
</p>
<p className="text-xs text-zinc-500 mt-1">Avg Difficulty</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[#39FF14]">
{formatTotalHours(totalTime)}
</p>
<p className="text-xs text-zinc-500 mt-1">Total Time</p>
</Card>
</div>
{/* Workout List */}
<div className="flex flex-col gap-3">
{workouts.map((cw) => (
<Card key={cw.id} className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-zinc-100 truncate">
{cw.workout.name}
</h3>
<p className="text-xs text-zinc-500 mt-0.5">
{formatDate(cw.workout_start_time)}
</p>
</div>
<Badge variant={getDifficultyVariant(cw.difficulty)}>
{DIFFICULTY_LABELS[cw.difficulty] || "N/A"}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-zinc-400">
<span>{formatDuration(cw.total_time)}</span>
{cw.workout.exercise_count > 0 && (
<span>
{cw.workout.exercise_count} exercise
{cw.workout.exercise_count !== 1 ? "s" : ""}
</span>
)}
</div>
{cw.notes && (
<p className="text-xs text-zinc-500 mt-2 italic">
{cw.notes}
</p>
)}
</Card>
))}
</div>
</div>
)}
</main>
</AuthGuard>
);
}