From ff752c4c7e52e5ee6196699ed71f5aa56172dfc0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 18:36:29 +0000 Subject: [PATCH 1/4] docs: adicionar plano de arquitetura e roadmap do CoderKeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documento detalhado de planejamento pré-implementação cobrindo: - Arquitetura local-first com IndexedDB/Dexie - Stack Vite + React + TypeScript + Tailwind - Estrutura de diretórios engine vs conteúdo - Schemas JSON para lições contribuíveis - Roadmap em 3 fases (MVP, experiência, comunidade) Co-authored-by: Iago Mendes --- docs/PLANEJAMENTO-ARQUITETURA.md | 861 +++++++++++++++++++++++++++++++ 1 file changed, 861 insertions(+) create mode 100644 docs/PLANEJAMENTO-ARQUITETURA.md diff --git a/docs/PLANEJAMENTO-ARQUITETURA.md b/docs/PLANEJAMENTO-ARQUITETURA.md new file mode 100644 index 0000000..a807b83 --- /dev/null +++ b/docs/PLANEJAMENTO-ARQUITETURA.md @@ -0,0 +1,861 @@ +# CoderKeys — Plano de Arquitetura e Roadmap + +> **Status:** Documento de planejamento (pré-implementação) +> **Versão:** 1.0 +> **Escopo:** Typing tutor open source, local-first, focado em skills específicas para programadores e escritores técnicos. + +--- + +## Sumário + +1. [Visão Geral da Arquitetura](#1-visão-geral-da-arquitetura) +2. [Stack Tecnológica Recomendada](#2-stack-tecnológica-recomendada) +3. [Estrutura de Diretórios](#3-estrutura-de-diretórios) +4. [Estrutura de Dados das Lições](#4-estrutura-de-dados-das-lições) +5. [Roadmap de Implementação](#5-roadmap-de-implementação) +6. [Apêndices](#6-apêndices) + +--- + +## 1. Visão Geral da Arquitetura + +### 1.1 Princípios de Design + +| Princípio | Descrição | +|-----------|-----------| +| **Local-first** | Toda a aplicação roda no navegador do usuário. Não há servidor backend, banco remoto ou autenticação. | +| **Content-as-data** | Lições são arquivos estáticos versionados no Git. Contribuidores editam JSON/Markdown, não código da engine. | +| **Separação engine/conteúdo** | O motor de digitação (WPM, precisão, estados) é independente do conteúdo das lições. | +| **Validação em build** | Schemas Zod validam lições e traduções antes do merge, reduzindo PRs quebrados. | +| **Zero custo** | Sem dependências pagas, sem deploy obrigatório. Hospedagem opcional via GitHub Pages. | + +### 1.2 Como a Aplicação Funciona Localmente + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Navegador (SPA) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ UI Layer │──▶│ App State │──▶│ Typing Engine │ │ +│ │ (React) │◀──│ (Zustand) │◀──│ (puro TypeScript) │ │ +│ └──────────────┘ └──────────────┘ └─────────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ i18n UI │ │ Progress │ │ Lesson Loader │ │ +│ │ (react- │ │ Repository │ │ (import estático │ │ +│ │ i18next) │ │ (Dexie/IDB) │ │ + lazy chunks) │ │ +│ └──────────────┘ └──────────────┘ └─────────────────────┘ │ +│ │ │ │ +└──────────────────────────────┼──────────────────────┼─────────────┘ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ IndexedDB │ │ content/ │ + │ (progresso, │ │ (JSON lições, │ + │ settings) │ │ versionado) │ + └──────────────────┘ └──────────────────┘ +``` + +**Fluxo de execução:** + +1. O usuário clona o repositório e executa `pnpm install && pnpm dev`. +2. O Vite serve a SPA em `http://localhost:5173` (ou build estático via `pnpm build && pnpm preview`). +3. Na inicialização, a app carrega o catálogo de lições (manifest JSON) e as preferências do usuário do IndexedDB. +4. Ao iniciar uma lição, o texto é carregado via `import()` dinâmico (code-splitting por trilha/idioma). +5. O **Typing Engine** (módulo puro, sem React) processa cada keystroke, calcula métricas e emite eventos. +6. A UI reage aos eventos e atualiza o display de caracteres (correto/incorreto/atual). +7. Ao finalizar, o resultado é persistido no IndexedDB e exibido no dashboard de progresso. + +### 1.3 Persistência do Progresso + +**Por que IndexedDB (via Dexie.js)?** + +- Suporta centenas de MB — mais que suficiente para histórico de sessões. +- API assíncrona, não bloqueia a UI durante digitação. +- Funciona 100% offline, sem servidor. +- Dexie oferece `liveQuery()` para atualizar dashboards automaticamente e migrations versionadas. + +**O que é persistido localmente:** + +| Entidade | Exemplos de campos | Quando gravar | +|----------|-------------------|---------------| +| `UserSettings` | idioma UI, tema, layout de teclado, modo strict | Ao alterar configuração | +| `LessonProgress` | lessonId, bestWpm, bestAccuracy, attempts, lastPlayedAt | Ao completar lição | +| `SessionResult` | sessionId, lessonId, wpm, accuracy, rawWpm, durationMs, errors[], completedAt | Ao completar sessão | +| `SkillStats` | skillTag, aggregateWpm, errorHotspots (ex: `{`, `=>`) | Debounced após sessão | + +**Estratégia de gravação:** + +- **Não** persistir a cada keystroke — apenas manter estado em memória durante a sessão. +- Gravar no IndexedDB ao completar a lição ou ao sair explicitamente (com confirmação se houver progresso parcial). +- Usar transações únicas (`db.transaction('rw', [...], async () => {...})`) para batch writes. +- Export/import de progresso via JSON (funcionalidade da Fase 2) para backup manual. + +### 1.4 Modelo de Métricas (Typing Engine) + +O motor deve implementar métricas transparentes e documentadas, seguindo convenções da indústria: + +``` +WPM (net) = (caracteres_corretos / 5) / minutos_decorridos +WPM (raw) = (total_caracteres_digitados / 5) / minutos_decorridos +Precisão (%) = (keystrokes_corretos / total_keystrokes) × 100 +AWPM = WPM × (precisão / 100) // velocidade ajustada +``` + +**Regras adicionais para o domínio do projeto:** + +| Regra | Justificativa | +|-------|---------------| +| Usar `performance.now()` para timing | Monotônico, alta resolução, imune a ajustes de relógio do SO | +| WPM ao vivo via janela deslizante (5s) | Feedback instantâneo sem subestimar no início da sessão | +| Backspace conta no total de keystrokes | Reflete esforço real de correção | +| Modo código: espaços e indentação contam | Programadores precisam de precisão em whitespace | +| Modo tradução: penalizar palavras inteiras incorretas opcionalmente | Métrica específica para escritores técnicos | + +### 1.5 Modos de Treinamento + +``` + ┌─────────────────────────────────────┐ + │ CoderKeys Tracks │ + └─────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ Programadores │ │ Escritores Téc. │ │ (futuro) │ + │ track │ │ track │ │ Outras tracks │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + ┌────────┴────────┐ ┌────────┴────────┐ + │ syntax-drills │ │ requirements │ + │ api-snippets │ │ api-docs │ + │ shell-commands │ │ translation │ + │ shortcuts │ │ glossaries │ + └─────────────────┘ └─────────────────┘ +``` + +Cada **track** agrupa **módulos**, que contêm **lições**. Essa hierarquia é refletida na estrutura de pastas em `content/`. + +--- + +## 2. Stack Tecnológica Recomendada + +### 2.1 Stack Principal + +| Camada | Tecnologia | Versão sugerida | Justificativa | +|--------|-----------|-----------------|---------------| +| **Build** | Vite | 6.x | Dev server instantâneo, HMR, tree-shaking, `import.meta.glob` para descoberta automática de lições | +| **Framework UI** | React | 19.x | Ecossistema massivo, familiaridade da comunidade OSS, excelente suporte a TypeScript | +| **Linguagem** | TypeScript | 5.x | Type-safety para engine e schemas; contratos claros para contribuidores | +| **Estilização** | Tailwind CSS | 4.x | Utility-first, design consistente, fácil para contribuidores ajustarem UI | +| **Roteamento** | React Router | 7.x | Padrão de fato, documentação abundante, loaders para prefetch de lições | +| **Estado global** | Zustand | 5.x | ~1KB, API mínima, sem boilerplate; ideal para estado de sessão ativa | +| **i18n (UI)** | react-i18next + i18next | 24.x / 23.x | Padrão da indústria, pluralização, lazy loading, tipagem via `i18next-resources-for-ts` | +| **Persistência** | Dexie.js | 4.x | Wrapper maduro sobre IndexedDB, migrations, `liveQuery`, bulk operations | +| **Validação** | Zod | 3.x | Schemas para lições, settings e resultados; validação em CI | +| **Testes** | Vitest + Testing Library | — | Mesmo ecossistema Vite, rápido, zero config extra | +| **Lint/Format** | ESLint + Prettier | — | Consistência em PRs da comunidade | +| **Package manager** | pnpm | 9.x | Workspaces para monorepo leve (`packages/engine`, `packages/schemas`) | + +### 2.2 Por que React e não Vue? + +Ambos atendem aos requisitos. **React é recomendado** porque: + +- Maior pool de contribuidores potenciais em projetos OSS de ferramentas dev. +- `react-i18next` e ecossistema de acessibilidade (Radix UI) são mais maduros para o caso de uso. +- A preferência inicial do autor já aponta para React. + +> Se a equipe tiver forte preferência por Vue, a arquitetura se mantém idêntica — apenas troca-se a camada UI. O `packages/engine` permanece framework-agnóstico. + +### 2.3 Bibliotecas Complementares + +| Biblioteca | Uso | Por que não alternativa X | +|-----------|-----|--------------------------| +| **@radix-ui/react-*** | Componentes acessíveis (Dialog, Select, Tabs) | Headless, composable, sem CSS opinativo | +| **clsx + tailwind-merge** | Classes condicionais | Padrão Tailwind | +| **date-fns** | Formatação de datas no histórico | Leve, tree-shakeable (vs moment) | +| **recharts** (Fase 2) | Gráficos de progresso | Declarativo, React-native | +| **@codemirror/view** (Fase 2) | Highlight de código nas lições | Renderização fiel de syntax sem executar código | + +### 2.4 O que deliberadamente NÃO usar + +| Tecnologia | Motivo da exclusão | +|-----------|-------------------| +| Backend (Node/Express/Fastify) | Requisito de custo zero e execução local | +| Firebase / Supabase | Custo e dependência de nuvem | +| Redux | Boilerplate excessivo para o escopo | +| SQLite WASM + OPFS | Over-engineering para volume de dados de um typing tutor (~500KB de progresso) | +| Next.js | SSR desnecessário; app é 100% client-side | +| Electron | Bundle pesado (~150MB); Tauri é alternativa futura mais leve | + +### 2.5 Execução Local — Comandos do Usuário Final + +```bash +# Desenvolvimento +git clone https://github.com//coderkeys.git +cd coderkeys +pnpm install +pnpm dev # → http://localhost:5173 + +# Produção local +pnpm build +pnpm preview # → serve build estático localmente + +# (Fase 3) App desktop opcional +pnpm tauri dev +``` + +### 2.6 Hospedagem Opcional (sem custo) + +- **GitHub Pages** ou **Cloudflare Pages** para demo pública — deploy via GitHub Actions no push para `main`. +- O app continua funcionando offline após primeiro carregamento (Service Worker na Fase 2). + +--- + +## 3. Estrutura de Diretórios + +### 3.1 Árvore Completa Proposta + +``` +coderkeys/ +├── .github/ +│ ├── ISSUE_TEMPLATE/ +│ │ ├── new-lesson.yml # Template: contribuir lição +│ │ └── new-language.yml # Template: contribuir idioma +│ ├── PULL_REQUEST_TEMPLATE.md +│ └── workflows/ +│ ├── ci.yml # lint + test + validate-content +│ └── deploy-demo.yml # GitHub Pages (opcional) +│ +├── apps/ +│ └── web/ # Aplicação principal (Vite + React) +│ ├── public/ +│ │ ├── favicon.svg +│ │ └── fonts/ # Fontes monospace para código +│ ├── src/ +│ │ ├── app/ # Bootstrap, providers, router +│ │ │ ├── App.tsx +│ │ │ ├── router.tsx +│ │ │ └── providers.tsx +│ │ ├── features/ # Módulos por funcionalidade (vertical slices) +│ │ │ ├── lesson/ +│ │ │ │ ├── components/ # LessonView, CharDisplay, KeyboardHints +│ │ │ │ ├── hooks/ # useLessonSession, useTypingInput +│ │ │ │ └── pages/ # LessonPage, LessonCompletePage +│ │ │ ├── dashboard/ +│ │ │ │ ├── components/ # StatsCard, ProgressChart, SkillHeatmap +│ │ │ │ └── pages/ # DashboardPage +│ │ │ ├── catalog/ +│ │ │ │ ├── components/ # TrackCard, ModuleList, LessonCard +│ │ │ │ └── pages/ # CatalogPage, TrackPage +│ │ │ └── settings/ +│ │ │ ├── components/ +│ │ │ └── pages/ # SettingsPage +│ │ ├── shared/ # Componentes e utils compartilhados na app +│ │ │ ├── components/ # Button, Card, Layout, Header +│ │ │ └── hooks/ # useMediaQuery, useLocalStorage +│ │ ├── i18n/ # Traduções da INTERFACE (não das lições) +│ │ │ ├── index.ts +│ │ │ └── locales/ +│ │ │ ├── en-US/ +│ │ │ │ ├── common.json +│ │ │ │ ├── catalog.json +│ │ │ │ ├── lesson.json +│ │ │ │ └── settings.json +│ │ │ └── pt-BR/ +│ │ │ └── ... (mesma estrutura) +│ │ ├── db/ # Camada de persistência (Dexie) +│ │ │ ├── database.ts +│ │ │ ├── migrations.ts +│ │ │ └── repositories/ +│ │ │ ├── progress.repo.ts +│ │ │ └── settings.repo.ts +│ │ └── main.tsx +│ ├── index.html +│ ├── vite.config.ts +│ ├── tailwind.config.ts +│ └── package.json +│ +├── packages/ +│ ├── engine/ # ⭐ MOTOR PURO (zero dependência de React) +│ │ ├── src/ +│ │ │ ├── index.ts +│ │ │ ├── session.ts # Máquina de estados: idle → active → paused → complete +│ │ │ ├── keystroke.ts # Processamento de input, backspace, skip +│ │ │ ├── metrics/ +│ │ │ │ ├── wpm.ts # Cálculo WPM (live + final) +│ │ │ │ ├── accuracy.ts +│ │ │ │ └── error-analysis.ts # Hotspots: {}, =>, etc. +│ │ │ ├── diff.ts # Comparação caractere a caractere +│ │ │ └── types.ts +│ │ ├── tests/ +│ │ │ ├── wpm.test.ts +│ │ │ ├── accuracy.test.ts +│ │ │ └── session.test.ts +│ │ └── package.json +│ │ +│ └── schemas/ # ⭐ CONTRATOS ZOD (lições + progresso) +│ ├── src/ +│ │ ├── lesson.schema.ts +│ │ ├── manifest.schema.ts +│ │ ├── session-result.schema.ts +│ │ └── index.ts +│ └── package.json +│ +├── content/ # ⭐ CONTEÚDO DAS LIÇÕES (PRs da comunidade) +│ ├── manifest.json # Índice global de tracks/módulos/lições +│ ├── tracks/ +│ │ ├── programmers/ +│ │ │ ├── _meta.json # Metadados do track +│ │ │ ├── en-US/ +│ │ │ │ ├── syntax-brackets/ +│ │ │ │ │ ├── 001-curly-basics.json +│ │ │ │ │ └── 002-array-brackets.json +│ │ │ │ └── api-snippets/ +│ │ │ │ └── 001-fetch-async.json +│ │ │ └── pt-BR/ +│ │ │ └── syntax-brackets/ +│ │ │ └── 001-chaves-basico.json +│ │ └── technical-writers/ +│ │ ├── _meta.json +│ │ ├── en-US/ +│ │ │ ├── requirements/ +│ │ │ │ └── 001-user-story.json +│ │ │ └── api-docs/ +│ │ │ └── 001-endpoint-description.json +│ │ └── pt-BR/ +│ │ └── requirements/ +│ │ └── 001-historia-usuario.json +│ └── CONTRIBUTING-CONTENT.md # Guia para contribuidores de conteúdo +│ +├── scripts/ +│ ├── validate-content.ts # Valida todos os JSONs contra Zod +│ ├── generate-manifest.ts # Regenera manifest.json (opcional) +│ └── check-i18n-keys.ts # Verifica chaves faltantes pt-BR ↔ en-US +│ +├── docs/ +│ ├── PLANEJAMENTO-ARQUITETURA.md # Este documento +│ ├── METRICS.md # Especificação formal de WPM/precisão +│ └── CONTRIBUTING.md +│ +├── package.json # Root workspace (pnpm) +├── pnpm-workspace.yaml +├── tsconfig.base.json +├── LICENSE +└── README.md +``` + +### 3.2 Zonas de Responsabilidade para Contribuidores + +| Zona | Quem contribui | O que fazem | O que NÃO tocam | +|------|---------------|-------------|-----------------| +| `content/` | **Qualquer pessoa** | Adicionam lições, traduções de conteúdo | Engine, UI | +| `apps/web/src/i18n/` | Tradutores | Traduzem labels da interface | Lógica de negócio | +| `packages/engine/` | Devs experientes | Melhoram métricas, corrigem bugs | Conteúdo | +| `packages/schemas/` | Maintainers | Evoluem contratos de dados | — | +| `apps/web/src/features/` | Devs frontend | Novas telas, UX | Métricas core | + +### 3.3 Convenções de Nomenclatura + +- **IDs de lição:** `{track}/{module}/{locale}/{filename}` → ex: `programmers/syntax-brackets/en-US/001-curly-basics` +- **Arquivos de lição:** `{ordem}-{slug}.json` com padding de 3 dígitos para ordenação +- **Metadados:** `_meta.json` (prefixo underscore = não é lição) +- **Namespaces i18n:** um arquivo JSON por feature (`catalog.json`, `lesson.json`) + +--- + +## 4. Estrutura de Dados das Lições + +### 4.1 Filosofia: JSON como Fonte da Verdade + +**Lições em JSON** (não Markdown) para o conteúdo digitável, pelos seguintes motivos: + +- Validação estrutural rigorosa via Zod em CI. +- Metadados ricos (skills, dificuldade, tags) sem frontmatter frágil. +- Comparação caractere-a-caractere exige string literal exata — JSON escapa corretamente `\n`, `\t`, `\"`. +- Inspiração comprovada: [Monkeytype](https://github.com/monkeytypegame/monkeytype) usa JSON estático com sucesso em produção. + +**Markdown** fica reservado para documentação (`CONTRIBUTING-CONTENT.md`, README de módulos). + +### 4.2 Schema: Manifest Global (`content/manifest.json`) + +```json +{ + "version": "1.0.0", + "tracks": [ + { + "id": "programmers", + "icon": "code", + "modules": [ + { + "id": "syntax-brackets", + "skills": ["brackets", "symbols"], + "lessons": { + "en-US": ["001-curly-basics", "002-array-brackets"], + "pt-BR": ["001-chaves-basico"] + } + } + ] + }, + { + "id": "technical-writers", + "icon": "document", + "modules": [ + { + "id": "requirements", + "skills": ["user-stories", "acceptance-criteria"], + "lessons": { + "en-US": ["001-user-story"], + "pt-BR": ["001-historia-usuario"] + } + } + ] + } + ] +} +``` + +> O manifest pode ser **gerado automaticamente** pelo script `generate-manifest.ts` varrendo `content/tracks/`, evitando edição manual e dessincronia. + +### 4.3 Schema: Metadados do Track (`_meta.json`) + +```json +{ + "id": "programmers", + "name": { + "en-US": "Programmers", + "pt-BR": "Programadores" + }, + "description": { + "en-US": "Master code syntax, symbols, and IDE shortcuts.", + "pt-BR": "Domine sintaxe de código, símbolos e atalhos de IDE." + }, + "defaultModule": "syntax-brackets" +} +``` + +### 4.4 Schema: Lição Individual (`*.json`) + +```json +{ + "$schema": "../../../packages/schemas/lesson.schema.json", + "id": "001-curly-basics", + "module": "syntax-brackets", + "track": "programmers", + "locale": "en-US", + "title": "Curly Braces Basics", + "description": "Practice typing opening and closing curly braces in common patterns.", + "difficulty": 1, + "estimatedMinutes": 2, + "skills": ["curly-braces", "indentation"], + "tags": ["javascript", "typescript", "symbols"], + "mode": "code", + "content": { + "text": "const obj = { key: 'value' };\nif (true) {\n console.log('hello');\n}", + "display": "code", + "language": "javascript" + }, + "goals": { + "minWpm": 30, + "minAccuracy": 90 + }, + "hints": { + "keyboard": ["{", "}", "[", "]"], + "shortcuts": [] + }, + "author": "coderkeys-team", + "source": null, + "version": 1 +} +``` + +### 4.5 Campos do Schema — Referência + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `id` | `string` | ✅ | Identificador único dentro do módulo + locale | +| `module` | `string` | ✅ | Referência ao módulo pai | +| `track` | `string` | ✅ | Referência ao track pai | +| `locale` | `"en-US" \| "pt-BR"` | ✅ | BCP-47 do conteúdo digitável | +| `title` | `string` | ✅ | Título exibido (no idioma da lição) | +| `description` | `string` | ✅ | Descrição curta | +| `difficulty` | `1..5` | ✅ | Nível de dificuldade | +| `estimatedMinutes` | `number` | ✅ | Tempo estimado | +| `skills` | `string[]` | ✅ | Skills treinadas (para analytics) | +| `tags` | `string[]` | ❌ | Tags livres para busca | +| `mode` | `"code" \| "prose" \| "translation"` | ✅ | Afeta renderização e métricas | +| `content.text` | `string` | ✅ | Texto exato a ser digitado | +| `content.display` | `"code" \| "prose"` | ✅ | Tipo de renderização | +| `content.language` | `string` | ❌ | Linguagem para highlight (ex: `typescript`) | +| `content.sourceText` | `string` | ❌ | Texto original (modo translation) | +| `goals.minWpm` | `number` | ❌ | Meta de WPM para "passar" | +| `goals.minAccuracy` | `number` | ❌ | Meta de precisão (%) | +| `hints.keyboard` | `string[]` | ❌ | Teclas especiais enfatizadas | +| `hints.shortcuts` | `object[]` | ❌ | Atalhos `{ keys, description }` | +| `author` | `string` | ✅ | Autor ou `@github-username` | +| `source` | `string \| null` | ❌ | Atribuição (livro, docs, etc.) | +| `version` | `number` | ✅ | Versão da lição (incrementar ao editar) | + +### 4.6 Modo `translation` — Estrutura Estendida + +Para escritores técnicos e tradutores: + +```json +{ + "id": "001-historia-usuario", + "mode": "translation", + "locale": "pt-BR", + "content": { + "text": "Como usuário, eu quero redefinir minha senha para recuperar o acesso à minha conta.", + "sourceText": "As a user, I want to reset my password so that I can regain access to my account.", + "display": "prose", + "language": "pt-BR" + }, + "goals": { + "minAccuracy": 95 + } +} +``` + +A UI exibe o `sourceText` como referência e o usuário digita a tradução em `text`. + +### 4.7 Validação em CI + +O script `scripts/validate-content.ts` executa: + +1. Parse JSON de cada lição. +2. Validação contra `LessonSchema` (Zod). +3. Verificação de unicidade de `id` por módulo + locale. +4. Verificação de que `content.text` não está vazio e tem comprimento mínimo (ex: 20 chars). +5. Verificação de que `locale` corresponde à pasta pai. +6. (Opcional) Detecção de caracteres invisíveis problemáticos (BOM, non-breaking spaces). + +```bash +pnpm validate-content # roda no CI em todo PR que toca content/ +``` + +### 4.8 Fluxo de Contribuição de Conteúdo + +``` +1. Fork do repositório +2. Criar arquivo em content/tracks/{track}/{locale}/{module}/{id}.json +3. Seguir CONTRIBUTING-CONTENT.md (guidelines de qualidade) +4. Rodar pnpm validate-content localmente +5. Abrir PR → CI valida → Review de conteúdo → Merge +``` + +**Guidelines de qualidade (resumo):** + +| Tipo | Regra | +|------|-------| +| Código | Trechos compiláveis ou plausíveis; sem secrets/credenciais | +| Prosa técnica | Tom profissional; sem conteúdo ofensivo | +| Tradução | Fiel ao sourceText; sem anglicismos desnecessários | +| Comprimento | 50–500 caracteres por lição (ideal: 100–200) | +| Dificuldade | Progressão gradual dentro do módulo | + +--- + +## 5. Roadmap de Implementação + +### Visão Geral das Fases + +``` +Fase 1 — Fundação Fase 2 — Experiência Fase 3 — Comunidade +(MVP funcional) (polish + features) (escala + desktop) + │ │ │ + ├─ Engine + métricas ├─ Dashboard ├─ Tauri desktop + ├─ UI básica de lição ├─ Gráficos de progresso ├─ Temas customizáveis + ├─ 2 tracks, 2 idiomas ├─ Modo translation ├─ Plugin de lições + ├─ Persistência IDB ├─ Export/import progresso ├─ PWA offline + └─ 10+ lições seed ├─ Error heatmap └─ Contribuição automatizada + └─ Service Worker +``` + +--- + +### Fase 1 — Fundação (MVP) + +**Objetivo:** Aplicação funcional que roda localmente, com uma lição jogável ponta a ponta e progresso persistido. + +#### 1.1 Setup do Monorepo + +- [ ] Inicializar workspace pnpm (`apps/web`, `packages/engine`, `packages/schemas`) +- [ ] Configurar TypeScript project references +- [ ] Configurar ESLint + Prettier + Vitest +- [ ] Configurar Tailwind CSS 4 no `apps/web` +- [ ] README com instruções de `pnpm install && pnpm dev` + +#### 1.2 Typing Engine (`packages/engine`) + +- [ ] Definir tipos: `SessionState`, `KeystrokeEvent`, `SessionResult` +- [ ] Implementar máquina de estados: `idle → ready → active → complete` +- [ ] Implementar processamento de keystrokes (incluindo backspace) +- [ ] Implementar `diff.ts` — comparação caractere a caractere +- [ ] Implementar `wpm.ts` — WPM final e WPM ao vivo (janela 5s) +- [ ] Implementar `accuracy.ts` +- [ ] Testes unitários com cobertura > 90% no engine + +#### 1.3 Schemas (`packages/schemas`) + +- [ ] `LessonSchema` com todos os campos da seção 4 +- [ ] `ManifestSchema` +- [ ] `SessionResultSchema` +- [ ] Exportar tipos TypeScript inferidos dos schemas + +#### 1.4 Persistência + +- [ ] Configurar Dexie com tabelas: `settings`, `lessonProgress`, `sessions` +- [ ] Implementar `settings.repo.ts` (get/set locale, theme) +- [ ] Implementar `progress.repo.ts` (save session, get best scores) +- [ ] Migration v1 inicial + +#### 1.5 UI — Fluxo Principal + +- [ ] Layout base (Header, navegação, footer com link GitHub) +- [ ] Página de catálogo: listar tracks e módulos +- [ ] Página de lição: + - Display de caracteres (verde/vermelho/cursor) + - Input invisível capturando keystrokes + - Timer e WPM/accuracy ao vivo + - Tela de resultados ao completar +- [ ] Integrar engine ↔ UI via Zustand store +- [ ] Página de configurações: troca de idioma UI (pt-BR / en-US) + +#### 1.6 i18n + +- [ ] Configurar react-i18next com namespaces +- [ ] Criar traduções `en-US` e `pt-BR` para UI (common, catalog, lesson, settings) +- [ ] Script `check-i18n-keys.ts` no CI + +#### 1.7 Conteúdo Seed + +- [ ] Track `programmers` / módulo `syntax-brackets`: 5 lições en-US + 5 pt-BR +- [ ] Track `technical-writers` / módulo `requirements`: 5 lições en-US + 5 pt-BR +- [ ] `manifest.json` e `_meta.json` dos tracks +- [ ] Script `validate-content.ts` integrado ao CI + +#### 1.8 CI/CD Básico + +- [ ] Workflow: lint → typecheck → test → validate-content +- [ ] Badge de CI no README + +**Critério de conclusão da Fase 1:** +> Um usuário clona o repo, roda `pnpm dev`, seleciona uma lição, digita o texto, vê WPM/precisão ao final, fecha o navegador, reabre, e o progresso está salvo. + +--- + +### Fase 2 — Experiência e Retenção + +**Objetivo:** Transformar o MVP em uma ferramenta de treino que o usuário quer usar diariamente. + +#### 2.1 Dashboard de Progresso + +- [ ] Página com resumo: WPM médio, precisão, streak de dias +- [ ] Gráfico de evolução de WPM (recharts) — últimos 7/30 dias +- [ ] Lista de lições com status (não iniciada / em progresso / completa / melhor score) +- [ ] Filtro por track, módulo e skill + +#### 2.2 Análise de Erros + +- [ ] `error-analysis.ts` no engine: mapear posições de erro por caractere +- [ ] Heatmap de teclas problemáticas (ex: `{`, `}`, `=>`, `;`) +- [ ] Sugestão automática de lições baseada em weak spots + +#### 2.3 Modo Translation + +- [ ] UI split: sourceText (readonly) + área de digitação +- [ ] Métricas adaptadas (precisão por palavra opcional) +- [ ] 10 lições de tradução pt-BR ↔ en-US + +#### 2.4 Renderização de Código + +- [ ] Integrar CodeMirror (view-only) para highlight de syntax +- [ ] Suporte a `content.language` para temas de highlight +- [ ] Preservar indentação visual (tabs vs spaces) + +#### 2.5 UX Avançada + +- [ ] Atalhos de teclado (Esc = pausar, Ctrl+Enter = reiniciar) +- [ ] Animação de transição entre caracteres +- [ ] Modo "strict" (erro bloqueia avanço) vs "flexible" (permite corrigir) +- [ ] Sons opcionais de keystroke (mute por padrão) +- [ ] Tema claro/escuro + +#### 2.6 Dados e Backup + +- [ ] Export de progresso para JSON (download) +- [ ] Import de progresso de JSON (upload) +- [ ] Reset de progresso por track ou global + +#### 2.7 PWA / Offline + +- [ ] Service Worker via `vite-plugin-pwa` +- [ ] Cache de lições e assets para uso offline +- [ ] Manifest.json para "instalar" no desktop + +**Critério de conclusão da Fase 2:** +> Usuário acompanha evolução no dashboard, recebe sugestões de lições baseadas em erros, e usa o app offline. + +--- + +### Fase 3 — Comunidade e Escala + +**Objetivo:** Infraestrutura para o projeto crescer organicamente via contribuições. + +#### 3.1 App Desktop (Tauri) + +- [ ] Integrar Tauri 2.x como wrapper do build Vite +- [ ] Build para Linux, macOS, Windows via GitHub Actions +- [ ] Ícone e identidade visual + +#### 3.2 Sistema de Temas + +- [ ] Schema de tema (cores, fontes, cursor) +- [ ] 3 temas built-in + instruções para contribuidores criarem temas +- [ ] Inspiração: modelo de temas do Monkeytype + +#### 3.3 Contribuição Automatizada + +- [ ] GitHub Issue Forms para nova lição e novo idioma +- [ ] PR template com checklist de conteúdo +- [ ] Bot de CI que comenta no PR com preview do conteúdo renderizado +- [ ] `CONTRIBUTING.md` e `CONTRIBUTING-CONTENT.md` completos + +#### 3.4 Novos Tracks (comunidade) + +- [ ] Track `devops` (Docker, kubectl, terraform) +- [ ] Track `data-science` (Python, SQL, pandas) +- [ ] Track `legal-translators` (vocabulário jurídico bilíngue) +- [ ] Documentar processo para propor novo track + +#### 3.5 Internacionalização Expandida + +- [ ] Suporte a `es-ES` como terceiro idioma (validar arquitetura) +- [ ] Documentar como adicionar novo locale (UI + content) +- [ ] RTL readiness (preparar CSS, não implementar ainda) + +#### 3.6 Performance e Qualidade + +- [ ] Lighthouse score > 95 em Performance e Accessibility +- [ ] Testes E2E com Playwright (fluxo completo de lição) +- [ ] Benchmark do engine (meta: < 1ms por keystroke) + +**Critério de conclusão da Fase 3:** +> Projeto pronto para anunciar como open source, com documentação de contribuição, app desktop, e pelo menos 50 lições em 2 idiomas. + +--- + +## 6. Apêndices + +### A. Diagrama de Estados da Sessão + +``` + ┌──────────┐ + │ idle │ + └────┬─────┘ + │ select lesson + ▼ + ┌──────────┐ + ┌────│ ready │──── countdown (opcional) + │ └────┬─────┘ + │ │ first keystroke + │ ▼ + │ ┌──────────┐ + │ │ active │◀─────┐ + │ └────┬─────┘ │ + │ │ │ resume + │ ┌────┴─────┐ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌──────┐ ┌────────┐ │ + │ │paused│ │complete│ │ + │ └──┬───┘ └────────┘ │ + │ │ │ + │ └─────────────────┘ + │ + └──── reset / exit +``` + +### B. Modelo de Dados — IndexedDB + +```typescript +// Tabelas Dexie (referência, não é código de implementação) + +interface UserSettings { + id: 'default'; + uiLocale: 'en-US' | 'pt-BR'; + theme: 'light' | 'dark' | 'system'; + strictMode: boolean; + soundEnabled: boolean; +} + +interface LessonProgress { + lessonId: string; // PK: "{track}/{module}/{locale}/{id}" + track: string; + module: string; + bestWpm: number; + bestAccuracy: number; + bestAwpm: number; + attempts: number; + completed: boolean; + lastPlayedAt: string; // ISO 8601 +} + +interface SessionResult { + id: string; // UUID + lessonId: string; + wpm: number; + rawWpm: number; + accuracy: number; + awpm: number; + durationMs: number; + errorPositions: number[]; + errorChars: string[]; + completedAt: string; +} +``` + +### C. Riscos e Mitigações + +| Risco | Impacto | Mitigação | +|-------|---------|-----------| +| Lições com caracteres especiais quebram o diff | Alto | Validação CI + testes com trechos reais de código | +| IndexedDB limpo pelo browser (Safari ITP) | Médio | Export/import de progresso (Fase 2) | +| Contribuidores enviam conteúdo com licença inadequada | Médio | CLA ou licença CC0 para `content/`; review manual | +| Escopo cresce demais na Fase 1 | Alto | Critério de conclusão claro; resistir a feature creep | +| Layout de teclado ABNT vs US afeta métricas | Baixo | Documentar layout assumido; setting de layout (Fase 2) | + +### D. Referências + +- [Monkeytype — Static Content Architecture](https://github.com/monkeytypegame/monkeytype) — modelo de lições JSON +- [Local-First Web Development (Smashing Magazine, 2026)](https://www.smashingmagazine.com/2026/05/architecture-local-first-web-development/) — princípios de persistência local +- [Dexie.js Documentation](https://dexie.org/) — IndexedDB wrapper +- [react-i18next TypeScript Guide](https://react.i18next.com/latest/typescript) — i18n tipado +- [WPM Calculation Best Practices](https://dev.to/clackpit_dev/how-i-calculate-wpm-in-real-time-and-why-most-typing-sites-get-the-math-wrong-4c92) — janela deslizante e timing + +### E. Nome e Identidade + +| Item | Valor | +|------|-------| +| Nome do projeto | **CoderKeys** | +| Repositório | `coderkeys` | +| Público-alvo primário | Desenvolvedores e escritores técnicos bilíngues (pt-BR / en-US) | +| Licença código | MIT (já presente no repositório) | +| Licença conteúdo | CC0 ou MIT (definir antes da Fase 3) | + +--- + +## Próximos Passos + +Após aprovação deste plano: + +1. Criar branch `cursor/setup-monorepo-8eb5` e executar itens **1.1** a **1.3** da Fase 1. +2. Implementar o engine com testes antes de qualquer UI (TDD). +3. Criar 2 lições "hello world" (uma code, uma prose) para validar o pipeline de conteúdo. +4. Iterar na UI da lição até o fluxo completo funcionar. + +--- + +*Documento gerado para revisão. Ajustes são esperados antes do início da implementação.* From 3644d7ad8808e5f648a0476a2224281f328246c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 18:44:49 +0000 Subject: [PATCH 2/4] feat: implement Phase 1 foundation for CoderKeys - Monorepo with pnpm workspaces (apps/web, packages/engine, packages/schemas) - Typing engine with WPM, accuracy, session state machine and 15 unit tests - Zod schemas for lessons, manifest and session results - React SPA with catalog, lesson flow, settings and i18n (en-US, pt-BR) - Dexie/IndexedDB persistence for progress and settings - 20 seed lessons across programmers and technical-writers tracks - Content validation script, i18n parity check and GitHub Actions CI Co-authored-by: Iago Mendes --- .github/workflows/ci.yml | 34 + .gitignore | 11 + .prettierrc | 6 + README.md | 57 +- apps/web/.gitignore | 24 + apps/web/.oxlintrc.json | 8 + apps/web/README.md | 32 + apps/web/index.html | 16 + apps/web/package.json | 39 + apps/web/public/favicon.svg | 1 + apps/web/public/icons.svg | 24 + apps/web/src/app/providers.tsx | 29 + apps/web/src/app/router.tsx | 27 + apps/web/src/assets/hero.png | Bin 0 -> 13057 bytes apps/web/src/assets/react.svg | 1 + apps/web/src/assets/vite.svg | 1 + apps/web/src/db/database.ts | 63 + apps/web/src/db/repositories/progress.repo.ts | 52 + apps/web/src/db/repositories/settings.repo.ts | 18 + .../web/src/features/catalog/lesson-loader.ts | 84 + .../features/catalog/pages/CatalogPage.tsx | 118 + .../lesson/components/CharDisplay.tsx | 37 + .../lesson/components/LessonSession.tsx | 154 + .../src/features/lesson/pages/LessonPage.tsx | 36 + .../features/settings/pages/SettingsPage.tsx | 100 + apps/web/src/i18n/index.ts | 45 + apps/web/src/i18n/locales/en-US/catalog.json | 11 + apps/web/src/i18n/locales/en-US/common.json | 17 + apps/web/src/i18n/locales/en-US/lesson.json | 12 + apps/web/src/i18n/locales/en-US/settings.json | 9 + apps/web/src/i18n/locales/pt-BR/catalog.json | 11 + apps/web/src/i18n/locales/pt-BR/common.json | 17 + apps/web/src/i18n/locales/pt-BR/lesson.json | 12 + apps/web/src/i18n/locales/pt-BR/settings.json | 9 + apps/web/src/index.css | 23 + apps/web/src/main.tsx | 14 + apps/web/src/shared/components/Layout.tsx | 54 + apps/web/src/shared/components/ui.tsx | 32 + apps/web/src/shared/lib/cn.ts | 6 + apps/web/src/shared/stores/settings.store.ts | 46 + apps/web/src/vite-env.d.ts | 13 + apps/web/tsconfig.app.json | 27 + apps/web/tsconfig.json | 7 + apps/web/tsconfig.node.json | 23 + apps/web/vite.config.ts | 21 + content/CONTRIBUTING-CONTENT.md | 44 + content/manifest.json | 57 + content/tracks/programmers/_meta.json | 12 + .../syntax-brackets/001-curly-basics.json | 23 + .../syntax-brackets/002-array-brackets.json | 23 + .../en-US/syntax-brackets/003-pipe-chain.json | 23 + .../004-template-literals.json | 23 + .../syntax-brackets/005-arrow-functions.json | 23 + .../syntax-brackets/001-chaves-basico.json | 23 + .../syntax-brackets/002-colchetes-array.json | 23 + .../syntax-brackets/003-cadeia-pipe.json | 23 + .../004-template-literals.json | 23 + .../syntax-brackets/005-arrow-functions.json | 23 + content/tracks/technical-writers/_meta.json | 12 + .../en-US/requirements/001-user-story.json | 21 + .../requirements/002-acceptance-criteria.json | 21 + .../requirements/003-given-when-then.json | 21 + .../requirements/004-api-requirement.json | 21 + .../en-US/requirements/005-edge-case.json | 21 + .../requirements/001-historia-usuario.json | 21 + .../requirements/002-criterios-aceite.json | 21 + .../requirements/003-dado-quando-entao.json | 21 + .../pt-BR/requirements/004-requisito-api.json | 21 + .../pt-BR/requirements/005-caso-limite.json | 21 + eslint.config.js | 32 + package.json | 32 + packages/engine/package.json | 18 + packages/engine/src/diff.ts | 51 + packages/engine/src/index.ts | 19 + packages/engine/src/metrics/accuracy.ts | 8 + packages/engine/src/metrics/wpm.ts | 66 + packages/engine/src/session.ts | 210 ++ packages/engine/src/types.ts | 55 + packages/engine/tests/diff.test.ts | 30 + packages/engine/tests/session.test.ts | 66 + packages/engine/tests/wpm.test.ts | 47 + packages/engine/tsconfig.json | 8 + packages/engine/vitest.config.ts | 8 + packages/schemas/package.json | 20 + packages/schemas/src/index.ts | 15 + packages/schemas/src/lesson.schema.ts | 102 + packages/schemas/tsconfig.json | 8 + pnpm-lock.yaml | 3132 +++++++++++++++++ pnpm-workspace.yaml | 3 + scripts/check-i18n-keys.ts | 78 + scripts/validate-content.ts | 62 + tsconfig.base.json | 19 + 92 files changed, 6033 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 apps/web/.gitignore create mode 100644 apps/web/.oxlintrc.json create mode 100644 apps/web/README.md create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/public/icons.svg create mode 100644 apps/web/src/app/providers.tsx create mode 100644 apps/web/src/app/router.tsx create mode 100644 apps/web/src/assets/hero.png create mode 100644 apps/web/src/assets/react.svg create mode 100644 apps/web/src/assets/vite.svg create mode 100644 apps/web/src/db/database.ts create mode 100644 apps/web/src/db/repositories/progress.repo.ts create mode 100644 apps/web/src/db/repositories/settings.repo.ts create mode 100644 apps/web/src/features/catalog/lesson-loader.ts create mode 100644 apps/web/src/features/catalog/pages/CatalogPage.tsx create mode 100644 apps/web/src/features/lesson/components/CharDisplay.tsx create mode 100644 apps/web/src/features/lesson/components/LessonSession.tsx create mode 100644 apps/web/src/features/lesson/pages/LessonPage.tsx create mode 100644 apps/web/src/features/settings/pages/SettingsPage.tsx create mode 100644 apps/web/src/i18n/index.ts create mode 100644 apps/web/src/i18n/locales/en-US/catalog.json create mode 100644 apps/web/src/i18n/locales/en-US/common.json create mode 100644 apps/web/src/i18n/locales/en-US/lesson.json create mode 100644 apps/web/src/i18n/locales/en-US/settings.json create mode 100644 apps/web/src/i18n/locales/pt-BR/catalog.json create mode 100644 apps/web/src/i18n/locales/pt-BR/common.json create mode 100644 apps/web/src/i18n/locales/pt-BR/lesson.json create mode 100644 apps/web/src/i18n/locales/pt-BR/settings.json create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/shared/components/Layout.tsx create mode 100644 apps/web/src/shared/components/ui.tsx create mode 100644 apps/web/src/shared/lib/cn.ts create mode 100644 apps/web/src/shared/stores/settings.store.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.app.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts create mode 100644 content/CONTRIBUTING-CONTENT.md create mode 100644 content/manifest.json create mode 100644 content/tracks/programmers/_meta.json create mode 100644 content/tracks/programmers/en-US/syntax-brackets/001-curly-basics.json create mode 100644 content/tracks/programmers/en-US/syntax-brackets/002-array-brackets.json create mode 100644 content/tracks/programmers/en-US/syntax-brackets/003-pipe-chain.json create mode 100644 content/tracks/programmers/en-US/syntax-brackets/004-template-literals.json create mode 100644 content/tracks/programmers/en-US/syntax-brackets/005-arrow-functions.json create mode 100644 content/tracks/programmers/pt-BR/syntax-brackets/001-chaves-basico.json create mode 100644 content/tracks/programmers/pt-BR/syntax-brackets/002-colchetes-array.json create mode 100644 content/tracks/programmers/pt-BR/syntax-brackets/003-cadeia-pipe.json create mode 100644 content/tracks/programmers/pt-BR/syntax-brackets/004-template-literals.json create mode 100644 content/tracks/programmers/pt-BR/syntax-brackets/005-arrow-functions.json create mode 100644 content/tracks/technical-writers/_meta.json create mode 100644 content/tracks/technical-writers/en-US/requirements/001-user-story.json create mode 100644 content/tracks/technical-writers/en-US/requirements/002-acceptance-criteria.json create mode 100644 content/tracks/technical-writers/en-US/requirements/003-given-when-then.json create mode 100644 content/tracks/technical-writers/en-US/requirements/004-api-requirement.json create mode 100644 content/tracks/technical-writers/en-US/requirements/005-edge-case.json create mode 100644 content/tracks/technical-writers/pt-BR/requirements/001-historia-usuario.json create mode 100644 content/tracks/technical-writers/pt-BR/requirements/002-criterios-aceite.json create mode 100644 content/tracks/technical-writers/pt-BR/requirements/003-dado-quando-entao.json create mode 100644 content/tracks/technical-writers/pt-BR/requirements/004-requisito-api.json create mode 100644 content/tracks/technical-writers/pt-BR/requirements/005-caso-limite.json create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/diff.ts create mode 100644 packages/engine/src/index.ts create mode 100644 packages/engine/src/metrics/accuracy.ts create mode 100644 packages/engine/src/metrics/wpm.ts create mode 100644 packages/engine/src/session.ts create mode 100644 packages/engine/src/types.ts create mode 100644 packages/engine/tests/diff.test.ts create mode 100644 packages/engine/tests/session.test.ts create mode 100644 packages/engine/tests/wpm.test.ts create mode 100644 packages/engine/tsconfig.json create mode 100644 packages/engine/vitest.config.ts create mode 100644 packages/schemas/package.json create mode 100644 packages/schemas/src/index.ts create mode 100644 packages/schemas/src/lesson.schema.ts create mode 100644 packages/schemas/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-i18n-keys.ts create mode 100644 scripts/validate-content.ts create mode 100644 tsconfig.base.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..68480e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm typecheck + + - run: pnpm test + + - run: pnpm validate-content + + - run: pnpm check-i18n + + - run: pnpm build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1637f4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +dist-ssr +*.local +.DS_Store +coverage +.turbo +*.log +.env +.env.* +!.env.example diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..47174e4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/README.md b/README.md index 58a1dbc..f5a89d7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# coderkeys -An open-source, local-first typing tutor tailored for programmers and technical writers to master code syntax and specific vocabularies. +# CoderKeys + +Open-source, local-first typing tutor tailored for **programmers** and **technical writers** to master code syntax and real-world vocabularies. + +## Features + +- **Skill-based tracks** — Programmers (code syntax, symbols) and Technical Writers (requirements, documentation) +- **Bilingual** — Interface and lessons in `en-US` and `pt-BR` +- **100% local** — No backend, no cloud costs; progress saved in IndexedDB +- **Contributor-friendly** — Add lessons by creating JSON files in `content/` + +## Quick Start + +```bash +git clone https://github.com/iagommendes/coderkeys.git +cd coderkeys +pnpm install +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173) in your browser. + +### Other commands + +```bash +pnpm build # Production build +pnpm preview # Preview production build locally +pnpm test # Run all tests +pnpm validate-content # Validate lesson JSON files +pnpm check-i18n # Verify translation key parity +``` + +## Project Structure + +``` +coderkeys/ +├── apps/web/ # React SPA (Vite + Tailwind) +├── packages/engine/ # Typing engine (WPM, accuracy) — framework-agnostic +├── packages/schemas/ # Zod schemas for lessons and progress +├── content/ # Lesson content (contributors edit here) +└── docs/ # Architecture and planning docs +``` + +## Contributing Lessons + +1. Create a JSON file in `content/tracks/{track}/{locale}/{module}/` +2. Follow the schema in `packages/schemas/src/lesson.schema.ts` +3. Run `pnpm validate-content` +4. Open a Pull Request + +See [docs/PLANEJAMENTO-ARQUITETURA.md](docs/PLANEJAMENTO-ARQUITETURA.md) for the full architecture plan. + +## License + +MIT diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/.oxlintrc.json b/apps/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/apps/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..483cd24 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,16 @@ + + + + + + + CoderKeys — Typing Tutor + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..eef1fc5 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@coderkeys/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "test": "vitest run --passWithNoTests", + "lint": "eslint src" + }, + "dependencies": { + "@coderkeys/engine": "workspace:*", + "@coderkeys/schemas": "workspace:*", + "clsx": "^2.1.1", + "dexie": "^4.0.11", + "i18next": "^24.2.3", + "i18next-browser-languagedetector": "^8.0.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-i18next": "^15.4.1", + "react-router-dom": "^7.4.0", + "tailwind-merge": "^3.0.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.14", + "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tailwindcss": "^4.0.14", + "typescript": "^5.8.2", + "vite": "^8.1.0", + "vitest": "^3.0.9" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 0000000..bc19427 --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import '@/i18n'; + +export function AppProviders({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); + const { load, uiLocale, loaded } = useSettingsStore(); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (loaded) { + void i18n.changeLanguage(uiLocale); + } + }, [loaded, uiLocale, i18n]); + + if (!loaded) { + return ( +
+ Loading... +
+ ); + } + + return <>{children}; +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..21e7d84 --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,27 @@ +import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { Layout } from '@/shared/components/Layout'; +import { CatalogPage, TrackPage } from '@/features/catalog/pages/CatalogPage'; +import { LessonPage } from '@/features/lesson/pages/LessonPage'; +import { SettingsPage } from '@/features/settings/pages/SettingsPage'; + +function TrackRoute() { + const { trackId } = useParams<{ trackId: string }>(); + if (!trackId) return ; + return ; +} + +export function AppRouter() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/web/src/db/database.ts b/apps/web/src/db/database.ts new file mode 100644 index 0000000..cb63d64 --- /dev/null +++ b/apps/web/src/db/database.ts @@ -0,0 +1,63 @@ +import Dexie, { type EntityTable } from 'dexie'; +import type { Locale } from '@coderkeys/schemas'; + +export interface UserSettings { + id: 'default'; + uiLocale: Locale; + contentLocale: Locale; + theme: 'light' | 'dark' | 'system'; + strictMode: boolean; + soundEnabled: boolean; +} + +export interface LessonProgress { + lessonId: string; + track: string; + module: string; + bestWpm: number; + bestAccuracy: number; + bestAwpm: number; + attempts: number; + completed: boolean; + lastPlayedAt: string; +} + +export interface SessionRecord { + id: string; + lessonId: string; + wpm: number; + rawWpm: number; + accuracy: number; + awpm: number; + durationMs: number; + errorPositions: number[]; + errorChars: string[]; + completedAt: string; +} + +export class CoderKeysDatabase extends Dexie { + settings!: EntityTable; + lessonProgress!: EntityTable; + sessions!: EntityTable; + + constructor() { + super('coderkeys'); + + this.version(1).stores({ + settings: 'id', + lessonProgress: 'lessonId, track, module, lastPlayedAt', + sessions: 'id, lessonId, completedAt', + }); + } +} + +export const db = new CoderKeysDatabase(); + +export const DEFAULT_SETTINGS: UserSettings = { + id: 'default', + uiLocale: 'en-US', + contentLocale: 'en-US', + theme: 'dark', + strictMode: false, + soundEnabled: false, +}; diff --git a/apps/web/src/db/repositories/progress.repo.ts b/apps/web/src/db/repositories/progress.repo.ts new file mode 100644 index 0000000..10b2d72 --- /dev/null +++ b/apps/web/src/db/repositories/progress.repo.ts @@ -0,0 +1,52 @@ +import type { SessionResult as EngineResult } from '@coderkeys/engine'; +import { db, type LessonProgress, type SessionRecord } from '../database'; + +export async function getLessonProgress(lessonId: string): Promise { + return db.lessonProgress.get(lessonId); +} + +export async function getAllLessonProgress(): Promise { + return db.lessonProgress.toArray(); +} + +export async function saveSessionResult( + lessonId: string, + track: string, + module: string, + result: EngineResult, +): Promise { + const sessionId = crypto.randomUUID(); + const completedAt = new Date().toISOString(); + + const session: SessionRecord = { + id: sessionId, + lessonId, + wpm: result.wpm, + rawWpm: result.rawWpm, + accuracy: result.accuracy, + awpm: result.awpm, + durationMs: result.durationMs, + errorPositions: result.errorPositions, + errorChars: result.errorChars, + completedAt, + }; + + const existing = await db.lessonProgress.get(lessonId); + + const progress: LessonProgress = { + lessonId, + track, + module, + bestWpm: Math.max(existing?.bestWpm ?? 0, result.wpm), + bestAccuracy: Math.max(existing?.bestAccuracy ?? 0, result.accuracy), + bestAwpm: Math.max(existing?.bestAwpm ?? 0, result.awpm), + attempts: (existing?.attempts ?? 0) + 1, + completed: result.completed || (existing?.completed ?? false), + lastPlayedAt: completedAt, + }; + + await db.transaction('rw', db.sessions, db.lessonProgress, async () => { + await db.sessions.put(session); + await db.lessonProgress.put(progress); + }); +} diff --git a/apps/web/src/db/repositories/settings.repo.ts b/apps/web/src/db/repositories/settings.repo.ts new file mode 100644 index 0000000..d7a6c01 --- /dev/null +++ b/apps/web/src/db/repositories/settings.repo.ts @@ -0,0 +1,18 @@ +import type { Locale } from '@coderkeys/schemas'; +import { db, DEFAULT_SETTINGS, type UserSettings } from '../database'; + +export async function getSettings(): Promise { + const settings = await db.settings.get('default'); + return settings ?? DEFAULT_SETTINGS; +} + +export async function saveSettings(partial: Partial>): Promise { + const current = await getSettings(); + const updated: UserSettings = { ...current, ...partial }; + await db.settings.put(updated); + return updated; +} + +export async function setUiLocale(locale: Locale): Promise { + return saveSettings({ uiLocale: locale }); +} diff --git a/apps/web/src/features/catalog/lesson-loader.ts b/apps/web/src/features/catalog/lesson-loader.ts new file mode 100644 index 0000000..fb54d95 --- /dev/null +++ b/apps/web/src/features/catalog/lesson-loader.ts @@ -0,0 +1,84 @@ +import manifest from '@content/manifest.json'; +import { LessonSchema, ManifestSchema, TrackMetaSchema, type Lesson, type Locale, type Manifest } from '@coderkeys/schemas'; + +const lessonModules = import.meta.glob<{ default: Lesson }>('@content/tracks/**/*.json', { + eager: true, +}); + +const trackMetaModules = import.meta.glob<{ default: Record & { id: string } }>( + '@content/tracks/*/_meta.json', + { eager: true }, +); + +export interface LessonRef { + lessonId: string; + trackId: string; + moduleId: string; + locale: Locale; + fileSlug: string; +} + +export interface TrackMetaView { + id: string; + name: Record; + description: Record; + defaultModule: string; +} + +function parseLessonPath(path: string): LessonRef | null { + const match = path.match(/tracks\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\.json$/); + if (!match) return null; + + const [, trackId, locale, moduleId, fileSlug] = match; + if (locale !== 'en-US' && locale !== 'pt-BR') return null; + + return { + lessonId: `${trackId}/${moduleId}/${locale}/${fileSlug}`, + trackId, + moduleId, + locale, + fileSlug, + }; +} + +export function getManifest(): Manifest { + return ManifestSchema.parse(manifest); +} + +export function getTrackMeta(trackId: string): TrackMetaView | null { + const entry = Object.entries(trackMetaModules).find(([path]) => path.includes(`/tracks/${trackId}/`)); + if (!entry) return null; + return TrackMetaSchema.parse(entry[1].default) as TrackMetaView; +} + +export function getAllLessons(): Lesson[] { + return Object.entries(lessonModules) + .filter(([path]) => !path.includes('_meta.json')) + .map(([, mod]) => LessonSchema.parse(mod.default)); +} + +export function getLessonById(lessonId: string): Lesson | null { + const entry = Object.entries(lessonModules) + .filter(([path]) => !path.includes('_meta.json')) + .find(([path]) => { + const ref = parseLessonPath(path); + return ref?.lessonId === lessonId; + }); + + if (!entry) return null; + return LessonSchema.parse(entry[1].default); +} + +export function getLessonsForTrack(trackId: string, locale: Locale): Lesson[] { + return getAllLessons().filter((l) => l.track === trackId && l.locale === locale); +} + +export function getLessonsForModule(trackId: string, moduleId: string, locale: Locale): Lesson[] { + return getAllLessons().filter( + (l) => l.track === trackId && l.module === moduleId && l.locale === locale, + ); +} + +export function buildLessonId(trackId: string, moduleId: string, locale: Locale, fileSlug: string): string { + return `${trackId}/${moduleId}/${locale}/${fileSlug}`; +} diff --git a/apps/web/src/features/catalog/pages/CatalogPage.tsx b/apps/web/src/features/catalog/pages/CatalogPage.tsx new file mode 100644 index 0000000..d3d8c07 --- /dev/null +++ b/apps/web/src/features/catalog/pages/CatalogPage.tsx @@ -0,0 +1,118 @@ +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; +import type { LessonProgress } from '@/db/database'; +import { getAllLessonProgress } from '@/db/repositories/progress.repo'; +import { getManifest, getTrackMeta } from '@/features/catalog/lesson-loader'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import { Card } from '@/shared/components/ui'; + +export function CatalogPage() { + const { t } = useTranslation('catalog'); + const { t: tc } = useTranslation('common'); + const contentLocale = useSettingsStore((s) => s.contentLocale); + const manifest = getManifest(); + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ {manifest.tracks.map((track) => { + const meta = getTrackMeta(track.id); + if (!meta) return null; + + return ( + + +
+ {track.icon === 'code' ? '💻' : '📝'} +
+

{meta.name[contentLocale]}

+

{meta.description[contentLocale]}

+

+ {track.modules.length} {t('modules').toLowerCase()} +

+
+
+
+ + ); + })} +
+ +

+ {tc('tagline')} +

+
+ ); +} + +export function TrackPage({ trackId }: { trackId: string }) { + const { t } = useTranslation('catalog'); + const contentLocale = useSettingsStore((s) => s.contentLocale); + const manifest = getManifest(); + const meta = getTrackMeta(trackId); + const track = manifest.tracks.find((tr) => tr.id === trackId); + const [progress, setProgress] = useState([]); + + useEffect(() => { + void getAllLessonProgress().then(setProgress); + }, []); + + if (!track || !meta) { + return

Track not found.

; + } + + return ( +
+
+

{meta.name[contentLocale]}

+

{meta.description[contentLocale]}

+
+ + {track.modules.map((mod) => { + const lessonSlugs = mod.lessons[contentLocale] ?? []; + + return ( +
+

{mod.id.replace(/-/g, ' ')}

+ + {lessonSlugs.length === 0 ? ( +

{t('noLessons')}

+ ) : ( +
+ {lessonSlugs.map((slug) => { + const lessonId = `${trackId}/${mod.id}/${contentLocale}/${slug}`; + const lp = progress.find((p) => p.lessonId === lessonId); + + return ( + + +
+

{slug.replace(/^\d{3}-/, '').replace(/-/g, ' ')}

+

+ {lp?.completed ? t('completed') : lp ? `${t('attempts')}: ${lp.attempts}` : t('notStarted')} +

+
+ {lp && ( +
+

{t('bestWpm')}

+

{lp.bestWpm}

+
+ )} +
+ + ); + })} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/features/lesson/components/CharDisplay.tsx b/apps/web/src/features/lesson/components/CharDisplay.tsx new file mode 100644 index 0000000..a14d60b --- /dev/null +++ b/apps/web/src/features/lesson/components/CharDisplay.tsx @@ -0,0 +1,37 @@ +import { buildCharStates } from '@coderkeys/engine'; +import { cn } from '@/shared/lib/cn'; + +interface CharDisplayProps { + passage: string; + input: string; + display: 'code' | 'prose'; +} + +export function CharDisplay({ passage, input, display }: CharDisplayProps) { + const states = buildCharStates(passage, input); + + return ( +
+ {states.map((state, index) => ( + + {state.char === '\n' ? '\n' : state.char} + + ))} +
+ ); +} diff --git a/apps/web/src/features/lesson/components/LessonSession.tsx b/apps/web/src/features/lesson/components/LessonSession.tsx new file mode 100644 index 0000000..0def6c6 --- /dev/null +++ b/apps/web/src/features/lesson/components/LessonSession.tsx @@ -0,0 +1,154 @@ +import { useEffect, useReducer, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TypingSession } from '@coderkeys/engine'; +import type { Lesson } from '@coderkeys/schemas'; +import { saveSessionResult } from '@/db/repositories/progress.repo'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import { CharDisplay } from './CharDisplay'; +import { Button, Card } from '@/shared/components/ui'; +import { buildLessonId } from '@/features/catalog/lesson-loader'; + +interface LessonSessionProps { + lesson: Lesson; + onComplete: () => void; +} + +function formatTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export function LessonSession({ lesson, onComplete }: LessonSessionProps) { + const { t } = useTranslation('lesson'); + const strictMode = useSettingsStore((s) => s.strictMode); + const inputRef = useRef(null); + const sessionRef = useRef(null); + const savedRef = useRef(false); + const [, forceRender] = useReducer((x: number) => x + 1, 0); + + useEffect(() => { + sessionRef.current = new TypingSession({ + passage: lesson.content.text, + strictMode, + }); + sessionRef.current.prepare(); + inputRef.current?.focus(); + savedRef.current = false; + }, [lesson, strictMode]); + + const session = sessionRef.current; + const state = session?.getState(); + const metrics = session?.getLiveMetrics(); + const isComplete = state?.status === 'complete'; + + useEffect(() => { + if (!isComplete || !session || savedRef.current) return; + + savedRef.current = true; + const result = session.getResult(); + const lessonId = buildLessonId(lesson.track, lesson.module, lesson.locale, lesson.id); + + void saveSessionResult(lessonId, lesson.track, lesson.module, result); + }, [isComplete, lesson, session]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!session) return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === 'Tab') return; + + event.preventDefault(); + + if (state?.status === 'ready') { + session.start(); + } + + const handled = session.handleKey(event.key); + if (handled) { + forceRender(); + } + }; + + const goalMet = + lesson.goals && + metrics && + (!lesson.goals.minWpm || metrics.wpm >= lesson.goals.minWpm) && + (!lesson.goals.minAccuracy || metrics.accuracy >= lesson.goals.minAccuracy); + + if (isComplete && session) { + const result = session.getResult(); + return ( + +
+

{t('completeTitle')}

+

{t('completeSubtitle')}

+
+ +
+ + + + +
+ +

+ {goalMet ? t('goalMet') : t('goalMissed')} +

+ +
+ + +
+
+ ); + } + + return ( +
+
+ + + +
+ + {strictMode &&

{t('strictModeHint')}

} + +
inputRef.current?.focus()} + role="presentation" + > + + {state?.status === 'ready' && ( +

{t('pressToStart')}

+ )} +
+ + {}} + /> +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + +

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/src/features/lesson/pages/LessonPage.tsx b/apps/web/src/features/lesson/pages/LessonPage.tsx new file mode 100644 index 0000000..361c354 --- /dev/null +++ b/apps/web/src/features/lesson/pages/LessonPage.tsx @@ -0,0 +1,36 @@ +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { getLessonById } from '@/features/catalog/lesson-loader'; +import { LessonSession } from '../components/LessonSession'; +import { Button } from '@/shared/components/ui'; + +export function LessonPage() { + const { lessonId: encodedId } = useParams<{ lessonId: string }>(); + const navigate = useNavigate(); + const lessonId = encodedId ? decodeURIComponent(encodedId) : ''; + const lesson = getLessonById(lessonId); + + if (!lesson) { + return ( +
+

Lesson not found.

+ + + +
+ ); + } + + return ( +
+
+ + ← {lesson.track} + +

{lesson.title}

+

{lesson.description}

+
+ + navigate(`/tracks/${lesson.track}`)} /> +
+ ); +} diff --git a/apps/web/src/features/settings/pages/SettingsPage.tsx b/apps/web/src/features/settings/pages/SettingsPage.tsx new file mode 100644 index 0000000..d2093e6 --- /dev/null +++ b/apps/web/src/features/settings/pages/SettingsPage.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Locale } from '@coderkeys/schemas'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import { Button, Card } from '@/shared/components/ui'; + +export function SettingsPage() { + const { t, i18n } = useTranslation('settings'); + const { uiLocale, contentLocale, strictMode, loaded, load, setUiLocale, setContentLocale, setStrictMode } = + useSettingsStore(); + const [saved, setSaved] = useState(false); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (loaded) { + void i18n.changeLanguage(uiLocale); + } + }, [loaded, uiLocale, i18n]); + + const handleUiLocale = async (locale: Locale) => { + await setUiLocale(locale); + await i18n.changeLanguage(locale); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + if (!loaded) return null; + + return ( +
+

{t('title')}

+ + +
+ +
+ handleUiLocale('en-US')}> + English + + handleUiLocale('pt-BR')}> + Português (BR) + +
+
+ +
+ +
+ void setContentLocale('en-US')} + > + English + + void setContentLocale('pt-BR')} + > + Português (BR) + +
+
+ +
+
+

{t('strictMode')}

+

{t('strictModeDesc')}

+
+ void setStrictMode(e.target.checked)} + className="h-4 w-4 accent-accent" + /> +
+ + {saved &&

{t('saved')}

} +
+
+ ); +} + +function LocaleButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 0000000..b88b8a5 --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,45 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import enCommon from './locales/en-US/common.json'; +import enCatalog from './locales/en-US/catalog.json'; +import enLesson from './locales/en-US/lesson.json'; +import enSettings from './locales/en-US/settings.json'; + +import ptCommon from './locales/pt-BR/common.json'; +import ptCatalog from './locales/pt-BR/catalog.json'; +import ptLesson from './locales/pt-BR/lesson.json'; +import ptSettings from './locales/pt-BR/settings.json'; + +void i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + 'en-US': { + common: enCommon, + catalog: enCatalog, + lesson: enLesson, + settings: enSettings, + }, + 'pt-BR': { + common: ptCommon, + catalog: ptCatalog, + lesson: ptLesson, + settings: ptSettings, + }, + }, + fallbackLng: 'en-US', + supportedLngs: ['en-US', 'pt-BR'], + defaultNS: 'common', + ns: ['common', 'catalog', 'lesson', 'settings'], + interpolation: { escapeValue: false }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'coderkeys-ui-locale', + }, + }); + +export default i18n; diff --git a/apps/web/src/i18n/locales/en-US/catalog.json b/apps/web/src/i18n/locales/en-US/catalog.json new file mode 100644 index 0000000..998466d --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/catalog.json @@ -0,0 +1,11 @@ +{ + "title": "Lesson Catalog", + "subtitle": "Choose a track and module to start practicing", + "modules": "Modules", + "lessons": "Lessons", + "noLessons": "No lessons available for this language yet.", + "bestWpm": "Best WPM", + "attempts": "Attempts", + "completed": "Completed", + "notStarted": "Not started" +} diff --git a/apps/web/src/i18n/locales/en-US/common.json b/apps/web/src/i18n/locales/en-US/common.json new file mode 100644 index 0000000..8ba6382 --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/common.json @@ -0,0 +1,17 @@ +{ + "appName": "CoderKeys", + "tagline": "Typing skills for programmers and technical writers", + "nav": { + "catalog": "Catalog", + "settings": "Settings", + "github": "GitHub" + }, + "actions": { + "start": "Start", + "retry": "Try again", + "back": "Back", + "save": "Save" + }, + "difficulty": "Difficulty", + "minutes": "min" +} diff --git a/apps/web/src/i18n/locales/en-US/lesson.json b/apps/web/src/i18n/locales/en-US/lesson.json new file mode 100644 index 0000000..2a00b42 --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/lesson.json @@ -0,0 +1,12 @@ +{ + "liveWpm": "WPM", + "liveAccuracy": "Accuracy", + "liveAwpm": "AWPM", + "elapsed": "Time", + "completeTitle": "Lesson complete!", + "completeSubtitle": "Great job. Your results have been saved locally.", + "goalMet": "Goal met", + "goalMissed": "Keep practicing", + "pressToStart": "Click here or press any key to start", + "strictModeHint": "Strict mode: errors block progress" +} diff --git a/apps/web/src/i18n/locales/en-US/settings.json b/apps/web/src/i18n/locales/en-US/settings.json new file mode 100644 index 0000000..ca96215 --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/settings.json @@ -0,0 +1,9 @@ +{ + "title": "Settings", + "uiLocale": "Interface language", + "contentLocale": "Lesson content language", + "strictMode": "Strict mode", + "strictModeDesc": "Block typing when a character is incorrect", + "theme": "Theme", + "saved": "Settings saved" +} diff --git a/apps/web/src/i18n/locales/pt-BR/catalog.json b/apps/web/src/i18n/locales/pt-BR/catalog.json new file mode 100644 index 0000000..3878d03 --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/catalog.json @@ -0,0 +1,11 @@ +{ + "title": "Catálogo de Lições", + "subtitle": "Escolha uma trilha e módulo para começar a praticar", + "modules": "Módulos", + "lessons": "Lições", + "noLessons": "Nenhuma lição disponível para este idioma ainda.", + "bestWpm": "Melhor WPM", + "attempts": "Tentativas", + "completed": "Concluída", + "notStarted": "Não iniciada" +} diff --git a/apps/web/src/i18n/locales/pt-BR/common.json b/apps/web/src/i18n/locales/pt-BR/common.json new file mode 100644 index 0000000..0e83180 --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/common.json @@ -0,0 +1,17 @@ +{ + "appName": "CoderKeys", + "tagline": "Habilidades de digitação para programadores e escritores técnicos", + "nav": { + "catalog": "Catálogo", + "settings": "Configurações", + "github": "GitHub" + }, + "actions": { + "start": "Iniciar", + "retry": "Tentar novamente", + "back": "Voltar", + "save": "Salvar" + }, + "difficulty": "Dificuldade", + "minutes": "min" +} diff --git a/apps/web/src/i18n/locales/pt-BR/lesson.json b/apps/web/src/i18n/locales/pt-BR/lesson.json new file mode 100644 index 0000000..af1cd9e --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/lesson.json @@ -0,0 +1,12 @@ +{ + "liveWpm": "PPM", + "liveAccuracy": "Precisão", + "liveAwpm": "PPM ajustado", + "elapsed": "Tempo", + "completeTitle": "Lição concluída!", + "completeSubtitle": "Ótimo trabalho. Seus resultados foram salvos localmente.", + "goalMet": "Meta atingida", + "goalMissed": "Continue praticando", + "pressToStart": "Clique aqui ou pressione qualquer tecla para começar", + "strictModeHint": "Modo rigoroso: erros bloqueiam o progresso" +} diff --git a/apps/web/src/i18n/locales/pt-BR/settings.json b/apps/web/src/i18n/locales/pt-BR/settings.json new file mode 100644 index 0000000..e3ca90a --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/settings.json @@ -0,0 +1,9 @@ +{ + "title": "Configurações", + "uiLocale": "Idioma da interface", + "contentLocale": "Idioma do conteúdo das lições", + "strictMode": "Modo rigoroso", + "strictModeDesc": "Bloquear digitação quando um caractere estiver incorreto", + "theme": "Tema", + "saved": "Configurações salvas" +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..7d88242 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,23 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; + --color-surface: #0f1419; + --color-surface-elevated: #1a2332; + --color-border: #2d3a4f; + --color-accent: #3b82f6; + --color-accent-hover: #2563eb; + --color-success: #22c55e; + --color-error: #ef4444; + --color-muted: #94a3b8; + --color-foreground: #e2e8f0; +} + +body { + @apply m-0 min-h-screen bg-surface font-sans text-foreground antialiased; +} + +#root { + @apply min-h-screen; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..a41871f --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppProviders } from './app/providers'; +import { AppRouter } from './app/router'; +import './index.css'; +import './i18n'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/apps/web/src/shared/components/Layout.tsx b/apps/web/src/shared/components/Layout.tsx new file mode 100644 index 0000000..67d770b --- /dev/null +++ b/apps/web/src/shared/components/Layout.tsx @@ -0,0 +1,54 @@ +import { Link, NavLink } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/shared/lib/cn'; + +export function Header() { + const { t } = useTranslation('common'); + + return ( +
+
+ + ⌨ + {t('appName')} + + + +
+
+ ); +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
+
{children}
+
+ ); +} diff --git a/apps/web/src/shared/components/ui.tsx b/apps/web/src/shared/components/ui.tsx new file mode 100644 index 0000000..af4d54d --- /dev/null +++ b/apps/web/src/shared/components/ui.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/shared/lib/cn'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'ghost'; +} + +export function Button({ className, variant = 'primary', ...props }: ButtonProps) { + return ( +