From 684fe434c862f546ff18b82f6ce76e7db5cb3b4b Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sun, 12 Apr 2026 09:34:04 +0200 Subject: [PATCH] feat(plan): redesign Plan page with version cards and create modal Replace inline plan editor and collapsible version history with a card-based layout matching the Reports page pattern: - Active version shown as expanded card with green ring indicator and "Active" badge (CheckCircle2 icon) - Previous versions as collapsed cards (click to expand and view day/exercise breakdown via PlanDayCard) - Plan creation moved to a modal Dialog triggered by "New Plan" button in the page header - New usePlanVersions hook fetches all versions from the API - Version query invalidation added to useCreatePlan and useDecideAdjustments for cache consistency - Empty state with centered CTA matching other pages - PlanVersionHistory component removed (replaced by PlanVersionCard) - 3 new E2E tests for version display, active indicator, and expand/collapse - Product capabilities doc updated for UC-PLAN-01 and UC-PLAN-02 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/product-capabilities.md | 21 ++- e2e/fixtures/plan-versions.json | 174 ++++++++++++++++++ e2e/pages/PlanPage.ts | 28 ++- e2e/workout-plan-tuner.spec.ts | 99 +++++++++- .../frontend/src/api/hooks/useWorkoutPlan.ts | 14 ++ .../frontend/src/components/plan/PlanPage.tsx | 133 ++++++------- .../src/components/plan/PlanVersionCard.tsx | 81 ++++++++ .../components/plan/PlanVersionHistory.tsx | 83 --------- 8 files changed, 461 insertions(+), 172 deletions(-) create mode 100644 e2e/fixtures/plan-versions.json create mode 100644 packages/frontend/src/components/plan/PlanVersionCard.tsx delete mode 100644 packages/frontend/src/components/plan/PlanVersionHistory.tsx diff --git a/docs/product-capabilities.md b/docs/product-capabilities.md index 43f03cb..fabc063 100644 --- a/docs/product-capabilities.md +++ b/docs/product-capabilities.md @@ -670,13 +670,14 @@ adjustments creates an immutable new plan version. **so that** the system has a structured record of my program to tune. **Behavior:** -- Route: `POST /api/workout-plans` with `{ text }` body +- Frontend: "New Plan" button in page header opens a modal Dialog containing `PlanEditor` textarea +- Route: `POST /api/workout-plans` with `{ rawText }` body - `plan-parser.ts` uses regex heuristics to extract days, exercises, sets × reps, target load, RPE, and notes; falls back to a single "Notes" day when structure isn't recognized - Persisted as `plan_versions` row with `source = 'user'`, `version_number = 1` - Input capped at 50 000 chars (HTTP 413 if exceeded); unstructured fallback blob capped at 10 000 chars - `workout_plans` unique constraint enforces one plan per user in v1 - `GET /api/workout-plans/current` returns the active plan with version data -- `PUT /api/workout-plans/:id` replaces the plan text and re-parses +- `PUT /api/workout-plans/:id` replaces the plan text and re-parses (creates new version) **E2E Coverage:** `e2e/workout-plan-tuner.spec.ts` @@ -687,12 +688,16 @@ adjustments creates an immutable new plan version. **Behavior:** - Route: `/plan` -- Day cards show: day name, exercise rows with sets × reps, target load/RPE, notes -- Collapsible version history panel lists all prior versions with source badge (`user` or `tuner`) and version number -- Empty state: paste prompt with textarea and submit button -- `PlanVersionHistory` component renders list-only (no per-version diff view in v1) - -**E2E Coverage:** `e2e/workout-plan-tuner.spec.ts` +- Active version displayed as an expanded card with green ring indicator (`ring-2 ring-green-500/70`) and "Active" badge with CheckCircle2 icon +- Previous versions shown as collapsed cards below, each showing: version number, source badge (Manual edit / AI tuned / Imported), creation date, notes +- Clicking a collapsed version card expands it to reveal full day/exercise breakdown via `PlanDayCard` components +- Day cards show: day name, target muscles badges, exercise rows with sets × reps, target load/RPE, notes +- All versions fetched from `GET /api/workout-plans/:id/versions` via `usePlanVersions` hook +- Empty state: centered message with "Create your first plan" button that opens the create modal +- Plan creation is a modal Dialog triggered by "New Plan" button in the page header (matching Reports page pattern) +- Loading state: skeleton cards (consistent with other pages) + +**E2E Coverage:** `e2e/workout-plan-tuner.spec.ts` — empty state, create modal flow, active indicator, version list, expand/collapse ### UC-PLAN-03: Optimize plan from weekly report diff --git a/e2e/fixtures/plan-versions.json b/e2e/fixtures/plan-versions.json new file mode 100644 index 0000000..7abe114 --- /dev/null +++ b/e2e/fixtures/plan-versions.json @@ -0,0 +1,174 @@ +{ + "data": [ + { + "id": "version-fixture-002", + "planId": "plan-fixture-001", + "versionNumber": 2, + "source": "tuner", + "parentVersionId": "version-fixture-001", + "data": { + "splitType": "PPL", + "progressionPersonality": "balanced", + "days": [ + { + "name": "Push A", + "targetMuscles": ["chest", "front deltoid", "triceps"], + "exercises": [ + { + "id": "ex-001", + "exerciseName": "Bench Press", + "orderInDay": 1, + "sets": [ + { "type": "warmup", "targetReps": 8, "targetWeightKg": 60 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 82.5 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 82.5 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 82.5 } + ], + "progressionRule": "double", + "primaryMuscle": "chest", + "secondaryMuscles": ["front deltoid", "triceps"], + "pattern": "push", + "equipment": "barbell", + "sfrTier": "S" + } + ] + }, + { + "name": "Pull A", + "targetMuscles": ["back", "rear deltoid", "biceps"], + "exercises": [ + { + "id": "ex-004", + "exerciseName": "Barbell Row", + "orderInDay": 1, + "sets": [ + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 82.5 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 82.5 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 82.5 } + ], + "progressionRule": "double", + "primaryMuscle": "back", + "secondaryMuscles": ["rear deltoid", "biceps"], + "pattern": "pull", + "equipment": "barbell", + "sfrTier": "S" + } + ] + } + ] + }, + "createdAt": "2026-04-11T11:00:00.000Z", + "acceptedAt": "2026-04-11T11:00:00.000Z", + "notes": "AI tuned — 2 changes applied" + }, + { + "id": "version-fixture-001", + "planId": "plan-fixture-001", + "versionNumber": 1, + "source": "user", + "parentVersionId": null, + "data": { + "splitType": "PPL", + "progressionPersonality": "balanced", + "days": [ + { + "name": "Push A", + "targetMuscles": ["chest", "front deltoid", "triceps"], + "exercises": [ + { + "id": "ex-001", + "exerciseName": "Bench Press", + "orderInDay": 1, + "sets": [ + { "type": "warmup", "targetReps": 8, "targetWeightKg": 60 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 } + ], + "progressionRule": "double", + "primaryMuscle": "chest", + "secondaryMuscles": ["front deltoid", "triceps"], + "pattern": "push", + "equipment": "barbell", + "sfrTier": "S" + }, + { + "id": "ex-002", + "exerciseName": "Incline Dumbbell Press", + "orderInDay": 2, + "sets": [ + { "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 }, + { "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 }, + { "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 } + ], + "progressionRule": "double", + "primaryMuscle": "chest", + "secondaryMuscles": ["front deltoid"], + "pattern": "push", + "equipment": "dumbbell", + "sfrTier": "A" + }, + { + "id": "ex-003", + "exerciseName": "Overhead Press", + "orderInDay": 3, + "sets": [ + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 50 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 50 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 50 } + ], + "progressionRule": "linear", + "primaryMuscle": "front deltoid", + "secondaryMuscles": ["triceps"], + "pattern": "push", + "equipment": "barbell", + "sfrTier": "A" + } + ] + }, + { + "name": "Pull A", + "targetMuscles": ["back", "rear deltoid", "biceps"], + "exercises": [ + { + "id": "ex-004", + "exerciseName": "Barbell Row", + "orderInDay": 1, + "sets": [ + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 } + ], + "progressionRule": "double", + "primaryMuscle": "back", + "secondaryMuscles": ["rear deltoid", "biceps"], + "pattern": "pull", + "equipment": "barbell", + "sfrTier": "S" + }, + { + "id": "ex-005", + "exerciseName": "Pull-ups", + "orderInDay": 2, + "sets": [ + { "type": "normal", "targetReps": [6, 10] }, + { "type": "normal", "targetReps": [6, 10] }, + { "type": "normal", "targetReps": [6, 10] } + ], + "progressionRule": "double", + "primaryMuscle": "back", + "secondaryMuscles": ["biceps"], + "pattern": "pull", + "equipment": "bodyweight", + "sfrTier": "S" + } + ] + } + ] + }, + "createdAt": "2026-04-11T09:00:00.000Z", + "acceptedAt": "2026-04-11T09:00:00.000Z", + "notes": "Parsed from user paste" + } + ] +} diff --git a/e2e/pages/PlanPage.ts b/e2e/pages/PlanPage.ts index a6aaa2b..7d74261 100644 --- a/e2e/pages/PlanPage.ts +++ b/e2e/pages/PlanPage.ts @@ -7,24 +7,28 @@ export class PlanPagePOM { readonly page: Page; readonly heading: Locator; readonly emptyState: Locator; + readonly newPlanButton: Locator; + readonly createFirstPlanButton: Locator; + readonly createDialog: Locator; readonly pasteTextarea: Locator; readonly submitButton: Locator; - readonly versionHistorySection: Locator; constructor(page: Page) { this.page = page; this.heading = page.getByRole('heading', { name: 'Workout Plan' }); - this.emptyState = page.getByText('Create your plan'); + this.emptyState = page.getByText('No workout plan yet.'); + this.newPlanButton = page.getByRole('button', { name: /new plan/i }); + this.createFirstPlanButton = page.getByRole('button', { name: /create your first plan/i }); + this.createDialog = page.getByRole('heading', { name: /create workout plan/i }); this.pasteTextarea = page.getByTestId('plan-textarea'); this.submitButton = page.getByRole('button', { name: /parse & save/i }); - this.versionHistorySection = page.getByText(/version history/i); } async goto() { await this.page.goto('/plan'); } - /** Submit a raw text plan via the paste editor. */ + /** Submit a raw text plan via the create modal. */ async submitPlan(rawText: string) { await this.pasteTextarea.fill(rawText); await this.submitButton.click(); @@ -40,15 +44,25 @@ export class PlanPagePOM { return this.page.getByText(/v\d+/i).first(); } + /** Returns the active version indicator badge. */ + get activeIndicator() { + return this.page.getByTestId('active-indicator'); + } + + /** Returns a version card by version number. */ + getVersionCard(versionNumber: number): Locator { + return this.page.getByTestId(`version-card-${versionNumber}`); + } + /** Returns a day card by day name (matches CardTitle div text). */ getDayCard(dayName: string): Locator { return this.page.getByText(dayName, { exact: true }).first(); } - /** Expects N day cards to be visible. */ - async expectDayCount(n: number) { + /** Expects N version cards to be visible. */ + async expectVersionCount(n: number) { const { expect } = await import('@playwright/test'); - const cards = this.page.locator('[data-slot="card"]'); + const cards = this.page.locator('[data-testid^="version-card-"]'); await expect(cards).toHaveCount(n); } diff --git a/e2e/workout-plan-tuner.spec.ts b/e2e/workout-plan-tuner.spec.ts index bdd1658..a87ca2f 100644 --- a/e2e/workout-plan-tuner.spec.ts +++ b/e2e/workout-plan-tuner.spec.ts @@ -4,6 +4,7 @@ import { PlanPagePOM } from './pages/PlanPage'; import { ReportsPage } from './pages/reports.page'; import parsedPlanFixture from './fixtures/parsed-plan.json' with { type: 'json' }; import adjustmentBatchFixture from './fixtures/plan-adjustment-batch.json' with { type: 'json' }; +import planVersionsFixture from './fixtures/plan-versions.json' with { type: 'json' }; // --------------------------------------------------------------------------- // Fixtures & mocks @@ -126,6 +127,21 @@ async function mockDecideAdjustments(page: Page) { }); } +/** Mock GET /api/workout-plans/:id/versions returning version list. */ +async function mockPlanVersions(page: Page) { + await page.route('**/api/workout-plans/*/versions', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(planVersionsFixture), + }); + } else { + await route.fallback(); + } + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -139,12 +155,11 @@ test.describe('UC-PLAN-01: Workout Plan Fine Tuner', () => { await expect(planPage.heading).toBeVisible(); await expect(planPage.emptyState).toBeVisible(); - // Textarea for pasting plan text should be present - await expect(planPage.pasteTextarea).toBeVisible(); + // "Create your first plan" button should be present + await expect(planPage.createFirstPlanButton).toBeVisible(); }); test('pasting plan text and submitting parses and displays days', async ({ page }) => { - // First response: no plan; after POST create: return plan v1 let planCreated = false; await page.route('**/api/workout-plans/current', async (route) => { @@ -155,7 +170,6 @@ test.describe('UC-PLAN-01: Workout Plan Fine Tuner', () => { }); }); - // Mock POST — toggle planCreated flag so subsequent GETs return the plan await page.route('**/api/workout-plans', async (route) => { if (route.request().method() === 'POST') { planCreated = true; @@ -169,22 +183,89 @@ test.describe('UC-PLAN-01: Workout Plan Fine Tuner', () => { } }); + // Mock versions endpoint for after plan creation + await page.route('**/api/workout-plans/*/versions', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [parsedPlanFixture.data.latestVersion], + }), + }); + } else { + await route.fallback(); + } + }); + const planPage = new PlanPagePOM(page); await planPage.goto(); await expect(planPage.emptyState).toBeVisible(); - // Fill and submit — planCreated is still false here, so the textarea is visible + // Open create modal and fill + await planPage.createFirstPlanButton.click(); + await expect(planPage.createDialog).toBeVisible(); + await planPage.pasteTextarea.fill( 'Push A\nBench Press 3x8 @ 80kg\n\nPull A\nBarbell Row 3x8 @ 80kg', ); await planPage.submitButton.click(); - // Day cards should appear after successful parse (planCreated is now true → GET returns plan) + // Day cards should appear inside the active version card await expect(planPage.getDayCard('Push A')).toBeVisible({ timeout: 8000 }); await expect(planPage.getDayCard('Pull A')).toBeVisible({ timeout: 8000 }); }); }); + test.describe('Version list display', () => { + test('shows active version with green indicator and expanded content', async ({ page }) => { + await mockPlanV2(page); + await mockPlanVersions(page); + const planPage = new PlanPagePOM(page); + await planPage.goto(); + + // Active version card should be visible with indicator + await expect(planPage.activeIndicator).toBeVisible({ timeout: 5000 }); + await expect(planPage.getVersionCard(2)).toBeVisible(); + + // Active card should be expanded — day cards visible + await expect(planPage.getDayCard('Push A')).toBeVisible(); + await expect(planPage.getDayCard('Pull A')).toBeVisible(); + }); + + test('shows previous versions as collapsed cards', async ({ page }) => { + await mockPlanV2(page); + await mockPlanVersions(page); + const planPage = new PlanPagePOM(page); + await planPage.goto(); + + // Both version cards should be visible + await planPage.expectVersionCount(2); + + // v1 should be visible but collapsed (exercise names not visible inside it) + await expect(planPage.getVersionCard(1)).toBeVisible({ timeout: 5000 }); + // The v1 card should show "Manual edit" badge + const v1Card = planPage.getVersionCard(1); + await expect(v1Card.getByText('Manual edit')).toBeVisible(); + }); + + test('clicking previous version expands to show exercises', async ({ page }) => { + await mockPlanV2(page); + await mockPlanVersions(page); + const planPage = new PlanPagePOM(page); + await planPage.goto(); + + // Click expand on v1 card + const v1Card = planPage.getVersionCard(1); + await v1Card.getByRole('button').first().click(); + + // Now exercises from v1 should be visible within v1's card + // v1 has Bench Press, Incline Dumbbell Press, Overhead Press, Barbell Row, Pull-ups + await expect(v1Card.getByText('Incline Dumbbell Press')).toBeVisible({ timeout: 5000 }); + await expect(v1Card.getByText('Overhead Press')).toBeVisible(); + }); + }); + test.describe('Optimize plan CTA on report card', () => { test('report card shows "Optimize next week\'s plan" button when plan exists', async ({ page, @@ -280,13 +361,15 @@ test.describe('UC-PLAN-01: Workout Plan Fine Tuner', () => { test('after accepting, /plan shows new version number and history entry', async ({ page }) => { await mockPlanV2(page); + await mockPlanVersions(page); const planPage = new PlanPagePOM(page); await planPage.goto(); - // Should display version 2 badge + // Should display version 2 as active await expect(planPage.activeVersionBadge).toBeVisible({ timeout: 5000 }); - await expect(planPage.activeVersionBadge).toContainText('2'); + await expect(planPage.activeIndicator).toBeVisible(); + await expect(planPage.getVersionCard(2)).toBeVisible(); }); }); }); diff --git a/packages/frontend/src/api/hooks/useWorkoutPlan.ts b/packages/frontend/src/api/hooks/useWorkoutPlan.ts index af47cf9..d519037 100644 --- a/packages/frontend/src/api/hooks/useWorkoutPlan.ts +++ b/packages/frontend/src/api/hooks/useWorkoutPlan.ts @@ -46,6 +46,7 @@ export function useCreatePlan() { onSuccess: () => { toast.success('Plan created successfully'); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.workoutPlan.current }); + queryClient.invalidateQueries({ queryKey: ['workout-plan', 'versions'] }); }, }); } @@ -93,6 +94,19 @@ export function useDecideAdjustments(batchId: string) { const { versionNumber } = response.data; toast.success(`Plan updated to version ${versionNumber ?? 'unchanged'}`); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.workoutPlan.current }); + queryClient.invalidateQueries({ queryKey: ['workout-plan', 'versions'] }); }, }); } + +/** + * Fetches all versions for a given plan, ordered by version_number DESC. + * Only enabled when planId is provided. + */ +export function usePlanVersions(planId: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.workoutPlan.versions(planId!), + queryFn: () => apiFetch>(`/api/workout-plans/${planId}/versions`), + enabled: !!planId, + }); +} diff --git a/packages/frontend/src/components/plan/PlanPage.tsx b/packages/frontend/src/components/plan/PlanPage.tsx index 7a5c751..0dedc61 100644 --- a/packages/frontend/src/components/plan/PlanPage.tsx +++ b/packages/frontend/src/components/plan/PlanPage.tsx @@ -1,97 +1,98 @@ import { useState } from 'react'; -import { Loader2, Pencil } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; +import { Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { useCurrentPlan } from '@/api/hooks/useWorkoutPlan'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useCurrentPlan, usePlanVersions } from '@/api/hooks/useWorkoutPlan'; +import { CardSkeleton } from '@/components/ui/LoadingSkeleton'; import { PlanEditor } from './PlanEditor'; -import { PlanDayCard } from './PlanDayCard'; -import { PlanVersionHistory } from './PlanVersionHistory'; +import { PlanVersionCard } from './PlanVersionCard'; -/** - * /plan route — Workout Plan page. - * - * Empty state: "Create your plan" card with inline PlanEditor. - * Populated state: version badge + day cards + version history panel. - */ export function PlanPage() { const { data, isLoading } = useCurrentPlan(); - const [editOpen, setEditOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const planResponse = data?.data; const plan = planResponse?.plan ?? null; - const latestVersion = planResponse?.latestVersion ?? null; + + const { data: versionsData, isLoading: versionsLoading } = usePlanVersions(plan?.id); + const versions = versionsData?.data ?? []; + + const activeVersion = versions.find((v) => v.id === plan?.activeVersionId); + const previousVersions = versions.filter((v) => v.id !== plan?.activeVersionId); + + const createDialog = ( + + + + Create workout plan + +

+ Paste your workout plan below. The AI will parse it into a structured format. +

+ setCreateOpen(false)} /> +
+
+ ); if (isLoading) { return ( -
- +
+

Workout Plan

+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
); } - // Empty state - if (!plan || !latestVersion) { + if (!plan) { return ( -
+

Workout Plan

- - - Create your plan - - -

- Paste your workout plan below. The AI will parse it into a structured format. -

- -
-
+
+

+ No workout plan yet. Paste your training program to get started. +

+ +
+ {createDialog}
); } - const days = latestVersion.data.days ?? []; - return ( -
- {/* Header */} -
-
-

{plan.name}

- v{latestVersion.versionNumber} - {latestVersion.data.splitType} -
- - - }> - - Edit plan text - - - - Edit workout plan - - setEditOpen(false)} /> - - +
+
+

