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:
Keysat
2026-05-10 07:15:31 -05:00
parent 55c17614b8
commit 3a5b929284
16 changed files with 2280 additions and 7 deletions
@@ -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 });
}
}
+149
View File
@@ -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 });
}
}
+2
View File
@@ -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>
);
}
+120
View File
@@ -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&apos;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>
);
}
+160
View File
@@ -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;
}
+8 -1
View File
@@ -82,17 +82,23 @@ 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
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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])
+492
View File
@@ -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);
});
});
+8
View File
@@ -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
+18 -6
View File
@@ -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,
],
})
+52
View File
@@ -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,
},
})