Skip to content

feat(practice): rota /practice com Treino Livre solo, sem sala (#25) - #40

Merged
caioross merged 2 commits into
mainfrom
auto/issue-25-practice-route
Jul 23, 2026
Merged

feat(practice): rota /practice com Treino Livre solo, sem sala (#25)#40
caioross merged 2 commits into
mainfrom
auto/issue-25-practice-route

Conversation

@caioross

Copy link
Copy Markdown
Owner

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 de Race.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 (com heartbeat) + FloatingChat. Comportamento multiplayer inalterado (mesmas props, mesmo onProgress).
  • src/app/api/snippet/route.ts (novo)GET /api/snippet?language=&difficulty= reusando pickSnippet server-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 → default javascript/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 em matches/scores, forja impossível por construção) e zero Realtime. Sem animações de entrada (nada a suprimir sob prefers-reduced-motion).
  • src/components/HomeView.tsx — CTA "treinar sozinho →" no painel de entrada, com aria-label, navegando para /practice sem criar sala.
  • src/components/CodeEditor.tsx — prop opcional finishedPlaceholder (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, TypingCore passa a consumir src/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 ✅ — /practice está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/27
  • pnpm lint — N/A (sem config ESLint no repo; a CI não roda lint)
  • Verificação no browser (dev server do worktree): /practice carrega e a aba Network mostra apenas assets + /api/snippetzero 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 //practice client-side (AC 6) · GET /api/snippet?language=lolcode400 · console sem erros.
  • Fluxo multiplayer: refactor 1:1 coberto por typecheck/build + validate-metrics; corrida multiplayer real não é exercitável no dev local (sem credenciais Supabase — worktree não tem .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

  • Baixo. O risco real seria regressão no Race multiplayer — mitigado por extração literal (diff de Race.tsx é só remoção+composição) e pelas fórmulas validadas. finishedPlaceholder tem default = comportamento atual.
  • "Treinar de novo" pode sortear o mesmo snippet (pool de 5 por nível — mesmo comportamento do multiplayer). Aceitável nesta fatia; dedup fica para o epic Practice/Solo mode (§V.5): treinar sem sala — modo inteiro a fatiar #24.

Closes #25

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>
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

@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:

  • Make your repository public. Collaboration is free for open source and public repositories.
  • Upgrade to pro and add @caioross as a member. A Pro subscription is required to access Vercel's collaborative features.
    • If you're the owner of the team, click here to upgrade and add @caioross as a member.
    • If you're the user who initiated this build request, click here to request access.
    • If you're already a member of the caioross' projects team, make sure that your Vercel account is connected to your GitHub account.

To read more about collaboration on Vercel, click here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +86 to +92
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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]);
  // ...

Comment on lines +121 to +126
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")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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",
)}

Comment on lines +143 to +148
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")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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",
)}

@caioross

Copy link
Copy Markdown
Owner Author

🅿️ Parking-lot do PR Doctor

PR pronta e MERGEABLE, mas não mergeada nesta rodada — teto de 2 merges/rodada já usado (#42, #33).

Além do teto, esta é a maior mudança em aberto: extrai TypingCore de Race.tsx (superfície de regressão na área sagrada — latência de input) e adiciona a rota nova GET /api/snippet. Merece um quórum §7.2 dedicado, focado em (a) paridade da extração (comportamento multiplayer de Race inalterado) e (b) a fronteira do /api/snippet (allowlist de language/difficulty, mesma doutrina da #35). Deferida para uma rodada com espaço, para não misturá-la com outros merges.
Destrava: quórum dedicado do PR Doctor na próxima rodada.

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>
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Error Error Jul 22, 2026 1:26pm

@caioross

Copy link
Copy Markdown
Owner Author

🩺 PR Doctor — REPARADA (conflito com a main) · fila de merge da próxima rodada

Diff lido inteiro (7 arquivos, +520/−182). Classificação: §7.3 Normal — UI + engine com validate-metrics verde; a rota nova /api/snippet é só-leitura (não toca banco, Realtime nem leaderboard), então não puxa a PR para o quórum.

Por que não mergeou hoje: o teto de 2 merges/rodada (HANDBOOK §8) foi consumido por #48 (metrics.ts) e #38 (allowlist) — justamente as duas PRs com que esta conflitava. Não é bloqueio de conteúdo: é ordem de fila. Esta e a #44 abrem a próxima rodada.

Reparo por união (git merge origin/main, nunca rebase/force) — cfc6dbb

O conflito em Race.tsx não era mecânico: a main recebeu #48 e #38 depois que esta branch foi escrita, e uma resolução ingênua reintroduziria exatamente as duplicações que essas duas PRs eliminaram.

Gate reproduzido no worktree (head cfc6dbb)

  • pnpm typecheck ✅ · pnpm build ✅ — /practice estática, 149 kB First Load (vs 221 kB de /room); pool de snippets segue fora do bundle (página 2.16 kB)
  • pnpm test34/34 · validate-metrics37/0 · validate-persistence62/0
  • lint = N/A (repo sem config ESLint; a CI roda typecheck+build).

Closes #25 confere — a issue #25 é a fatia "Treino Livre em 1 clique", não o epic #24 inteiro.

Nota de coordenação: a #52 (hero copy) toca HomeView.tsx, que esta PR também edita para o CTA "treinar sozinho →". O merge desta primeiro provavelmente pede uma união trivial na #52 — cuido disso na hora.

@caioross

Copy link
Copy Markdown
Owner Author

🩺 PR Doctor — APROVADA e mergeada

Diff lido inteiro (7 arquivos). Classificação §7.3 Normal confirmada: /api/snippet é
só-leitura (não toca banco, Realtime nem leaderboard) e reusa a allowlist que já está na
main (resolveLang/resolveDifficulty, #35) em vez de duplicar política; /practice não
persiste nada — forja de leaderboard é impossível por construção.

Área sagrada — o ponto que importava. Confronto linha a linha de TypingCore.tsx contra o
Race.tsx de origin/main: extração 1:1 de verdade. Mesmos estados, mesmo useMemo em
countCorrectChars, mesmo tick de 200ms, mesmo handleInput, mesmo anti-paste. As duas
extensões são inertes no multiplayer: heartbeat chega ligado pelo Race (comportamento de
hoje) e onStart só existe no practice (typed.length === 0 && onStart). startedAt: null
só muda o cálculo quando é null — no multiplayer nunca é.

Gate local re-executado no worktree da branch (HEAD cfc6dbb), não confiei no corpo da PR:

pnpm install --frozen-lockfile   ✓
pnpm typecheck                   ✓
pnpm build                       ✓  /practice estática 2.16 kB · 149 kB First Load (vs 221 kB de /room)
node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs   ✓ 37/0
node scripts/validate-persistence.mjs                               ✓ 49/0
pnpm test (vitest)                                                  ✓ 34/34

pnpm lint = N/A (repo sem config ESLint; a CI roda typecheck+build).

Limitação registrada com honestidade: a corrida multiplayer real não foi exercitada em
browser (sem credenciais Supabase no worktree) — é o ponto cego que a #37 existe para
fechar. Aqui o risco é baixo justamente porque o refactor é literal e as fórmulas estão pinadas
por teste.

Merge liberado. Closes #25 correto — fecha a issue inteira, e o epic #24 segue aberto
para as fatias seguintes (dedup de snippet, entre outras).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Practice — rota /practice com Treino Livre (sem sala), reusando Race

1 participant