7a62690a4a
User-feedback-driven release after testing v1.1.0:3. Nine themes:
1. Multi-config persistence
- New AIConfigProfile table (per-user). Save N configs, toggle one
active. Switching providers no longer wipes the previous setup.
- UserPreferences gains activeAIConfigId; legacy single-config
columns are mirrored from the active profile so existing reads
keep working without conditional logic.
- Idempotent boot migration lifts any existing single-config row
into a default profile.
2. Ollama auto-detect
- The "Add config" form probes /api/tags on the StartOS internal
addresses (ollama.startos / ollama.embassy on :11434). If
reachable: URL pre-fills, model field becomes a dropdown of
installed models. Fixes the copy-paste UX.
3. Curated model dropdowns for major providers
- Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
- OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
- Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
- "Other (type your own)" stays for niche models.
- Fixes "I tried gemini-3.0-pro and got 404."
4. Background generation
- lib/ai/generationRunner.ts: detached runner with in-memory
pub/sub bus. POST /api/ai/generate kicks it off and returns
immediately. SSE stream attaches by id. The runner survives
request cancellation; navigating away no longer kills it.
- New AIGeneration columns: progressText (in-flight stream),
durationMs (final wall-clock).
- Generate UI shows a banner explaining background-safety.
- History detail page polls progress + renders partial JSON
live for cross-process resume (page refresh, new tab).
5. System prompt overhaul
- lib/ai/systemPromptBase.ts: structural contract prepended to
every template. Forces JSON-only output, library-exerciseId
usage (kills "exerciseId doesn't belong to this user" errors),
and per-resistance-exercise suggestedWeight (with-history vs
without-history variants).
- aiExerciseSchema + ProgramExercise gain suggestedWeight +
suggestedWeightUnit. Starting a workout from a ProgramDay
pre-populates SetLog.weight from the suggestion.
6. Test connection improvements
- Latency in seconds (was ms — confusing for slow Ollama).
- Stale "✓ Connected" clears on form change.
- Per-config Test (no need to activate first).
- Generous maxOutputTokens for thinking models.
- Gemini surfaces finishReason on empty response (e.g. "blocked
by safety filter") instead of generic "empty response."
- Test endpoint accepts a draft body so you can verify before
saving + before activating.
7. History detail view
- Click row → full program tree + exact prompts sent. Apply from
here without re-generating. Pending rows poll for progress.
8. Sidebar sub-navigation
- AI: Generate / History / Templates
- Settings: General / Password / Sessions / AI integration /
Export / Instance (admin) / Danger zone, with anchor scroll.
9. API key UX
- "Key saved" indicator on saved configs (was confusing to see
an empty input after a successful save).
Schema migrations (additive, idempotent in entrypoint):
- AIConfigProfile table created
- UserPreferences.activeAIConfigId
- AIGeneration.progressText + durationMs
- ProgramExercise.suggestedWeight + suggestedWeightUnit
Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.
631 lines
21 KiB
TypeScript
631 lines
21 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import Link from 'next/link';
|
||
import { Loader2 } from 'lucide-react';
|
||
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||
|
||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
|
||
interface AIExercise {
|
||
exerciseId: string | null;
|
||
exerciseName: string;
|
||
order: number;
|
||
sets?: number | null;
|
||
repsMin?: number | null;
|
||
repsMax?: number | null;
|
||
rpe?: number | null;
|
||
restSeconds?: number | null;
|
||
suggestedWeight?: number | null;
|
||
suggestedWeightUnit?: 'lbs' | 'kg' | null;
|
||
notes?: string | null;
|
||
}
|
||
interface AIDay {
|
||
dayOfWeek: number;
|
||
name?: string | null;
|
||
description?: string | null;
|
||
exercises: AIExercise[];
|
||
}
|
||
interface AIWeek {
|
||
weekNumber: number;
|
||
phase?: string | null;
|
||
description?: string | null;
|
||
days: AIDay[];
|
||
}
|
||
interface AIProgram {
|
||
name: string;
|
||
description?: string | null;
|
||
type: string;
|
||
durationWeeks: number;
|
||
weeks: AIWeek[];
|
||
}
|
||
|
||
interface LibraryExercise {
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
}
|
||
|
||
interface Row {
|
||
id: string;
|
||
templateName: string | null;
|
||
userInput: string;
|
||
systemPrompt: string;
|
||
userPrompt: string;
|
||
rawResponse: string | null;
|
||
parsedProgram: string | null;
|
||
progressText: string | null;
|
||
provider: string;
|
||
model: string;
|
||
tokensIn: number | null;
|
||
tokensOut: number | null;
|
||
durationMs: number | null;
|
||
status: string;
|
||
errorMessage: string | null;
|
||
appliedProgramId: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
/**
|
||
* Client-side detail view for an AIGeneration. Three modes:
|
||
*
|
||
* - PENDING: poll for progress + render the live partial-JSON preview.
|
||
* The runner keeps writing `progressText` even if no SSE clients
|
||
* are subscribed, so polling works for cross-process resume too.
|
||
*
|
||
* - COMPLETED: render the parsed program tree with an Apply button.
|
||
* Same UI as the Generate page's preview, factored out below.
|
||
*
|
||
* - APPLIED: the user already turned this into a Program; show a
|
||
* link there. Re-applying isn't allowed (would create a duplicate).
|
||
*
|
||
* - FAILED: error message + raw response collapsed by default.
|
||
*/
|
||
export default function GenerationDetail({
|
||
row: initialRow,
|
||
exercises,
|
||
}: {
|
||
row: Row;
|
||
exercises: LibraryExercise[];
|
||
}) {
|
||
const router = useRouter();
|
||
const [row, setRow] = useState(initialRow);
|
||
|
||
// Poll while pending. 1.5s cadence — fast enough to feel live,
|
||
// gentle on the DB. Stops when status flips terminal.
|
||
useEffect(() => {
|
||
if (row.status !== 'pending') return;
|
||
let cancelled = false;
|
||
const tick = async () => {
|
||
try {
|
||
const r = await fetch(`/api/ai/generations/${row.id}`);
|
||
if (!r.ok || cancelled) return;
|
||
const fresh = await r.json();
|
||
if (cancelled) return;
|
||
setRow({
|
||
...fresh,
|
||
createdAt:
|
||
typeof fresh.createdAt === 'string'
|
||
? fresh.createdAt
|
||
: new Date(fresh.createdAt).toISOString(),
|
||
});
|
||
} catch {
|
||
/* transient — try again */
|
||
}
|
||
};
|
||
const id = setInterval(tick, 1500);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(id);
|
||
};
|
||
}, [row.id, row.status]);
|
||
|
||
const cost = useMemo(
|
||
() =>
|
||
estimateCost({
|
||
provider: row.provider,
|
||
model: row.model,
|
||
tokensIn: row.tokensIn,
|
||
tokensOut: row.tokensOut,
|
||
}),
|
||
[row.provider, row.model, row.tokensIn, row.tokensOut],
|
||
);
|
||
|
||
// Live partial during pending.
|
||
const partial = useMemo(
|
||
() =>
|
||
row.status === 'pending' && row.progressText
|
||
? (lenientJsonParse(row.progressText) as Partial<AIProgram> | null)
|
||
: null,
|
||
[row.status, row.progressText],
|
||
);
|
||
|
||
const parsedProgram = useMemo(
|
||
() =>
|
||
row.parsedProgram ? (JSON.parse(row.parsedProgram) as AIProgram) : null,
|
||
[row.parsedProgram],
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Header / metadata */}
|
||
<header className="space-y-2">
|
||
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider flex-wrap">
|
||
<StatusPill status={row.status} />
|
||
<span>{new Date(row.createdAt).toLocaleString()}</span>
|
||
<span className="text-zinc-600">·</span>
|
||
<span>
|
||
{row.provider} · {row.model}
|
||
</span>
|
||
{row.tokensIn != null && (
|
||
<>
|
||
<span className="text-zinc-600">·</span>
|
||
<span>
|
||
{row.tokensIn} in · {row.tokensOut ?? '?'} out
|
||
</span>
|
||
</>
|
||
)}
|
||
{cost != null && (
|
||
<>
|
||
<span className="text-zinc-600">·</span>
|
||
<span>{formatCost(cost)}</span>
|
||
</>
|
||
)}
|
||
{row.durationMs != null && (
|
||
<>
|
||
<span className="text-zinc-600">·</span>
|
||
<span>{formatDuration(row.durationMs)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{row.templateName && (
|
||
<p className="text-xs text-zinc-400">
|
||
Template: <span className="text-zinc-200">{row.templateName}</span>
|
||
</p>
|
||
)}
|
||
</header>
|
||
|
||
{/* User's prompt */}
|
||
<section className="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2">
|
||
Your specifics
|
||
</h2>
|
||
<p className="text-sm text-zinc-200 whitespace-pre-wrap">{row.userInput}</p>
|
||
</section>
|
||
|
||
{/* Pending: live preview */}
|
||
{row.status === 'pending' && (
|
||
<section className="space-y-3">
|
||
<div className="rounded bg-blue-950/30 border border-blue-900 px-4 py-3 text-xs text-blue-200">
|
||
<p className="font-bold text-blue-100 mb-1 flex items-center gap-2">
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
Still generating…
|
||
</p>
|
||
<p>
|
||
Polling every 1.5s for progress. Safe to leave this page —
|
||
the model keeps running on the server and you'll see the
|
||
result when you come back.
|
||
</p>
|
||
</div>
|
||
{partial ? (
|
||
<PartialTree partial={partial} />
|
||
) : (
|
||
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
Waiting for the first parseable JSON…
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* Failed */}
|
||
{row.status === 'failed' && (
|
||
<section className="space-y-3">
|
||
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
|
||
{row.errorMessage ?? 'Failed.'}
|
||
</div>
|
||
<Link
|
||
href="/main/ai/generate"
|
||
className="inline-block text-xs text-zinc-400 underline hover:text-white"
|
||
>
|
||
← Try again from Generate
|
||
</Link>
|
||
</section>
|
||
)}
|
||
|
||
{/* Applied — link to the program */}
|
||
{row.status === 'applied' && row.appliedProgramId && (
|
||
<section>
|
||
<Link
|
||
href={`/main/programs/${row.appliedProgramId}`}
|
||
className="inline-block px-4 py-2 rounded bg-emerald-700 text-white text-xs uppercase tracking-wider font-bold hover:bg-emerald-600"
|
||
>
|
||
View applied program →
|
||
</Link>
|
||
</section>
|
||
)}
|
||
|
||
{/* Completed (not yet applied) — show preview + Apply */}
|
||
{row.status === 'completed' && parsedProgram && (
|
||
<ProgramPreview
|
||
generationId={row.id}
|
||
program={parsedProgram}
|
||
exercises={exercises}
|
||
onApplied={(programId) => router.push(`/main/programs/${programId}`)}
|
||
/>
|
||
)}
|
||
|
||
{/* Raw response + prompts (collapsed) */}
|
||
{row.rawResponse && (
|
||
<details className="text-xs text-zinc-500">
|
||
<summary className="cursor-pointer">Raw model response</summary>
|
||
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap max-h-96 overflow-auto">
|
||
{row.rawResponse}
|
||
</pre>
|
||
</details>
|
||
)}
|
||
<details className="text-xs text-zinc-500">
|
||
<summary className="cursor-pointer">Exact prompts sent</summary>
|
||
<div className="mt-2 space-y-2">
|
||
<div>
|
||
<p className="font-semibold text-zinc-400 uppercase tracking-wider mb-1">
|
||
System
|
||
</p>
|
||
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 whitespace-pre-wrap max-h-72 overflow-auto">
|
||
{row.systemPrompt}
|
||
</pre>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-zinc-400 uppercase tracking-wider mb-1">
|
||
User
|
||
</p>
|
||
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 whitespace-pre-wrap max-h-72 overflow-auto">
|
||
{row.userPrompt}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProgramPreview({
|
||
generationId,
|
||
program: initial,
|
||
exercises,
|
||
onApplied,
|
||
}: {
|
||
generationId: string;
|
||
program: AIProgram;
|
||
exercises: LibraryExercise[];
|
||
onApplied: (programId: string) => void;
|
||
}) {
|
||
const [program, setProgram] = useState<AIProgram>(initial);
|
||
const [applying, setApplying] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [startDate, setStartDate] = useState(
|
||
new Date().toISOString().slice(0, 10),
|
||
);
|
||
const [activate, setActivate] = useState(true);
|
||
|
||
const exerciseLookup = useMemo(
|
||
() => new Map(exercises.map((e) => [e.id, e])),
|
||
[exercises],
|
||
);
|
||
const unresolvedCount = useMemo(() => {
|
||
let n = 0;
|
||
for (const w of program.weeks)
|
||
for (const d of w.days)
|
||
for (const ex of d.exercises) {
|
||
if (!ex.exerciseId || !exerciseLookup.has(ex.exerciseId)) n++;
|
||
}
|
||
return n;
|
||
}, [program, exerciseLookup]);
|
||
|
||
const setExerciseId = (
|
||
weekIdx: number,
|
||
dayIdx: number,
|
||
exIdx: number,
|
||
newId: string | null,
|
||
) => {
|
||
setProgram((p) => {
|
||
const next = structuredClone(p);
|
||
next.weeks[weekIdx].days[dayIdx].exercises[exIdx].exerciseId = newId;
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const removeExercise = (weekIdx: number, dayIdx: number, exIdx: number) => {
|
||
setProgram((p) => {
|
||
const next = structuredClone(p);
|
||
next.weeks[weekIdx].days[dayIdx].exercises.splice(exIdx, 1);
|
||
next.weeks[weekIdx].days[dayIdx].exercises.forEach(
|
||
(ex: AIExercise, i: number) => {
|
||
ex.order = i;
|
||
},
|
||
);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleApply = async () => {
|
||
if (unresolvedCount > 0) {
|
||
setError(
|
||
`Resolve all ${unresolvedCount} unknown exercise(s) before applying.`,
|
||
);
|
||
return;
|
||
}
|
||
setError(null);
|
||
setApplying(true);
|
||
try {
|
||
const res = await fetch('/api/ai/apply', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
generationId,
|
||
program,
|
||
startDate,
|
||
isActive: activate,
|
||
}),
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||
onApplied(body.programId);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setApplying(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-white">{program.name}</h3>
|
||
<p className="text-xs text-zinc-500 mt-1">
|
||
{program.type} · {program.durationWeeks} week
|
||
{program.durationWeeks === 1 ? '' : 's'} · {program.weeks.length}{' '}
|
||
week{program.weeks.length === 1 ? '' : 's'} planned
|
||
</p>
|
||
{program.description && (
|
||
<p className="text-sm text-zinc-300 mt-2">{program.description}</p>
|
||
)}
|
||
</div>
|
||
|
||
{unresolvedCount > 0 && (
|
||
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
|
||
{unresolvedCount} exercise(s) the AI couldn't map to your
|
||
library. Pick a replacement or remove them before applying.
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
{program.weeks.map((w, wIdx) => (
|
||
<details
|
||
key={w.weekNumber}
|
||
open={wIdx === 0}
|
||
className="bg-zinc-950 border border-zinc-800 rounded"
|
||
>
|
||
<summary className="cursor-pointer px-3 py-2 text-sm text-white">
|
||
Week {w.weekNumber}
|
||
{w.phase && <span className="text-zinc-500"> · {w.phase}</span>}
|
||
<span className="text-zinc-600 text-xs">
|
||
{' '}
|
||
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
||
</span>
|
||
</summary>
|
||
<div className="p-3 space-y-2">
|
||
{w.days.map((d, dIdx) => (
|
||
<div
|
||
key={d.dayOfWeek}
|
||
className="bg-zinc-900 border border-zinc-800 rounded p-3"
|
||
>
|
||
<p className="text-xs font-semibold text-zinc-300 uppercase tracking-wider">
|
||
{DAY_LABELS[d.dayOfWeek]}
|
||
{d.name && (
|
||
<span className="text-zinc-500 normal-case font-normal">
|
||
{' '}
|
||
· {d.name}
|
||
</span>
|
||
)}
|
||
</p>
|
||
<ul className="mt-2 space-y-2">
|
||
{d.exercises.map((ex, eIdx) => {
|
||
const isUnknown =
|
||
!ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
|
||
const lib = ex.exerciseId
|
||
? exerciseLookup.get(ex.exerciseId)
|
||
: null;
|
||
return (
|
||
<li
|
||
key={eIdx}
|
||
className={`text-sm ${
|
||
isUnknown
|
||
? 'bg-amber-950/30 border border-amber-900'
|
||
: 'bg-zinc-950 border border-zinc-800'
|
||
} rounded p-2`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-white">
|
||
{lib?.name ?? ex.exerciseName}
|
||
{isUnknown && (
|
||
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||
not in library
|
||
</span>
|
||
)}
|
||
</div>
|
||
{(ex.sets || ex.repsMin || ex.repsMax || ex.rpe || ex.restSeconds || ex.suggestedWeight) && (
|
||
<div className="text-xs text-zinc-500 mt-0.5">
|
||
{ex.sets ? `${ex.sets}×` : ''}
|
||
{ex.repsMin === ex.repsMax || !ex.repsMax
|
||
? (ex.repsMin ?? '?')
|
||
: `${ex.repsMin}-${ex.repsMax}`}
|
||
{ex.suggestedWeight != null && (
|
||
<> @ {ex.suggestedWeight}{ex.suggestedWeightUnit ?? ''}</>
|
||
)}
|
||
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
||
{ex.restSeconds ? ` · rest ${ex.restSeconds}s` : ''}
|
||
</div>
|
||
)}
|
||
{ex.notes && (
|
||
<div className="text-xs text-zinc-400 mt-1 italic">
|
||
{ex.notes}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeExercise(wIdx, dIdx, eIdx)}
|
||
className="text-xs text-red-400 hover:text-red-300 px-1"
|
||
title="Remove from program"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{isUnknown && (
|
||
<div className="mt-2">
|
||
<select
|
||
value={ex.exerciseId ?? ''}
|
||
onChange={(e) =>
|
||
setExerciseId(wIdx, dIdx, eIdx, e.target.value || null)
|
||
}
|
||
className="w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
|
||
>
|
||
<option value="">
|
||
Map to existing exercise…
|
||
</option>
|
||
{exercises.map((opt) => (
|
||
<option key={opt.id} value={opt.id}>
|
||
{opt.name} ({opt.type})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
|
||
<div className="border-t border-zinc-800 pt-4 space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<label className="block">
|
||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||
Start date
|
||
</span>
|
||
<input
|
||
type="date"
|
||
value={startDate}
|
||
onChange={(e) => setStartDate(e.target.value)}
|
||
className="w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white"
|
||
/>
|
||
</label>
|
||
<label className="flex items-end gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={activate}
|
||
onChange={(e) => setActivate(e.target.checked)}
|
||
className="mb-2"
|
||
/>
|
||
<span className="text-xs text-zinc-300 mb-2">
|
||
Activate this program after applying
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleApply}
|
||
disabled={applying || unresolvedCount > 0}
|
||
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||
>
|
||
{applying ? (
|
||
<>
|
||
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||
Applying…
|
||
</>
|
||
) : (
|
||
'Apply this program'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PartialTree({ partial }: { partial: Partial<AIProgram> }) {
|
||
const weeks = (partial.weeks as AIWeek[] | undefined) ?? [];
|
||
return (
|
||
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
|
||
<div className="text-xs">
|
||
{partial.name && (
|
||
<span className="text-white font-semibold">{partial.name}</span>
|
||
)}
|
||
{partial.type && (
|
||
<span className="text-zinc-500"> · {partial.type}</span>
|
||
)}
|
||
{typeof partial.durationWeeks === 'number' && (
|
||
<span className="text-zinc-500"> · {partial.durationWeeks} wk</span>
|
||
)}
|
||
</div>
|
||
{weeks.length > 0 && (
|
||
<ul className="text-xs text-zinc-300 space-y-1">
|
||
{weeks.map((w, i) => (
|
||
<li key={i}>
|
||
<span className="text-zinc-500">Week {w?.weekNumber ?? '?'}:</span>{' '}
|
||
{Array.isArray(w?.days)
|
||
? `${w.days.length} day${w.days.length === 1 ? '' : 's'} (${w.days.reduce(
|
||
(n: number, d: AIDay) =>
|
||
n + (Array.isArray(d?.exercises) ? d.exercises.length : 0),
|
||
0,
|
||
)} exercises)`
|
||
: '…'}
|
||
{w?.phase && <span className="text-zinc-500"> · {w.phase}</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusPill({ status }: { status: string }) {
|
||
const map: Record<string, { color: string; label: string }> = {
|
||
pending: { color: 'text-zinc-400 bg-zinc-800', label: 'pending' },
|
||
completed: { color: 'text-emerald-400 bg-emerald-950', label: 'completed' },
|
||
applied: { color: 'text-emerald-400 bg-emerald-950', label: 'applied' },
|
||
failed: { color: 'text-red-400 bg-red-950', label: 'failed' },
|
||
};
|
||
const m = map[status] ?? map.pending;
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center gap-1 ${m.color} rounded px-2 py-0.5 text-[10px]`}
|
||
>
|
||
{m.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function formatDuration(ms: number): string {
|
||
if (ms < 1000) return `${ms}ms`;
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||
const m = Math.floor(ms / 60_000);
|
||
const s = Math.round((ms % 60_000) / 1000);
|
||
return `${m}m ${s}s`;
|
||
}
|