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.
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import { redirect } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { Sparkles, ListChecks, History, Dumbbell } from 'lucide-react';
|
|
import { getCurrentUser } from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export default async function AIIndexPage() {
|
|
const user = await getCurrentUser();
|
|
if (!user) redirect('/auth/login');
|
|
|
|
const prefs = await prisma.userPreferences.findUnique({
|
|
where: { userId: user.id },
|
|
select: { aiProvider: true, aiModel: true },
|
|
});
|
|
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
|
|
|
const cards = [
|
|
{
|
|
href: '/main/ai/generate-workout',
|
|
icon: Dumbbell,
|
|
title: "Today's workout",
|
|
blurb:
|
|
'Describe today\'s session in plain words and get a ready-to-log workout — exercises with suggested weights and reps from your history. Refine it, then pre-fill the log.',
|
|
disabled: !aiConfigured,
|
|
},
|
|
{
|
|
href: '/main/ai/generate',
|
|
icon: Sparkles,
|
|
title: 'Generate program',
|
|
blurb:
|
|
'Pick a template, describe what you want, and get a full multi-week program back. Review before applying.',
|
|
disabled: !aiConfigured,
|
|
},
|
|
{
|
|
href: '/main/ai/templates',
|
|
icon: ListChecks,
|
|
title: 'Prompt templates',
|
|
blurb:
|
|
'Built-in templates ship with the app. Create + save your own for repeated use.',
|
|
},
|
|
{
|
|
href: '/main/ai/history',
|
|
icon: History,
|
|
title: 'Generation history',
|
|
blurb: 'Every prompt you sent, every response, every applied program.',
|
|
},
|
|
];
|
|
|
|
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">AI</h1>
|
|
<p className="text-xs text-zinc-500">
|
|
{aiConfigured ? (
|
|
<>
|
|
{prefs!.aiProvider} · {prefs!.aiModel}
|
|
</>
|
|
) : (
|
|
<Link href="/main/settings" className="underline">
|
|
Configure in Settings →
|
|
</Link>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="max-w-3xl mx-auto px-4 py-6 grid gap-3">
|
|
{cards.map((c) => {
|
|
const Icon = c.icon;
|
|
const disabled = !!c.disabled;
|
|
return (
|
|
<Link
|
|
key={c.href}
|
|
href={disabled ? '/main/settings' : c.href}
|
|
className={`block bg-zinc-900 border border-zinc-800 rounded p-5 hover:border-zinc-700 transition ${disabled ? 'opacity-60' : ''}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<Icon className="w-5 h-5 text-zinc-400 mt-0.5" />
|
|
<div>
|
|
<h2 className="text-base font-semibold text-white">
|
|
{c.title}
|
|
{disabled && (
|
|
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
|
|
configure first
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<p className="text-sm text-zinc-500 mt-1">{c.blurb}</p>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|