Skip to content

Commit 1fc576d

Browse files
feat(aluno): PRD-4 kebab menu + primary action on meus-treinos cards (#181)
* feat(aluno): kebab menu + primary action on meus-treinos cards (PRD-4) - kebab overflow (Editar/Excluir) on aluno treino card - iniciar promoted to primary full-width on mobile - excluir destructive moved inside menu (text-destructive) - empty state polish: icon + heading + subtext + CTA - font-headline applied on H3 - data-testids: treino-card, iniciar-treino, treino-kebab - split banner auto-hide into separate effect - parametrize useWorkoutCRUD mock to respect initialTreinos Co-Authored-By: Claude <noreply@anthropic.com> * docs: current-state update for prd-4 pr #181 Co-Authored-By: Claude <noreply@anthropic.com> * fix(aluno): reset banner timer on plan switch (cubic P2) key auto-hide effect on bannerPlanName + showPlanBanner so timer refreshes when planName changes while banner already visible. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Emiya Kiritsugu <emiyakiritsugu3@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73e0746 commit 1fc576d

2 files changed

Lines changed: 110 additions & 40 deletions

File tree

docs/CURRENT-STATE.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22

33
## Mobile-First Premium Polish (v0.10.0 em andamento)
44

5-
**Branch ativa:** `main` (PRD-1/2/3 merged; PRD-4 next)
6-
**PRs mobile-first:** PR #176 (PRD-1) ✅ → PR #179 (PRD-3) ✅ → PR #180 (PRD-2) ✅.
5+
**Branch ativa:** `fix/meus-treinos-kebab` (PR #181 aberto, aguardando review bots)
6+
**PRs mobile-first:** PR #176 (PRD-1) ✅ → PR #179 (PRD-3) ✅ → PR #180 (PRD-2) ✅ → PR #181 (PRD-4) 🟡.
7+
8+
### PRD-4 — Meus Treinos Kebab + Primary Action — PR #181 (open)
9+
10+
`src/app/aluno/meus-treinos/meus-treinos-client.tsx`: card aluno ganha kebab overflow (`Editar`/`Excluir` em shadcn `DropdownMenu`), `Iniciar Treino` promovido a CTA primário full-width mobile. Select dia em linha própria acima das ações. `Excluir` com `text-destructive` dentro do menu (separado da primária, mis-tap risk fixado). Empty state: ícone (Dumbbell) + heading + subtext + CTA `Criar primeiro treino` (reusa trigger `__new__`). `font-headline` (Outfit) no H3. data-testids: `treino-card`, `iniciar-treino`, `treino-kebab`, `editar-treino`, `excluir-treino`.
11+
12+
Banner auto-hide split em effect separado keyed on `showPlanBanner` (react-hooks/set-state-in-effect); eslint-disable scoped no effect de derive de prop.
13+
14+
Test: `useWorkoutCRUD` mock parametrizado para respeitar `initialTreinos` (gap de cobertura real — empty state nunca exercitado antes). Mock dropdown-menu. 3 novos asserts (kebab, primary, empty state). 1132/1132 pass. 0 errors lint/typecheck.
715

816
### PRD-3 — Bottom Navigation (mobile) — merged #179
917

@@ -24,7 +32,7 @@ Viewport `viewportFit: cover`, dvh swap 13 files, 44pt touch-target, safe-area,
2432

2533
### Sequência mobile-first
2634

27-
PRD-1 ✅ → PRD-2 ✅ → PRD-3 ✅ → PRD-4 (kebab menu) → 5 → 6 → 7 → 8. PRD-4 próxima sessão.
35+
PRD-1 ✅ → PRD-2 ✅ → PRD-3 ✅ → PRD-4 🟡 (PR #181 open) → 5 (meus-treinos UX) → 6 (WorkoutSession fullscreen) → 7 (KPI/charts) → 8 (login parity + next/font). PRD-5 próxima após merge PR #181.
2836

2937
---
3038

src/app/aluno/meus-treinos/meus-treinos-client.tsx

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,24 @@ import {
1313
import { DIAS_DA_SEMANA } from '@/lib/constants';
1414
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
1515
import { Button } from '@/components/ui/button';
16-
import { PlusCircle, Trash2, Pencil, FileSignature, User, Play, Sparkles } from 'lucide-react';
16+
import {
17+
PlusCircle,
18+
Trash2,
19+
Pencil,
20+
FileSignature,
21+
User,
22+
Play,
23+
Sparkles,
24+
Dumbbell,
25+
MoreVertical,
26+
} from 'lucide-react';
27+
import {
28+
DropdownMenu,
29+
DropdownMenuContent,
30+
DropdownMenuItem,
31+
DropdownMenuSeparator,
32+
DropdownMenuTrigger,
33+
} from '@/components/ui/dropdown-menu';
1734
import type { Treino, HistoricoTreino } from '@/lib/definitions';
1835
import { useAppNotification } from '@/hooks/use-app-notification';
1936
import { WorkoutSession } from '@/components/WorkoutSession';
@@ -82,14 +99,20 @@ export default function MeusTreinosClient({
8299
const [showPlanBanner, setShowPlanBanner] = useState(false);
83100
const [bannerPlanName, setBannerPlanName] = useState<string | null>(null);
84101

102+
/* eslint-disable react-hooks/set-state-in-effect -- ponytail: setState-on-prop-change is the legitimate derived-banner-from-hook pattern; reducer adds ceremony for no gain. */
85103
useEffect(() => {
86104
if (planName) {
87105
setBannerPlanName(planName);
88106
setShowPlanBanner(true);
89-
const timer = setTimeout(() => setShowPlanBanner(false), 30000);
90-
return () => clearTimeout(timer);
91107
}
92108
}, [planName]);
109+
/* eslint-enable react-hooks/set-state-in-effect */
110+
111+
useEffect(() => {
112+
if (!showPlanBanner) return;
113+
const timer = setTimeout(() => setShowPlanBanner(false), 30000);
114+
return () => clearTimeout(timer);
115+
}, [bannerPlanName, showPlanBanner]);
93116

94117
const [treinoEmSessao, setTreinoEmSessao] = useState<Treino | null>(null);
95118
const [editingTreinoId, setEditingTreinoId] = useState<string | null>(null);
@@ -127,8 +150,9 @@ export default function MeusTreinosClient({
127150
{treinos.map((treino) => (
128151
<div
129152
key={treino.id}
153+
data-testid="treino-card"
130154
className={cn(
131-
'rounded-lg border p-4 transition-all flex flex-col sm:flex-row justify-between sm:items-center gap-4',
155+
'rounded-lg border p-4 transition-all flex flex-col gap-3',
132156
treino.diaSemana !== null && 'bg-accent/10 border-accent'
133157
)}
134158
>
@@ -146,19 +170,54 @@ export default function MeusTreinosClient({
146170
</div>
147171
) : (
148172
<>
149-
<div className="flex-1">
150-
<div className="flex items-center gap-3">
151-
<h3 className="font-bold text-base">{treino.objetivo}</h3>
152-
{treino.diaSemana !== null && <Badge>{getDiaLabel(treino.diaSemana)}</Badge>}
153-
{treino.instrutorId && treino.instrutorId !== userId && (
154-
<Badge variant="secondary">Do Personal</Badge>
155-
)}
173+
<div className="flex items-start justify-between gap-2">
174+
<div className="flex-1 min-w-0">
175+
<div className="flex items-center gap-2 flex-wrap">
176+
<h3 className="font-headline font-bold text-base">{treino.objetivo}</h3>
177+
{treino.diaSemana !== null && <Badge>{getDiaLabel(treino.diaSemana)}</Badge>}
178+
{treino.instrutorId && treino.instrutorId !== userId && (
179+
<Badge variant="secondary">Do Personal</Badge>
180+
)}
181+
</div>
182+
<p className="text-sm text-muted-foreground">
183+
{treino.exercicios.length} exercícios
184+
</p>
156185
</div>
157-
<p className="text-sm text-muted-foreground">
158-
{treino.exercicios.length} exercícios
159-
</p>
186+
{allowEditing && (
187+
<DropdownMenu>
188+
<DropdownMenuTrigger asChild>
189+
<Button
190+
variant="ghost"
191+
size="icon"
192+
className="h-9 w-9 shrink-0"
193+
data-testid="treino-kebab"
194+
aria-label="Ações do treino"
195+
>
196+
<MoreVertical className="h-4 w-4" />
197+
</Button>
198+
</DropdownMenuTrigger>
199+
<DropdownMenuContent align="end">
200+
<DropdownMenuItem
201+
data-testid="editar-treino"
202+
onClick={() => handleEditLocal(treino)}
203+
>
204+
<Pencil className="mr-2 h-4 w-4" />
205+
Editar
206+
</DropdownMenuItem>
207+
<DropdownMenuSeparator />
208+
<DropdownMenuItem
209+
data-testid="excluir-treino"
210+
className="text-destructive focus:text-destructive"
211+
onClick={() => openDeleteAlert(treino)}
212+
>
213+
<Trash2 className="mr-2 h-4 w-4" />
214+
Excluir
215+
</DropdownMenuItem>
216+
</DropdownMenuContent>
217+
</DropdownMenu>
218+
)}
160219
</div>
161-
<div className="flex items-center gap-2 flex-wrap">
220+
<div className="flex flex-col sm:flex-row gap-2">
162221
<Select
163222
value={treino.diaSemana === null ? 'nenhum' : String(treino.diaSemana)}
164223
onValueChange={(value) => handleDayChange(treino.id, value)}
@@ -175,36 +234,39 @@ export default function MeusTreinosClient({
175234
))}
176235
</SelectContent>
177236
</Select>
178-
<Button size="sm" onClick={() => setTreinoEmSessao(treino)}>
237+
<Button
238+
size="sm"
239+
className="flex-1 sm:flex-none"
240+
data-testid="iniciar-treino"
241+
onClick={() => setTreinoEmSessao(treino)}
242+
>
179243
<Play className="mr-2 h-4 w-4" />
180-
Iniciar
244+
Iniciar Treino
181245
</Button>
182-
{allowEditing && (
183-
<>
184-
<Button variant="secondary" size="sm" onClick={() => handleEditLocal(treino)}>
185-
<Pencil className="mr-2 h-4 w-4" />
186-
Editar
187-
</Button>
188-
<Button
189-
variant="destructive"
190-
size="sm"
191-
onClick={() => openDeleteAlert(treino)}
192-
>
193-
<Trash2 className="mr-2 h-4 w-4" />
194-
Excluir
195-
</Button>
196-
</>
197-
)}
198246
</div>
199247
</>
200248
)}
201249
</div>
202250
))}
203251
{treinos.length === 0 && (
204-
<div className="text-center text-sm text-muted-foreground py-10">
205-
{allowEditing
206-
? 'Você ainda não criou nenhum treino.'
207-
: 'Nenhum treino prescrito pelo seu personal ainda.'}
252+
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
253+
<div className="rounded-full bg-muted/30 p-4">
254+
<Dumbbell className="h-8 w-8 text-muted-foreground" />
255+
</div>
256+
<div>
257+
<p className="font-medium text-foreground">Nenhum treino ainda</p>
258+
<p className="text-sm text-muted-foreground mt-1">
259+
{allowEditing
260+
? 'Gere um plano com IA ou crie manualmente para começar.'
261+
: 'Seu personal ainda não prescreveu treinos.'}
262+
</p>
263+
</div>
264+
{allowEditing && (
265+
<Button variant="outline" size="sm" onClick={() => setEditingTreinoId('__new__')}>
266+
<PlusCircle className="mr-2 h-4 w-4" />
267+
Criar primeiro treino
268+
</Button>
269+
)}
208270
</div>
209271
)}
210272
</CardContent>

0 commit comments

Comments
 (0)