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:
59
werkout-frontend/components/onboarding/DurationStep.tsx
Normal file
59
werkout-frontend/components/onboarding/DurationStep.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Slider } from "@/components/ui/Slider";
|
||||
|
||||
interface DurationStepProps {
|
||||
duration: number;
|
||||
onChange: (min: number) => void;
|
||||
}
|
||||
|
||||
export function DurationStep({ duration, onChange }: DurationStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Workout Duration
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-10">
|
||||
How long do you want each workout to be? This is the total time
|
||||
including warm-up, working sets, and rest periods.
|
||||
</p>
|
||||
|
||||
{/* Big centered display */}
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="text-7xl font-bold text-accent tabular-nums mb-2">
|
||||
{duration}
|
||||
</div>
|
||||
<div className="text-lg text-zinc-400">minutes</div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="max-w-md mx-auto">
|
||||
<Slider
|
||||
min={20}
|
||||
max={90}
|
||||
value={duration}
|
||||
onChange={onChange}
|
||||
step={5}
|
||||
label="Duration"
|
||||
unit="min"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration guide */}
|
||||
<div className="mt-10 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-300">Quick</div>
|
||||
<div className="text-xs text-zinc-500">20-30 min</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-300">Standard</div>
|
||||
<div className="text-xs text-zinc-500">40-60 min</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-300">Extended</div>
|
||||
<div className="text-xs text-zinc-500">70-90 min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
werkout-frontend/components/onboarding/EquipmentStep.tsx
Normal file
102
werkout-frontend/components/onboarding/EquipmentStep.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { Equipment } from "@/lib/types";
|
||||
|
||||
interface EquipmentStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function EquipmentStep({ selectedIds, onChange }: EquipmentStepProps) {
|
||||
const [equipment, setEquipment] = useState<Equipment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const data = await api.getEquipment();
|
||||
setEquipment(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch equipment:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
// Group by category
|
||||
const grouped = equipment.reduce<Record<string, Equipment[]>>(
|
||||
(acc, item) => {
|
||||
const cat = item.category || "Other";
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(item);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
What equipment do you have?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select all the equipment you have access to. This helps us build
|
||||
workouts tailored to your setup.
|
||||
</p>
|
||||
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={`p-4 text-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
werkout-frontend/components/onboarding/ExcludedExercisesStep.tsx
Normal file
167
werkout-frontend/components/onboarding/ExcludedExercisesStep.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { Exercise } from "@/lib/types";
|
||||
|
||||
interface ExcludedExercisesStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function ExcludedExercisesStep({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: ExcludedExercisesStepProps) {
|
||||
const [exercises, setExercises] = useState<Exercise[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchExercises() {
|
||||
try {
|
||||
const data = await api.getExercises();
|
||||
setExercises(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch exercises:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchExercises();
|
||||
}, []);
|
||||
|
||||
const excludedExercises = useMemo(
|
||||
() => exercises.filter((e) => selectedIds.includes(e.id)),
|
||||
[exercises, selectedIds]
|
||||
);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (search.length < 2) return [];
|
||||
const query = search.toLowerCase();
|
||||
return exercises.filter((e) =>
|
||||
e.name.toLowerCase().includes(query)
|
||||
);
|
||||
}, [exercises, search]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Exclude Exercises
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Search for exercises you can't or won't do. They'll never
|
||||
appear in your generated workouts.
|
||||
</p>
|
||||
|
||||
{/* Excluded chips */}
|
||||
<div className="mb-4">
|
||||
{excludedExercises.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 italic">
|
||||
No exercises excluded yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{excludedExercises.map((ex) => (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
onClick={() => toggle(ex.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 transition-colors"
|
||||
>
|
||||
{ex.name}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
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>
|
||||
|
||||
{/* Search input */}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search exercises..."
|
||||
className="w-full px-4 py-3 rounded-xl bg-zinc-900 border border-zinc-700/50 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-zinc-500 transition-colors mb-4"
|
||||
/>
|
||||
|
||||
{/* Search results */}
|
||||
{search.length >= 2 && (
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-zinc-700/50">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="p-4 text-sm text-zinc-500">
|
||||
No exercises match "{search}"
|
||||
</p>
|
||||
) : (
|
||||
searchResults.map((ex) => {
|
||||
const isExcluded = selectedIds.includes(ex.id);
|
||||
return (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
onClick={() => toggle(ex.id)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-zinc-800 last:border-b-0 transition-colors ${
|
||||
isExcluded
|
||||
? "bg-red-500/10 hover:bg-red-500/15"
|
||||
: "bg-zinc-900 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isExcluded ? "text-red-400" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{ex.name}
|
||||
</span>
|
||||
{ex.muscle_groups && (
|
||||
<span className="ml-2 text-xs text-zinc-500">
|
||||
{ex.muscle_groups}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExcluded && (
|
||||
<span className="text-xs text-red-400 font-medium">
|
||||
Excluded
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
werkout-frontend/components/onboarding/GoalsStep.tsx
Normal file
118
werkout-frontend/components/onboarding/GoalsStep.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { GOAL_LABELS, FITNESS_LEVEL_LABELS } from "@/lib/types";
|
||||
|
||||
interface GoalsStepProps {
|
||||
fitnessLevel: number;
|
||||
primaryGoal: string;
|
||||
secondaryGoal: string;
|
||||
onChange: (data: {
|
||||
fitness_level?: number;
|
||||
primary_goal?: string;
|
||||
secondary_goal?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const FITNESS_LEVEL_DESCRIPTIONS: Record<number, string> = {
|
||||
1: "New to structured training or returning after a long break.",
|
||||
2: "Consistent training for 6+ months with good form knowledge.",
|
||||
3: "Years of experience with complex programming and periodization.",
|
||||
4: "Competitive athlete or highly experienced lifter.",
|
||||
};
|
||||
|
||||
const goalOptions = Object.keys(GOAL_LABELS);
|
||||
|
||||
export function GoalsStep({
|
||||
fitnessLevel,
|
||||
primaryGoal,
|
||||
secondaryGoal,
|
||||
onChange,
|
||||
}: GoalsStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Your Fitness Profile
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-8">
|
||||
Tell us about your experience level and what you want to achieve.
|
||||
</p>
|
||||
|
||||
{/* Fitness Level */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
Fitness Level
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Object.entries(FITNESS_LEVEL_LABELS).map(([key, label]) => {
|
||||
const level = Number(key);
|
||||
const isSelected = fitnessLevel === level;
|
||||
return (
|
||||
<Card
|
||||
key={level}
|
||||
onClick={() => onChange({ fitness_level: level })}
|
||||
className={`p-4 transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-base font-semibold ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-400">
|
||||
{FITNESS_LEVEL_DESCRIPTIONS[level]}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Goal */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Primary Goal
|
||||
</label>
|
||||
<select
|
||||
value={primaryGoal}
|
||||
onChange={(e) => onChange({ primary_goal: e.target.value })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-3 text-base focus:outline-none focus:border-accent transition-colors"
|
||||
>
|
||||
{goalOptions.map((goal) => (
|
||||
<option key={goal} value={goal}>
|
||||
{GOAL_LABELS[goal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Secondary Goal */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Secondary Goal
|
||||
</label>
|
||||
<select
|
||||
value={secondaryGoal}
|
||||
onChange={(e) => onChange({ secondary_goal: e.target.value })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 text-zinc-100 rounded-lg px-4 py-3 text-base focus:outline-none focus:border-accent transition-colors"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{goalOptions
|
||||
.filter((g) => g !== primaryGoal)
|
||||
.map((goal) => (
|
||||
<option key={goal} value={goal}>
|
||||
{GOAL_LABELS[goal]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
werkout-frontend/components/onboarding/InjuryStep.tsx
Normal file
150
werkout-frontend/components/onboarding/InjuryStep.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import type { InjuryType } from "@/lib/types";
|
||||
|
||||
interface InjuryStepProps {
|
||||
injuryTypes: InjuryType[];
|
||||
onChange: (injuries: InjuryType[]) => void;
|
||||
}
|
||||
|
||||
const INJURY_AREAS: { type: string; label: string; icon: string }[] = [
|
||||
{ type: "knee", label: "Knee", icon: "🦵" },
|
||||
{ type: "lower_back", label: "Lower Back", icon: "🔻" },
|
||||
{ type: "upper_back", label: "Upper Back", icon: "🔺" },
|
||||
{ type: "shoulder", label: "Shoulder", icon: "💪" },
|
||||
{ type: "hip", label: "Hip", icon: "🦴" },
|
||||
{ type: "wrist", label: "Wrist", icon: "✋" },
|
||||
{ type: "ankle", label: "Ankle", icon: "🦶" },
|
||||
{ type: "neck", label: "Neck", icon: "🧣" },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS: {
|
||||
value: "mild" | "moderate" | "severe";
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "mild",
|
||||
label: "Mild",
|
||||
color: "text-yellow-400",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
borderColor: "border-yellow-500/50",
|
||||
},
|
||||
{
|
||||
value: "moderate",
|
||||
label: "Moderate",
|
||||
color: "text-orange-400",
|
||||
bgColor: "bg-orange-500/10",
|
||||
borderColor: "border-orange-500/50",
|
||||
},
|
||||
{
|
||||
value: "severe",
|
||||
label: "Severe",
|
||||
color: "text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
borderColor: "border-red-500/50",
|
||||
},
|
||||
];
|
||||
|
||||
export function InjuryStep({ injuryTypes, onChange }: InjuryStepProps) {
|
||||
const getInjury = (type: string): InjuryType | undefined =>
|
||||
injuryTypes.find((i) => i.type === type);
|
||||
|
||||
const toggleInjury = (type: string) => {
|
||||
const existing = getInjury(type);
|
||||
if (existing) {
|
||||
onChange(injuryTypes.filter((i) => i.type !== type));
|
||||
} else {
|
||||
onChange([...injuryTypes, { type, severity: "moderate" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const setSeverity = (type: string, severity: "mild" | "moderate" | "severe") => {
|
||||
onChange(
|
||||
injuryTypes.map((i) => (i.type === type ? { ...i, severity } : i))
|
||||
);
|
||||
};
|
||||
|
||||
const getSeverityStyle = (type: string) => {
|
||||
const injury = getInjury(type);
|
||||
if (!injury) return "";
|
||||
const opt = SEVERITY_OPTIONS.find((s) => s.value === injury.severity);
|
||||
return opt ? `${opt.borderColor} ${opt.bgColor}` : "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Any injuries or limitations?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select any body areas with current injuries. We will adjust your
|
||||
workouts to avoid aggravating them. You can skip this step if you have
|
||||
no injuries.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{INJURY_AREAS.map((area) => {
|
||||
const injury = getInjury(area.type);
|
||||
const isSelected = !!injury;
|
||||
|
||||
return (
|
||||
<div key={area.type}>
|
||||
<Card
|
||||
onClick={() => toggleInjury(area.type)}
|
||||
className={`p-4 text-center transition-all duration-150 ${
|
||||
isSelected ? getSeverityStyle(area.type) : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-2xl">{area.icon}</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-zinc-100" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{area.label}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isSelected && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{SEVERITY_OPTIONS.map((sev) => (
|
||||
<button
|
||||
key={sev.value}
|
||||
type="button"
|
||||
onClick={() => setSeverity(area.type, sev.value)}
|
||||
className={`flex-1 text-xs py-1.5 rounded-lg border transition-all duration-150 ${
|
||||
injury.severity === sev.value
|
||||
? `${sev.bgColor} ${sev.borderColor} ${sev.color} font-semibold`
|
||||
: "border-zinc-700 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{sev.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{injuryTypes.length > 0 && (
|
||||
<div className="mt-6 p-4 rounded-lg bg-zinc-800/50 border border-zinc-700/50">
|
||||
<p className="text-sm text-zinc-400">
|
||||
<span className="font-medium text-zinc-300">
|
||||
{injuryTypes.length} area{injuryTypes.length !== 1 ? "s" : ""} selected.
|
||||
</span>{" "}
|
||||
Exercises that could aggravate these areas will be excluded or
|
||||
modified based on severity level.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
werkout-frontend/components/onboarding/MusclesStep.tsx
Normal file
98
werkout-frontend/components/onboarding/MusclesStep.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { Muscle } from "@/lib/types";
|
||||
|
||||
interface MusclesStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function MusclesStep({ selectedIds, onChange }: MusclesStepProps) {
|
||||
const [muscles, setMuscles] = useState<Muscle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const data = await api.getMuscles();
|
||||
setMuscles(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch muscles:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Target Muscles
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select the muscle groups you want to focus on. Leave empty to target all
|
||||
muscle groups equally.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedIds.length === muscles.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(muscles.map((m) => m.id));
|
||||
}
|
||||
}}
|
||||
className="mb-4 text-sm font-medium text-accent hover:underline"
|
||||
>
|
||||
{selectedIds.length === muscles.length ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{muscles.map((muscle) => {
|
||||
const isSelected = selectedIds.includes(muscle.id);
|
||||
return (
|
||||
<Card
|
||||
key={muscle.id}
|
||||
onClick={() => toggle(muscle.id)}
|
||||
className={`p-4 text-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{muscle.name}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
werkout-frontend/components/onboarding/ScheduleStep.tsx
Normal file
97
werkout-frontend/components/onboarding/ScheduleStep.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { DAY_NAMES } from "@/lib/types";
|
||||
|
||||
interface ScheduleStepProps {
|
||||
daysPerWeek: number;
|
||||
preferredDays: number[];
|
||||
onChange: (data: {
|
||||
days_per_week?: number;
|
||||
preferred_days?: number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const DAYS_OPTIONS = [3, 4, 5, 6];
|
||||
|
||||
export function ScheduleStep({
|
||||
daysPerWeek,
|
||||
preferredDays,
|
||||
onChange,
|
||||
}: ScheduleStepProps) {
|
||||
const toggleDay = (dayIndex: number) => {
|
||||
if (preferredDays.includes(dayIndex)) {
|
||||
onChange({
|
||||
preferred_days: preferredDays.filter((d) => d !== dayIndex),
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
preferred_days: [...preferredDays, dayIndex],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Your Schedule
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-8">
|
||||
How often do you want to work out, and which days work best for you?
|
||||
</p>
|
||||
|
||||
{/* Days per week */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
Days Per Week
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{DAYS_OPTIONS.map((num) => {
|
||||
const isSelected = daysPerWeek === num;
|
||||
return (
|
||||
<button
|
||||
key={num}
|
||||
onClick={() => onChange({ days_per_week: num })}
|
||||
className={`flex-1 py-3 rounded-lg text-lg font-bold transition-all duration-150 cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-accent text-black"
|
||||
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred days */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
|
||||
Preferred Days
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{DAY_NAMES.map((name, index) => {
|
||||
const isSelected = preferredDays.includes(index);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => toggleDay(index)}
|
||||
className={`flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-150 cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-accent text-black"
|
||||
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-3">
|
||||
Select the days you prefer to train. This helps us schedule rest days
|
||||
optimally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
werkout-frontend/components/onboarding/WorkoutTypesStep.tsx
Normal file
104
werkout-frontend/components/onboarding/WorkoutTypesStep.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import type { WorkoutType } from "@/lib/types";
|
||||
|
||||
interface WorkoutTypesStepProps {
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
const intensityVariant: Record<string, "success" | "warning" | "error"> = {
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "error",
|
||||
};
|
||||
|
||||
export function WorkoutTypesStep({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: WorkoutTypesStepProps) {
|
||||
const [workoutTypes, setWorkoutTypes] = useState<WorkoutType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const data = await api.getWorkoutTypes();
|
||||
setWorkoutTypes(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch workout types:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-zinc-100 mb-2">
|
||||
Workout Types
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Select the types of workouts you enjoy. We'll use these to build
|
||||
your weekly plans.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{workoutTypes.map((wt) => {
|
||||
const isSelected = selectedIds.includes(wt.id);
|
||||
return (
|
||||
<Card
|
||||
key={wt.id}
|
||||
onClick={() => toggle(wt.id)}
|
||||
className={`p-4 transition-all duration-150 ${
|
||||
isSelected
|
||||
? "border-[#39FF14] bg-[rgba(57,255,20,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span
|
||||
className={`text-base font-semibold ${
|
||||
isSelected ? "text-accent" : "text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{wt.name}
|
||||
</span>
|
||||
<Badge variant={intensityVariant[wt.typical_intensity] || "default"}>
|
||||
{wt.typical_intensity}
|
||||
</Badge>
|
||||
</div>
|
||||
{wt.description && (
|
||||
<p className="text-sm text-zinc-400 line-clamp-2">
|
||||
{wt.description}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user