Workout Plan

+
- {/* Day cards */}
- {days.map((day, i) => ( - - ))} + {versionsLoading ? ( + Array.from({ length: 2 }).map((_, i) => ) + ) : versions.length === 0 ? ( +

No versions found.

+ ) : ( + <> + {activeVersion && ( + + )} + {previousVersions.map((v) => ( + + ))} + + )}
- {/* Version history */} - + {createDialog}
); } diff --git a/packages/frontend/src/components/plan/PlanVersionCard.tsx b/packages/frontend/src/components/plan/PlanVersionCard.tsx new file mode 100644 index 0000000..ed8d80b --- /dev/null +++ b/packages/frontend/src/components/plan/PlanVersionCard.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { format, parseISO } from 'date-fns'; +import type { PlanVersion } from '@vitals/shared'; +import { ChevronDown, ChevronUp, CheckCircle2 } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { PlanDayCard } from './PlanDayCard'; + +interface PlanVersionCardProps { + version: PlanVersion; + isActive: boolean; + defaultExpanded?: boolean; +} + +const SOURCE_LABELS: Record = { + user: 'Manual edit', + tuner: 'AI tuned', + imported: 'Imported', +}; + +const SOURCE_VARIANTS: Record< + PlanVersion['source'], + 'default' | 'secondary' | 'outline' | 'destructive' +> = { + user: 'secondary', + tuner: 'default', + imported: 'outline', +}; + +export function PlanVersionCard({ + version, + isActive, + defaultExpanded = false, +}: PlanVersionCardProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const days = version.data.days ?? []; + + return ( + + +
+
+ + v{version.versionNumber} + {isActive && ( + + + Active + + )} + + {SOURCE_LABELS[version.source]} + + +
+ {format(parseISO(version.createdAt), 'MMM d, yyyy')} + {version.notes && — {version.notes}} +
+
+ +
+
+ {expanded && ( + + {days.map((day, i) => ( + + ))} + + )} +
+ ); +} diff --git a/packages/frontend/src/components/plan/PlanVersionHistory.tsx b/packages/frontend/src/components/plan/PlanVersionHistory.tsx deleted file mode 100644 index 7ade7cc..0000000 --- a/packages/frontend/src/components/plan/PlanVersionHistory.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from 'react'; -import { format, parseISO } from 'date-fns'; -import type { PlanVersion } from '@vitals/shared'; -import { ChevronDown, ChevronUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; - -interface PlanVersionHistoryProps { - versions: PlanVersion[]; - activeVersionId: string | null; -} - -const SOURCE_LABELS: Record = { - user: 'Manual edit', - tuner: 'AI tuned', - imported: 'Imported', -}; - -const SOURCE_VARIANTS: Record< - PlanVersion['source'], - 'default' | 'secondary' | 'outline' | 'destructive' -> = { - user: 'secondary', - tuner: 'default', - imported: 'outline', -}; - -/** - * Collapsible list of plan versions with source badge and date. - */ -export function PlanVersionHistory({ versions, activeVersionId }: PlanVersionHistoryProps) { - const [open, setOpen] = useState(false); - - if (versions.length === 0) return null; - - return ( -
- - - {open && ( -
- {versions.map((version) => { - const isActive = version.id === activeVersionId; - return ( -
-
- v{version.versionNumber} - {isActive && ( - - current - - )} - - {SOURCE_LABELS[version.source]} - -
-
- {version.notes && {version.notes}} - - {format(parseISO(version.createdAt), 'MMM d, yyyy')} - -
-
- ); - })} -
- )} -
- ); -}