/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; type DayName = | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday"; interface WorkoutExerciseSeed { name?: string; muscleGroup?: string; defaultSets?: number; defaultReps?: number; } interface WorkoutExercise { name: string; muscleGroup: string; defaultSets: number; defaultReps: number; } interface WorkoutPlanSeed { day?: string; exercise?: string; sets?: number; reps?: number; } interface WorkoutPlanEntry { day: string; exercise: string; muscleGroup: string; sets: number; reps: number; } interface MuscleVolumeEntry { muscleGroup: string; sessionCount: number; totalSets: number; totalReps: number; totalVolume: number; } interface WorkoutRoutinePlannerArgs { days: Default; catalog: Default; plan: Default; } interface ScheduleEvent { day?: string; exercise?: string; sets?: number; reps?: number; } interface RemovalEvent { day?: string; exercise?: string; } const defaultDays: DayName[] = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]; const defaultCatalog: WorkoutExerciseSeed[] = [ { name: "Back Squat", muscleGroup: "Legs", defaultSets: 4, defaultReps: 6, }, { name: "Bench Press", muscleGroup: "Chest", defaultSets: 3, defaultReps: 8, }, { name: "Deadlift", muscleGroup: "Back", defaultSets: 3, defaultReps: 5, }, { name: "Overhead Press", muscleGroup: "Shoulders", defaultSets: 3, defaultReps: 8, }, { name: "Pull Up", muscleGroup: "Back", defaultSets: 3, defaultReps: 10, }, ]; const sanitizeText = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; const titleCase = (value: string): string => { return value .split(/\s+/) .filter((part) => part.length > 0) .map((part) => part[0].toUpperCase() + part.slice(1).toLowerCase()) .join(" "); }; const sanitizeDays = ( value: readonly string[] | undefined, ): string[] => { if (!Array.isArray(value)) return [...defaultDays]; const seen = new Set(); const result: string[] = []; for (const entry of value) { const text = sanitizeText(entry); if (!text) continue; const normalized = titleCase(text); if (seen.has(normalized)) continue; seen.add(normalized); result.push(normalized); } return result.length > 0 ? result : [...defaultDays]; }; const sanitizeMuscleGroup = (value: unknown): string => { const text = sanitizeText(value); if (!text) return "General"; return titleCase(text); }; const sanitizePositiveInt = ( value: unknown, fallback: number, minimum = 1, ): number => { const numeric = typeof value === "number" ? value : Number(value); if (!Number.isFinite(numeric)) return Math.max(minimum, fallback); const rounded = Math.floor(numeric); return Math.max(minimum, rounded); }; const sanitizeCatalog = ( value: readonly WorkoutExerciseSeed[] | undefined, ): WorkoutExercise[] => { if (!Array.isArray(value) || value.length === 0) { return defaultCatalog.map((entry) => ({ name: entry.name!, muscleGroup: entry.muscleGroup!, defaultSets: entry.defaultSets!, defaultReps: entry.defaultReps!, })); } const seen = new Set(); const catalog: WorkoutExercise[] = []; for (const seed of value) { const rawName = sanitizeText(seed?.name); if (!rawName) continue; const name = titleCase(rawName); if (seen.has(name)) continue; const muscleGroup = sanitizeMuscleGroup(seed?.muscleGroup); const defaultSets = sanitizePositiveInt(seed?.defaultSets, 3); const defaultReps = sanitizePositiveInt(seed?.defaultReps, 5); seen.add(name); catalog.push({ name, muscleGroup, defaultSets, defaultReps, }); } return catalog.length > 0 ? catalog : defaultCatalog.map((entry) => ({ name: entry.name!, muscleGroup: entry.muscleGroup!, defaultSets: entry.defaultSets!, defaultReps: entry.defaultReps!, })); }; const lookupExercise = ( name: unknown, catalog: readonly WorkoutExercise[], ): WorkoutExercise | null => { const text = sanitizeText(name); if (!text) return null; const normalized = text.toLowerCase(); for (const entry of catalog) { if (entry.name.toLowerCase() === normalized) { return entry; } } return null; }; const pickDay = ( value: unknown, days: readonly string[], ): string | null => { if (days.length === 0) return null; const text = sanitizeText(value); if (!text) { return days[0] ?? null; } const normalized = text.toLowerCase(); for (const day of days) { if (day.toLowerCase() === normalized) return day; } return null; }; const keyForPlanEntry = (entry: WorkoutPlanEntry): string => { return `${entry.day}__${entry.exercise}`; }; const sanitizePlan = ( value: | readonly WorkoutPlanSeed[] | readonly WorkoutPlanEntry[] | undefined, days: readonly string[], catalog: readonly WorkoutExercise[], ): WorkoutPlanEntry[] => { if (!Array.isArray(value)) return []; const dayOrder = new Map(); days.forEach((day, index) => dayOrder.set(day, index)); const dedup = new Map(); for (const seed of value) { const day = pickDay(seed?.day, days); if (!day) continue; const exercise = lookupExercise(seed?.exercise, catalog); if (!exercise) continue; const sets = sanitizePositiveInt(seed?.sets, exercise.defaultSets); const reps = sanitizePositiveInt(seed?.reps, exercise.defaultReps); const entry: WorkoutPlanEntry = { day, exercise: exercise.name, muscleGroup: exercise.muscleGroup, sets, reps, }; dedup.set(keyForPlanEntry(entry), entry); } const entries = Array.from(dedup.values()); entries.sort((a, b) => { const dayA = dayOrder.get(a.day) ?? 0; const dayB = dayOrder.get(b.day) ?? 0; if (dayA === dayB) { return a.exercise.localeCompare(b.exercise); } return dayA - dayB; }); return entries; }; const buildScheduleByDay = ( days: readonly string[], plan: readonly WorkoutPlanEntry[], ): Record => { const schedule: Record = {}; for (const day of days) { schedule[day] = []; } for (const entry of plan) { if (!schedule[entry.day]) { schedule[entry.day] = []; } schedule[entry.day].push(entry); } for (const day of days) { schedule[day].sort((a, b) => a.exercise.localeCompare(b.exercise)); } return schedule; }; const computeVolumeByGroup = ( plan: readonly WorkoutPlanEntry[], ): MuscleVolumeEntry[] => { const buckets = new Map(); for (const entry of plan) { const bucket = buckets.get(entry.muscleGroup) ?? { muscleGroup: entry.muscleGroup, sessionCount: 0, totalSets: 0, totalReps: 0, totalVolume: 0, }; bucket.sessionCount += 1; bucket.totalSets += entry.sets; bucket.totalReps += entry.reps; bucket.totalVolume += entry.sets * entry.reps; buckets.set(entry.muscleGroup, bucket); } return Array.from(buckets.values()).sort((a, b) => a.muscleGroup.localeCompare(b.muscleGroup) ); }; const describeSchedule = ( entry: WorkoutPlanEntry, ): string => { return `${entry.exercise} for ${entry.day} (${entry.sets}x${entry.reps})`; }; const scheduleWorkout = handler( ( event: ScheduleEvent | undefined, context: { plan: Cell; daysView: Cell; catalogView: Cell; lastAction: Cell; }, ) => { const days = context.daysView.get(); const catalog = context.catalogView.get(); if (days.length === 0 || catalog.length === 0) return; const exercise = lookupExercise(event?.exercise, catalog); if (!exercise) return; const day = pickDay(event?.day, days); if (!day) return; const sets = sanitizePositiveInt(event?.sets, exercise.defaultSets); const reps = sanitizePositiveInt(event?.reps, exercise.defaultReps); const current = sanitizePlan(context.plan.get(), days, catalog); const nextEntry: WorkoutPlanEntry = { day, exercise: exercise.name, muscleGroup: exercise.muscleGroup, sets, reps, }; const keyed = new Map(); for (const entry of current) { keyed.set(keyForPlanEntry(entry), entry); } keyed.set(keyForPlanEntry(nextEntry), nextEntry); const sorted = sanitizePlan(Array.from(keyed.values()), days, catalog); context.plan.set(sorted); context.lastAction.set(`Scheduled ${describeSchedule(nextEntry)}`); }, ); const removeWorkout = handler( ( event: RemovalEvent | undefined, context: { plan: Cell; daysView: Cell; catalogView: Cell; lastAction: Cell; }, ) => { const days = context.daysView.get(); const catalog = context.catalogView.get(); if (days.length === 0 || catalog.length === 0) return; const exercise = lookupExercise(event?.exercise, catalog); if (!exercise) return; const day = pickDay(event?.day, days); if (!day) return; const current = sanitizePlan(context.plan.get(), days, catalog); const filtered = current.filter((entry) => !(entry.day === day && entry.exercise === exercise.name) ); if (filtered.length === current.length) return; const sorted = sanitizePlan(filtered, days, catalog); context.plan.set(sorted); context.lastAction.set(`Removed ${exercise.name} on ${day}`); }, ); export const workoutRoutinePlanner = recipe( "Workout Routine Planner", ({ days, catalog, plan }) => { const lastAction = cell("initialized"); const daysView = lift(sanitizeDays)(days); const catalogView = lift(sanitizeCatalog)(catalog); const planView = lift((inputs: { plan: WorkoutPlanSeed[] | undefined; days: string[]; catalog: WorkoutExercise[]; }) => sanitizePlan(inputs.plan, inputs.days, inputs.catalog))({ plan, days: daysView, catalog: catalogView, }); const scheduleByDay = lift((inputs: { days: string[]; plan: WorkoutPlanEntry[]; }) => buildScheduleByDay(inputs.days, inputs.plan))({ days: daysView, plan: planView, }); const volumeByGroup = lift((entries: WorkoutPlanEntry[]) => computeVolumeByGroup(entries) )(planView); const totalVolume = lift((entries: MuscleVolumeEntry[]) => entries.reduce((sum, entry) => sum + entry.totalVolume, 0) )(volumeByGroup); const groupCount = lift((entries: MuscleVolumeEntry[]) => entries.length)( volumeByGroup, ); const focusGroup = lift((entries: MuscleVolumeEntry[]) => { if (entries.length === 0) return "None"; let candidate = entries[0]; for (const entry of entries) { if (entry.totalVolume > candidate.totalVolume) { candidate = entry; } else if ( entry.totalVolume === candidate.totalVolume && entry.muscleGroup.localeCompare(candidate.muscleGroup) < 0 ) { candidate = entry; } } return `${candidate.muscleGroup} (${candidate.totalVolume} reps)`; })(volumeByGroup); const status = str`${totalVolume} total reps across ${groupCount} muscle groups`; const focusSummary = str`Top focus: ${focusGroup}`; return { plan: planView, scheduleByDay, volumeByGroup, totalVolume, status, focusSummary, lastAction, scheduleWorkout: scheduleWorkout({ plan, daysView, catalogView, lastAction, }), removeWorkout: removeWorkout({ plan, daysView, catalogView, lastAction, }), }; }, ); export type { MuscleVolumeEntry, WorkoutPlanEntry };