diff --git a/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts b/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts new file mode 100644 index 0000000..dccaf9b --- /dev/null +++ b/proof-of-work/app/api/programs/[id]/days/[dayId]/start/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/programs/[id]/days/[dayId]/start + * + * Creates a new Workout for the actor pre-populated with the + * ProgramDay's exercise list — one SetLog per planned set, with + * empty reps/weight that the user fills in as they actually log + * the session. Stamps `Workout.programDayId` for adherence + * tracking. + * + * Body (optional): { date?: string (ISO) } + * - date defaults to today + */ + +const bodySchema = z.object({ + date: z.string().optional(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: { id: string; dayId: string } }, +) { + try { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let body: unknown = {}; + try { + body = await request.json(); + } catch { + // Empty body is fine. + } + const parsed = bodySchema.safeParse(body); + const dateStr = parsed.success ? parsed.data.date : undefined; + + const day = await prisma.programDay.findFirst({ + where: { + id: params.dayId, + week: { programId: params.id, program: { userId: user.id } }, + }, + include: { + exercises: { + orderBy: { order: "asc" }, + include: { exercise: true }, + }, + week: { include: { program: { select: { name: true } } } }, + }, + }); + if (!day) { + return NextResponse.json( + { error: "Program day not found" }, + { status: 404 }, + ); + } + + // Build SetLog rows: for each planned exercise, pre-create N + // empty sets where N = exercise.sets ?? 1. The user fills in + // reps/weight when they actually do them. + const setLogsCreate: { + exerciseId: string; + setNumber: number; + weightUnit: string; + }[] = []; + for (const ex of day.exercises) { + const setCount = ex.sets ?? 1; + for (let n = 1; n <= setCount; n++) { + setLogsCreate.push({ + exerciseId: ex.exerciseId, + setNumber: n, + weightUnit: ex.exercise.defaultWeightUnit ?? "lbs", + }); + } + } + + const workoutDate = dateStr ? new Date(dateStr) : new Date(); + const workoutName = + day.name ?? + `${day.week.program.name} · Week ${day.week.weekNumber} Day ${day.dayOfWeek}`; + + const workout = await prisma.workout.create({ + data: { + userId: user.id, + date: workoutDate, + name: workoutName, + programDayId: day.id, + setLogs: setLogsCreate.length > 0 ? { create: setLogsCreate } : undefined, + }, + include: { + setLogs: { + include: { exercise: true }, + orderBy: { setNumber: "asc" }, + }, + }, + }); + + return NextResponse.json(workout, { status: 201 }); + } catch (err) { + console.error("POST /api/programs/[id]/days/[dayId]/start error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/proof-of-work/app/api/programs/[id]/route.ts b/proof-of-work/app/api/programs/[id]/route.ts new file mode 100644 index 0000000..b33475b --- /dev/null +++ b/proof-of-work/app/api/programs/[id]/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { getProgramById } from "@/lib/db/programs"; + +/** + * GET /api/programs/[id] — full program tree (weeks + days + exercises). + * PATCH /api/programs/[id] — update metadata (name/description/type/ + * durationWeeks/startDate/isActive) AND/OR + * replace the entire weeks tree if provided. + * Replace-tree is the simpler mental model + * for the UI editor + the AI apply flow: + * same payload shape as POST /api/programs. + * DELETE /api/programs/[id] — cascading delete (weeks/days/exercises go + * via Prisma onDelete: Cascade). + */ + +const exerciseInput = z.object({ + exerciseId: z.string().min(1), + order: z.number().int().nonnegative(), + sets: z.number().int().positive().optional().nullable(), + repsMin: z.number().int().positive().optional().nullable(), + repsMax: z.number().int().positive().optional().nullable(), + rpe: z.number().int().min(1).max(10).optional().nullable(), + restSeconds: z.number().int().nonnegative().optional().nullable(), + notes: z.string().optional().nullable(), +}); +const dayInput = z.object({ + dayOfWeek: z.number().int().min(0).max(6), + name: z.string().optional().nullable(), + description: z.string().optional().nullable(), + exercises: z.array(exerciseInput), +}); +const weekInput = z.object({ + weekNumber: z.number().int().positive(), + phase: z.string().optional().nullable(), + description: z.string().optional().nullable(), + days: z.array(dayInput), +}); + +const patchSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional().nullable(), + type: z.string().min(1).optional(), + durationWeeks: z.number().int().positive().optional(), + startDate: z.string().optional(), + isActive: z.boolean().optional(), + /** When provided, REPLACES the entire weeks tree. */ + weeks: z.array(weekInput).optional(), +}); + +export async function GET( + _request: NextRequest, + { params }: { params: { id: string } }, +) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const program = await getProgramById(user.id, params.id); + if (!program) { + return NextResponse.json({ error: "Program not found" }, { status: 404 }); + } + return NextResponse.json(program); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } }, +) { + try { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const existing = await prisma.program.findFirst({ + where: { id: params.id, userId: user.id }, + select: { id: true }, + }); + if (!existing) { + return NextResponse.json({ error: "Program not found" }, { status: 404 }); + } + + const body = await request.json(); + const validated = patchSchema.parse(body); + + // If replacing the tree, verify exercise ownership. + if (validated.weeks) { + const allExerciseIds = new Set(); + for (const w of validated.weeks) + for (const d of w.days) + for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId); + if (allExerciseIds.size > 0) { + const owned = await prisma.exercise.findMany({ + where: { userId: user.id, id: { in: Array.from(allExerciseIds) } }, + select: { id: true }, + }); + const ownedIds = new Set(owned.map((e) => e.id)); + const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id)); + if (bad.length > 0) { + return NextResponse.json( + { error: "Some exerciseIds don't exist in your library", details: bad }, + { status: 400 }, + ); + } + } + } + + const programData: Prisma.ProgramUpdateInput = {}; + if (validated.name !== undefined) programData.name = validated.name; + if (validated.description !== undefined) + programData.description = validated.description; + if (validated.type !== undefined) programData.type = validated.type; + if (validated.durationWeeks !== undefined) + programData.durationWeeks = validated.durationWeeks; + if (validated.startDate !== undefined) + programData.startDate = new Date(validated.startDate); + if (validated.isActive !== undefined) programData.isActive = validated.isActive; + + await prisma.$transaction(async (tx) => { + if (Object.keys(programData).length > 0) { + await tx.program.update({ where: { id: params.id }, data: programData }); + } + + if (validated.weeks) { + // Wipe and rebuild the entire tree. Cascading delete on + // ProgramWeek removes ProgramDay + ProgramExercise; Workouts + // referencing those days have their programDayId set to NULL + // by the FK ON DELETE SET NULL clause we declared in the + // schema, so adherence references aren't catastrophic. + await tx.programWeek.deleteMany({ where: { programId: params.id } }); + for (const w of validated.weeks) { + const week = await tx.programWeek.create({ + data: { + programId: params.id, + weekNumber: w.weekNumber, + phase: w.phase ?? null, + description: w.description ?? null, + }, + }); + for (const d of w.days) { + const day = await tx.programDay.create({ + data: { + weekId: week.id, + dayOfWeek: d.dayOfWeek, + name: d.name ?? null, + description: d.description ?? null, + }, + }); + if (d.exercises.length > 0) { + await tx.programExercise.createMany({ + data: d.exercises.map((ex) => ({ + dayId: day.id, + exerciseId: ex.exerciseId, + order: ex.order, + sets: ex.sets ?? null, + repsMin: ex.repsMin ?? null, + repsMax: ex.repsMax ?? null, + rpe: ex.rpe ?? null, + restSeconds: ex.restSeconds ?? null, + notes: ex.notes ?? null, + })) as Prisma.ProgramExerciseCreateManyInput[], + }); + } + } + } + } + }); + + const updated = await getProgramById(user.id, params.id); + return NextResponse.json(updated); + } catch (err) { + if (err instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid patch payload", details: err.errors }, + { status: 400 }, + ); + } + console.error("PATCH /api/programs/[id] error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: { id: string } }, +) { + try { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const existing = await prisma.program.findFirst({ + where: { id: params.id, userId: user.id }, + select: { id: true }, + }); + if (!existing) { + return NextResponse.json({ error: "Program not found" }, { status: 404 }); + } + await prisma.program.delete({ where: { id: params.id } }); + return NextResponse.json({ success: true }); + } catch (err) { + console.error("DELETE /api/programs/[id] error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/proof-of-work/app/api/programs/route.ts b/proof-of-work/app/api/programs/route.ts new file mode 100644 index 0000000..6b1783e --- /dev/null +++ b/proof-of-work/app/api/programs/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { getPrograms } from "@/lib/db/programs"; + +/** + * Programs CRUD. + * + * Programs are multi-week training plans. The full structure is + * Program -> ProgramWeek -> ProgramDay -> ProgramExercise. POST + * accepts the entire tree in a single payload and writes it in + * one transaction (the v1.1.0:2 AI flow uses the same shape). + */ + +const exerciseInput = z.object({ + exerciseId: z.string().min(1), + order: z.number().int().nonnegative(), + sets: z.number().int().positive().optional().nullable(), + repsMin: z.number().int().positive().optional().nullable(), + repsMax: z.number().int().positive().optional().nullable(), + rpe: z.number().int().min(1).max(10).optional().nullable(), + restSeconds: z.number().int().nonnegative().optional().nullable(), + notes: z.string().optional().nullable(), +}); + +const dayInput = z.object({ + dayOfWeek: z.number().int().min(0).max(6), + name: z.string().optional().nullable(), + description: z.string().optional().nullable(), + exercises: z.array(exerciseInput), +}); + +const weekInput = z.object({ + weekNumber: z.number().int().positive(), + phase: z.string().optional().nullable(), + description: z.string().optional().nullable(), + days: z.array(dayInput), +}); + +const createProgramSchema = z.object({ + name: z.string().min(1), + description: z.string().optional().nullable(), + type: z.string().min(1), + durationWeeks: z.number().int().positive(), + startDate: z.string(), // ISO + isActive: z.boolean().optional().default(false), + weeks: z.array(weekInput).default([]), +}); + +export async function GET() { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const programs = await getPrograms(user.id); + return NextResponse.json(programs); +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await request.json(); + const validated = createProgramSchema.parse(body); + + // Verify any referenced exerciseIds belong to this user. + const allExerciseIds = new Set(); + for (const w of validated.weeks) + for (const d of w.days) + for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId); + + if (allExerciseIds.size > 0) { + const owned = await prisma.exercise.findMany({ + where: { userId: user.id, id: { in: Array.from(allExerciseIds) } }, + select: { id: true }, + }); + const ownedIds = new Set(owned.map((e) => e.id)); + const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id)); + if (bad.length > 0) { + return NextResponse.json( + { error: "Some exerciseIds don't exist in your library", details: bad }, + { status: 400 }, + ); + } + } + + const program = await prisma.$transaction(async (tx) => { + const created = await tx.program.create({ + data: { + userId: user.id, + name: validated.name, + description: validated.description ?? null, + type: validated.type, + durationWeeks: validated.durationWeeks, + startDate: new Date(validated.startDate), + isActive: validated.isActive, + }, + }); + for (const w of validated.weeks) { + const week = await tx.programWeek.create({ + data: { + programId: created.id, + weekNumber: w.weekNumber, + phase: w.phase ?? null, + description: w.description ?? null, + }, + }); + for (const d of w.days) { + const day = await tx.programDay.create({ + data: { + weekId: week.id, + dayOfWeek: d.dayOfWeek, + name: d.name ?? null, + description: d.description ?? null, + }, + }); + if (d.exercises.length > 0) { + await tx.programExercise.createMany({ + data: d.exercises.map((ex) => ({ + dayId: day.id, + exerciseId: ex.exerciseId, + order: ex.order, + sets: ex.sets ?? null, + repsMin: ex.repsMin ?? null, + repsMax: ex.repsMax ?? null, + rpe: ex.rpe ?? null, + restSeconds: ex.restSeconds ?? null, + notes: ex.notes ?? null, + })) as Prisma.ProgramExerciseCreateManyInput[], + }); + } + } + } + return created; + }); + + return NextResponse.json(program, { status: 201 }); + } catch (err) { + if (err instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid program payload", details: err.errors }, + { status: 400 }, + ); + } + console.error("POST /api/programs error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/proof-of-work/app/main/navigation.tsx b/proof-of-work/app/main/navigation.tsx index 0d09801..2801042 100644 --- a/proof-of-work/app/main/navigation.tsx +++ b/proof-of-work/app/main/navigation.tsx @@ -5,6 +5,7 @@ import { LayoutDashboard, Dumbbell, ListChecks, + Calendar, Settings, LogOut, } from 'lucide-react'; @@ -17,6 +18,7 @@ interface NavigationProps { const navLinks = [ { href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/main/workouts', label: 'Workouts', icon: Dumbbell }, + { href: '/main/programs', label: 'Programs', icon: Calendar }, { href: '/main/exercises', label: 'Exercises', icon: ListChecks }, { href: '/main/settings', label: 'Settings', icon: Settings }, ]; diff --git a/proof-of-work/app/main/programs/[id]/page.tsx b/proof-of-work/app/main/programs/[id]/page.tsx new file mode 100644 index 0000000..99b1ac4 --- /dev/null +++ b/proof-of-work/app/main/programs/[id]/page.tsx @@ -0,0 +1,135 @@ +import { notFound, redirect } from 'next/navigation'; +import Link from 'next/link'; +import { ChevronLeft } from 'lucide-react'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getProgramById, computeTodaysSessionForProgram } from '@/lib/db/programs'; +import ProgramEditor, { + type DraftProgram, +} from '@/components/programs/ProgramEditor'; +import ProgramActions from '@/components/programs/ProgramActions'; +import StartSessionButton from '@/components/programs/StartSessionButton'; + +export const dynamic = 'force-dynamic'; + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export default async function ProgramDetailPage({ + params, +}: { + params: { id: string }; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/auth/login'); + + const program = await getProgramById(user.id, params.id); + if (!program) notFound(); + + const exercises = await prisma.exercise.findMany({ + where: { userId: user.id }, + select: { id: true, name: true, type: true }, + orderBy: [{ type: 'asc' }, { name: 'asc' }], + }); + + const todaysSession = program.isActive + ? computeTodaysSessionForProgram(program, new Date()) + : null; + + const draft: DraftProgram = { + name: program.name, + description: program.description, + type: program.type, + durationWeeks: program.durationWeeks, + startDate: program.startDate.toISOString().slice(0, 10), + isActive: program.isActive, + weeks: program.weeks.map((w) => ({ + weekNumber: w.weekNumber, + phase: w.phase, + description: w.description, + days: w.days.map((d) => ({ + dayOfWeek: d.dayOfWeek, + name: d.name, + description: d.description, + exercises: d.exercises.map((ex) => ({ + exerciseId: ex.exerciseId, + order: ex.order, + sets: ex.sets, + repsMin: ex.repsMin, + repsMax: ex.repsMax, + rpe: ex.rpe, + restSeconds: ex.restSeconds, + notes: ex.notes, + })), + })), + })), + }; + + return ( +
+
+
+
+ + + +

+ {program.name} + {program.isActive && ( + + active + + )} +

+
+ +
+
+ +
+ {todaysSession?.day && ( +
+

+ Today · Week {todaysSession.weekNumber} ·{' '} + {DAY_LABELS[todaysSession.dayOfWeek]} +

+

+ {todaysSession.day.name ?? + `${DAY_LABELS[todaysSession.dayOfWeek]} session`} +

+
    + {todaysSession.day.exercises.map((ex) => ( +
  • + {ex.exercise.name} + {ex.sets && ( + + {' '} + · {ex.sets}× + {ex.repsMin === ex.repsMax || !ex.repsMax + ? (ex.repsMin ?? '?') + : `${ex.repsMin}-${ex.repsMax}`} + {ex.rpe ? ` @ RPE ${ex.rpe}` : ''} + + )} +
  • + ))} +
+ +
+ )} + + +
+
+ ); +} diff --git a/proof-of-work/app/main/programs/new/page.tsx b/proof-of-work/app/main/programs/new/page.tsx new file mode 100644 index 0000000..7e5fbe5 --- /dev/null +++ b/proof-of-work/app/main/programs/new/page.tsx @@ -0,0 +1,42 @@ +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { ChevronLeft } from 'lucide-react'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import ProgramEditor from '@/components/programs/ProgramEditor'; + +export const dynamic = 'force-dynamic'; + +export default async function NewProgramPage() { + const user = await getCurrentUser(); + if (!user) redirect('/auth/login'); + + const exercises = await prisma.exercise.findMany({ + where: { userId: user.id }, + select: { id: true, name: true, type: true }, + orderBy: [{ type: 'asc' }, { name: 'asc' }], + }); + + return ( +
+
+
+ + + +

+ New program +

+
+
+ +
+ +
+
+ ); +} diff --git a/proof-of-work/app/main/programs/page.tsx b/proof-of-work/app/main/programs/page.tsx new file mode 100644 index 0000000..216c87d --- /dev/null +++ b/proof-of-work/app/main/programs/page.tsx @@ -0,0 +1,120 @@ +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { Plus, Calendar, Activity } from 'lucide-react'; +import { getCurrentUser } from '@/lib/auth'; +import { getPrograms, getTodaysSession } from '@/lib/db/programs'; + +export const dynamic = 'force-dynamic'; + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export default async function ProgramsPage() { + const user = await getCurrentUser(); + if (!user) redirect('/auth/login'); + + const [programs, today] = await Promise.all([ + getPrograms(user.id), + getTodaysSession(user.id), + ]); + + return ( +
+
+
+

Programs

+ + + New + +
+
+ +
+ {today && today.day && ( +
+
+ +
+

+ Today's session +

+

+ {today.day.name ?? `${DAY_LABELS[today.dayOfWeek]} session`} +

+

+ {today.program.name} · Week {today.weekNumber} ·{' '} + {today.day.exercises.length} exercise + {today.day.exercises.length === 1 ? '' : 's'} +

+ + Open program → + +
+
+
+ )} + + {programs.length === 0 ? ( +
+ +

+ No programs yet +

+

+ Build a multi-week training plan, then follow it day by day. +

+ + + Create your first program + +
+ ) : ( +
    + {programs.map((p) => ( +
  • + +
    +
    +

    + {p.name} +

    +

    + {p.type} · {p.durationWeeks} week + {p.durationWeeks === 1 ? '' : 's'} · started{' '} + {new Date(p.startDate).toLocaleDateString()} ·{' '} + {p._count.weeks} week + {p._count.weeks === 1 ? '' : 's'} planned +

    + {p.description && ( +

    + {p.description} +

    + )} +
    + {p.isActive && ( + + active + + )} +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/proof-of-work/components/programs/ProgramActions.tsx b/proof-of-work/components/programs/ProgramActions.tsx new file mode 100644 index 0000000..e79eeb6 --- /dev/null +++ b/proof-of-work/components/programs/ProgramActions.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { Trash2 } from 'lucide-react'; + +export default function ProgramActions({ programId }: { programId: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + const handleDelete = () => { + if ( + !confirm( + 'Delete this program? Workouts you logged against it stay; only the plan goes away.', + ) + ) { + return; + } + startTransition(async () => { + const res = await fetch(`/api/programs/${programId}`, { + method: 'DELETE', + }); + if (res.ok) { + router.push('/main/programs'); + router.refresh(); + } else { + const body = await res.json().catch(() => ({})); + alert(body.error ?? `HTTP ${res.status}`); + } + }); + }; + + return ( + + ); +} diff --git a/proof-of-work/components/programs/ProgramEditor.tsx b/proof-of-work/components/programs/ProgramEditor.tsx new file mode 100644 index 0000000..ec8f6ab --- /dev/null +++ b/proof-of-work/components/programs/ProgramEditor.tsx @@ -0,0 +1,686 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Plus, Trash2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +interface LibraryExercise { + id: string; + name: string; + type: string; +} + +export interface DraftExercise { + exerciseId: string; + order: number; + sets?: number | null; + repsMin?: number | null; + repsMax?: number | null; + rpe?: number | null; + restSeconds?: number | null; + notes?: string | null; +} +export interface DraftDay { + dayOfWeek: number; + name?: string | null; + description?: string | null; + exercises: DraftExercise[]; +} +export interface DraftWeek { + weekNumber: number; + phase?: string | null; + description?: string | null; + days: DraftDay[]; +} +export interface DraftProgram { + name: string; + description?: string | null; + type: string; + durationWeeks: number; + startDate: string; // ISO yyyy-mm-dd + isActive: boolean; + weeks: DraftWeek[]; +} + +const TYPE_OPTIONS = [ + 'hypertrophy', + 'strength', + 'power', + 'endurance', + 'recovery', + 'general', +]; + +export default function ProgramEditor({ + initialProgram, + programId, + exercises, +}: { + initialProgram?: DraftProgram; + programId?: string; // if present, this is an edit; otherwise create + exercises: LibraryExercise[]; +}) { + const router = useRouter(); + const [program, setProgram] = useState( + initialProgram ?? { + name: '', + description: '', + type: 'hypertrophy', + durationWeeks: 8, + startDate: new Date().toISOString().slice(0, 10), + isActive: false, + weeks: [], + }, + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Track expansion state for the tree + const [openWeeks, setOpenWeeks] = useState>({}); + + const update = (patch: Partial) => + setProgram((p) => ({ ...p, ...patch })); + + const addWeek = () => { + const nextNum = Math.max(0, ...program.weeks.map((w) => w.weekNumber)) + 1; + setProgram((p) => ({ + ...p, + weeks: [...p.weeks, { weekNumber: nextNum, phase: null, days: [] }].sort( + (a, b) => a.weekNumber - b.weekNumber, + ), + })); + setOpenWeeks((s) => ({ ...s, [nextNum]: true })); + }; + + const removeWeek = (weekNumber: number) => { + if (!confirm(`Remove week ${weekNumber}?`)) return; + setProgram((p) => ({ + ...p, + weeks: p.weeks.filter((w) => w.weekNumber !== weekNumber), + })); + }; + + const updateWeek = (weekNumber: number, patch: Partial) => + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber ? { ...w, ...patch } : w, + ), + })); + + const addDay = (weekNumber: number, dayOfWeek: number) => { + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber + ? { + ...w, + days: [ + ...w.days, + { dayOfWeek, name: null, description: null, exercises: [] }, + ].sort((a, b) => a.dayOfWeek - b.dayOfWeek), + } + : w, + ), + })); + }; + + const removeDay = (weekNumber: number, dayOfWeek: number) => + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber + ? { ...w, days: w.days.filter((d) => d.dayOfWeek !== dayOfWeek) } + : w, + ), + })); + + const updateDay = ( + weekNumber: number, + dayOfWeek: number, + patch: Partial, + ) => + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber + ? { + ...w, + days: w.days.map((d) => + d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d, + ), + } + : w, + ), + })); + + const addExercise = ( + weekNumber: number, + dayOfWeek: number, + exerciseId: string, + ) => { + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber + ? { + ...w, + days: w.days.map((d) => + d.dayOfWeek === dayOfWeek + ? { + ...d, + exercises: [ + ...d.exercises, + { + exerciseId, + order: d.exercises.length, + sets: 3, + repsMin: 8, + repsMax: 12, + rpe: null, + restSeconds: 90, + notes: null, + }, + ], + } + : d, + ), + } + : w, + ), + })); + }; + + const updateExercise = ( + weekNumber: number, + dayOfWeek: number, + order: number, + patch: Partial, + ) => + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber + ? { + ...w, + days: w.days.map((d) => + d.dayOfWeek === dayOfWeek + ? { + ...d, + exercises: d.exercises.map((ex) => + ex.order === order ? { ...ex, ...patch } : ex, + ), + } + : d, + ), + } + : w, + ), + })); + + const removeExercise = ( + weekNumber: number, + dayOfWeek: number, + order: number, + ) => + setProgram((p) => ({ + ...p, + weeks: p.weeks.map((w) => + w.weekNumber === weekNumber + ? { + ...w, + days: w.days.map((d) => + d.dayOfWeek === dayOfWeek + ? { + ...d, + exercises: d.exercises + .filter((ex) => ex.order !== order) + .map((ex, idx) => ({ ...ex, order: idx })), + } + : d, + ), + } + : w, + ), + })); + + const handleSave = async () => { + if (!program.name.trim()) { + setError('Program name is required.'); + return; + } + setError(null); + setSaving(true); + try { + const url = programId ? `/api/programs/${programId}` : '/api/programs'; + const method = programId ? 'PATCH' : 'POST'; + const res = await fetch(url, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(program), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `HTTP ${res.status}`); + } + const saved = await res.json(); + router.push(`/main/programs/${saved.id}`); + router.refresh(); + } catch (e) { + setError((e as Error).message); + } finally { + setSaving(false); + } + }; + + const exerciseLookup = new Map(exercises.map((e) => [e.id, e])); + + return ( +
+ {/* Top-level metadata */} +
+ + update({ name: e.target.value })} + placeholder="8 Week Hypertrophy" + className={inputClass} + /> + + +