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>
This commit is contained in:
246
werkout-frontend/app/dashboard/page.tsx
Normal file
246
werkout-frontend/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
import { WeeklyPlanGrid } from "@/components/plans/WeeklyPlanGrid";
|
||||
import { WeekPicker } from "@/components/plans/WeekPicker";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { GeneratedWeeklyPlan, WeeklyPreview } from "@/lib/types";
|
||||
|
||||
function getCurrentMonday(): string {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + diff);
|
||||
const yyyy = monday.getFullYear();
|
||||
const mm = String(monday.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(monday.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [selectedMonday, setSelectedMonday] = useState(getCurrentMonday);
|
||||
const [plans, setPlans] = useState<GeneratedWeeklyPlan[]>([]);
|
||||
const [preview, setPreview] = useState<WeeklyPreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchPlans = useCallback(async () => {
|
||||
try {
|
||||
try {
|
||||
const prefs = await api.getPreferences();
|
||||
const hasPrefs =
|
||||
prefs.available_equipment.length > 0 ||
|
||||
prefs.preferred_workout_types.length > 0 ||
|
||||
prefs.target_muscle_groups.length > 0;
|
||||
if (!hasPrefs) {
|
||||
router.replace("/onboarding");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
router.replace("/onboarding");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api.getPlans();
|
||||
setPlans(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch plans:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [fetchPlans]);
|
||||
|
||||
// Clear preview when week changes
|
||||
useEffect(() => {
|
||||
setPreview(null);
|
||||
}, [selectedMonday]);
|
||||
|
||||
const savedPlan = plans.find((p) => p.week_start_date === selectedMonday);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.previewPlan(selectedMonday);
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Failed to generate preview";
|
||||
setError(msg);
|
||||
console.error("Failed to generate preview:", err);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!savedPlan) return;
|
||||
setConfirming(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.confirmPlan(savedPlan.id);
|
||||
await fetchPlans();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to confirm plan";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!preview) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.savePlan(preview);
|
||||
setPreview(null);
|
||||
await fetchPlans();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to save plan";
|
||||
setError(msg);
|
||||
console.error("Failed to save plan:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Navbar />
|
||||
<BottomNav />
|
||||
<main className="pt-20 pb-20 px-4 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Dashboard</h1>
|
||||
<WeekPicker
|
||||
selectedMonday={selectedMonday}
|
||||
onChange={setSelectedMonday}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview?.warnings && preview.warnings.length > 0 && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-300 text-sm">
|
||||
<div className="font-medium mb-1">Heads up</div>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{preview.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : preview ? (
|
||||
/* ===== Preview mode ===== */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-zinc-200">
|
||||
Preview
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={generating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WeeklyPlanGrid
|
||||
preview={preview}
|
||||
onPreviewChange={setPreview}
|
||||
/>
|
||||
</div>
|
||||
) : savedPlan ? (
|
||||
/* ===== Saved plan mode ===== */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-zinc-200">
|
||||
This Week's Plan
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{savedPlan.generated_workouts.some(
|
||||
(w) => !w.is_rest_day && w.status === "pending"
|
||||
) && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={confirming}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Save to Calendar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={generating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WeeklyPlanGrid plan={savedPlan} onUpdate={fetchPlans} />
|
||||
</div>
|
||||
) : (
|
||||
/* ===== No plan ===== */
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-6">
|
||||
<p className="text-zinc-400 text-lg text-center">
|
||||
No plan for this week yet. Let's get started!
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={generating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
44
werkout-frontend/app/globals.css
Normal file
44
werkout-frontend/app/globals.css
Normal file
@@ -0,0 +1,44 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Range slider custom styling */
|
||||
input[type="range"].range-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
accent-color: #39ff14;
|
||||
}
|
||||
|
||||
input[type="range"].range-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #39ff14;
|
||||
cursor: pointer;
|
||||
border: 2px solid #09090b;
|
||||
box-shadow: 0 0 6px rgba(57, 255, 20, 0.4);
|
||||
}
|
||||
|
||||
input[type="range"].range-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #39ff14;
|
||||
cursor: pointer;
|
||||
border: 2px solid #09090b;
|
||||
box-shadow: 0 0 6px rgba(57, 255, 20, 0.4);
|
||||
}
|
||||
|
||||
input[type="range"].range-slider::-webkit-slider-runnable-track {
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
input[type="range"].range-slider::-moz-range-track {
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
background: #3f3f46;
|
||||
}
|
||||
157
werkout-frontend/app/history/page.tsx
Normal file
157
werkout-frontend/app/history/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
28
werkout-frontend/app/layout.tsx
Normal file
28
werkout-frontend/app/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import "@/app/globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Werkout",
|
||||
description:
|
||||
"AI-powered workout generator. Build personalized training plans based on your history and preferences.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`bg-zinc-950 text-zinc-100 antialiased ${inter.className}`}
|
||||
>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
174
werkout-frontend/app/login/page.tsx
Normal file
174
werkout-frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading: authLoading, login, register } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Redirect if already logged in
|
||||
if (!authLoading && user) {
|
||||
router.replace("/dashboard");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
await register(email, password, firstName, lastName);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
router.push("/dashboard");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Something went wrong. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-zinc-900 border border-zinc-700/50 rounded-xl p-8">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-black text-center text-accent tracking-wider mb-8">
|
||||
WERKOUT
|
||||
</h1>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{isRegister && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
|
||||
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
|
||||
transition-colors duration-150"
|
||||
placeholder="First name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
|
||||
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
|
||||
transition-colors duration-150"
|
||||
placeholder="Last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
|
||||
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
|
||||
transition-colors duration-150"
|
||||
placeholder="Email or username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-2.5 text-sm
|
||||
placeholder:text-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30
|
||||
transition-colors duration-150"
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
{isRegister ? "Create Account" : "Log In"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Toggle */}
|
||||
<p className="mt-6 text-center text-sm text-zinc-400">
|
||||
{isRegister
|
||||
? "Already have an account? "
|
||||
: "Don't have an account? "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsRegister(!isRegister);
|
||||
setError("");
|
||||
}}
|
||||
className="text-accent hover:underline font-medium"
|
||||
>
|
||||
{isRegister ? "Log In" : "Register"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
werkout-frontend/app/onboarding/page.tsx
Normal file
262
werkout-frontend/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import { EquipmentStep } from "@/components/onboarding/EquipmentStep";
|
||||
import { GoalsStep } from "@/components/onboarding/GoalsStep";
|
||||
import { WorkoutTypesStep } from "@/components/onboarding/WorkoutTypesStep";
|
||||
import { ScheduleStep } from "@/components/onboarding/ScheduleStep";
|
||||
import { DurationStep } from "@/components/onboarding/DurationStep";
|
||||
import { MusclesStep } from "@/components/onboarding/MusclesStep";
|
||||
import { InjuryStep } from "@/components/onboarding/InjuryStep";
|
||||
import { ExcludedExercisesStep } from "@/components/onboarding/ExcludedExercisesStep";
|
||||
import type { InjuryType } from "@/lib/types";
|
||||
|
||||
const STEP_LABELS = [
|
||||
"Equipment",
|
||||
"Goals",
|
||||
"Workout Types",
|
||||
"Schedule",
|
||||
"Duration",
|
||||
"Muscles",
|
||||
"Injuries",
|
||||
"Excluded Exercises",
|
||||
];
|
||||
|
||||
interface PreferencesData {
|
||||
equipment_ids: number[];
|
||||
muscle_ids: number[];
|
||||
workout_type_ids: number[];
|
||||
fitness_level: number;
|
||||
primary_goal: string;
|
||||
secondary_goal: string;
|
||||
days_per_week: number;
|
||||
preferred_workout_duration: number;
|
||||
preferred_days: number[];
|
||||
injury_types: InjuryType[];
|
||||
excluded_exercise_ids: number[];
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasExistingPrefs, setHasExistingPrefs] = useState(false);
|
||||
const [preferences, setPreferences] = useState<PreferencesData>({
|
||||
equipment_ids: [],
|
||||
muscle_ids: [],
|
||||
workout_type_ids: [],
|
||||
fitness_level: 1,
|
||||
primary_goal: "general_fitness",
|
||||
secondary_goal: "",
|
||||
days_per_week: 4,
|
||||
preferred_workout_duration: 45,
|
||||
preferred_days: [],
|
||||
injury_types: [],
|
||||
excluded_exercise_ids: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchExisting() {
|
||||
try {
|
||||
const existing = await api.getPreferences();
|
||||
const hasPrefs =
|
||||
existing.available_equipment.length > 0 ||
|
||||
existing.preferred_workout_types.length > 0 ||
|
||||
existing.target_muscle_groups.length > 0;
|
||||
setHasExistingPrefs(hasPrefs);
|
||||
setPreferences({
|
||||
equipment_ids: existing.available_equipment.map((e) => e.id),
|
||||
muscle_ids: existing.target_muscle_groups.map((m) => m.id),
|
||||
workout_type_ids: existing.preferred_workout_types.map((w) => w.id),
|
||||
fitness_level: existing.fitness_level || 1,
|
||||
primary_goal: existing.primary_goal || "general_fitness",
|
||||
secondary_goal: existing.secondary_goal || "",
|
||||
days_per_week: existing.days_per_week || 4,
|
||||
preferred_workout_duration: existing.preferred_workout_duration || 45,
|
||||
preferred_days: existing.preferred_days || [],
|
||||
injury_types: existing.injury_types || [],
|
||||
excluded_exercise_ids: existing.excluded_exercises || [],
|
||||
});
|
||||
} catch {
|
||||
// No existing preferences - use defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchExisting();
|
||||
}, []);
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
(updates: Partial<PreferencesData>) => {
|
||||
setPreferences((prev) => ({ ...prev, ...updates }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleNext = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updatePreferences({ ...preferences });
|
||||
|
||||
if (currentStep === STEP_LABELS.length - 1) {
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save preferences:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep((prev) => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const progressPercent = ((currentStep + 1) / STEP_LABELS.length) * 100;
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col">
|
||||
{/* Progress bar */}
|
||||
<div className="sticky top-0 z-10 bg-zinc-950 border-b border-zinc-800 px-4 py-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Step {currentStep + 1} of {STEP_LABELS.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-zinc-100">
|
||||
{STEP_LABELS[currentStep]}
|
||||
</span>
|
||||
{hasExistingPrefs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
title="Back to dashboard"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === 0 && (
|
||||
<EquipmentStep
|
||||
selectedIds={preferences.equipment_ids}
|
||||
onChange={(ids) => updatePreferences({ equipment_ids: ids })}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<GoalsStep
|
||||
fitnessLevel={preferences.fitness_level}
|
||||
primaryGoal={preferences.primary_goal}
|
||||
secondaryGoal={preferences.secondary_goal}
|
||||
onChange={(data) => updatePreferences(data)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<WorkoutTypesStep
|
||||
selectedIds={preferences.workout_type_ids}
|
||||
onChange={(ids) =>
|
||||
updatePreferences({ workout_type_ids: ids })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<ScheduleStep
|
||||
daysPerWeek={preferences.days_per_week}
|
||||
preferredDays={preferences.preferred_days}
|
||||
onChange={(data) => updatePreferences(data)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<DurationStep
|
||||
duration={preferences.preferred_workout_duration}
|
||||
onChange={(min) =>
|
||||
updatePreferences({ preferred_workout_duration: min })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<MusclesStep
|
||||
selectedIds={preferences.muscle_ids}
|
||||
onChange={(ids) => updatePreferences({ muscle_ids: ids })}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 6 && (
|
||||
<InjuryStep
|
||||
injuryTypes={preferences.injury_types}
|
||||
onChange={(injuries) =>
|
||||
updatePreferences({ injury_types: injuries })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 7 && (
|
||||
<ExcludedExercisesStep
|
||||
selectedIds={preferences.excluded_exercise_ids}
|
||||
onChange={(ids) =>
|
||||
updatePreferences({ excluded_exercise_ids: ids })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
{!loading && (
|
||||
<div className="sticky bottom-0 bg-zinc-950 border-t border-zinc-800 px-4 py-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleNext}
|
||||
loading={saving}
|
||||
>
|
||||
{currentStep === STEP_LABELS.length - 1
|
||||
? "Finish"
|
||||
: "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
27
werkout-frontend/app/page.tsx
Normal file
27
werkout-frontend/app/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace("/dashboard");
|
||||
} else {
|
||||
router.replace("/login");
|
||||
}
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
werkout-frontend/app/plans/[planId]/page.tsx
Normal file
119
werkout-frontend/app/plans/[planId]/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
import { WeeklyPlanGrid } from "@/components/plans/WeeklyPlanGrid";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { GeneratedWeeklyPlan } from "@/lib/types";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusVariant(
|
||||
status: string
|
||||
): "success" | "warning" | "error" | "default" {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "failed":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export default function PlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { planId: string };
|
||||
}) {
|
||||
const { planId } = params;
|
||||
const [plan, setPlan] = useState<GeneratedWeeklyPlan | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchPlan = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getPlan(Number(planId));
|
||||
setPlan(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch plan:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan();
|
||||
}, [fetchPlan]);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Navbar />
|
||||
<BottomNav />
|
||||
<main className="pt-20 pb-20 px-4 max-w-5xl mx-auto">
|
||||
<Link
|
||||
href="/plans"
|
||||
className="inline-flex items-center gap-1 text-sm text-zinc-400 hover:text-zinc-100 transition-colors mb-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to Plans
|
||||
</Link>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : !plan ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-zinc-400">Plan not found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100 mb-1">
|
||||
{formatDate(plan.week_start_date)} –{" "}
|
||||
{formatDate(plan.week_end_date)}
|
||||
</h1>
|
||||
<Badge variant={getStatusVariant(plan.status)}>
|
||||
{plan.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={fetchPlan}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WeeklyPlanGrid plan={plan} onUpdate={fetchPlan} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
64
werkout-frontend/app/plans/page.tsx
Normal file
64
werkout-frontend/app/plans/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
import { PlanCard } from "@/components/plans/PlanCard";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { GeneratedWeeklyPlan } from "@/lib/types";
|
||||
|
||||
export default function PlansPage() {
|
||||
const [plans, setPlans] = useState<GeneratedWeeklyPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getPlans()
|
||||
.then((data) => {
|
||||
const sorted = [...data].sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
setPlans(sorted);
|
||||
})
|
||||
.catch((err) => console.error("Failed to fetch plans:", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
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">Plans</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<p className="text-zinc-400 text-lg text-center">
|
||||
No plans generated yet.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-[#39FF14] hover:underline text-sm font-medium"
|
||||
>
|
||||
Go to Dashboard to generate one
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
227
werkout-frontend/app/preferences/page.tsx
Normal file
227
werkout-frontend/app/preferences/page.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import { EquipmentStep } from "@/components/onboarding/EquipmentStep";
|
||||
import { GoalsStep } from "@/components/onboarding/GoalsStep";
|
||||
import { WorkoutTypesStep } from "@/components/onboarding/WorkoutTypesStep";
|
||||
import { ScheduleStep } from "@/components/onboarding/ScheduleStep";
|
||||
import { DurationStep } from "@/components/onboarding/DurationStep";
|
||||
import { MusclesStep } from "@/components/onboarding/MusclesStep";
|
||||
import { InjuryStep } from "@/components/onboarding/InjuryStep";
|
||||
import { ExcludedExercisesStep } from "@/components/onboarding/ExcludedExercisesStep";
|
||||
import type { InjuryType } from "@/lib/types";
|
||||
|
||||
interface PreferencesData {
|
||||
equipment_ids: number[];
|
||||
muscle_ids: number[];
|
||||
workout_type_ids: number[];
|
||||
fitness_level: number;
|
||||
primary_goal: string;
|
||||
secondary_goal: string;
|
||||
days_per_week: number;
|
||||
preferred_workout_duration: number;
|
||||
preferred_days: number[];
|
||||
injury_types: InjuryType[];
|
||||
excluded_exercise_ids: number[];
|
||||
}
|
||||
|
||||
export default function PreferencesPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [preferences, setPreferences] = useState<PreferencesData>({
|
||||
equipment_ids: [],
|
||||
muscle_ids: [],
|
||||
workout_type_ids: [],
|
||||
fitness_level: 1,
|
||||
primary_goal: "general_fitness",
|
||||
secondary_goal: "",
|
||||
days_per_week: 4,
|
||||
preferred_workout_duration: 45,
|
||||
preferred_days: [],
|
||||
injury_types: [],
|
||||
excluded_exercise_ids: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchExisting() {
|
||||
try {
|
||||
const existing = await api.getPreferences();
|
||||
setPreferences({
|
||||
equipment_ids: existing.available_equipment.map((e) => e.id),
|
||||
muscle_ids: existing.target_muscle_groups.map((m) => m.id),
|
||||
workout_type_ids: existing.preferred_workout_types.map((w) => w.id),
|
||||
fitness_level: existing.fitness_level || 1,
|
||||
primary_goal: existing.primary_goal || "general_fitness",
|
||||
secondary_goal: existing.secondary_goal || "",
|
||||
days_per_week: existing.days_per_week || 4,
|
||||
preferred_workout_duration: existing.preferred_workout_duration || 45,
|
||||
preferred_days: existing.preferred_days || [],
|
||||
injury_types: existing.injury_types || [],
|
||||
excluded_exercise_ids: existing.excluded_exercises || [],
|
||||
});
|
||||
} catch {
|
||||
// No existing preferences - use defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchExisting();
|
||||
}, []);
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
(updates: Partial<PreferencesData>) => {
|
||||
setPreferences((prev) => ({ ...prev, ...updates }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.updatePreferences({ ...preferences });
|
||||
router.push("/dashboard");
|
||||
} catch (err) {
|
||||
console.error("Failed to save preferences:", err);
|
||||
setError("Failed to save preferences. Please try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Navbar />
|
||||
<BottomNav />
|
||||
<main className="pt-20 pb-28 px-4 max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-zinc-100 mb-8">Preferences</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{/* 1. Equipment */}
|
||||
<section>
|
||||
<EquipmentStep
|
||||
selectedIds={preferences.equipment_ids}
|
||||
onChange={(ids) => updatePreferences({ equipment_ids: ids })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 2. Goals */}
|
||||
<section>
|
||||
<GoalsStep
|
||||
fitnessLevel={preferences.fitness_level}
|
||||
primaryGoal={preferences.primary_goal}
|
||||
secondaryGoal={preferences.secondary_goal}
|
||||
onChange={(data) => updatePreferences(data)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 3. Workout Types */}
|
||||
<section>
|
||||
<WorkoutTypesStep
|
||||
selectedIds={preferences.workout_type_ids}
|
||||
onChange={(ids) =>
|
||||
updatePreferences({ workout_type_ids: ids })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 4. Schedule */}
|
||||
<section>
|
||||
<ScheduleStep
|
||||
daysPerWeek={preferences.days_per_week}
|
||||
preferredDays={preferences.preferred_days}
|
||||
onChange={(data) => updatePreferences(data)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 5. Duration */}
|
||||
<section>
|
||||
<DurationStep
|
||||
duration={preferences.preferred_workout_duration}
|
||||
onChange={(min) =>
|
||||
updatePreferences({ preferred_workout_duration: min })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 6. Target Muscles */}
|
||||
<section>
|
||||
<MusclesStep
|
||||
selectedIds={preferences.muscle_ids}
|
||||
onChange={(ids) => updatePreferences({ muscle_ids: ids })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 7. Injuries */}
|
||||
<section>
|
||||
<InjuryStep
|
||||
injuryTypes={preferences.injury_types}
|
||||
onChange={(injuries) =>
|
||||
updatePreferences({ injury_types: injuries })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
{/* 8. Excluded Exercises */}
|
||||
<section>
|
||||
<ExcludedExercisesStep
|
||||
selectedIds={preferences.excluded_exercise_ids}
|
||||
onChange={(ids) =>
|
||||
updatePreferences({ excluded_exercise_ids: ids })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky save bar */}
|
||||
{!loading && (
|
||||
<div className="fixed bottom-16 md:bottom-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur border-t border-zinc-800 px-4 py-3">
|
||||
<div className="max-w-2xl mx-auto flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
{error && (
|
||||
<span className="text-red-400">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
123
werkout-frontend/app/rules/page.tsx
Normal file
123
werkout-frontend/app/rules/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"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 { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Rule {
|
||||
value: unknown;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
rep_floors: "Rep Floors",
|
||||
duration: "Duration",
|
||||
superset: "Superset Structure",
|
||||
coherence: "Workout Coherence",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ["rep_floors", "duration", "superset", "coherence"];
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return value.replace(/_/g, " ");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function RulesPage() {
|
||||
const [rules, setRules] = useState<Record<string, Rule> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRules() {
|
||||
try {
|
||||
const data = await api.getRules();
|
||||
setRules(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load rules");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchRules();
|
||||
}, []);
|
||||
|
||||
// Group rules by category
|
||||
const grouped: Record<string, [string, Rule][]> = {};
|
||||
if (rules) {
|
||||
for (const [key, rule] of Object.entries(rules)) {
|
||||
const cat = rule.category;
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push([key, rule]);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedCategories = CATEGORY_ORDER.filter((c) => grouped[c]);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Navbar />
|
||||
<BottomNav />
|
||||
<main className="pt-20 pb-20 px-4 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-zinc-100 mb-6">
|
||||
Generation Rules
|
||||
</h1>
|
||||
<p className="text-zinc-400 text-sm mb-8">
|
||||
These guardrails are enforced during workout generation to ensure
|
||||
quality and coherence.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sortedCategories.map((category) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-3 bg-zinc-800/50 border-b border-zinc-800">
|
||||
<h2 className="text-sm font-semibold text-zinc-200 uppercase tracking-wide">
|
||||
{CATEGORY_LABELS[category] || category}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{grouped[category].map(([key, rule]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="px-4 py-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-zinc-200">
|
||||
{rule.description}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 font-mono">
|
||||
{key}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-sm font-medium text-[#39FF14] bg-[#39FF14]/10 px-2.5 py-1 rounded-md">
|
||||
{formatValue(rule.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
126
werkout-frontend/app/workout/[workoutId]/page.tsx
Normal file
126
werkout-frontend/app/workout/[workoutId]/page.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AuthGuard } from "@/components/auth/AuthGuard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
import { SupersetCard } from "@/components/workout/SupersetCard";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { WorkoutDetail } from "@/lib/types";
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (!seconds) return "N/A";
|
||||
const mins = Math.round(seconds / 60);
|
||||
if (mins >= 60) {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export default function WorkoutDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { workoutId: string };
|
||||
}) {
|
||||
const { workoutId } = params;
|
||||
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getWorkoutDetail(Number(workoutId))
|
||||
.then(setWorkout)
|
||||
.catch((err) => console.error("Failed to fetch workout:", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [workoutId]);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Navbar />
|
||||
<BottomNav />
|
||||
<main className="pt-20 pb-20 px-4 max-w-4xl mx-auto">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-1 text-sm text-zinc-400 hover:text-zinc-100 transition-colors mb-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back
|
||||
</Link>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : !workout ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-zinc-400">Workout not found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
{workout.name}
|
||||
</h1>
|
||||
{workout.description && (
|
||||
<p className="text-sm text-zinc-400 mb-3">
|
||||
{workout.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-zinc-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
{formatTime(workout.estimated_time)}
|
||||
</span>
|
||||
<span>
|
||||
{workout.supersets.length} superset
|
||||
{workout.supersets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{[...workout.supersets]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((superset, i) => (
|
||||
<SupersetCard
|
||||
key={superset.id}
|
||||
superset={superset}
|
||||
defaultOpen={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user