v1.1.0:1 — Programs UI (manual create / save / follow)
Schema
- Workout.programDayId added (nullable FK to ProgramDay) so a
Workout logged from a program day can be tied back to the planned
session for adherence analytics. Compat ALTER in entrypoint adds
the column + index to existing /data; ON DELETE SET NULL so
deleting a program doesn't remove historical workouts logged
against it.
- Back-relation `workouts: Workout[]` added to ProgramDay.
API (proof-of-work/app/api/programs/...)
- GET /api/programs — list user's programs
- POST /api/programs — create with full nested
weeks/days/exercises
tree in one transaction
- GET /api/programs/[id] — full tree
- PATCH /api/programs/[id] — update metadata AND/OR
replace entire weeks
tree (same shape as
POST). UI editor + AI
apply flow share this.
- DELETE /api/programs/[id] — cascading
- POST /api/programs/[id]/days/[dayId]/start
— creates a Workout
pre-populated with
empty SetLogs (one per
planned set), tagged
with programDayId.
UI (proof-of-work/app/main/programs/...)
- /main/programs — list with cards, today's-session
callout, "active" badge
- /main/programs/new — create form using ProgramEditor
- /main/programs/[id] — detail + edit using same editor;
today's-session card + Start button
if program is active
- ProgramEditor component (components/programs/ProgramEditor.tsx) —
expandable tree editor for weeks -> days -> exercises with
per-row sets/reps/RPE/rest/notes fields + library exercise picker
- ProgramActions: delete button
- StartSessionButton: POSTs to start endpoint, redirects to new
workout
Navigation
- "Programs" link added to bottom nav + sidebar (between Workouts
and Exercises).
- /main/programs page itself shows the today's-session card; the
same component pattern can be lifted into the dashboard later
if we want.
lib/db/programs.ts
- getPrograms, getProgramById, getActivePrograms,
computeTodaysSessionForProgram, getTodaysSession helpers.
- Today's session math: floor((todayUTC - startDateUTC) / 1day),
weekNumber = floor(.../7) + 1, dayOfWeek = today.getUTCDay().
Returns null if not started, past durationWeeks, or no day
matching today's slot (= rest day).
Tests (tests/routes-programs.test.ts)
- 11 new tests covering: 401 unauthenticated, full-tree create
with nested weeks+days+exercises, cross-user exerciseId
rejection, list scoped to actor, GET detail returns 404 for
another user's program, PATCH replace-tree atomicity,
cascading DELETE, start-day Workout creation with the right
number of empty SetLogs + programDayId stamped, start-day
refused for cross-user program day.
- Total: 96 tests across 11 files.
This is the foundation for v1.1.0:2's AI-generated programs —
the AI will produce the same JSON shape POST /api/programs
already accepts, so the apply path is `editor.tsx + POST
/api/programs` with no new API surface.
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -17,6 +18,7 @@ interface NavigationProps {
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
||||||
|
{ href: '/main/programs', label: 'Programs', icon: Calendar },
|
||||||
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
||||||
{ href: '/main/settings', label: 'Settings', icon: Settings },
|
{ href: '/main/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link
|
||||||
|
href="/main/programs"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to programs"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-white truncate">
|
||||||
|
{program.name}
|
||||||
|
{program.isActive && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider bg-emerald-900/50 text-emerald-300 px-2 py-0.5 rounded font-normal">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<ProgramActions programId={program.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{todaysSession?.day && (
|
||||||
|
<section className="bg-emerald-950/30 border border-emerald-900 rounded p-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-emerald-400">
|
||||||
|
Today · Week {todaysSession.weekNumber} ·{' '}
|
||||||
|
{DAY_LABELS[todaysSession.dayOfWeek]}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-white mt-1">
|
||||||
|
{todaysSession.day.name ??
|
||||||
|
`${DAY_LABELS[todaysSession.dayOfWeek]} session`}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-2 space-y-1 text-sm text-zinc-300">
|
||||||
|
{todaysSession.day.exercises.map((ex) => (
|
||||||
|
<li key={ex.id}>
|
||||||
|
{ex.exercise.name}
|
||||||
|
{ex.sets && (
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{' '}
|
||||||
|
· {ex.sets}×
|
||||||
|
{ex.repsMin === ex.repsMax || !ex.repsMax
|
||||||
|
? (ex.repsMin ?? '?')
|
||||||
|
: `${ex.repsMin}-${ex.repsMax}`}
|
||||||
|
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<StartSessionButton
|
||||||
|
programId={program.id}
|
||||||
|
dayId={todaysSession.day.id}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgramEditor
|
||||||
|
initialProgram={draft}
|
||||||
|
programId={program.id}
|
||||||
|
exercises={exercises}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/main/programs"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to programs"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
New program
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
<ProgramEditor exercises={exercises} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">Programs</h1>
|
||||||
|
<Link
|
||||||
|
href="/main/programs/new"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{today && today.day && (
|
||||||
|
<section className="bg-emerald-950/30 border border-emerald-900 rounded p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar className="w-5 h-5 text-emerald-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-emerald-400">
|
||||||
|
Today's session
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-white mt-1">
|
||||||
|
{today.day.name ?? `${DAY_LABELS[today.dayOfWeek]} session`}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-400 mt-0.5">
|
||||||
|
{today.program.name} · Week {today.weekNumber} ·{' '}
|
||||||
|
{today.day.exercises.length} exercise
|
||||||
|
{today.day.exercises.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/main/programs/${today.program.id}`}
|
||||||
|
className="inline-block mt-3 text-xs text-emerald-300 underline"
|
||||||
|
>
|
||||||
|
Open program →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{programs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<Activity className="w-12 h-12 text-zinc-700 mx-auto mb-3" />
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-2">
|
||||||
|
No programs yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-500 mb-6 text-sm">
|
||||||
|
Build a multi-week training plan, then follow it day by day.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/main/programs/new"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-white text-black font-semibold text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create your first program
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{programs.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link
|
||||||
|
href={`/main/programs/${p.id}`}
|
||||||
|
className="block bg-zinc-900 border border-zinc-800 rounded p-4 hover:border-zinc-700 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-white truncate">
|
||||||
|
{p.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{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>
|
||||||
|
{p.description && (
|
||||||
|
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">
|
||||||
|
{p.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.isActive && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider bg-emerald-900/50 text-emerald-300 px-2 py-0.5 rounded flex-shrink-0">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="inline w-3.5 h-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<DraftProgram>(
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
// Track expansion state for the tree
|
||||||
|
const [openWeeks, setOpenWeeks] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
const update = (patch: Partial<DraftProgram>) =>
|
||||||
|
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<DraftWeek>) =>
|
||||||
|
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<DraftDay>,
|
||||||
|
) =>
|
||||||
|
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<DraftExercise>,
|
||||||
|
) =>
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top-level metadata */}
|
||||||
|
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||||||
|
<Field label="Name">
|
||||||
|
<input
|
||||||
|
value={program.name}
|
||||||
|
onChange={(e) => update({ name: e.target.value })}
|
||||||
|
placeholder="8 Week Hypertrophy"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description (optional)">
|
||||||
|
<textarea
|
||||||
|
value={program.description ?? ''}
|
||||||
|
onChange={(e) => update({ description: e.target.value || null })}
|
||||||
|
rows={2}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<Field label="Type">
|
||||||
|
<select
|
||||||
|
value={program.type}
|
||||||
|
onChange={(e) => update({ type: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Duration (weeks)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={52}
|
||||||
|
value={program.durationWeeks}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ durationWeeks: parseInt(e.target.value || '1', 10) })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Start date">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={program.startDate}
|
||||||
|
onChange={(e) => update({ startDate: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Active">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => update({ isActive: !program.isActive })}
|
||||||
|
className={`mt-1 inline-flex items-center h-9 px-3 rounded border text-xs uppercase tracking-wider ${
|
||||||
|
program.isActive
|
||||||
|
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
|
||||||
|
: 'border-zinc-700 text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{program.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</button>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Weeks */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
Weeks ({program.weeks.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addWeek}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Plus className="inline w-3.5 h-3.5 mr-1" />
|
||||||
|
Add week
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{program.weeks.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
No weeks yet. Click <strong>Add week</strong> to start the plan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{program.weeks.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.weekNumber}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenWeeks((s) => ({ ...s, [w.weekNumber]: !s[w.weekNumber] }))
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
{openWeeks[w.weekNumber] ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Week {w.weekNumber}
|
||||||
|
{w.phase && (
|
||||||
|
<span className="text-xs text-zinc-500">· {w.phase}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-600">
|
||||||
|
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeWeek(w.weekNumber)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openWeeks[w.weekNumber] && (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<Field label="Phase (optional)">
|
||||||
|
<input
|
||||||
|
value={w.phase ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateWeek(w.weekNumber, {
|
||||||
|
phase: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Volume, intensity, deload..."
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<input
|
||||||
|
value={w.description ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateWeek(w.weekNumber, {
|
||||||
|
description: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day picker */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Add day:
|
||||||
|
</span>
|
||||||
|
{DAY_LABELS.map((label, idx) => {
|
||||||
|
const exists = w.days.some((d) => d.dayOfWeek === idx);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
disabled={exists}
|
||||||
|
onClick={() => addDay(w.weekNumber, idx)}
|
||||||
|
className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
exists
|
||||||
|
? 'border-zinc-800 text-zinc-700 cursor-not-allowed'
|
||||||
|
: 'border-zinc-700 text-zinc-300 hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{w.days.map((d) => (
|
||||||
|
<DayRow
|
||||||
|
key={d.dayOfWeek}
|
||||||
|
day={d}
|
||||||
|
weekNumber={w.weekNumber}
|
||||||
|
exercises={exercises}
|
||||||
|
exerciseLookup={exerciseLookup}
|
||||||
|
onUpdateDay={(patch) => updateDay(w.weekNumber, d.dayOfWeek, patch)}
|
||||||
|
onRemoveDay={() => removeDay(w.weekNumber, d.dayOfWeek)}
|
||||||
|
onAddExercise={(exId) => addExercise(w.weekNumber, d.dayOfWeek, exId)}
|
||||||
|
onUpdateExercise={(order, patch) =>
|
||||||
|
updateExercise(w.weekNumber, d.dayOfWeek, order, patch)
|
||||||
|
}
|
||||||
|
onRemoveExercise={(order) =>
|
||||||
|
removeExercise(w.weekNumber, d.dayOfWeek, order)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-4 py-3 border border-red-800 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-5 py-2 rounded bg-white text-black font-bold text-sm uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : programId ? (
|
||||||
|
'Save changes'
|
||||||
|
) : (
|
||||||
|
'Create program'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayRow({
|
||||||
|
day,
|
||||||
|
weekNumber: _weekNumber,
|
||||||
|
exercises,
|
||||||
|
exerciseLookup,
|
||||||
|
onUpdateDay,
|
||||||
|
onRemoveDay,
|
||||||
|
onAddExercise,
|
||||||
|
onUpdateExercise,
|
||||||
|
onRemoveExercise,
|
||||||
|
}: {
|
||||||
|
day: DraftDay;
|
||||||
|
weekNumber: number;
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
exerciseLookup: Map<string, LibraryExercise>;
|
||||||
|
onUpdateDay: (patch: Partial<DraftDay>) => void;
|
||||||
|
onRemoveDay: () => void;
|
||||||
|
onAddExercise: (exerciseId: string) => void;
|
||||||
|
onUpdateExercise: (order: number, patch: Partial<DraftExercise>) => void;
|
||||||
|
onRemoveExercise: (order: number) => void;
|
||||||
|
}) {
|
||||||
|
const [pickerValue, setPickerValue] = useState('');
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-zinc-300 uppercase tracking-wider">
|
||||||
|
{DAY_LABELS[day.dayOfWeek]}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={day.name ?? ''}
|
||||||
|
onChange={(e) => onUpdateDay({ name: e.target.value || null })}
|
||||||
|
placeholder="Day name (Push, Pull, Lower, etc.)"
|
||||||
|
className="flex-1 px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-900 text-white placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemoveDay}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{day.exercises.map((ex) => {
|
||||||
|
const lib = exerciseLookup.get(ex.exerciseId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ex.order}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm text-white">
|
||||||
|
{lib?.name ?? '(missing exercise)'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveExercise(ex.order)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||||
|
<NumField
|
||||||
|
label="Sets"
|
||||||
|
value={ex.sets ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { sets: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Reps min"
|
||||||
|
value={ex.repsMin ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { repsMin: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Reps max"
|
||||||
|
value={ex.repsMax ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { repsMax: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="RPE"
|
||||||
|
value={ex.rpe ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { rpe: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Rest (s)"
|
||||||
|
value={ex.restSeconds ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { restSeconds: v })}
|
||||||
|
/>
|
||||||
|
<Field label="Notes">
|
||||||
|
<input
|
||||||
|
value={ex.notes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateExercise(ex.order, { notes: e.target.value || null })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 text-xs rounded border border-zinc-700 bg-zinc-800 text-white"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={pickerValue}
|
||||||
|
onChange={(e) => setPickerValue(e.target.value)}
|
||||||
|
className="flex-1 px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-900 text-white"
|
||||||
|
>
|
||||||
|
<option value="">Add exercise...</option>
|
||||||
|
{exercises.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name} ({e.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!pickerValue}
|
||||||
|
onClick={() => {
|
||||||
|
if (pickerValue) {
|
||||||
|
onAddExercise(pickerValue);
|
||||||
|
setPickerValue('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="inline w-3.5 h-3.5 mr-1" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
onChange: (v: number | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field label={label}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v === '' ? null : Number(v));
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 text-xs rounded border border-zinc-700 bg-zinc-800 text-white"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function StartSessionButton({
|
||||||
|
programId,
|
||||||
|
dayId,
|
||||||
|
}: {
|
||||||
|
programId: string;
|
||||||
|
dayId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/programs/${programId}/days/${dayId}/start`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
alert(body.error ?? `HTTP ${res.status}`);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workout = await res.json();
|
||||||
|
router.push(`/main/workouts/${workout.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
alert((e as Error).message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-3 inline-block text-xs uppercase tracking-wider px-3 py-1.5 rounded bg-emerald-700 hover:bg-emerald-600 text-white font-bold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-1" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start this session →'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { prisma } from "../prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side helpers for the Programs feature (v1.1.0:1).
|
||||||
|
*
|
||||||
|
* A "program" is a multi-week training plan: Program -> ProgramWeek
|
||||||
|
* -> ProgramDay -> ProgramExercise. The user follows it day-by-day,
|
||||||
|
* logging actual workouts that get tagged with `Workout.programDayId`
|
||||||
|
* so we can later compute adherence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function getPrograms(userId: string) {
|
||||||
|
return prisma.program.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ isActive: "desc" }, { createdAt: "desc" }],
|
||||||
|
include: {
|
||||||
|
_count: { select: { weeks: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProgramById(userId: string, programId: string) {
|
||||||
|
return prisma.program.findFirst({
|
||||||
|
where: { id: programId, userId },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
orderBy: { weekNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
days: {
|
||||||
|
orderBy: { dayOfWeek: "asc" },
|
||||||
|
include: {
|
||||||
|
exercises: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { exercise: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivePrograms(userId: string) {
|
||||||
|
return prisma.program.findMany({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
orderBy: { weekNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
days: {
|
||||||
|
orderBy: { dayOfWeek: "asc" },
|
||||||
|
include: {
|
||||||
|
exercises: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { exercise: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For an active program + a target date, compute which planned
|
||||||
|
* ProgramDay (if any) is "today's session." Algorithm:
|
||||||
|
*
|
||||||
|
* daysSinceStart = floor((today - startDate) / 1 day)
|
||||||
|
* weekNumber = floor(daysSinceStart / 7) + 1
|
||||||
|
* dayOfWeek = today.getDay() (0=Sun..6=Sat)
|
||||||
|
*
|
||||||
|
* Then look up the ProgramDay matching (weekNumber, dayOfWeek). If
|
||||||
|
* the program has no day for today's dayOfWeek, returns null (rest
|
||||||
|
* day or program doesn't cover this slot).
|
||||||
|
*
|
||||||
|
* Returns null if:
|
||||||
|
* - the date is before the program's startDate
|
||||||
|
* - the date is past durationWeeks * 7 days from the start
|
||||||
|
* - the program has no matching day for that (weekNumber, dayOfWeek)
|
||||||
|
*
|
||||||
|
* The date comparison is anchored to UTC midnight on both sides so
|
||||||
|
* timezone fuzz doesn't shift you across day boundaries.
|
||||||
|
*/
|
||||||
|
export interface TodaysSession {
|
||||||
|
program: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
durationWeeks: number;
|
||||||
|
startDate: Date;
|
||||||
|
};
|
||||||
|
weekNumber: number;
|
||||||
|
dayOfWeek: number;
|
||||||
|
day: NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getProgramById>>
|
||||||
|
>["weeks"][number]["days"][number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTodaysSessionForProgram(
|
||||||
|
program: NonNullable<Awaited<ReturnType<typeof getProgramById>>>,
|
||||||
|
today: Date,
|
||||||
|
): TodaysSession | null {
|
||||||
|
const startUtc = Date.UTC(
|
||||||
|
program.startDate.getUTCFullYear(),
|
||||||
|
program.startDate.getUTCMonth(),
|
||||||
|
program.startDate.getUTCDate(),
|
||||||
|
);
|
||||||
|
const todayUtc = Date.UTC(
|
||||||
|
today.getUTCFullYear(),
|
||||||
|
today.getUTCMonth(),
|
||||||
|
today.getUTCDate(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
const daysSinceStart = Math.floor((todayUtc - startUtc) / dayMs);
|
||||||
|
|
||||||
|
if (daysSinceStart < 0) return null; // hasn't started yet
|
||||||
|
if (daysSinceStart >= program.durationWeeks * 7) return null; // program over
|
||||||
|
|
||||||
|
const weekNumber = Math.floor(daysSinceStart / 7) + 1;
|
||||||
|
const dayOfWeek = today.getUTCDay();
|
||||||
|
|
||||||
|
const week = program.weeks.find((w) => w.weekNumber === weekNumber);
|
||||||
|
const day = week?.days.find((d) => d.dayOfWeek === dayOfWeek) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
program: {
|
||||||
|
id: program.id,
|
||||||
|
name: program.name,
|
||||||
|
type: program.type,
|
||||||
|
durationWeeks: program.durationWeeks,
|
||||||
|
startDate: program.startDate,
|
||||||
|
},
|
||||||
|
weekNumber,
|
||||||
|
dayOfWeek,
|
||||||
|
day,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the user's "today's session" across all active programs.
|
||||||
|
* If multiple programs are active and have a session today, returns
|
||||||
|
* the most-recently-started one (only the first match in practice
|
||||||
|
* since active programs are usually unique).
|
||||||
|
*/
|
||||||
|
export async function getTodaysSession(
|
||||||
|
userId: string,
|
||||||
|
today: Date = new Date(),
|
||||||
|
): Promise<TodaysSession | null> {
|
||||||
|
const programs = await getActivePrograms(userId);
|
||||||
|
for (const p of programs.sort(
|
||||||
|
(a, b) => b.startDate.getTime() - a.startDate.getTime(),
|
||||||
|
)) {
|
||||||
|
const session = computeTodaysSessionForProgram(p, today);
|
||||||
|
if (session?.day) return session;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -82,6 +82,10 @@ model Workout {
|
|||||||
durationMinutes Int?
|
durationMinutes Int?
|
||||||
difficulty Int? // 1-10 scale
|
difficulty Int? // 1-10 scale
|
||||||
caloriesBurned Int?
|
caloriesBurned Int?
|
||||||
|
/// Optional: which planned ProgramDay this workout was logged
|
||||||
|
/// against. Set when the user clicks "Start workout from program
|
||||||
|
/// day" so we can later compute adherence vs plan.
|
||||||
|
programDayId String?
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -89,10 +93,12 @@ model Workout {
|
|||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
setLogs SetLog[]
|
setLogs SetLog[]
|
||||||
|
programDay ProgramDay? @relation(fields: [programDayId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@index([deletedAt])
|
@@index([deletedAt])
|
||||||
|
@@index([programDayId])
|
||||||
// Hot path: workout list page is `where userId AND deletedAt IS NULL
|
// Hot path: workout list page is `where userId AND deletedAt IS NULL
|
||||||
// ORDER BY date DESC LIMIT N`. Composite index lets SQLite walk it
|
// ORDER BY date DESC LIMIT N`. Composite index lets SQLite walk it
|
||||||
// straight off the index without sorting.
|
// straight off the index without sorting.
|
||||||
@@ -178,6 +184,7 @@ model ProgramDay {
|
|||||||
// Relations
|
// Relations
|
||||||
week ProgramWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
week ProgramWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||||
exercises ProgramExercise[]
|
exercises ProgramExercise[]
|
||||||
|
workouts Workout[] // sessions logged against this planned day
|
||||||
|
|
||||||
@@unique([weekId, dayOfWeek])
|
@@unique([weekId, dayOfWeek])
|
||||||
@@index([weekId])
|
@@index([weekId])
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||||
|
getCurrentUserMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', async (orig) => {
|
||||||
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
|
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||||
|
});
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
GET as getPrograms,
|
||||||
|
POST as createProgram,
|
||||||
|
} from '@/app/api/programs/route';
|
||||||
|
import {
|
||||||
|
GET as getProgram,
|
||||||
|
PATCH as patchProgram,
|
||||||
|
DELETE as deleteProgram,
|
||||||
|
} from '@/app/api/programs/[id]/route';
|
||||||
|
import { POST as startDay } from '@/app/api/programs/[id]/days/[dayId]/start/route';
|
||||||
|
|
||||||
|
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
|
||||||
|
return new NextRequest(url, {
|
||||||
|
method: method ?? (body !== undefined ? 'POST' : 'GET'),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeUserAndExercises(opts: {
|
||||||
|
email: string;
|
||||||
|
exerciseNames: string[];
|
||||||
|
}) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||||
|
});
|
||||||
|
const exercises = [];
|
||||||
|
for (const name of opts.exerciseNames) {
|
||||||
|
exercises.push(
|
||||||
|
await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { user, exercises };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.setLog.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.programExercise.deleteMany();
|
||||||
|
await prisma.programDay.deleteMany();
|
||||||
|
await prisma.programWeek.deleteMany();
|
||||||
|
await prisma.program.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/programs', () => {
|
||||||
|
it('returns 401 unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'X',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
startDate: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a program with the full nested tree in one transaction', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench Press', 'Squat'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
|
||||||
|
const res = await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Test 4-week',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
phase: 'Volume',
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: exercises[0].id,
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
repsMin: 6,
|
||||||
|
repsMax: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dayOfWeek: 3,
|
||||||
|
name: 'Pull',
|
||||||
|
exercises: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
weekNumber: 2,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: exercises[0].id,
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
repsMin: 6,
|
||||||
|
repsMax: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseId: exercises[1].id,
|
||||||
|
order: 1,
|
||||||
|
sets: 5,
|
||||||
|
repsMin: 5,
|
||||||
|
repsMax: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const programs = await prisma.program.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
include: {
|
||||||
|
days: { include: { exercises: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(programs).toHaveLength(1);
|
||||||
|
expect(programs[0].weeks).toHaveLength(2);
|
||||||
|
const week2 = programs[0].weeks.find((w) => w.weekNumber === 2);
|
||||||
|
expect(week2!.days[0].exercises).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects exerciseIds that belong to a different user', async () => {
|
||||||
|
const { exercises: aliceExs } = await makeUserAndExercises({
|
||||||
|
email: 'alice@x',
|
||||||
|
exerciseNames: ['Alice Squat'],
|
||||||
|
});
|
||||||
|
const { user: bob } = await makeUserAndExercises({
|
||||||
|
email: 'bob@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
|
||||||
|
const res = await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Bob trying to use Alice exercise',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [{ exerciseId: aliceExs[0].id, order: 0, sets: 3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/programs + GET /api/programs/[id]', () => {
|
||||||
|
it('lists programs scoped to the actor and returns full tree on detail', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Plan A',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [{ exerciseId: exercises[0].id, order: 0, sets: 3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await (await getPrograms()).json();
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
const programId = list[0].id;
|
||||||
|
|
||||||
|
const detail = await (
|
||||||
|
await getProgram(jsonReq(`http://x/api/programs/${programId}`), {
|
||||||
|
params: { id: programId },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
expect(detail.weeks[0].days[0].exercises[0].exercise.name).toBe('Bench');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET detail returns 404 for another user\'s program', async () => {
|
||||||
|
const { user: aliceForOtherTest } = await makeUserAndExercises({
|
||||||
|
email: 'alice@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
const aliceProg = await prisma.program.create({
|
||||||
|
data: {
|
||||||
|
userId: aliceForOtherTest.id,
|
||||||
|
name: 'Alice plan',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { user: bob } = await makeUserAndExercises({
|
||||||
|
email: 'bob@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
const res = await getProgram(
|
||||||
|
jsonReq(`http://x/api/programs/${aliceProg.id}`),
|
||||||
|
{ params: { id: aliceProg.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/programs/[id] (replace tree)', () => {
|
||||||
|
it('replaces the entire weeks tree atomically', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench', 'Squat'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
const created = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Original',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[0].id, order: 0, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const patchRes = await patchProgram(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/programs/${created.id}`,
|
||||||
|
{
|
||||||
|
name: 'Updated name',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 2,
|
||||||
|
name: 'Pull',
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[1].id, order: 0, sets: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'PATCH',
|
||||||
|
),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
);
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await prisma.program.findUnique({
|
||||||
|
where: { id: created.id },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
include: {
|
||||||
|
days: { include: { exercises: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(after!.name).toBe('Updated name');
|
||||||
|
expect(after!.weeks[0].days).toHaveLength(1);
|
||||||
|
expect(after!.weeks[0].days[0].dayOfWeek).toBe(2);
|
||||||
|
expect(after!.weeks[0].days[0].exercises[0].exerciseId).toBe(
|
||||||
|
exercises[1].id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/programs/[id]', () => {
|
||||||
|
it('cascades to weeks/days/exercises and refuses cross-user', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
const created = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Will be deleted',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[0].id, order: 0, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
expect(await prisma.programWeek.count()).toBeGreaterThan(0);
|
||||||
|
expect(await prisma.programExercise.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const res = await deleteProgram(
|
||||||
|
jsonReq(`http://x/api/programs/${created.id}`, undefined, 'DELETE'),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await prisma.programWeek.count()).toBe(0);
|
||||||
|
expect(await prisma.programExercise.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/programs/[id]/days/[dayId]/start', () => {
|
||||||
|
it('creates a workout pre-populated with empty SetLogs from the program day', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench', 'Squat'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
const created = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Plan',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push Day',
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[0].id, order: 0, sets: 4 },
|
||||||
|
{ exerciseId: exercises[1].id, order: 1, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const day = await prisma.programDay.findFirst({
|
||||||
|
where: { week: { programId: created.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const startRes = await startDay(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/programs/${created.id}/days/${day!.id}/start`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
{ params: { id: created.id, dayId: day!.id } },
|
||||||
|
);
|
||||||
|
expect(startRes.status).toBe(201);
|
||||||
|
const workout = await startRes.json();
|
||||||
|
|
||||||
|
// 4 sets of Bench + 3 sets of Squat = 7 SetLogs
|
||||||
|
expect(workout.setLogs).toHaveLength(7);
|
||||||
|
expect(workout.programDayId).toBe(day!.id);
|
||||||
|
expect(workout.name).toBe('Push Day');
|
||||||
|
// SetLogs should be empty (no reps/weight) — user fills them in
|
||||||
|
for (const sl of workout.setLogs) {
|
||||||
|
expect(sl.reps).toBeNull();
|
||||||
|
expect(sl.weight).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses if the program day belongs to a different user', async () => {
|
||||||
|
const { user: alice, exercises: aliceExs } = await makeUserAndExercises({
|
||||||
|
email: 'alice@x',
|
||||||
|
exerciseNames: ['Alice Bench'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const aliceProg = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Alice Plan',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: aliceExs[0].id, order: 0, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
const aliceDay = await prisma.programDay.findFirst({
|
||||||
|
where: { week: { programId: aliceProg.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user: bob } = await makeUserAndExercises({
|
||||||
|
email: 'bob@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
const res = await startDay(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/programs/${aliceProg.id}/days/${aliceDay!.id}/start`,
|
||||||
|
),
|
||||||
|
{ params: { id: aliceProg.id, dayId: aliceDay!.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,6 +97,14 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
|||||||
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;"
|
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# v1.1.0:1 added Workout.programDayId so workouts can be tagged with the
|
||||||
|
# planned ProgramDay they were logged against (for adherence tracking).
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|programDayId|"; then
|
||||||
|
log "adding missing column Workout.programDayId (nullable)"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN programDayId TEXT REFERENCES ProgramDay(id) ON DELETE SET NULL;"
|
||||||
|
sqlite3 "$DB_PATH" "CREATE INDEX IF NOT EXISTS Workout_programDayId_idx ON Workout(programDayId);"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! sqlite3 "$DB_PATH" \
|
if ! sqlite3 "$DB_PATH" \
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
||||||
2>/dev/null | grep -q InstanceSettings; then
|
2>/dev/null | grep -q InstanceSettings; then
|
||||||
|
|||||||
@@ -6,20 +6,32 @@ import { v_1_0_0_4 } from './v1.0.0.4'
|
|||||||
import { v_1_0_0_5 } from './v1.0.0.5'
|
import { v_1_0_0_5 } from './v1.0.0.5'
|
||||||
import { v_1_0_0_6 } from './v1.0.0.6'
|
import { v_1_0_0_6 } from './v1.0.0.6'
|
||||||
import { v_1_0_0_7 } from './v1.0.0.7'
|
import { v_1_0_0_7 } from './v1.0.0.7'
|
||||||
|
import { v_1_1_0_1 } from './v1.1.0.1'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version graph for the `proof-of-work` package.
|
* Version graph for the `proof-of-work` package.
|
||||||
*
|
*
|
||||||
* v1.0.0:1 — initial release, seeded cutover from `workout-log`.
|
* 1.0.0 line — feature-complete logger + multi-user + library curation.
|
||||||
|
* 1.1.0 line — Programs (manual + AI) + AI integration.
|
||||||
|
*
|
||||||
|
* v1.0.0:1 — initial release, seeded cutover.
|
||||||
* v1.0.0:2 — CSP fix.
|
* v1.0.0:2 — CSP fix.
|
||||||
* v1.0.0:3 — post-cutover seed strip.
|
* v1.0.0:3 — post-cutover seed strip.
|
||||||
* v1.0.0:4 — removes default admin@local credentials.
|
* v1.0.0:4 — removes default admin@local credentials.
|
||||||
* v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround).
|
* v1.0.0:5 — caloriesBurned raw-SQL workaround removed.
|
||||||
* v1.0.0:6 — paginate workout history (infinite scroll).
|
* v1.0.0:6 — paginate workout history (infinite scroll).
|
||||||
* v1.0.0:7 — exercise library cleanup, photo-import removal,
|
* v1.0.0:7 — exercise library cleanup, photo-import removal.
|
||||||
* UI honesty about AI.
|
* v1.1.0:1 — Programs UI (manual create / save / follow).
|
||||||
*/
|
*/
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_0_0_7,
|
current: v_1_1_0_1,
|
||||||
other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4, v_1_0_0_5, v_1_0_0_6],
|
other: [
|
||||||
|
v_1_0_0_1,
|
||||||
|
v_1_0_0_2,
|
||||||
|
v_1_0_0_3,
|
||||||
|
v_1_0_0_4,
|
||||||
|
v_1_0_0_5,
|
||||||
|
v_1_0_0_6,
|
||||||
|
v_1_0_0_7,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:1 — Programs (manual create / save / follow).
|
||||||
|
*
|
||||||
|
* The Program / ProgramWeek / ProgramDay / ProgramExercise schema
|
||||||
|
* has existed since the legacy `workout-log` package but had no
|
||||||
|
* UI to use it. This release ships:
|
||||||
|
*
|
||||||
|
* - /main/programs (list) with a today's-session card if any
|
||||||
|
* program is active.
|
||||||
|
* - /main/programs/new (create form) with the full nested
|
||||||
|
* editor: program metadata -> weeks -> days -> exercises.
|
||||||
|
* - /main/programs/[id] (detail + edit) using the same editor,
|
||||||
|
* plus today's-session callout + "Start this session"
|
||||||
|
* button when applicable.
|
||||||
|
* - API: GET/POST /api/programs, GET/PATCH/DELETE
|
||||||
|
* /api/programs/[id]. PATCH replaces the entire weeks tree
|
||||||
|
* in one transaction (same shape POST accepts) — keeps the
|
||||||
|
* UI editor and the upcoming AI apply flow on the same code
|
||||||
|
* path.
|
||||||
|
* - "Start this session" wires through
|
||||||
|
* POST /api/programs/[id]/days/[dayId]/start which creates a
|
||||||
|
* Workout pre-populated with empty SetLogs from the planned
|
||||||
|
* ProgramExercises (one row per planned set), tagged with
|
||||||
|
* Workout.programDayId so we can later compute adherence.
|
||||||
|
*
|
||||||
|
* Schema change
|
||||||
|
* - Workout.programDayId (nullable FK to ProgramDay) added.
|
||||||
|
* - Compat ALTER in docker_entrypoint.sh adds the column +
|
||||||
|
* index to existing /data on first boot. ON DELETE SET NULL
|
||||||
|
* so deleting a program doesn't catastrophically remove
|
||||||
|
* historical workouts logged against it.
|
||||||
|
*
|
||||||
|
* This release is the foundation for v1.1.0:2's AI-generated
|
||||||
|
* programs — the AI will produce the same JSON shape that POST
|
||||||
|
* /api/programs already accepts.
|
||||||
|
*
|
||||||
|
* No data migration. /data on existing installs is untouched
|
||||||
|
* apart from the new column.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_1 = VersionInfo.of({
|
||||||
|
version: '1.1.0:1',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Programs UI shipped. Build a multi-week training plan, mark it active, and follow it day by day from a new "Today\'s session" card. Includes a full nested editor (program -> weeks -> days -> exercises), starts a session as a pre-populated workout you fill in as you go, and tracks Workout.programDayId for upcoming adherence analytics. Foundation for v1.1.0:2 (AI-generated programs).',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user