diff --git a/docs/superpowers/plans/2026-07-05-meus-treinos-ux-improvements.md b/docs/superpowers/plans/2026-07-05-meus-treinos-ux-improvements.md new file mode 100644 index 00000000..016c095a --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-meus-treinos-ux-improvements.md @@ -0,0 +1,431 @@ +# Meus Treinos UX Improvements — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Inline editor (editor inside card, no scroll), visual grouper banner after AI gen, after-gen scroll anchor, remove bottom-of-page editor. + +**Architecture:** State `editingTreinoId` drives which card shows inline `WorkoutEditor`. `WorkoutEditor` gets `compact` prop to drop outer Card wrapper. `useWorkoutGeneration` exposes `planName`. Banner + scroll driven by local state in `meus-treinos-client.tsx`. + +**Tech Stack:** React 18, TypeScript 5, Next.js 15 App Router, Vitest + +## Global Constraints + +- UX only — zero schema/API changes +- 6 files touched max +- Quality gates (lint, format, typecheck, test) must pass +- Branch: `fix/meus-treinos-ux-improvements` + +**Architecture note:** The treino list cards are inline `
` blocks inside `meus-treinos-client.tsx` `renderWorkoutList()`, NOT the `CardTreino` component (`card-treino.tsx` is for workout session tracking with checkboxes — different feature). Inline editor goes into `renderWorkoutList`, not `card-treino.tsx`. + +--- + +### Task 1: Add `compact` prop to WorkoutEditor + +**Files:** +- Modify: `src/components/dashboard/aluno/workout-editor.tsx` +- Modify: `src/components/dashboard/aluno/workout-editor.test.tsx` + +**Interfaces:** +- Consumes: nothing from other tasks +- Produces: `WorkoutEditor({ onSave, treinoToEdit, onCancel, compact? })` — `compact?: boolean` suppresses outer ``, renders content in plain `
` + +- [ ] **Step 1: Add compact prop + conditional Card wrapper** + +```typescript +// In workout-editor.tsx, change signature to: +export function WorkoutEditor({ + onSave, + treinoToEdit, + onCancel, + compact = false, +}: Readonly<{ + onSave: (treino: Omit) => void; + treinoToEdit: Treino | null; + onCancel: () => void; + compact?: boolean; +}>) { +``` + +Extract the return content into a `const content = (<>...)` variable, then return conditionally: + +```typescript + if (compact) { + return
{content}
; + } + return ( +
+ {content} +
+ ); +``` + +- [ ] **Step 2: Add tests for compact prop** + +```typescript +// In workout-editor.test.tsx, add after existing describe block: + describe('compact mode', () => { + it('does not render outer Card when compact', () => { + render( + + ); + expect(screen.queryByTestId('card')).toBeNull(); + }); + + it('renders outer Card by default', () => { + render( + + ); + expect(screen.getByTestId('card')).toBeTruthy(); + }); + }); +``` + +- [ ] **Step 3: Run tests — verify all pass** + +``` +npx vitest run --reporter verbose src/components/dashboard/aluno/workout-editor.test.tsx +``` +Expected: 9 tests pass (7 original + 2 new compact mode). + +- [ ] **Step 4: Commit** + +```bash +git add src/components/dashboard/aluno/workout-editor.tsx src/components/dashboard/aluno/workout-editor.test.tsx +git commit -m "feat(ux): add compact prop to WorkoutEditor for inline rendering" +``` + +--- + +### Task 2: Expose planName from useWorkoutGeneration + +**Files:** +- Modify: `src/hooks/use-workout-generation.ts` + +**Interfaces:** +- Consumes: nothing +- Produces: hook return `{ isGenerating, handleGenerate, planName }` where `planName: string | null` + +- [ ] **Step 1: Add planName state + expose in return** + +```typescript +// Add state after isGenerating useState: + const [planName, setPlanName] = useState(null); + +// In handleGenerate, after workout loop completes, before notify.success: + setPlanName(result.planName); + +// Change return statement (line ~72): + return { isGenerating, handleGenerate, planName }; +``` + +- [ ] **Step 2: Run typecheck** + +``` +npx tsc --noEmit +``` +Expected: pass — `planName` is `string | null`, compatible with existing interface. + +- [ ] **Step 3: Commit** + +```bash +git add src/hooks/use-workout-generation.ts +git commit -m "feat(ux): expose planName from useWorkoutGeneration for visual grouper banner" +``` + +--- + +### Task 3: Inline editor in meus-treinos-client + remove bottom editor + +**Files:** +- Modify: `src/app/aluno/meus-treinos/meus-treinos-client.tsx` +- Modify: `src/hooks/use-workout-crud.ts` + +**Interfaces:** +- Consumes: `WorkoutEditor` compact prop (Task 1), `planName` (Task 2) +- Produces: inline editor per card, no bottom editor, `handleEdit` triggers inline mode + +- [ ] **Step 1: Add editingTreinoId state + replace handleEdit** + +```typescript +// After line 71, add: + const [editingTreinoId, setEditingTreinoId] = useState(null); + +// Remove handleEdit from useWorkoutCRUD destructuring (line 59). +// Add local handleEdit: + const handleEditLocal = useCallback((treino: Treino) => { + setEditingTreinoId(treino.id); + }, []); +``` + +- [ ] **Step 2: Render inline WorkoutEditor inside card when editing** + +In `renderWorkoutList`, inside the card `
`, after the `objetivo`/Badge header, replace the exercise count + buttons with conditional: + +```typescript +{allowEditing && editingTreinoId === treino.id ? ( +
+ { + handleSave(data); + setEditingTreinoId(null); + }} + onCancel={() => setEditingTreinoId(null)} + /> +
+) : ( + // all existing non-editing content: +
+ {/* ... existing header, badges, exercise count ... */} +
+ // ... existing buttons (Select, Play, Edit, Delete) +)} +``` + +- [ ] **Step 3: Move "Criar Manual" button above lists, use WorkoutEditor inline** + +Remove lines 214-241 (bottom editor + button). Replace with: + +```typescript +{/* Above renderWorkoutList calls, before the lists: */} +
+ {renderWorkoutList( + treinosDoAluno, + 'Meus Treinos Pessoais', + 'Treinos que voce criou manualmente ou gerou com a IA.', + , + true + )} +
+ +
+ +
+ +{editingTreinoId === '__new__' && ( +
+ { + handleSave(data); + setEditingTreinoId(null); + }} + onCancel={() => setEditingTreinoId(null)} + /> +
+)} +``` + +- [ ] **Step 4: Remove window.scrollTo from use-workout-crud.ts** + +```typescript +// In use-workout-crud.ts line 60, delete: +// window.scrollTo({ top: 0, behavior: 'smooth' }); +// Replace with nothing (remove the line). +``` + +- [ ] **Step 5: Run typecheck + lint** + +``` +npx tsc --noEmit +npx eslint src/app/aluno/meus-treinos/meus-treinos-client.tsx src/hooks/use-workout-crud.ts +``` +Expected: both pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/aluno/meus-treinos/meus-treinos-client.tsx src/hooks/use-workout-crud.ts +git commit -m "feat(ux): inline editor inside treino cards, remove bottom editor" +``` + +--- + +### Task 4: Visual grouper banner + after-gen scroll + +**Files:** +- Modify: `src/app/aluno/meus-treinos/meus-treinos-client.tsx` + +**Interfaces:** +- Consumes: `planName` from Task 2 +- Produces: banner above treino list (auto-clears 30s), smooth scroll after gen + +- [ ] **Step 1: Add banner state + auto-clear effect** + +```typescript +// After useWorkoutGeneration call (line ~70), add: + const [showPlanBanner, setShowPlanBanner] = useState(false); + const [bannerPlanName, setBannerPlanName] = useState(null); + + // Capture planName changes into banner state + useEffect(() => { + if (planName) { + setBannerPlanName(planName); + setShowPlanBanner(true); + const timer = setTimeout(() => setShowPlanBanner(false), 30000); + return () => clearTimeout(timer); + } + }, [planName]); +``` + +- [ ] **Step 2: Update onSuccess with scroll** + +```typescript +// In useWorkoutGeneration options: + onSuccess: () => { + router.refresh(); + setTimeout(() => { + document.getElementById('treinos-pessoais')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 300); + }, +``` + +- [ ] **Step 3: Render banner above treino list** + +```typescript +// Import Sparkles from lucide-react (line 16, already imported) +// Before
, add: +{showPlanBanner && bannerPlanName && ( + + + + + Plano Semanal: {bannerPlanName} + + + +)} +``` + +- [ ] **Step 4: Run typecheck + lint** + +``` +npx tsc --noEmit +npx eslint src/app/aluno/meus-treinos/meus-treinos-client.tsx +``` +Expected: both pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/aluno/meus-treinos/meus-treinos-client.tsx +git commit -m "feat(ux): visual grouper banner + after-gen scroll anchor" +``` + +--- + +### Task 5: Update tests + +**Files:** +- Modify: `src/app/aluno/meus-treinos/meus-treinos-client.test.tsx` + +- [ ] **Step 1: Add planName to mock** + +```typescript +// Lines 57-62, add planName to hook mock return: +vi.mock('@/hooks/use-workout-generation', () => ({ + useWorkoutGeneration: () => ({ + isGenerating: false, + handleGenerate: vi.fn(), + planName: null, + }), +})); +``` + +- [ ] **Step 2: Import useWorkoutGeneration for dynamic mocking** + +```typescript +// Add at top after existing imports: +import { useWorkoutGeneration } from '@/hooks/use-workout-generation'; +``` + +- [ ] **Step 3: Add banner test** + +```typescript +// After last test in meus-treinos-client.test.tsx: + it('renders plan banner when planName is set', () => { + (useWorkoutGeneration as ReturnType).mockReturnValue({ + isGenerating: false, + handleGenerate: vi.fn(), + planName: 'Hipertrofia 3x', + }); + render(); + expect(screen.getByText(/Plano Semanal/)).toBeTruthy(); + expect(screen.getByText(/Hipertrofia 3x/)).toBeTruthy(); + }); +``` + +- [ ] **Step 4: Run tests** + +``` +npx vitest run --reporter verbose src/app/aluno/meus-treinos/ +``` +Expected: all existing tests pass + new banner test. + +- [ ] **Step 5: Run all quality gates** + +``` +npx prettier --write src/app/aluno/meus-treinos/meus-treinos-client.tsx src/components/dashboard/aluno/workout-editor.tsx src/hooks/use-workout-generation.ts src/hooks/use-workout-crud.ts +npx eslint src +npx tsc --noEmit +npx vitest run +``` +Expected: all gates pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/aluno/meus-treinos/meus-treinos-client.test.tsx +git commit -m "test(ux): update tests for inline editor + banner" +``` + +--- + +### Task 6: Push + PR + +- [ ] **Step 1: Push** + +```bash +git push origin fix/meus-treinos-ux-improvements +``` + +- [ ] **Step 2: Open PR** + +```bash +gh pr create --repo EmiyaKiritsugu3/PWeb_Project \ + --base main \ + --head fix/meus-treinos-ux-improvements \ + --title "feat(ux): inline editor + visual grouper for meus treinos" \ + --body "## Changes + +- WorkoutEditor renders inline inside treino cards (no scroll jump) +- Visual grouper banner after AI weekly plan generation (auto-hides 30s) +- After-gen smooth scroll to treino list +- Create button moved above treino list, opens inline editor +- Removed bottom-of-page editor + +## Files + +- \`workout-editor.tsx\` — \`compact\` prop for inline rendering +- \`use-workout-generation.ts\` — exposes \`planName\` +- \`meus-treinos-client.tsx\` — inline editor, banner, scroll, repositioned create button +- \`use-workout-crud.ts\` — removed scrollTo call + +Closes UX issues from design spec 2026-07-05. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" + +- [ ] **Step 3: Wait for CI, merge** + +Monitor checks, merge when green. diff --git a/docs/superpowers/specs/2026-07-05-meus-treinos-ux-improvements-design.md b/docs/superpowers/specs/2026-07-05-meus-treinos-ux-improvements-design.md new file mode 100644 index 00000000..00b3f11b --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-meus-treinos-ux-improvements-design.md @@ -0,0 +1,88 @@ +# Design: Meus Treinos UX Improvements + +**Date:** 2026-07-05 +**Branch:** `fix/meus-treinos-ux-improvements` +**Scope:** UX only — zero schema/API changes + +## Problem + +1. **Scroll paradox** — clicking "Editar" on a treino card runs `window.scrollTo({top:0})` but the editor appears at page bottom. User loses context, must scroll down manually. +2. **No visual grouping** — AI generates 3-day split (e.g. Peito/Triceps + Costas/Biceps + Pernas/Ombros). Three independent cards with no visual connection. +3. **After-gen feedback gap** — `router.refresh()` fixed (commit 1118d92), but no visual anchor after generation completes. + +## Design + +### 1. Inline Editor + +Clicking "Editar" on a card renders `WorkoutEditor` **inside the card**, replacing exercise list. No scroll. Cancel/Save collapses back to normal view. + +**Card states:** + +``` +NORMAL: EDITING: +┌─────────────────────────────┐ ┌──────────────────────────────────┐ +│ Peito/Triceps [Seg] [▶][✏][🗑]│ │ ✏ Editando: Peito/Triceps │ +│ 5 exercicios │ │ ┌──────────────────────────────┐ │ +│ │ │ │ WorkoutEditor (exercises) │ │ +│ Dropdown agendamento │ │ └──────────────────────────────┘ │ +└─────────────────────────────┘ │ [Cancelar] [Salvar] │ + └──────────────────────────────────┘ +``` + +**Files:** +- `card-treino.tsx` — add `isEditing` state + inline render of WorkoutEditor +- `workout-editor.tsx` — add `compact?: boolean` prop to suppress outer Card wrapper +- `meus-treinos-client.tsx` — remove bottom-of-page editor, wire inline mode + +### 2. Visual Grouper — Plano Semanal + +After AI generation, banner appears above "Meus Treinos Pessoais" for 30s. + +**Detection:** `useWorkoutGeneration` exposes `planName` from `streamWorkoutPlan` return. Client stores in state, renders banner, auto-clears after 30s. + +```tsx + + + + + Plano Semanal: {planName} + + {diaLabels.join(' → ')} + + +``` + +Cards part of plan get `border-l-2 border-primary/30 ml-2 pl-4` timeline visual. + +### 3. After-gen Scroll + +Scroll smooth to `#treinos-pessoais` after `router.refresh()`. + +### 4. Remove Bottom Editor + +"Criar Novo Treino Manualmente" button moves **above** treino list. When clicked, renders `WorkoutEditor` inline above first card. Bottom conditional editor removed. + +## Files Changed + +| File | Change | Risk | +|---|---|---| +| `card-treino.tsx` | +isEditing state, inline WorkoutEditor render | Low | +| `workout-editor.tsx` | +`compact?: boolean` prop | Low | +| `meus-treinos-client.tsx` | -bottom editor, +banner, +planName, +scroll anchor | Medium | +| `use-workout-generation.ts` | +expose planName from hook return | Low | +| `card-treino.test.tsx` | +inline edit tests | Low | +| `meus-treinos-client.test.tsx` | +banner tests | Low | + +## Out of Scope + +- PlanoSemanal Prisma model +- Plan-wide progress tracking +- 1-day = 1-card data model change + +## Success Criteria + +1. Click "Editar" → editor appears inside same card, zero page jump +2. "Cancelar" / "Salvar" → card returns to normal +3. AI gen: banner 30s + day sequence + scroll to treino list +4. "Criar Manual" above list, opens inline editor +5. All quality gates pass diff --git a/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx b/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx index 33d733a2..38c21013 100644 --- a/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx +++ b/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'; import MeusTreinosClient from './meus-treinos-client'; import type { Treino } from '@/lib/definitions'; import type { ReactNode } from 'react'; +import { useWorkoutGeneration } from '@/hooks/use-workout-generation'; vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -55,10 +56,11 @@ vi.mock('@/hooks/use-workout-crud', () => ({ })); vi.mock('@/hooks/use-workout-generation', () => ({ - useWorkoutGeneration: () => ({ + useWorkoutGeneration: vi.fn(() => ({ isGenerating: false, handleGenerate: vi.fn(), - }), + planName: null, + })), })); vi.mock('@/lib/actions/treinos', () => ({ @@ -194,4 +196,15 @@ describe('MeusTreinosClient', () => { render(); expect(screen.getByText('Criar Novo Treino Manualmente')).toBeTruthy(); }); + + it('renders plan banner when planName is set', () => { + vi.mocked(useWorkoutGeneration).mockReturnValue({ + isGenerating: false, + handleGenerate: vi.fn(), + planName: 'Hipertrofia 3x', + }); + render(); + expect(screen.getByText(/Plano Semanal/)).toBeTruthy(); + expect(screen.getByText(/Hipertrofia 3x/)).toBeTruthy(); + }); }); diff --git a/src/app/aluno/meus-treinos/meus-treinos-client.tsx b/src/app/aluno/meus-treinos/meus-treinos-client.tsx index 8ac5dab3..f6f5ee44 100644 --- a/src/app/aluno/meus-treinos/meus-treinos-client.tsx +++ b/src/app/aluno/meus-treinos/meus-treinos-client.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { PageHeader } from '@/components/page-header'; import { @@ -13,7 +13,7 @@ import { import { DIAS_DA_SEMANA } from '@/lib/constants'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { PlusCircle, Trash2, Pencil, FileSignature, User, Play } from 'lucide-react'; +import { PlusCircle, Trash2, Pencil, FileSignature, User, Play, Sparkles } from 'lucide-react'; import type { Treino, HistoricoTreino } from '@/lib/definitions'; import { useAppNotification } from '@/hooks/use-app-notification'; import { WorkoutSession } from '@/components/WorkoutSession'; @@ -48,28 +48,55 @@ export default function MeusTreinosClient({ const { meusTreinos, - isFormVisible, - setIsFormVisible, - editingTreino, - setEditingTreino, isAlertOpen, setIsAlertOpen, deletingTreino, handleSave, - handleEdit, handleDayChange, openDeleteAlert, handleDelete, } = useWorkoutCRUD({ initialTreinos, userId, router, notify }); - const { isGenerating, handleGenerate } = useWorkoutGeneration({ + const { isGenerating, handleGenerate, planName } = useWorkoutGeneration({ userId, meusTreinos, notify, - onSuccess: () => router.refresh(), + onSuccess: () => { + router.refresh(); + let scrollAttempts = 0; + const MAX_SCROLL_ATTEMPTS = 20; + const scrollToTreinos = () => { + const el = document.getElementById('treinos-pessoais'); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + if (++scrollAttempts < MAX_SCROLL_ATTEMPTS) { + requestAnimationFrame(scrollToTreinos); + } + }; + requestAnimationFrame(scrollToTreinos); + }, }); + const [showPlanBanner, setShowPlanBanner] = useState(false); + const [bannerPlanName, setBannerPlanName] = useState(null); + + useEffect(() => { + if (planName) { + setBannerPlanName(planName); + setShowPlanBanner(true); + const timer = setTimeout(() => setShowPlanBanner(false), 30000); + return () => clearTimeout(timer); + } + }, [planName]); + const [treinoEmSessao, setTreinoEmSessao] = useState(null); + const [editingTreinoId, setEditingTreinoId] = useState(null); + + const handleEditLocal = useCallback((treino: Treino) => { + setEditingTreinoId(treino.id); + }, []); const { treinosDoPersonal, treinosDoAluno } = useMemo(() => { const treinosDoPersonal = meusTreinos.filter((t) => t.instrutorId && t.instrutorId !== userId); @@ -105,50 +132,72 @@ export default function MeusTreinosClient({ treino.diaSemana !== null && 'bg-accent/10 border-accent' )} > -
-
-

