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,
|
||||
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 },
|
||||
];
|
||||
|
||||
@@ -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?
|
||||
difficulty Int? // 1-10 scale
|
||||
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?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -89,10 +93,12 @@ model Workout {
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
setLogs SetLog[]
|
||||
programDay ProgramDay? @relation(fields: [programDayId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
@@index([deletedAt])
|
||||
@@index([programDayId])
|
||||
// Hot path: workout list page is `where userId AND deletedAt IS NULL
|
||||
// ORDER BY date DESC LIMIT N`. Composite index lets SQLite walk it
|
||||
// straight off the index without sorting.
|
||||
@@ -178,6 +184,7 @@ model ProgramDay {
|
||||
// Relations
|
||||
week ProgramWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||
exercises ProgramExercise[]
|
||||
workouts Workout[] // sessions logged against this planned day
|
||||
|
||||
@@unique([weekId, dayOfWeek])
|
||||
@@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;"
|
||||
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" \
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
||||
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_6 } from './v1.0.0.6'
|
||||
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.
|
||||
*
|
||||
* 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:3 — post-cutover seed strip.
|
||||
* 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:7 — exercise library cleanup, photo-import removal,
|
||||
* UI honesty about AI.
|
||||
* v1.0.0:7 — exercise library cleanup, photo-import removal.
|
||||
* v1.1.0:1 — Programs UI (manual create / save / follow).
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_0_0_7,
|
||||
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],
|
||||
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,
|
||||
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