2b0abad68e
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).
Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).
Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.
Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
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 GenerateWorkoutClient from '@/components/ai/GenerateWorkoutClient';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export default async function GenerateWorkoutPage() {
|
|
const user = await getCurrentUser();
|
|
if (!user) redirect('/auth/login');
|
|
|
|
const [exercises, prefs, workoutCount] = await Promise.all([
|
|
prisma.exercise.findMany({
|
|
where: { userId: user.id },
|
|
select: { id: true, name: true, type: true },
|
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
|
}),
|
|
prisma.userPreferences.findUnique({
|
|
where: { userId: user.id },
|
|
select: { aiProvider: true, aiModel: true },
|
|
}),
|
|
prisma.workout.count({
|
|
where: { userId: user.id, deletedAt: null },
|
|
}),
|
|
]);
|
|
|
|
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
|
|
|
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/ai"
|
|
className="text-zinc-400 hover:text-white"
|
|
aria-label="Back to AI"
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</Link>
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
|
AI · Today's workout
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
|
{!aiConfigured ? (
|
|
<div className="bg-amber-950/30 border border-amber-900 rounded p-5 text-sm text-amber-200">
|
|
<p className="font-bold text-amber-100 mb-2">AI is not configured.</p>
|
|
<p>
|
|
Pick a provider, model, and (if needed) API key in{' '}
|
|
<Link href="/main/settings" className="underline hover:text-amber-100">
|
|
Settings → AI integration
|
|
</Link>{' '}
|
|
before you can generate a workout.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<GenerateWorkoutClient
|
|
exercises={exercises}
|
|
providerLabel={prefs!.aiProvider!}
|
|
modelLabel={prefs!.aiModel!}
|
|
workoutCount={workoutCount}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|