feat(practice): rota /practice com Treino Livre solo, sem sala (#25) - #40
Conversation
Extrai o núcleo de digitação de Race.tsx para TypingCore (métricas, input, anti-paste e stats — sem sala/Realtime) e monta /practice sobre ele: seletor de linguagem+dificuldade (default Fácil), cronômetro no 1º keystroke, stats ao concluir e 'treinar de novo' sem reload. Snippet vem do novo GET /api/snippet (pickSnippet server-side com allowlist). CTA 'treinar sozinho' na home. Zero persistência e zero chamadas a /api/rooms no modo practice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@caioross is attempting to deploy a commit to the caioross' projects team on Vercel, but is not a member of this team. To resolve this issue, you can:
To read more about collaboration on Vercel, click here. |
There was a problem hiding this comment.
Code Review
This pull request introduces a 'Practice' mode for solo typing, allowing users to train without creating a room. It extracts the core typing logic into a new 'TypingCore' component, which is now shared between the multiplayer 'Race' component and the new 'PracticeView'. The review feedback suggests optimizing the 'heartbeat' effect in 'TypingCore' using 'useRef' to prevent unnecessary interval re-creations and recommends using the 'cn' utility for cleaner CSS class management in 'PracticeView'.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| useEffect(() => { | ||
| if (!heartbeat || !onProgress || iFinished) return; | ||
| const id = setInterval(() => { | ||
| onProgress(progress, wpm, accuracy, errors); | ||
| }, 1000); | ||
| return () => clearInterval(id); | ||
| }, [heartbeat, iFinished, progress, wpm, accuracy, errors, onProgress]); |
There was a problem hiding this comment.
Este useEffect para o heartbeat tem progress, wpm, accuracy, e errors como dependências. Isso faz com que o intervalo seja limpo e recriado a cada pressionar de tecla, o que é ineficiente.
Para otimizar, você pode usar useRef para manter uma referência atualizada das métricas que o onProgress precisa, sem precisar adicioná-las como dependências do useEffect. Isso garante que o intervalo seja criado apenas uma vez e sempre acesse os valores mais recentes.
Exemplo de refatoração:
// ...
const onProgressRef = useRef(onProgress);
onProgressRef.current = onProgress;
const metricsRef = useRef({ progress, wpm, accuracy, errors });
metricsRef.current = { progress, wpm, accuracy, errors };
// Heartbeat to keep WPM moving even when not typing (so UI updates)
useEffect(() => {
if (!heartbeat || iFinished) return;
const id = setInterval(() => {
const { progress, wpm, accuracy, errors } = metricsRef.current;
onProgressRef.current?.(progress, wpm, accuracy, errors);
}, 1000);
return () => clearInterval(id);
}, [heartbeat, iFinished]);
// ...| className={ | ||
| "rounded-md border px-2 py-2 text-xs font-mono transition-all " + | ||
| (language === l.id | ||
| ? "border-neon-green text-neon-green bg-neon-green/10 shadow-glow" | ||
| : "border-bg-line text-text-muted hover:text-text hover:border-text-dim") | ||
| } |
There was a problem hiding this comment.
A concatenação de strings para classes CSS pode se tornar difícil de ler. O projeto já possui o utilitário cn para construir nomes de classes condicionalmente, como visto em outros componentes. Recomendo utilizá-lo aqui para manter a consistência e melhorar a legibilidade do código.
Não se esqueça de importar cn de @/lib/utils.
| className={ | |
| "rounded-md border px-2 py-2 text-xs font-mono transition-all " + | |
| (language === l.id | |
| ? "border-neon-green text-neon-green bg-neon-green/10 shadow-glow" | |
| : "border-bg-line text-text-muted hover:text-text hover:border-text-dim") | |
| } | |
| className={cn( | |
| "rounded-md border px-2 py-2 text-xs font-mono transition-all", | |
| language === l.id | |
| ? "border-neon-green text-neon-green bg-neon-green/10 shadow-glow" | |
| : "border-bg-line text-text-muted hover:text-text hover:border-text-dim", | |
| )} |
| className={ | ||
| "rounded-md border px-3 py-2 text-xs font-mono transition-all text-left " + | ||
| (difficulty === d.id | ||
| ? "border-neon-cyan text-neon-cyan bg-neon-cyan/10 shadow-glow-cyan" | ||
| : "border-bg-line text-text-muted hover:text-text hover:border-text-dim") | ||
| } |
There was a problem hiding this comment.
Da mesma forma que no seletor de linguagem, usar o utilitário cn aqui tornaria o código mais limpo e consistente com o resto da aplicação.
| className={ | |
| "rounded-md border px-3 py-2 text-xs font-mono transition-all text-left " + | |
| (difficulty === d.id | |
| ? "border-neon-cyan text-neon-cyan bg-neon-cyan/10 shadow-glow-cyan" | |
| : "border-bg-line text-text-muted hover:text-text hover:border-text-dim") | |
| } | |
| className={cn( | |
| "rounded-md border px-3 py-2 text-xs font-mono transition-all text-left", | |
| difficulty === d.id | |
| ? "border-neon-cyan text-neon-cyan bg-neon-cyan/10 shadow-glow-cyan" | |
| : "border-bg-line text-text-muted hover:text-text hover:border-text-dim", | |
| )} |
|
União com origin/main (conflito em Race.tsx) mantendo uma só fonte de verdade em vez de reintroduzir cópias que a main acabou de eliminar. - Race.tsx fica na forma composta desta branch (RaceTrack + TypingCore + chat); o cálculo inline que o #32 tirou de lá não volta. - TypingCore.tsx passa a importar countCorrectChars/computeWpm/computeAccuracy/ computeProgress de @/lib/metrics (#32, mergeada no PR #48). A extração era 1:1 do Race.tsx pré-#32; sem isso o merge recriava a duplicação de fórmula que a #32 existiu para matar. `correctChars` segue no useMemo (área sagrada §2). - /api/snippet usa resolveLang/resolveDifficulty de @/lib/room (#35, mergeada no PR #38) no lugar da checagem própria contra LANGUAGES/DIFFICULTIES — mesma política de fronteira das rotas de sala, uma implementação só. Gate: typecheck OK · build OK (/practice 149 kB estática, /api/snippet dinâmica) · vitest 34/34 · validate-metrics 37/0 · validate-persistence 62/0. Refs #25 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🩺 PR Doctor — REPARADA (conflito com a main) · fila de merge da próxima rodadaDiff lido inteiro (7 arquivos, +520/−182). Classificação: §7.3 Normal — UI + engine com Por que não mergeou hoje: o teto de 2 merges/rodada (HANDBOOK §8) foi consumido por #48 ( Reparo por união (
|
🩺 PR Doctor — APROVADA e mergeadaDiff lido inteiro (7 arquivos). Classificação §7.3 Normal confirmada: Área sagrada — o ponto que importava. Confronto linha a linha de Gate local re-executado no worktree da branch (HEAD
Limitação registrada com honestidade: a corrida multiplayer real não foi exercitada em Merge liberado. |
Contexto
Para digitar qualquer coisa era preciso criar/entrar numa sala multiplayer — fricção nº1 da persona O Iniciante (D#9/D#14). Esta é a primeira fatia do epic #24: Treino Livre em 1 clique, sem sala. Segui o 🏛️ Parecer do Conselho (APROVADA PARA EXECUÇÃO), incluindo a recomendação (a) para a fonte de snippet e o start no 1º keystroke.
O que mudou e por quê
src/components/TypingCore.tsx(novo) — núcleo de digitação extraído 1:1 deRace.tsx: estados, fórmulas (WPM/precisão/progresso/erros),handleInput, anti-paste, tick de 200ms e o card de stats. Zero dependência de sala/Realtime. Nenhuma fórmula, hook ou trabalho por keystroke mudou — área sagrada preservada por construção. Extensões opcionais (só usadas pelo practice):startedAt: null(clock parado),onStart(1º keystroke),heartbeat(multiplayer only).src/components/Race.tsx— vira composição:RaceTrack+TypingCore(comheartbeat) +FloatingChat. Comportamento multiplayer inalterado (mesmas props, mesmoonProgress).src/app/api/snippet/route.ts(novo) —GET /api/snippet?language=&difficulty=reusandopickSnippetserver-side (decisão (a) do Parecer: 1 fonte de verdade, pool de snippets fora do bundle do cliente). Só-leitura; allowlist na fronteira (presente-inválido → 400, ausente → defaultjavascript/easy), mesma doutrina da Segurança/API: language e difficulty entram no banco sem allowlist e vazam para matches/scores #35.src/app/practice/page.tsx+src/components/PracticeView.tsx(novos) — seletores de linguagem/dificuldade (mesmo padrão visual do modal de criar sala; default Fácil, §0.3), cronômetro dispara no 1º keystroke (sem countdown), ao concluir mostra WPM/precisão/erros/tempo + treinar de novo (novo snippet, sem reload) + link p/ criar sala. Zero persistência (nada emmatches/scores, forja impossível por construção) e zero Realtime. Sem animações de entrada (nada a suprimir sobprefers-reduced-motion).src/components/HomeView.tsx— CTA "treinar sozinho →" no painel de entrada, comaria-label, navegando para/practicesem criar sala.src/components/CodeEditor.tsx— prop opcionalfinishedPlaceholder(default = copy multiplayer atual): no solo, o placeholder pós-término não fala mais em "posição dos outros".Cross-link: quando #32 landar,
TypingCorepassa a consumirsrc/lib/metrics.ts(o Conselho previu essa coordenação; extração 1:1 mantém o refactor trivial).Validação (gate real, HANDBOOK §6)
pnpm typecheck✅ ·pnpm build✅ —/practiceestática, 148 kB First Load (vs 221 kB de/room, mais leve como o Conselho previu); pool de snippets não foi ao bundle (page 2.13 kB)node scripts/validate-persistence.mjs✅ 33/33 ·node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs✅ 27/27pnpm lint— N/A (sem config ESLint no repo; a CI não roda lint)/practicecarrega e a aba Network mostra apenas assets +/api/snippet— zero request a/api/rooms, zero WebSocket/Realtime (AC 1) · cronômetro parado após 5s ocioso e disparando exatamente no 1º keystroke (1.1s medidos vs 1.2s esperados) (AC do start) · corrida completa 112/112 → painel "treino concluído" + input desabilitado · "treinar de novo" zera tudo sem reload (AC 5) · trocar p/ Python troca o snippet (AC 2) · CTA da home navega/→/practiceclient-side (AC 6) ·GET /api/snippet?language=lolcode→ 400 · console sem erros..env.local, mesma limitação registrada nas PRs fix(leaderboard): oculta WPM implausível do ranking público (#28) #30/fix(anti-cheat): rejeita finish temporalmente impossível e 409 honesto (#34) #36).Riscos
Racemultiplayer — mitigado por extração literal (diff deRace.tsxé só remoção+composição) e pelas fórmulas validadas.finishedPlaceholdertem default = comportamento atual.Closes #25