{treino.objetivo}

- {treino.diaSemana !== null && {getDiaLabel(treino.diaSemana)}} - {treino.instrutorId && treino.instrutorId !== userId && ( - Do Personal - )} + {allowEditing && editingTreinoId === treino.id ? ( +
+ { + const ok = await handleSave(data, treino.id); + if (ok) setEditingTreinoId(null); + }} + onCancel={() => setEditingTreinoId(null)} + />
-

{treino.exercicios.length} exercícios

-
-
- - - {allowEditing && ( - <> - - - - )} -
+ {allowEditing && ( + <> + + + + )} +
+ + )}
))} {treinos.length === 0 && ( @@ -201,44 +250,57 @@ export default function MeusTreinosClient({ false )} - {renderWorkoutList( - treinosDoAluno, - 'Meus Treinos Pessoais', - 'Treinos que você criou manualmente ou gerou com a IA.', - , - true + + + {showPlanBanner && bannerPlanName && ( + + + + + Plano Semanal: {bannerPlanName} + + + Seu plano semanal foi gerado! Confira os treinos abaixo. + + + )} +
+ {renderWorkoutList( + treinosDoAluno, + 'Meus Treinos Pessoais', + 'Treinos que voce criou manualmente ou gerou com a IA.', + , + true + )} +
- +
+ +
- {isFormVisible && ( -
+ {editingTreinoId === '__new__' && ( +
{ - setIsFormVisible(false); - setEditingTreino(null); + compact + treinoToEdit={null} + onSave={(data) => { + handleSave(data); + setEditingTreinoId(null); }} + onCancel={() => setEditingTreinoId(null)} />
)} - - {!isFormVisible && ( -
- -
- )}
diff --git a/src/components/dashboard/aluno/workout-editor.test.tsx b/src/components/dashboard/aluno/workout-editor.test.tsx index 13cea808..b55e0b24 100644 --- a/src/components/dashboard/aluno/workout-editor.test.tsx +++ b/src/components/dashboard/aluno/workout-editor.test.tsx @@ -200,6 +200,20 @@ describe('WorkoutEditor', () => { expect(mockOnSave).not.toHaveBeenCalled(); }); + describe('compact mode', () => { + it('does not render outer Card when compact', () => { + render( + + ); + expect(screen.queryByTestId('card')).toBeNull(); + }); + + it('renders outer Card by default', () => { + render(); + expect(screen.getByTestId('card')).toBeTruthy(); + }); + }); + it('renders exercise rows when exercises exist', () => { const exercises = [ { id: 'ex-1', nomeExercicio: 'Supino', series: 3, repeticoes: '10-12' }, diff --git a/src/components/dashboard/aluno/workout-editor.tsx b/src/components/dashboard/aluno/workout-editor.tsx index 86158e6e..cd5d51cc 100644 --- a/src/components/dashboard/aluno/workout-editor.tsx +++ b/src/components/dashboard/aluno/workout-editor.tsx @@ -19,10 +19,12 @@ export function WorkoutEditor({ onSave, treinoToEdit, onCancel, + compact = false, }: Readonly<{ onSave: (treino: Omit) => void; treinoToEdit: Treino | null; onCancel: () => void; + compact?: boolean; }>) { const { toast } = useToast(); const { @@ -54,60 +56,68 @@ export function WorkoutEditor({ onSave(novoTreino); }; - return ( -
- - - - - {treinoToEdit ? 'Editar Treino Pessoal' : 'Criar Novo Treino Pessoal'} - - - Crie um plano de treino do zero ou ajuste um existente que você criou. - - - -
- - setObjetivo(e.target.value)} - /> -
+ const content = ( + <> + + + + {treinoToEdit ? 'Editar Treino Pessoal' : 'Criar Novo Treino Pessoal'} + + + Crie um plano de treino do zero ou ajuste um existente que você criou. + + + +
+ + setObjetivo(e.target.value)} + /> +
-
-

Exercícios

- {exercicios.map((exercicio, index) => ( -
- -
- ))} - -
-
- - - - -
+
+ + + + + + + ); + + if (compact) { + return
{content}
; + } + + return ( +
+ {content}
); } diff --git a/src/hooks/use-workout-crud.test.ts b/src/hooks/use-workout-crud.test.ts index 580dc59d..c89319d4 100644 --- a/src/hooks/use-workout-crud.test.ts +++ b/src/hooks/use-workout-crud.test.ts @@ -128,11 +128,14 @@ describe('useWorkoutCRUD', () => { act(() => result.current.handleEdit(createMockTreino('t-1', 1))); await act(async () => { - await result.current.handleSave({ - objetivo: 'Updated', - diaSemana: 1, - exercicios: [], - }); + await result.current.handleSave( + { + objetivo: 'Updated', + diaSemana: 1, + exercicios: [], + }, + 't-1' + ); }); expect(upsertTreinoAction).toHaveBeenCalledWith( @@ -153,14 +156,6 @@ describe('useWorkoutCRUD', () => { expect(result.current.editingTreino).toEqual(treino); expect(result.current.isFormVisible).toBe(true); }); - - it('should scroll to top', () => { - const { result } = renderHook(() => useWorkoutCRUD(defaultOptions())); - - act(() => result.current.handleEdit(createMockTreino('t-1'))); - - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); - }); }); describe('handleDayChange', () => { diff --git a/src/hooks/use-workout-crud.ts b/src/hooks/use-workout-crud.ts index 078d4c0c..124f5573 100644 --- a/src/hooks/use-workout-crud.ts +++ b/src/hooks/use-workout-crud.ts @@ -25,25 +25,27 @@ export function useWorkoutCRUD({ initialTreinos, userId, router, notify }: UseWo const [deletingTreino, setDeletingTreino] = useState(null); const handleSave = useCallback( - async (treinoData: Omit) => { + async (treinoData: Omit, treinoId?: string) => { + const isEdit = Boolean(treinoId); try { const res = await upsertTreinoAction({ ...treinoData, - ...(editingTreino ? { id: editingTreino.id } : {}), + ...(isEdit ? { id: treinoId } : {}), alunoId: userId, }); if (res.success) { - notify.success(editingTreino ? 'Treino atualizado!' : 'Novo treino salvo!'); - if (editingTreino) { + notify.success(isEdit ? 'Treino atualizado!' : 'Novo treino salvo!'); + if (isEdit) { setMeusTreinos((prev) => - prev.map((t) => (t.id === editingTreino.id ? { ...t, ...treinoData } : t)) + prev.map((t) => (t.id === treinoId ? { ...t, ...treinoData } : t)) ); } else { router.refresh(); } setIsFormVisible(false); setEditingTreino(null); + return true; } else { throw new Error(res.error); } @@ -51,13 +53,12 @@ export function useWorkoutCRUD({ initialTreinos, userId, router, notify }: UseWo notify.error('Erro ao salvar', undefined, error); } }, - [editingTreino, userId, router, notify] + [userId, router, notify] ); const handleEdit = useCallback((treino: Treino) => { setEditingTreino(treino); setIsFormVisible(true); - window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); const handleDayChange = useCallback( @@ -79,6 +80,8 @@ export function useWorkoutCRUD({ initialTreinos, userId, router, notify }: UseWo setMeusTreinos((prev) => prev.map((t) => (t.id === treinoId ? { ...t, diaSemana: novoDia } : t)) ); + } else { + notify.error('Erro ao atualizar agenda', res.error || 'Tente novamente.'); } } catch (error) { notify.error('Erro ao atualizar agenda', undefined, error); @@ -100,6 +103,8 @@ export function useWorkoutCRUD({ initialTreinos, userId, router, notify }: UseWo if (res.success) { notify.success('Treino excluído!'); setMeusTreinos((prev) => prev.filter((t) => t.id !== deletingTreino.id)); + } else { + notify.error('Erro ao excluir', res.error || 'Tente novamente.'); } } catch (error) { notify.error('Erro ao excluir', undefined, error); diff --git a/src/hooks/use-workout-generation.ts b/src/hooks/use-workout-generation.ts index 18a723c1..5b8ebf74 100644 --- a/src/hooks/use-workout-generation.ts +++ b/src/hooks/use-workout-generation.ts @@ -15,7 +15,7 @@ interface UseWorkoutGenerationOptions { success: (title: string, description?: string) => void; error: (title: string, description?: string, error?: unknown) => void; }; - onSuccess: () => void; + onSuccess?: () => void; } export function useWorkoutGeneration({ @@ -25,10 +25,12 @@ export function useWorkoutGeneration({ onSuccess, }: UseWorkoutGenerationOptions) { const [isGenerating, setIsGenerating] = useState(false); + const [planName, setPlanName] = useState(null); const handleGenerate = useCallback( async (data: WorkoutGeneratorInput) => { setIsGenerating(true); + setPlanName(null); try { Logger.info('Chamando a IA com dados:', data); const result = await streamWorkoutPlan(data); @@ -76,8 +78,9 @@ export function useWorkoutGeneration({ } } + setPlanName(result.planName); notify.success('Plano Pessoal Gerado!', `${result.planName} foi criado com sucesso.`); - onSuccess(); + onSuccess?.(); } else { Logger.error('Resultado inesperado da IA:', result); throw new Error('Formato de retorno inválido.'); @@ -89,8 +92,8 @@ export function useWorkoutGeneration({ setIsGenerating(false); } }, - [userId, meusTreinos, notify] + [userId, meusTreinos, notify, onSuccess] ); - return { isGenerating, handleGenerate }; + return { isGenerating, handleGenerate, planName }; } diff --git a/src/lib/actions/treinos.ts b/src/lib/actions/treinos.ts index c17a5c2c..4f7986fd 100644 --- a/src/lib/actions/treinos.ts +++ b/src/lib/actions/treinos.ts @@ -243,10 +243,12 @@ export async function updateTreinoDayAction(treinoId: string, diaSemana: number select: { instrutorId: true, alunoId: true }, }); + const alunoAuthId = await resolveAlunoId(user); + if ( funcData?.role !== 'GERENTE' && treino?.instrutorId !== user.id && - treino?.alunoId !== user.id + treino?.alunoId !== alunoAuthId ) { return { success: false, error: 'Acesso não autorizado' }; } @@ -286,10 +288,12 @@ export async function deleteTreinoAction(treinoId: string) { select: { instrutorId: true, alunoId: true }, }); + const alunoAuthId = await resolveAlunoId(user); + if ( funcData?.role !== 'GERENTE' && treino?.instrutorId !== user.id && - treino?.alunoId !== user.id + treino?.alunoId !== alunoAuthId ) { return { success: false, error: 'Acesso não autorizado' }; }