designer / training_program_designer.jsx
vshankar97's picture
Upload training_program_designer.jsx
bd7d572 verified
Raw
History Blame Contribute Delete
22.3 kB
import React, { useMemo, useState } from "react";
import { motion } from "framer-motion";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, CheckCircle2, Dumbbell, FileDown, ShieldAlert } from "lucide-react";
type EquipmentKey = "gym" | "bands" | "freeWeights" | "bodyWeight";
type FmsKey =
| "deepSquat"
| "hurdleStep"
| "inlineLunge"
| "shoulderMobility"
| "activeStraightLegRaise"
| "trunkStabilityPushUp"
| "rotaryStability";
type ContraKey =
| "highBloodPressure"
| "cardiacHistory"
| "lowBackPain"
| "shoulderPain"
| "kneePain"
| "anklePain"
| "pregnant"
| "postSurgery"
| "otherMedical";
type JointKey =
| "footAnkle"
| "knee"
| "hip"
| "lumbar"
| "thoracic"
| "cervical"
| "shoulder"
| "elbowWrist";
type Intake = {
clientName: string;
trainerName: string;
age: string;
sex: string;
weightKg: string;
heightCm: string;
bodyFat: string;
goal: string;
trainingAge: string;
frequencyPerWeek: string;
sessionDurationMin: string;
healthHistory: string;
currentMedications: string;
injuryNotes: string;
equipment: Record<EquipmentKey, boolean>;
contraindications: Record<ContraKey, boolean>;
kineticChain: Record<JointKey, string>;
fms: Record<FmsKey, number>;
clearingTests: {
shoulderPainWithClearing: boolean;
extensionPainWithClearing: boolean;
flexionPainWithClearing: boolean;
rotaryPainWithClearing: boolean;
};
};
type SubmissionPayload = {
intake: Intake;
rules: {
training_less_than_3_days_is_total_body: boolean;
include_all_7_patterns: boolean;
include_primary_and_assistance_exercises: boolean;
pdf_only_output: boolean;
deload_every_6_to_8_weeks: boolean;
progress_supported_to_integrated_then_more_closed_chain_when_tolerated: boolean;
};
};
const APP_CONFIG = {
appName: "Program Designer",
organizationName: "The Fitclub Academy",
apiBaseUrl: "/api/generate-program-pdf",
pdfFilenamePrefix: "training-program",
};
const equipmentLabels: Record<EquipmentKey, string> = {
gym: "Gym Machines",
bands: "Resistance Bands",
freeWeights: "Free Weights",
bodyWeight: "Body Weight",
};
const contraLabels: Record<ContraKey, string> = {
highBloodPressure: "High Blood Pressure",
cardiacHistory: "Cardiac History",
lowBackPain: "Low Back Pain",
shoulderPain: "Shoulder Pain",
kneePain: "Knee Pain",
anklePain: "Ankle Pain",
pregnant: "Pregnancy",
postSurgery: "Post Surgery",
otherMedical: "Other Medical Concern",
};
const jointLabels: Record<JointKey, string> = {
footAnkle: "Foot / Ankle",
knee: "Knee",
hip: "Hip",
lumbar: "Lumbar Spine",
thoracic: "Thoracic Spine",
cervical: "Cervical Spine",
shoulder: "Shoulder",
elbowWrist: "Elbow / Wrist",
};
const fmsFields: { key: FmsKey; label: string }[] = [
{ key: "deepSquat", label: "Deep Squat" },
{ key: "hurdleStep", label: "Hurdle Step" },
{ key: "inlineLunge", label: "Inline Lunge" },
{ key: "shoulderMobility", label: "Shoulder Mobility" },
{ key: "activeStraightLegRaise", label: "ASLR" },
{ key: "trunkStabilityPushUp", label: "Trunk Stability Push-Up" },
{ key: "rotaryStability", label: "Rotary Stability" },
];
const defaultIntake: Intake = {
clientName: "",
trainerName: "",
age: "",
sex: "",
weightKg: "",
heightCm: "",
bodyFat: "",
goal: "General fitness and movement quality",
trainingAge: "beginner",
frequencyPerWeek: "3",
sessionDurationMin: "60",
healthHistory: "",
currentMedications: "",
injuryNotes: "",
equipment: { gym: true, bands: true, freeWeights: true, bodyWeight: true },
contraindications: {
highBloodPressure: false,
cardiacHistory: false,
lowBackPain: false,
shoulderPain: false,
kneePain: false,
anklePain: false,
pregnant: false,
postSurgery: false,
otherMedical: false,
},
kineticChain: {
footAnkle: "",
knee: "",
hip: "",
lumbar: "",
thoracic: "",
cervical: "",
shoulder: "",
elbowWrist: "",
},
fms: {
deepSquat: 1,
hurdleStep: 1,
inlineLunge: 1,
shoulderMobility: 1,
activeStraightLegRaise: 1,
trunkStabilityPushUp: 1,
rotaryStability: 1,
},
clearingTests: {
shoulderPainWithClearing: false,
extensionPainWithClearing: false,
flexionPainWithClearing: false,
rotaryPainWithClearing: false,
},
};
function num(value: string, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function fmsTotal(intake: Intake) {
return Object.values(intake.fms).reduce((sum, value) => sum + value, 0);
}
function bmi(intake: Intake) {
const w = num(intake.weightKg);
const h = num(intake.heightCm) / 100;
if (!w || !h) return null;
return +(w / (h * h)).toFixed(1);
}
function riskMessages(intake: Intake) {
const flags: string[] = [];
if (intake.clearingTests.shoulderPainWithClearing) flags.push("Shoulder clearing test pain recorded.");
if (intake.clearingTests.extensionPainWithClearing) flags.push("Extension clearing test pain recorded.");
if (intake.clearingTests.flexionPainWithClearing) flags.push("Flexion clearing test pain recorded.");
if (intake.clearingTests.rotaryPainWithClearing) flags.push("Rotary clearing test pain recorded.");
if (intake.contraindications.highBloodPressure) flags.push("High blood pressure flagged.");
if (intake.contraindications.cardiacHistory) flags.push("Cardiac history flagged.");
if (intake.contraindications.postSurgery) flags.push("Post-surgical history flagged.");
return flags;
}
function buildPayload(intake: Intake): SubmissionPayload {
return {
intake,
rules: {
training_less_than_3_days_is_total_body: true,
include_all_7_patterns: true,
include_primary_and_assistance_exercises: true,
pdf_only_output: true,
deload_every_6_to_8_weeks: true,
progress_supported_to_integrated_then_more_closed_chain_when_tolerated: true,
},
};
}
async function requestProgramPdf(payload: SubmissionPayload) {
const response = await fetch(APP_CONFIG.apiBaseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("PDF generation failed");
}
const blob = await response.blob();
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("pdf")) {
throw new Error("Server did not return a PDF file");
}
return blob;
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function buildFilename(intake: Intake) {
const safeName = (intake.clientName || "client")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return `${APP_CONFIG.pdfFilenamePrefix}-${safeName || "client"}.pdf`;
}
function SectionHeader({ title, description }: { title: string; description: string }) {
return (
<div className="space-y-1">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}
export default function TrainingProgramDesignerEmbed() {
const [intake, setIntake] = useState<Intake>(defaultIntake);
const [isSubmitting, setIsSubmitting] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const [message, setMessage] = useState("");
const currentBmi = useMemo(() => bmi(intake), [intake]);
const currentFmsTotal = useMemo(() => fmsTotal(intake), [intake]);
const currentRiskMessages = useMemo(() => riskMessages(intake), [intake]);
function setField<K extends keyof Intake>(key: K, value: Intake[K]) {
setIntake((prev) => ({ ...prev, [key]: value }));
}
function setEquipment(key: EquipmentKey, value: boolean) {
setIntake((prev) => ({ ...prev, equipment: { ...prev.equipment, [key]: value } }));
}
function setContra(key: ContraKey, value: boolean) {
setIntake((prev) => ({ ...prev, contraindications: { ...prev.contraindications, [key]: value } }));
}
function setFms(key: FmsKey, value: number) {
setIntake((prev) => ({ ...prev, fms: { ...prev.fms, [key]: value } }));
}
function setJoint(key: JointKey, value: string) {
setIntake((prev) => ({ ...prev, kineticChain: { ...prev.kineticChain, [key]: value } }));
}
function setClearing<K extends keyof Intake["clearingTests"]>(key: K, value: boolean) {
setIntake((prev) => ({ ...prev, clearingTests: { ...prev.clearingTests, [key]: value } }));
}
async function handleGenerate() {
setIsSubmitting(true);
setStatus("idle");
setMessage("");
try {
const payload = buildPayload(intake);
const pdfBlob = await requestProgramPdf(payload);
downloadBlob(pdfBlob, buildFilename(intake));
setStatus("success");
setMessage("Your PDF program has been generated.");
} catch (error) {
setStatus("error");
setMessage(error instanceof Error ? error.message : "Unable to generate the PDF program.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-background p-4 md:p-6">
<div className="mx-auto max-w-5xl">
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
<Card className="rounded-3xl shadow-sm">
<CardHeader className="space-y-4">
<div className="flex items-start gap-3">
<div className="rounded-2xl border p-3">
<Dumbbell className="h-6 w-6" />
</div>
<div>
<CardTitle className="text-2xl">{APP_CONFIG.appName}</CardTitle>
<CardDescription>
Complete the form below and generate the client program PDF.
</CardDescription>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border p-4">
<div className="text-xs uppercase tracking-wide text-muted-foreground">BMI</div>
<div className="mt-1 text-2xl font-semibold">{currentBmi ?? "—"}</div>
</div>
<div className="rounded-2xl border p-4">
<div className="text-xs uppercase tracking-wide text-muted-foreground">FMS Total</div>
<div className="mt-1 text-2xl font-semibold">{currentFmsTotal}</div>
</div>
<div className="rounded-2xl border p-4">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Training Split Rule</div>
<div className="mt-1 text-sm font-semibold">
{num(intake.frequencyPerWeek, 3) < 3 ? "Total body sessions" : "Frequency-based split"}
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-8">
<div className="space-y-4">
<SectionHeader
title="Client Details"
description="Basic client information and training setup."
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2"><Label>Client Name</Label><Input value={intake.clientName} onChange={(e) => setField("clientName", e.target.value)} /></div>
<div className="space-y-2"><Label>Trainer Name</Label><Input value={intake.trainerName} onChange={(e) => setField("trainerName", e.target.value)} /></div>
<div className="space-y-2"><Label>Age</Label><Input value={intake.age} onChange={(e) => setField("age", e.target.value)} /></div>
<div className="space-y-2"><Label>Sex</Label><Input value={intake.sex} onChange={(e) => setField("sex", e.target.value)} /></div>
<div className="space-y-2"><Label>Weight (kg)</Label><Input value={intake.weightKg} onChange={(e) => setField("weightKg", e.target.value)} /></div>
<div className="space-y-2"><Label>Height (cm)</Label><Input value={intake.heightCm} onChange={(e) => setField("heightCm", e.target.value)} /></div>
<div className="space-y-2"><Label>Body Fat %</Label><Input value={intake.bodyFat} onChange={(e) => setField("bodyFat", e.target.value)} /></div>
<div className="space-y-2">
<Label>Training Age</Label>
<Select value={intake.trainingAge} onValueChange={(value) => setField("trainingAge", value)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2"><Label>Training Days / Week</Label><Input value={intake.frequencyPerWeek} onChange={(e) => setField("frequencyPerWeek", e.target.value)} /></div>
<div className="space-y-2"><Label>Session Duration (min)</Label><Input value={intake.sessionDurationMin} onChange={(e) => setField("sessionDurationMin", e.target.value)} /></div>
<div className="space-y-2 md:col-span-2"><Label>Primary Goal</Label><Input value={intake.goal} onChange={(e) => setField("goal", e.target.value)} /></div>
</div>
</div>
<Separator />
<div className="space-y-4">
<SectionHeader
title="Health and Injury History"
description="Use these notes to account for medical history, symptoms, and local tissue concerns."
/>
<div className="grid gap-4 xl:grid-cols-3">
<div className="space-y-2"><Label>Health History</Label><Textarea rows={6} value={intake.healthHistory} onChange={(e) => setField("healthHistory", e.target.value)} /></div>
<div className="space-y-2"><Label>Current Medications</Label><Textarea rows={6} value={intake.currentMedications} onChange={(e) => setField("currentMedications", e.target.value)} /></div>
<div className="space-y-2"><Label>General Injury Notes</Label><Textarea rows={6} value={intake.injuryNotes} onChange={(e) => setField("injuryNotes", e.target.value)} /></div>
</div>
</div>
<Separator />
<div className="space-y-4">
<SectionHeader
title="Available Equipment"
description="Only selected equipment should be used in the final program PDF."
/>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{(Object.keys(equipmentLabels) as EquipmentKey[]).map((key) => (
<label key={key} className="flex items-start gap-3 rounded-2xl border p-4">
<Checkbox checked={intake.equipment[key]} onCheckedChange={(checked) => setEquipment(key, Boolean(checked))} />
<div className="font-medium">{equipmentLabels[key]}</div>
</label>
))}
</div>
</div>
<Separator />
<div className="space-y-4">
<SectionHeader
title="FMS and Clearing Tests"
description="Movement quality and pain findings should constrain exercise selection and progression."
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{fmsFields.map(({ key, label }) => (
<div key={key} className="space-y-2 rounded-2xl border p-4">
<Label>{label}</Label>
<Select value={String(intake.fms[key])} onValueChange={(value) => setFms(key, Number(value))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">0</SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.shoulderPainWithClearing} onCheckedChange={(checked) => setClearing("shoulderPainWithClearing", Boolean(checked))} /><span className="text-sm">Shoulder clearing test pain</span></label>
<label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.extensionPainWithClearing} onCheckedChange={(checked) => setClearing("extensionPainWithClearing", Boolean(checked))} /><span className="text-sm">Extension clearing test pain</span></label>
<label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.flexionPainWithClearing} onCheckedChange={(checked) => setClearing("flexionPainWithClearing", Boolean(checked))} /><span className="text-sm">Flexion clearing test pain</span></label>
<label className="flex items-start gap-3 rounded-2xl border p-4"><Checkbox checked={intake.clearingTests.rotaryPainWithClearing} onCheckedChange={(checked) => setClearing("rotaryPainWithClearing", Boolean(checked))} /><span className="text-sm">Rotary clearing test pain</span></label>
</div>
</div>
<Separator />
<div className="space-y-4">
<SectionHeader
title="Contraindications and Kinetic Chain Checkpoints"
description="Flag contraindications and note site-specific issues."
/>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(Object.keys(contraLabels) as ContraKey[]).map((key) => (
<label key={key} className="flex items-start gap-3 rounded-2xl border p-4">
<Checkbox checked={intake.contraindications[key]} onCheckedChange={(checked) => setContra(key, Boolean(checked))} />
<div className="font-medium">{contraLabels[key]}</div>
</label>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{(Object.keys(jointLabels) as JointKey[]).map((key) => (
<div key={key} className="space-y-2 rounded-2xl border p-4">
<Label>{jointLabels[key]}</Label>
<Textarea rows={4} value={intake.kineticChain[key]} onChange={(e) => setJoint(key, e.target.value)} />
</div>
))}
</div>
</div>
<Separator />
<div className="rounded-2xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-medium">Readiness Summary</div>
<p className="text-sm text-muted-foreground">
{currentRiskMessages.length
? `${currentRiskMessages.length} caution flag${currentRiskMessages.length === 1 ? "" : "s"} recorded.`
: "No major caution flags currently selected."}
</p>
</div>
<Button onClick={handleGenerate} disabled={isSubmitting} className="rounded-2xl px-6">
<FileDown className="mr-2 h-4 w-4" />
{isSubmitting ? "Generating PDF..." : "Generate PDF"}
</Button>
</div>
</div>
{status !== "idle" && (
<div
className={`rounded-2xl border p-4 text-sm ${
status === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-red-200 bg-red-50 text-red-800"
}`}
>
<div className="flex items-start gap-2">
{status === "success" ? <CheckCircle2 className="mt-0.5 h-4 w-4" /> : <AlertCircle className="mt-0.5 h-4 w-4" />}
<span>{message}</span>
</div>
</div>
)}
{currentRiskMessages.length > 0 && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="mb-2 flex items-center gap-2 font-medium">
<ShieldAlert className="h-4 w-4" /> Caution Flags
</div>
<ul className="list-disc space-y-1 pl-5">
{currentRiskMessages.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}