diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fe56774 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# EditorConfig -- https://editorconfig.org +# LF em tudo, sempre. Este repo ja teve um arquivo salvo em UTF-16 com CRLF que +# quebrou o build de forma nao obvia; charset e end_of_line aqui nao sao estilo, +# sao protecao. +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +# Markdown usa dois espacos no fim da linha como quebra de linha. +trim_trailing_whitespace = false + +[*.{yml,yaml,json,jsonc}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7138224 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +# Normaliza tudo para LF no repositorio, independente do SO de quem commita. +* text=auto eol=lf + +# Fontes e configuracao: texto, sempre LF. +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.prisma text eol=lf +*.sql text eol=lf +*.sh text eol=lf +.gitignore text eol=lf +.dockerignore text eol=lf +.editorconfig text eol=lf +Dockerfile text eol=lf + +# Binarios: o git nao mexe no conteudo nem tenta fazer diff. +*.png binary +*.ico binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.woff binary +*.woff2 binary +*.db binary + +# Lockfiles nao poluem o diff da revisao. +package-lock.json linguist-generated=true -diff diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..776a4bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug +about: Algo não funciona como deveria +title: "fix: " +labels: bug +--- + +## O que acontece + + + +## O que deveria acontecer + +## Como reproduzir + +1. +2. +3. + +## Ambiente + +- Modo: +- Porta aberta no browser: +- Browser e SO: +- Abas abertas simultaneamente: + +## Erros no console / logs + + + +``` +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d97cfde --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature +about: Sugerir uma funcionalidade ou melhoria +title: "feat: " +labels: enhancement +--- + +## O problema + + + +## Proposta + + + +## Impacto no contrato + +- [ ] Nenhum — só frontend ou só backend, sem mudar a API +- [ ] Muda a API — precisa de atualização em `CLAUDE.md` e `docs/API.md` + + + +## Alternativas consideradas diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..26e999e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## O que muda + + + +## Por quê + + + +## Como testar + + + +## O contrato mudou? + +- [ ] Não +- [ ] Sim — e atualizei `CLAUDE.md` **e** `docs/API.md` neste mesmo PR + + + +## Checklist + +- [ ] Título em Conventional Commits (`feat(frontend): ...`) +- [ ] `npx tsc --noEmit` no backend e `npm run build` no frontend passam +- [ ] `node backend/scripts/smoke.mjs` passa contra o stack de pé +- [ ] Não editei o diretório da outra sessão (`backend/` vs `frontend/`) +- [ ] Sem `node_modules/`, `dist/`, `.env` ou `*.db` no diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1512196 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: backend (typecheck) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: backend/package-lock.json + + - name: Install + run: npm ci + + # Sem prisma generate o Prisma Client nao existe e o tsc quebra em + # arquivos que nao tem relacao com o schema. + - name: Prisma generate + run: npx prisma generate + + - name: Typecheck + run: npx tsc --noEmit + + # Testes unitarios: runner nativo do Node, sem dependencia extra. Rodam + # sem banco e sem stack de pe -- o smoke, que exige os dois, e outro job. + - name: Testes unitarios + run: npm test + + frontend: + name: frontend (build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install + run: npm ci + + # "npm run build" e "tsc -b && vite build": typecheck e bundle juntos. + - name: Build + run: npm run build + + smoke: + name: smoke (stack completo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: backend/package-lock.json + + # smoke.mjs importa "ws"; ele vem das dependencias do backend. + - name: Install backend deps + run: npm ci + working-directory: backend + + - name: Sobe o stack + run: docker compose up -d --build + + - name: Espera o healthcheck do backend + run: | + for i in $(seq 1 60); do + status=$(docker compose ps --format '{{.Health}}' backend | head -n 1) + echo "tentativa $i: health=${status:-}" + if [ "$status" = "healthy" ]; then + echo "backend saudavel" + exit 0 + fi + if [ "$status" = "unhealthy" ]; then + echo "backend unhealthy" + exit 1 + fi + sleep 5 + done + echo "timeout esperando o healthcheck do backend" + exit 1 + + - name: Smoke test do contrato + run: node backend/scripts/smoke.mjs http://localhost:3001 + + # Concorrencia: 10 clientes WS, mutacoes em rajada. Pegou um race real de + # read-modify-write no POST que o smoke sequencial nunca acionaria. + - name: Teste de concorrencia do WebSocket + run: node backend/scripts/ws-stress.mjs http://localhost:3001 + + - name: Frontend responde na 8090 + run: curl -fsS -o /dev/null http://localhost:8090 + + - name: Logs do backend (em caso de falha) + if: failure() + run: docker compose logs backend + + - name: Derruba o stack + if: always() + run: docker compose down -v diff --git a/CLAUDE.md b/CLAUDE.md index 2338887..95d62f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,37 @@ A consequência é que o cliente precisa aplicar todo evento como um **upsert id Formato: `{ "type": "card.moved", "payload": { ... } }`. O payload de movimento é só `{ id, listId, position }`, nunca o board inteiro. -Eventos: `list.created`, `list.updated`, `list.deleted`, `card.created`, `card.updated`, `card.moved`, `card.deleted`. +Eventos: `list.created`, `list.updated`, `list.deleted`, `card.created`, `card.updated`, `card.moved`, `card.deleted`, `trash.updated`, `activity.recorded`. + +### A Lixeira: DELETE é soft delete + +`DELETE /api/cards/:id` e `DELETE /api/lists/:id` **não apagam** — marcam `deletedAt` e o +item vai para a lixeira, de onde pode voltar. Só as rotas `/api/trash` apagam de verdade. + +A consequência que pega desprevenido: `findUnique({ where: { id } })` continua achando a +linha. **Toda rota que opera sobre um recurso vivo precisa filtrar `deletedAt: null`** — +use `findLiveCard()`/`findLiveList()` de `backend/src/trash.ts` em vez de escrever o where +na mão. Sem isso dá para renomear, mover e criar cards dentro de coisas que o board não +mostra mais. + +Mandar uma lista para a lixeira **não** marca os cards dela: eles somem porque a lista +sumiu, e voltam junto na restauração. Marcar em cascata ressuscitaria cards que já tinham +sido jogados fora sozinhos. + +Restaurar reusa `card.created`/`list.created` em vez de inventar `*.restored`: o cliente +já aplica esses eventos como upsert por `id`, então o item reaparece sem mudança no +reducer. `trash.updated` carrega **só a contagem** `{ cards, lists }` — o conteúdo vem de +`GET /api/trash` quando a janela abre. + +### Concorrencia: ler-e-inserir precisa ser atomico + +Toda rota que calcula uma position a partir do estado atual (`POST /api/cards`, +`POST /api/lists` — ambas fazem "ultima + 1") tem que envolver a leitura **e** a +escrita em `serialize()` de `backend/src/serialize.ts`. Sem isso duas criacoes +simultaneas leem a mesma "ultima" e nascem empatadas, e dois itens empatados +tornam o drag entre eles inoperante: `positionFor()` devolve `(a+b)/2 === a`, o +servidor responde 200 e o card nao se move. O desempate por `id` esconde o +sintoma. Detalhes no fim de `docs/API.md`. ### API REST @@ -87,11 +117,22 @@ GET /api/health GET /api/board board default com lists e cards aninhados, já ordenados POST /api/lists { title } PATCH /api/lists/:id { title?, position? } -DELETE /api/lists/:id cascateia nos cards +DELETE /api/lists/:id -> lixeira, leva os cards junto POST /api/cards { listId, title, description? } PATCH /api/cards/:id { title?, description? } PATCH /api/cards/:id/move { listId, position } <- o drag-and-drop -DELETE /api/cards/:id +DELETE /api/cards/:id -> lixeira, nao apaga + +GET /api/trash { cards, lists, counts } +POST /api/trash/cards/:id/restore 409 se a lista de origem esta na lixeira +POST /api/trash/lists/:id/restore +DELETE /api/trash/cards/:id apaga de vez +DELETE /api/trash/lists/:id apaga de vez +DELETE /api/trash esvaziar lixeira + +GET /api/activity?limit=50 log de atividade, mais recente primeiro +DELETE /api/activity limpa o log +GET /api/stats contagens e agregados do board ``` `GET /api/board` é a única leitura de que o frontend precisa; depois dela o estado se mantém pelo WebSocket. Não crie endpoints `GET` por recurso individual sem necessidade real. @@ -101,3 +142,23 @@ DELETE /api/cards/:id O frontend usa o pacote npm **`98.css`** (não CDN — o container precisa buildar offline). Cada lista é uma `.window` com `.title-bar`, cada card é um item dentro de `.window-body`. Não escreva CSS que recrie bordas ou botões que o `98.css` já fornece; use as classes dele e limite o CSS próprio a layout (o board é um flex row com scroll horizontal). Drag-and-drop é HTML5 nativo (`draggable`, `dragover`, `drop`) — sem biblioteca. + +### Ícones + +`frontend/src/icons.tsx` tem os ícones em SVG pixel art, desenhados numa grade de +16x16 com a paleta VGA de 16 cores. **São recriações próprias, não os arquivos da +Microsoft** — os ícones originais do Windows 95 são proprietários e este repositório +é público. + +Três regras mantêm a aparência: `shapeRendering="crispEdges"` (sem isso o navegador +aplica antialias e o resultado parece um ícone moderno borrado), escalar só em +múltiplos inteiros do tamanho base (16, 32, 48 — um ícone de 16px renderizado em +20px mostra linhas de espessura irregular), e nada de emoji na UI: é o detalhe que +denuncia a imitação mais rápido que qualquer outro. + +### Busca: realce, não filtro + +A busca marca os cards que casam e esmaece o resto, em vez de esconder os que não +casam. Filtrar mudaria os vizinhos de cada card, e como `positionFor()` calcula a +posição nova a partir dos vizinhos do destino, arrastar durante uma busca gravaria +a posição errada. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dcf6929 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,122 @@ +# Contribuindo + +Obrigado pelo interesse. Este documento é curto de propósito: leia `CLAUDE.md` +antes de abrir um PR, ele é a autoridade sobre o contrato do projeto. + +## Rodando o projeto + +O jeito normal é o stack completo: + +```bash +docker compose up --build # frontend em :8090, API em :3001 +docker compose down -v # -v apaga o volume do SQLite (reset total) +``` + +Para iterar rápido, rode fora do Docker, em dois terminais: + +```bash +cd backend +npm install +npx prisma migrate dev # cria/atualiza dev.db e o Prisma Client +npm run dev # tsx watch, :3001 + +cd frontend +npm install +npm run dev # vite, :5173 -> chama a API em :3001 +``` + +Node 24 (é o que o `backend/Dockerfile` e o CI usam). + +Depois de mexer em `backend/prisma/schema.prisma`, rode +`npx prisma migrate dev --name `. Sem isso o Prisma Client fica +desatualizado e o TypeScript quebra em arquivos que não têm relação com a +mudança. + +## Testando + +Não há suíte unitária. O que existe — e o que o CI roda — é um smoke test do +contrato, contra um stack **de pé**: + +```bash +docker compose up -d --build +node backend/scripts/smoke.mjs # default http://localhost:3001 +node backend/scripts/smoke.mjs http://outro-host:3001 +``` + +Ele exercita os endpoints REST, o preflight de CORS de cada método (`curl` não +aplica CORS, então um smoke feito só com curl passa com o drag-and-drop +totalmente quebrado no browser) e o broadcast do WebSocket. Sai com código 1 na +primeira asserção que falhar. + +Antes de abrir o PR, garanta também o que o CI checa: + +```bash +cd backend && npm ci && npx prisma generate && npx tsc --noEmit +cd frontend && npm ci && npm run build +``` + +Se você mexeu em drag-and-drop, lixeira ou WebSocket, teste **com duas abas +abertas**: metade dos bugs desta base só aparece quando o eco do broadcast chega +em quem originou a mutação. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org/pt-br/v1.0.0/): + +``` +(): +``` + +Tipos usados aqui: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, +`build`, `perf`, `style`. Escopos típicos: `backend`, `frontend`, `api`, +`docker`, `ci`, `trash`, `ws`. + +``` +feat(frontend): adiciona janela da lixeira na taskbar +fix(backend): declara methods no CORS para liberar PATCH e DELETE +docs(api): documenta o evento trash.updated +``` + +Breaking change vai com `!` antes dos dois-pontos (`feat(api)!: ...`) e uma +seção `BREAKING CHANGE:` no corpo. Um commit por mudança lógica; não misture +refatoração com correção de bug. + +## O contrato entre as duas sessões + +Este repositório é construído por **duas sessões do Claude Code em paralelo**, +que não conversam entre si: + +| Sessão | Possui | Não edita | +|---|---|---| +| `backend` | `backend/` (inclui `backend/prisma/`), `docker-compose.yml` | `frontend/` | +| `frontend` | `frontend/` | `backend/` | + +`CLAUDE.md` e `docs/` são compartilhados. O handoff é por arquivo, um canal em +cada direção: `docs/FRONTEND-REQUESTS.md` (backend escreve, frontend lê) e +`docs/BACKEND-REQUESTS.md` (frontend escreve, backend lê). + +Consequências práticas para qualquer PR: + +1. **`CLAUDE.md` é a autoridade.** Se o código divergir dele, o errado é o + código — corrija o código, ou mude o contrato deliberadamente. +2. **Mudar o contrato exige atualizar `CLAUDE.md` e `docs/API.md` no mesmo + PR.** Contrato aqui significa: forma de payload, código de status, nome ou + payload de evento de WebSocket, semântica de `position`, portas e origens de + CORS, ou quem é dono de qual diretório. Uma dessas mudanças sem a + documentação correspondente é um PR incompleto, mesmo com o CI verde — o CI + não tem como perceber que a outra ponta ficou para trás. +3. **Não edite o diretório da outra sessão** no mesmo PR. Se precisa de algo do + outro lado, escreva o pedido no arquivo `*-REQUESTS.md` correspondente. + +## Pull requests + +- Uma mudança lógica por PR, com título em Conventional Commits. +- Preencha o template; ele pergunta como testar e se o contrato mudou. +- CI verde: os três jobs (`backend`, `frontend`, `smoke`) precisam passar. +- Não commite `node_modules/`, `dist/`, `.env` nem `*.db`. + +## Reportando bugs + +Use os templates de issue. Para bug, diga em qual modo você rodou (Docker ou +local), qual porta abriu, e — se for algo de sincronização — quantas abas +estavam abertas. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c19680 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TechFernandes LTDA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab36703 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# teamapp + +Um clone básico de Trello com o visual do Windows 95. Um único board, listas +arrastáveis, cards com drag-and-drop entre colunas, lixeira com restauração e +sincronização em tempo real entre abas por WebSocket — tudo dentro de janelas +`98.css`, com barra de título, botões chanfrados e taskbar. + +Sem autenticação e sem multiusuário: qualquer um que abrir a página vê o mesmo +board. A tela de login, quando existe, é puramente cosmética. + +## Screenshot + + + +![Board do teamapp com visual Windows 95](docs/screenshot.png) + +## Stack + +| Camada | Tecnologia | +|---|---| +| Backend | Fastify 5 (REST + WebSocket no mesmo processo) | +| Persistência | Prisma 6 + SQLite | +| Frontend | React 19 + TypeScript + Vite 6 | +| Visual | pacote npm `98.css` (não CDN — o container builda offline) | +| Drag-and-drop | HTML5 nativo (`draggable` / `dragover` / `drop`), sem biblioteca | +| Empacotamento | Docker Compose (backend em Node 24, nginx servindo o build estático) | + +## Quickstart + +Requer Docker e Docker Compose. + +```bash +docker compose up --build +``` + +- Frontend: +- API + WebSocket: + +O board padrão (`slug: "default"`) é criado pelo seed na primeira subida. + +```bash +docker compose down # para o stack, preserva os dados +docker compose down -v # -v apaga o volume do SQLite (reset total) +docker compose logs -f backend +``` + +### Por que a porta 8090 + +A escolha natural seria 8080, mas ela já estava ocupada na máquina de +desenvolvimento — o nginx do container continua escutando na 80 e o mapeamento é +`8090:80`. Se 8090 também estiver ocupada para você, mude só o lado esquerdo do +mapeamento em `docker-compose.yml`. + +O browser fala com a API **direto** em `http://localhost:3001`; não há proxy +reverso na frente dela. Isso é deliberado: proxiar o upgrade do WebSocket pelo +nginx é a fonte número um de falha silenciosa nesse tipo de stack. + +## Desenvolvimento local + +Duas abas de terminal. Node 24 (o backend roda em `node:24-slim` no Docker). + +### Backend — porta 3001 + +```bash +cd backend +npm install +npx prisma migrate dev # cria/atualiza dev.db e o Prisma Client +npm run seed # recria o board padrão +npm run dev # tsx watch, :3001 +``` + +Produção: `npm run build && npm start`. + +Depois de mexer em `prisma/schema.prisma`, rode +`npx prisma migrate dev --name `. Sem isso o Prisma Client fica +desatualizado e o TypeScript quebra em lugares que não têm relação com a +mudança. + +### Frontend — porta 5173 + +```bash +cd frontend +npm install +npm run dev # vite, :5173 -> chama a API em :3001 +npm run build # tsc -b && vite build -> dist/ +``` + +A base da API vem de `VITE_API_URL` (default `http://localhost:3001`). O Vite +resolve env vars em **build time**, por isso o `docker-compose.yml` passa o +valor como build arg e não como variável de runtime do container. + +O CORS do backend libera as origens `5173`, `8080` e `8090`, e declara os +métodos explicitamente — o default do `@fastify/cors` (`GET,HEAD,POST`) reprova +PATCH e DELETE no preflight e derruba o drag-and-drop no browser. + +## Estrutura + +``` +teamapp/ +├── backend/ +│ ├── prisma/ +│ │ ├── schema.prisma Board 1─n List 1─n Card +│ │ ├── migrations/ +│ │ └── seed.ts cria o board "default" +│ ├── scripts/ +│ │ └── smoke.mjs smoke test do contrato (REST + WebSocket) +│ ├── src/ +│ │ ├── server.ts Fastify: CORS, error handlers, WebSocket, rotas +│ │ ├── bus.ts broadcast dos eventos para todos os clientes +│ │ ├── db.ts Prisma Client +│ │ ├── trash.ts regras do soft delete +│ │ └── routes/ board.ts · lists.ts · cards.ts · trash.ts +│ └── Dockerfile +├── frontend/ +│ ├── src/ +│ │ ├── api.ts wrappers dos endpoints + a matemática do drag +│ │ ├── useBoard.ts GET /api/board, WebSocket com backoff, reducer +│ │ ├── App.tsx board, header, taskbar, estado do drag +│ │ ├── ListColumn.tsx uma lista (.window) e seus alvos de drop +│ │ ├── CardDialog.tsx edição de título/descrição +│ │ └── styles.css só layout — bordas e botões vêm do 98.css +│ ├── nginx.conf +│ └── Dockerfile +├── docs/ +│ ├── API.md contrato completo da API +│ ├── BACKEND-REQUESTS.md canal frontend -> backend +│ └── FRONTEND-REQUESTS.md canal backend -> frontend +├── docker-compose.yml +└── CLAUDE.md o contrato entre as duas sessões de desenvolvimento +``` + +## API + +O contrato completo — payloads, códigos de erro, eventos de WebSocket e as +regras da lixeira — está em **[`docs/API.md`](docs/API.md)**. + +Resumo: + +``` +GET /api/health +GET /api/board board default, lists e cards já ordenados +POST /api/lists PATCH /api/lists/:id DELETE /api/lists/:id +POST /api/cards PATCH /api/cards/:id DELETE /api/cards/:id +PATCH /api/cards/:id/move { listId, position } <- o drag-and-drop +GET /api/trash restore e delete definitivo em /api/trash/... +ws://localhost:3001/ws eventos de toda mutação +``` + +Duas coisas que valem saber antes de ler o resto: + +- **`position` é um `Float` fracionário**, não um índice inteiro. Mover um card + entre `a` e `b` grava `(a.position + b.position) / 2`. Uma única linha por + drag; quem calcula a posição é o cliente, que conhece os vizinhos. +- **Todo evento de WebSocket vai para todos os clientes, inclusive o autor da + mutação.** O cliente aplica cada evento como upsert idempotente por `id` — + aplicar duas vezes tem que dar o mesmo resultado. + +## Testes + +Com o stack no ar: + +```bash +node backend/scripts/smoke.mjs # default http://localhost:3001 +node backend/scripts/smoke.mjs http://localhost:3001 +``` + +O script exercita os endpoints, o preflight de CORS de cada método e o broadcast +do WebSocket. Sai com código 1 na primeira falha de asserção. + +## Contribuindo + +Veja [`CONTRIBUTING.md`](CONTRIBUTING.md). Em resumo: Conventional Commits, CI +verde, e mudança de comportamento da API significa atualizar `CLAUDE.md` **e** +`docs/API.md` no mesmo PR. + +## Licença + +MIT — veja [`LICENSE`](LICENSE). diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7d0443b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +# Variaveis do backend. Copie para `.env` (`cp .env.example .env`) antes de rodar +# fora do Docker. Os defaults abaixo sao os que estao no codigo -- ver +# backend/src/server.ts (PORT/HOST) e backend/prisma/schema.prisma (DATABASE_URL). + +# Banco SQLite, lido por `env("DATABASE_URL")` no schema.prisma. Nao tem default +# no codigo: o Prisma falha na hora se a variavel estiver ausente. +# Caminho `file:` relativo e resolvido a partir de backend/prisma/, entao +# "file:./dev.db" e o arquivo backend/prisma/dev.db. +# No Docker o valor e absoluto e aponta para o volume nomeado: "file:/data/dev.db" +# (backend/Dockerfile e docker-compose.yml). Nunca aponte para um bind mount do +# Windows -- o file locking do SQLite falha de forma intermitente ali. +DATABASE_URL="file:./dev.db" + +# Porta do processo Fastify: REST e WebSocket no mesmo listener. +# Default no codigo: 3001 (`Number(process.env.PORT ?? 3001)`). +PORT=3001 + +# Interface de bind. Default no codigo: 0.0.0.0 (`process.env.HOST ?? '0.0.0.0'`), +# que e o necessario dentro do container para a porta ser publicada no host. +# Em dev local, 127.0.0.1 basta. +HOST=0.0.0.0 diff --git a/backend/package.json b/backend/package.json index 4dd6703..4fd1c2e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js", - "seed": "tsx prisma/seed.ts" + "seed": "tsx prisma/seed.ts", + "test": "node --test \"test/**/*.test.mjs\"" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/backend/prisma/migrations/20260821040639_lixeira/migration.sql b/backend/prisma/migrations/20260821040639_lixeira/migration.sql new file mode 100644 index 0000000..f715cfe --- /dev/null +++ b/backend/prisma/migrations/20260821040639_lixeira/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Card" ADD COLUMN "deletedAt" DATETIME; + +-- AlterTable +ALTER TABLE "List" ADD COLUMN "deletedAt" DATETIME; + +-- CreateIndex +CREATE INDEX "Card_deletedAt_idx" ON "Card"("deletedAt"); + +-- CreateIndex +CREATE INDEX "List_deletedAt_idx" ON "List"("deletedAt"); diff --git a/backend/prisma/migrations/20260821044819_atividade_e_stats/migration.sql b/backend/prisma/migrations/20260821044819_atividade_e_stats/migration.sql new file mode 100644 index 0000000..5933801 --- /dev/null +++ b/backend/prisma/migrations/20260821044819_atividade_e_stats/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "Activity" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Card" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "position" REAL NOT NULL, + "listId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" DATETIME, + CONSTRAINT "Card_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Card" ("deletedAt", "description", "id", "listId", "position", "title") SELECT "deletedAt", "description", "id", "listId", "position", "title" FROM "Card"; +DROP TABLE "Card"; +ALTER TABLE "new_Card" RENAME TO "Card"; +CREATE INDEX "Card_listId_position_idx" ON "Card"("listId", "position"); +CREATE INDEX "Card_deletedAt_idx" ON "Card"("deletedAt"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Activity_createdAt_idx" ON "Activity"("createdAt"); diff --git a/backend/prisma/migrations/20260821050000_normaliza_createdat_dos_cards/migration.sql b/backend/prisma/migrations/20260821050000_normaliza_createdat_dos_cards/migration.sql new file mode 100644 index 0000000..4bba81a --- /dev/null +++ b/backend/prisma/migrations/20260821050000_normaliza_createdat_dos_cards/migration.sql @@ -0,0 +1,26 @@ +-- Conserta o backfill de Card.createdAt feito pela migration anterior. +-- +-- A migration `atividade-e-stats` copiou os cards existentes para a tabela nova +-- sem a coluna `createdAt`, entao cada linha antiga caiu no +-- `DEFAULT CURRENT_TIMESTAMP` do SQLite -- que grava TEXT ("2026-08-21 04:48:19"). +-- O Prisma, ao inserir, grava DateTime como INTEGER (ms desde a epoch). +-- +-- Isso quebra ORDER BY, nao a leitura: o SQLite ordena por classe de tipo antes +-- do valor (INTEGER < TEXT), entao TODA linha antiga se ordena depois de TODA +-- linha nova, independentemente da data. Na pratica GET /api/stats devolvia como +-- `newestCard` um card do seed em vez do card recem-criado -- e os dois +-- apareciam com a data certa no JSON, o que torna o bug invisivel na resposta. +-- +-- A conversao preserva o instante: julianday() interpreta a string como UTC, +-- que e o fuso de CURRENT_TIMESTAMP. +UPDATE "Card" +SET "createdAt" = CAST((julianday("createdAt") - 2440587.5) * 86400000.0 AS INTEGER) +WHERE typeof("createdAt") = 'text'; + +-- Mesmo tratamento por seguranca no log: `Activity.createdAt` tambem tem +-- DEFAULT CURRENT_TIMESTAMP no schema SQL. Hoje o default nunca dispara (o +-- Prisma sempre manda o valor), mas um INSERT feito na mao pelo sqlite3 criaria +-- a mesma mistura de tipos e o log apareceria fora de ordem. +UPDATE "Activity" +SET "createdAt" = CAST((julianday("createdAt") - 2440587.5) * 86400000.0 AS INTEGER) +WHERE typeof("createdAt") = 'text'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 89f21d9..ac4bb5b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,23 +15,49 @@ model Board { } model Card { - id String @id @default(cuid()) + id String @id @default(cuid()) title String - description String @default("") + description String @default("") position Float listId String - list List @relation(fields: [listId], references: [id], onDelete: Cascade) + list List @relation(fields: [listId], references: [id], onDelete: Cascade) + // GET /api/stats precisa de oldestCard/newestCard, e "mais antigo" so existe se + // a linha souber quando nasceu. cuid() e ordenavel por tempo, mas so por acaso + // de implementacao -- nao da para ordenar por ele e chamar de data. + createdAt DateTime @default(now()) + // Lixeira: null = no board, timestamp = na lixeira. DELETE /api/cards/:id + // marca; DELETE /api/trash/... e que apaga de verdade. + deletedAt DateTime? @@index([listId, position]) + @@index([deletedAt]) } model List { - id String @id @default(cuid()) + id String @id @default(cuid()) title String position Float boardId String - board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) cards Card[] + // Lixeira. Mandar uma lista para a lixeira NAO marca os cards dela: eles somem + // do board porque a lista sumiu, e voltam junto quando ela e restaurada. Marcar + // em cascata ressuscitaria cards que ja tinham sido jogados fora sozinhos. + deletedAt DateTime? @@index([boardId, position]) + @@index([deletedAt]) +} + +/// O Visualizador de Eventos do Windows 95: uma linha por mutacao, so texto ja +/// formatado. NAO ha FK para Card/List de proposito -- o log tem que sobreviver +/// ao "Esvaziar Lixeira", que apaga as linhas de verdade; um onDelete: Cascade +/// levaria junto exatamente o historico que se quer consultar depois. +model Activity { + id String @id @default(cuid()) + type String + summary String + createdAt DateTime @default(now()) + + @@index([createdAt]) } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 69ed338..aafa82e 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -31,6 +31,21 @@ async function main() { }) } + // Um item ja na lixeira: deixa a feature demonstravel num stack novo e serve + // de conferencia de que o GET /api/board realmente esconde o que foi jogado fora. + const inbox = await prisma.list.findFirst({ where: { boardId: board.id }, orderBy: { position: 'asc' } }) + if (inbox) { + await prisma.card.create({ + data: { + listId: inbox.id, + title: 'Trabalho de Estagio.doc', + description: 'Apagado por engano em 1997.', + position: 99, + deletedAt: new Date(), + }, + }) + } + console.log('seed ok') } diff --git a/backend/scripts/reset-db.mjs b/backend/scripts/reset-db.mjs new file mode 100644 index 0000000..69e8cee --- /dev/null +++ b/backend/scripts/reset-db.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Reset destrutivo do banco de DESENVOLVIMENTO local. +// +// node scripts/reset-db.mjs # dry-run: mostra o que faria e sai 0 +// node scripts/reset-db.mjs --yes # executa de verdade +// +// O que faz, nessa ordem: +// 1. apaga o arquivo SQLite apontado por DATABASE_URL (e os -journal/-wal/-shm) +// 2. `npx prisma migrate deploy` -- recria o schema a partir de prisma/migrations +// 3. `npm run seed` -- recria o board default +// +// Sem dependencia nova: so node:fs, node:path, node:child_process. +// +// Nao serve para o container: la o banco vive no volume nomeado (/data/dev.db) e +// o reset e `docker compose down -v`. O script recusa caminhos fora de backend/ +// justamente para nao apagar o banco errado por acidente. + +import { execSync } from 'node:child_process' +import { existsSync, readFileSync, rmSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const BACKEND_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PRISMA_DIR = join(BACKEND_DIR, 'prisma') +const CONFIRM_FLAG = '--yes' + +const confirmed = process.argv.slice(2).includes(CONFIRM_FLAG) + +/** DATABASE_URL do ambiente, com fallback no backend/.env (o Prisma faz igual). */ +function databaseUrl() { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL + + const envFile = join(BACKEND_DIR, '.env') + if (existsSync(envFile)) { + for (const line of readFileSync(envFile, 'utf8').split(/\r?\n/)) { + const match = /^\s*DATABASE_URL\s*=\s*(.*)$/.exec(line) + if (match) return match[1].trim().replace(/^["']|["']$/g, '') + } + } + return null +} + +/** + * `file:./dev.db` -> caminho absoluto. Caminho relativo no Prisma e resolvido a + * partir do diretorio do schema (backend/prisma/), nao do cwd. + */ +function sqlitePath(url) { + if (!url.startsWith('file:')) return null + const raw = url.slice('file:'.length) + return resolve(PRISMA_DIR, raw) +} + +function fail(message) { + console.error(`reset-db: ${message}`) + process.exit(1) +} + +const url = databaseUrl() +if (!url) fail('DATABASE_URL nao definida (nem no ambiente, nem em backend/.env). Copie .env.example.') + +const dbFile = sqlitePath(url) +if (!dbFile) fail(`DATABASE_URL="${url}" nao e uma URL file: -- este script so mexe em SQLite local.`) + +// Trava de seguranca: so apaga dentro de backend/. `/data/dev.db` do container cai aqui. +const inside = relative(BACKEND_DIR, dbFile) +if (inside.startsWith('..') || resolve(dbFile) === resolve(BACKEND_DIR)) { + fail( + `recusando apagar "${dbFile}": esta fora de ${BACKEND_DIR}.\n` + + ' Se e o banco do container, o reset e `docker compose down -v`.', + ) +} + +const sidecars = ['-journal', '-wal', '-shm'].map((suffix) => dbFile + suffix) +const targets = [dbFile, ...sidecars].filter((file) => existsSync(file)) + +const size = existsSync(dbFile) ? `${statSync(dbFile).size} bytes` : 'nao existe ainda' +const steps = [ + targets.length ? `apagar: ${targets.join(', ')}` : 'nada para apagar (banco ausente)', + 'npx prisma migrate deploy', + 'npm run seed', +] + +console.log('') +console.log(' *** DESTRUTIVO: apaga TODO o conteudo do banco local. Nao da para desfazer. ***') +console.log('') +console.log(` DATABASE_URL : ${url}`) +console.log(` arquivo : ${dbFile} (${size})`) +console.log('') +steps.forEach((step, i) => console.log(` ${i + 1}. ${step}`)) +console.log('') + +if (!confirmed) { + console.log(` Dry-run: nada foi tocado. Rode com ${CONFIRM_FLAG} para executar de verdade.`) + console.log('') + process.exit(0) +} + +// `execSync` (linha de comando unica) e nao `execFileSync`: no Windows npx/npm +// sao .cmd, que o Node so executa atraves do shell -- e passar `args` junto com +// `shell: true` dispara DEP0190. Os comandos aqui sao literais fixos, sem +// interpolacao de entrada externa. +function run(commandLine) { + console.log(` $ ${commandLine}`) + execSync(commandLine, { + cwd: BACKEND_DIR, + stdio: 'inherit', + env: { ...process.env, DATABASE_URL: url }, + }) +} + +try { + for (const file of targets) { + rmSync(file, { force: true }) + console.log(` removido ${file}`) + } + + // `migrate deploy` (nao `migrate dev`): aplica as migrations existentes sem + // tentar gerar uma nova nem abrir prompt -- e o que um script nao-interativo quer. + run('npx prisma migrate deploy') + run('npm run seed') +} catch (err) { + fail(`falhou: ${err.message}`) +} + +console.log('') +console.log(' Banco recriado e semeado.') +console.log('') diff --git a/backend/scripts/smoke.mjs b/backend/scripts/smoke.mjs index 30b0fad..3dc21b8 100644 --- a/backend/scripts/smoke.mjs +++ b/backend/scripts/smoke.mjs @@ -139,6 +139,8 @@ async function main() { ['DELETE', `/api/cards/${cardId}`], ['DELETE', `/api/lists/${listId}`], ['POST', '/api/cards'], + ['DELETE', '/api/trash'], + ['POST', `/api/trash/cards/${cardId}/restore`], ]) { const { origin, methods } = await preflight(method, path) check( @@ -189,6 +191,155 @@ async function main() { const after = await api('GET', '/api/board') check('lista deletada sumiu do board', !after.body.lists.some((l) => l.id === listId)) + // --- Lixeira --- + // Depois dos DELETEs acima, `cardId` e `listId` estao na lixeira, nao apagados. + // Estas assercoes existem porque soft delete muda o significado de todo + // findUnique por id: sem filtro de deletedAt da para renomear, mover e criar + // dentro de coisas que o board nao mostra mais. + const trash = await api('GET', '/api/trash') + check('GET /api/trash -> 200', trash.status === 200) + const trashedCard = trash.body?.cards?.find((c) => c.id === cardId) + const trashedList = trash.body?.lists?.find((l) => l.id === listId) + check('card deletado aparece na lixeira', Boolean(trashedCard)) + check('lista deletada aparece na lixeira', Boolean(trashedList)) + check('card na lixeira traz originalList', Boolean(trashedCard?.originalList?.title)) + check( + 'originalList.deleted e false quando a lista de origem esta viva', + trashedCard?.originalList?.deleted === false, + ) + check('lista na lixeira traz cardCount', typeof trashedList?.cardCount === 'number') + check('WS recebeu trash.updated', events.some((e) => e.type === 'trash.updated')) + const trashEvent = events.filter((e) => e.type === 'trash.updated').pop() + check( + 'payload de trash.updated e so a contagem { cards, lists }', + trashEvent && Object.keys(trashEvent.payload).sort().join(',') === 'cards,lists', + trashEvent ? JSON.stringify(trashEvent.payload) : 'evento ausente', + ) + const boardWithTrash = await api('GET', '/api/board') + check('GET /api/board traz a contagem da lixeira', typeof boardWithTrash.body?.trash?.cards === 'number') + check( + 'card na lixeira nao aparece no board', + !boardWithTrash.body.lists.flatMap((l) => l.cards).some((c) => c.id === cardId), + ) + + // Nada que opera sobre recurso vivo pode enxergar item na lixeira. + const patchTrashed = await api('PATCH', `/api/cards/${cardId}`, { title: 'zumbi' }) + check('PATCH em card na lixeira -> 404', patchTrashed.status === 404, `status ${patchTrashed.status}`) + const moveTrashed = await api('PATCH', `/api/cards/${cardId}/move`, { listId: targetList.id, position: 0 }) + check('move de card na lixeira -> 404', moveTrashed.status === 404, `status ${moveTrashed.status}`) + const renameTrashedList = await api('PATCH', `/api/lists/${listId}`, { title: 'zumbi' }) + check('PATCH em lista na lixeira -> 404', renameTrashedList.status === 404, `status ${renameTrashedList.status}`) + const cardIntoTrashedList = await api('POST', '/api/cards', { listId, title: 'zumbi' }) + check('POST /api/cards em lista na lixeira -> 404', cardIntoTrashedList.status === 404, `status ${cardIntoTrashedList.status}`) + const someLiveCardId = boardWithTrash.body.lists.flatMap((l) => l.cards)[0]?.id ?? 'x' + const moveIntoTrashedList = await api('PATCH', `/api/cards/${someLiveCardId}/move`, { listId, position: 0 }) + check('move para lista na lixeira -> 404', moveIntoTrashedList.status === 404, `status ${moveIntoTrashedList.status}`) + + // Restaurar devolve o item ao lugar de origem e ele reaparece no board. + const restored = await api('POST', `/api/trash/cards/${cardId}/restore`) + check('POST /api/trash/cards/:id/restore -> 200', restored.status === 200, `status ${restored.status}`) + check('card restaurado sai da lixeira', restored.body?.deletedAt === null) + await waitFor(250) + const boardAfterRestore = await api('GET', '/api/board') + check( + 'card restaurado volta ao board', + boardAfterRestore.body.lists.flatMap((l) => l.cards).some((c) => c.id === cardId), + ) + check( + 'restaurar reusa card.created (o cliente ja faz upsert por id)', + events.filter((e) => e.type === 'card.created').some((e) => e.payload?.id === cardId), + ) + const doubleRestore = await api('POST', `/api/trash/cards/${cardId}/restore`) + check('restaurar card que ja voltou -> 404', doubleRestore.status === 404) + + const restoredList = await api('POST', `/api/trash/lists/${listId}/restore`) + check('POST /api/trash/lists/:id/restore -> 200', restoredList.status === 200, `status ${restoredList.status}`) + check( + 'lista restaurada vem com cards[] (senao o cliente sobrescreve com vazio)', + Array.isArray(restoredList.body?.cards), + ) + const boardAfterListRestore = await api('GET', '/api/board') + check('lista restaurada volta ao board', boardAfterListRestore.body.lists.some((l) => l.id === listId)) + + // Card na lixeira cuja lista tambem foi para a lixeira: 409 explicito, nao 500 + // nem restauracao dentro de uma lista que o board nao mostra. + const orphan = await api('POST', '/api/cards', { listId, title: 'Orfao' }) + const orphanId = orphan.body?.id + await api('DELETE', `/api/cards/${orphanId}`) + await api('DELETE', `/api/lists/${listId}`) + const orphanRestore = await api('POST', `/api/trash/cards/${orphanId}/restore`) + check('restaurar card cuja lista esta na lixeira -> 409', orphanRestore.status === 409, `status ${orphanRestore.status}`) + const trashWithOrphan = await api('GET', '/api/trash') + check( + 'originalList.deleted avisa que o restore vai falhar', + trashWithOrphan.body.cards.find((c) => c.id === orphanId)?.originalList?.deleted === true, + ) + const listBack = await api('POST', `/api/trash/lists/${listId}/restore`) + check('restaurar a lista destrava o card', listBack.status === 200) + check( + 'card jogado fora sozinho NAO ressuscita junto com a lista', + !listBack.body.cards.some((c) => c.id === orphanId), + ) + const trashAfterListBack = await api('GET', '/api/trash') + check('...e continua na lixeira', trashAfterListBack.body.cards.some((c) => c.id === orphanId)) + const orphanRestore2 = await api('POST', `/api/trash/cards/${orphanId}/restore`) + check('com a lista de volta, o card restaura', orphanRestore2.status === 200, `status ${orphanRestore2.status}`) + + // Apagar de vez, item a item. + await api('DELETE', `/api/cards/${orphanId}`) + const purge = await api('DELETE', `/api/trash/cards/${orphanId}`) + check('DELETE /api/trash/cards/:id -> 204', purge.status === 204) + const trashAfterPurge = await api('GET', '/api/trash') + check('apagado de vez some da lixeira', !trashAfterPurge.body.cards.some((c) => c.id === orphanId)) + const purgeLive = await api('DELETE', `/api/trash/cards/${cardId}`) + check('apagar de vez um card vivo -> 404', purgeLive.status === 404) + + // Esvaziar Lixeira. Destrutivo de verdade: guarda o card de demonstracao do + // seed antes e recria pela API depois, senao rodar o smoke apaga a piada. + const trashBeforeEmpty = await api('GET', '/api/trash') + const demo = trashBeforeEmpty.body.cards.find((c) => c.title === 'Trabalho de Estagio.doc') + await api('DELETE', `/api/cards/${cardId}`) + await api('DELETE', `/api/lists/${listId}`) + // Um card VIVO dentro de uma lista na lixeira morre no cascade do esvaziar. + // A contagem retornada tem que inclui-lo: contar so `deletedAt != null` + // reportava menos linhas do que a operacao realmente apagava. + const doomedList = await api('POST', '/api/lists', { title: 'Lista com card vivo' }) + const liveInside = await api('POST', '/api/cards', { + listId: doomedList.body.id, + title: 'Vivo, mas condenado', + }) + await api('DELETE', `/api/lists/${doomedList.body.id}`) + const countsBefore = (await api('GET', '/api/trash')).body.counts + + const emptied = await api('DELETE', '/api/trash') + check( + 'DELETE /api/trash -> 200 com { deleted }', + emptied.status === 200 && typeof emptied.body?.deleted?.cards === 'number', + `status ${emptied.status}`, + ) + check( + 'esvaziar conta o card vivo que o cascade leva junto', + emptied.body?.deleted?.cards > countsBefore.cards, + `reportou ${emptied.body?.deleted?.cards}, lixeira tinha ${countsBefore.cards} + 1 vivo dentro da lista`, + ) + const gone = await api('PATCH', `/api/cards/${liveInside.body.id}`, { title: 'x' }) + check('...e ele realmente sumiu', gone.status === 404, `status ${gone.status}`) + + const afterEmpty = await api('GET', '/api/trash') + check('lixeira fica vazia', afterEmpty.body.cards.length === 0 && afterEmpty.body.lists.length === 0) + const boardAfterEmpty = await api('GET', '/api/board') + check('esvaziar a lixeira nao toca no board', boardAfterEmpty.body.lists.length > 0) + check('lista apagada de vez nao volta', !boardAfterEmpty.body.lists.some((l) => l.id === listId)) + + if (demo && !demo.originalList.deleted) { + const remade = await api('POST', '/api/cards', { + listId: demo.originalList.id, + title: demo.title, + description: demo.description, + }) + if (remade.status === 201) await api('DELETE', `/api/cards/${remade.body.id}`) + } + ws.close() console.log(failures === 0 ? '\nSMOKE OK' : `\nSMOKE FALHOU: ${failures} assercao(oes)`) process.exit(failures === 0 ? 0 : 1) diff --git a/backend/scripts/ws-stress.mjs b/backend/scripts/ws-stress.mjs new file mode 100644 index 0000000..c086472 --- /dev/null +++ b/backend/scripts/ws-stress.mjs @@ -0,0 +1,577 @@ +/** + * Teste de carga do WebSocket: prova sob concorrencia as invariantes que o + * CLAUDE.md declara e que o smoke.mjs so verifica com um cliente e uma mutacao + * de cada vez. + * + * Uso: node scripts/ws-stress.mjs [baseUrl] (default http://localhost:3001) + * Sai com codigo 1 se qualquer assercao falhar. + * + * O que ele prova: + * + * 1. BROADCAST NAO FILTRA AUTOR -- 10 clientes abertos ao mesmo tempo recebem + * exatamente a mesma sequencia de eventos, na mesma ordem. Se o servidor + * algum dia tentar "otimizar" pulando quem originou a mutacao, ou se um + * socket lento perder mensagens, os logs divergem e o teste quebra. + * + * 2. IDEMPOTENCIA POR id -- o reducer daqui e um espelho de + * frontend/src/useBoard.ts. Aplicar cada evento duas vezes seguidas tem que + * dar o mesmo estado que aplicar uma vez (e o caso real: quem faz a mutacao + * ve a resposta HTTP E o eco do broadcast). E aplicar o log inteiro duas + * vezes tem que convergir para o mesmo estado. + * + * 3. CONVERGENCIA -- o estado derivado so dos eventos, partindo de um + * GET /api/board tirado ANTES da primeira mutacao, tem que bater com um + * GET /api/board fresco tirado DEPOIS da ultima. Esta e a assercao que vale + * o script: se ela falha, ou o servidor esqueceu de emitir um evento, ou + * emitiu um payload que nao descreve o que ele gravou. Nos dois casos o + * board do browser fica errado ate o proximo reload. + * + * O script assume que ninguem mais esta mutando o board enquanto ele roda (uma + * aba aberta so olhando nao atrapalha -- o WebSocket e so servidor -> cliente). + */ +import WebSocket from 'ws' + +const BASE = process.argv[2] ?? 'http://localhost:3001' +const WS_URL = BASE.replace(/^http/, 'ws') + '/ws' +const CLIENTS = 10 + +let failures = 0 + +function check(name, ok, detail = '') { + if (ok) { + console.log(` ok ${name}`) + } else { + failures++ + console.log(` FAIL ${name}${detail ? ' -- ' + detail : ''}`) + } +} + +function section(title) { + console.log(`\n${title}`) +} + +async function api(method, path, body) { + const res = await fetch(BASE + path, { + method, + headers: body ? { 'content-type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + return { status: res.status, body: text ? JSON.parse(text) : null } +} + +const waitFor = (ms) => new Promise((r) => setTimeout(r, ms)) + +/** Igualdade estrutural com chaves ordenadas -- serve tambem para o diff. */ +function canon(value) { + if (Array.isArray(value)) return `[${value.map(canon).join(',')}]` + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canon(value[k])}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +const same = (a, b) => canon(a) === canon(b) + +// --------------------------------------------------------------------------- +// Reducer: espelho de frontend/src/useBoard.ts. Se as duas semanticas +// divergirem, este teste passa e o browser quebra -- entao mantenha-os iguais. +// --------------------------------------------------------------------------- + +const byPosition = (items) => [...items].sort((a, b) => a.position - b.position) + +function reduce(board, event) { + switch (event.type) { + case 'list.created': + case 'list.updated': { + const incoming = event.payload + const exists = board.lists.some((l) => l.id === incoming.id) + const lists = exists + ? board.lists.map((l) => { + if (l.id !== incoming.id) return l + const cards = 'cards' in incoming ? incoming.cards : l.cards + return { ...l, ...incoming, cards: byPosition(cards) } + }) + : [...board.lists, { ...incoming, cards: byPosition(incoming.cards ?? []) }] + return { ...board, lists: byPosition(lists) } + } + + case 'list.deleted': + return { ...board, lists: board.lists.filter((l) => l.id !== event.payload.id) } + + case 'card.created': + case 'card.updated': { + const card = event.payload + if (!board.lists.some((l) => l.id === card.listId)) return { ...board, stale: true } + const lists = board.lists.map((list) => { + const without = list.cards.filter((c) => c.id !== card.id) + if (list.id !== card.listId) { + return without.length === list.cards.length ? list : { ...list, cards: without } + } + return { ...list, cards: byPosition([...without, card]) } + }) + return { ...board, lists } + } + + case 'card.moved': { + const { id, listId, position } = event.payload + const existing = board.lists.flatMap((l) => l.cards).find((c) => c.id === id) + if (!existing) return { ...board, stale: true } + const moved = { ...existing, listId, position } + const lists = board.lists.map((list) => { + const without = list.cards.filter((c) => c.id !== id) + if (list.id !== listId) { + return without.length === list.cards.length ? list : { ...list, cards: without } + } + return { ...list, cards: byPosition([...without, moved]) } + }) + return { ...board, lists } + } + + case 'trash.updated': + return { ...board, trash: event.payload } + + case 'card.deleted': { + const { id } = event.payload + return { + ...board, + lists: board.lists.map((l) => { + const cards = l.cards.filter((c) => c.id !== id) + return cards.length === l.cards.length ? l : { ...l, cards } + }), + } + } + + default: + return board + } +} + +const replay = (base, log) => log.reduce(reduce, base) + +/** Aplica cada evento DUAS vezes seguidas -- o eco do broadcast em cima da resposta HTTP. */ +const replayDoubled = (base, log) => + log.reduce((acc, e) => reduce(reduce(acc, e), e), base) + +/** + * Forma canonica de um board para comparacao. + * + * A ordenacao usa (position, id) porque e o que o servidor faz + * (`orderBy: [{position}, {id}]`, ver docs/API.md): com dois cards empatados na + * mesma position -- o que POSTs paralelos na mesma lista conseguem produzir -- + * comparar so por position acusaria uma divergencia que nao existe. Os dois + * lados passam pela mesma normalizacao, entao uma divergencia de verdade + * (card na lista errada, position errada, titulo velho) continua aparecendo. + */ +function snapshot(board) { + const ord = (a, b) => a.position - b.position || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + return { + trash: board.trash ?? null, + lists: [...board.lists].sort(ord).map((l) => ({ + id: l.id, + title: l.title, + position: l.position, + cards: [...l.cards].sort(ord).map((c) => ({ + id: c.id, + listId: c.listId, + title: c.title, + description: c.description, + position: c.position, + })), + })), + } +} + +/** Primeira diferenca entre dois snapshots, em texto -- para a mensagem do FAIL. */ +function firstDiff(a, b, path = '$') { + if (canon(a) === canon(b)) return null + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return `${path}: ${a.length} vs ${b.length} itens` + for (let i = 0; i < a.length; i++) { + const d = firstDiff(a[i], b[i], `${path}[${i}]`) + if (d) return d + } + } + if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a)) { + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + const d = firstDiff(a[k], b[k], `${path}.${k}`) + if (d) return d + } + } + return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}` +} + +// --------------------------------------------------------------------------- + +function openClient(index) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(WS_URL) + const client = { index, socket, events: [], raw: [] } + const timer = setTimeout(() => reject(new Error(`timeout no handshake do cliente ${index}`)), 5000) + socket.on('open', () => { + clearTimeout(timer) + resolve(client) + }) + socket.on('error', (e) => { + clearTimeout(timer) + reject(e) + }) + socket.on('message', (data) => { + const text = data.toString() + client.raw.push(text) + client.events.push(JSON.parse(text)) + }) + }) +} + +/** Espera o broadcast parar de chegar: `quietMs` sem nenhum evento novo. */ +async function settle(clients, quietMs = 600, maxMs = 15000) { + const total = () => clients.reduce((n, c) => n + c.events.length, 0) + const started = Date.now() + let last = total() + let lastChange = Date.now() + while (Date.now() - started < maxMs) { + await waitFor(100) + const now = total() + if (now !== last) { + last = now + lastChange = Date.now() + } else if (Date.now() - lastChange >= quietMs) { + return + } + } +} + +async function main() { + console.log(`ws-stress contra ${BASE} com ${CLIENTS} clientes`) + + const health = await api('GET', '/api/health') + if (health.status !== 200) { + console.error(`servidor nao respondeu /api/health (status ${health.status})`) + process.exit(1) + } + + section('conexoes') + const clients = await Promise.all(Array.from({ length: CLIENTS }, (_, i) => openClient(i))) + check( + `${CLIENTS} clientes conectados simultaneamente`, + clients.every((c) => c.socket.readyState === 1), + ) + + // Baseline DEPOIS de conectar: assim nenhum evento cabe na janela entre o + // GET e o primeiro listener, e o log sozinho descreve toda a mudanca. + const baseRes = await api('GET', '/api/board') + check('GET /api/board (baseline) -> 200', baseRes.status === 200) + const baseline = { + ...baseRes.body, + lists: byPosition(baseRes.body.lists).map((l) => ({ ...l, cards: byPosition(l.cards) })), + } + const trashBefore = (await api('GET', '/api/trash')).body.counts + + // ------------------------------------------------------------------------- + // Rajada de mutacoes. As chamadas de cada lote saem em paralelo de proposito: + // e onde nascem os empates de position e as corridas de broadcast. + // ------------------------------------------------------------------------- + section('rajada de mutacoes') + let mutations = 0 + const failed = [] + + const fire = async (label, calls) => { + const results = await Promise.all(calls.map((c) => c())) + results.forEach((r, i) => { + mutations++ + if (r.status >= 400) failed.push(`${label}#${i} -> ${r.status} ${JSON.stringify(r.body)}`) + }) + return results + } + + const listA = (await api('POST', '/api/lists', { title: 'ws-stress A' })).body + const listB = (await api('POST', '/api/lists', { title: 'ws-stress B' })).body + mutations += 2 + if (!listA?.id || !listB?.id) { + console.error('nao consegui criar as listas de trabalho') + process.exit(1) + } + + // 1) 8 cards em paralelo na mesma lista: POST calcula position como + // "ultima + 1", entao paralelismo aqui e o caso classico de empate. + const cardsA = ( + await fire( + 'create A', + Array.from({ length: 8 }, (_, i) => () => + api('POST', '/api/cards', { listId: listA.id, title: `stress A${i}`, description: `d${i}` }), + ), + ) + ).map((r) => r.body) + + // 2) 4 cards em paralelo na outra lista. + const cardsB = ( + await fire( + 'create B', + Array.from({ length: 4 }, (_, i) => () => + api('POST', '/api/cards', { listId: listB.id, title: `stress B${i}` }), + ), + ) + ).map((r) => r.body) + + // 3) 6 moves em paralelo de A para B, com positions fracionarias calculadas + // pelo cliente (como manda o CLAUDE.md). + await fire( + 'move A->B', + cardsA.slice(0, 6).map((card, i) => () => + api('PATCH', `/api/cards/${card.id}/move`, { + listId: listB.id, + position: 100 + i / 8, + }), + ), + ) + + // 4) 4 edicoes de titulo em paralelo. + await fire( + 'patch', + cardsB.map((card, i) => () => + api('PATCH', `/api/cards/${card.id}`, { title: `stress B${i} editado` }), + ), + ) + + // 5) 3 deletes em paralelo (vao para a lixeira) + 3 restores em paralelo. + // Restore emite card.created: o reducer faz upsert por id e o card volta. + const doomed = cardsA.slice(6) + await fire('delete', [ + ...doomed.map((card) => () => api('DELETE', `/api/cards/${card.id}`)), + () => api('DELETE', `/api/cards/${cardsB[0].id}`), + ]) + await fire('restore', [ + ...doomed.map((card) => () => api('POST', `/api/trash/cards/${card.id}/restore`)), + () => api('POST', `/api/trash/cards/${cardsB[0].id}/restore`), + ]) + + // 6) Lista inteira para a lixeira e de volta. O list.created do restore vem + // com cards[] populado -- se nao viesse, o reducer sobrescreveria a lista + // com uma vazia e a convergencia no fim do script acusaria. + await fire('delete lista B', [() => api('DELETE', `/api/lists/${listB.id}`)]) + await fire('restore lista B', [() => api('POST', `/api/trash/lists/${listB.id}/restore`)]) + + // 7) Ultimo lote: 4 moves em paralelo para dentro de A, empatando de proposito + // duas positions para exercitar o desempate por id. + await fire( + 'move B->A', + cardsB.map((card, i) => () => + api('PATCH', `/api/cards/${card.id}/move`, { + listId: listA.id, + position: i < 2 ? -5 : -5 + i, + }), + ), + ) + + // 8) Reordenar a propria lista A. + await fire('move lista', [ + () => api('PATCH', `/api/lists/${listA.id}`, { position: listB.position - 0.5 }), + ]) + + console.log(` ${mutations} mutacoes disparadas`) + check('nenhuma mutacao da rajada falhou', failed.length === 0, failed.join(' | ')) + + await settle(clients) + + // ------------------------------------------------------------------------- + section('broadcast identico para todos os clientes') + const counts = clients.map((c) => c.events.length) + check( + `todos os ${CLIENTS} clientes receberam a mesma quantidade de eventos`, + new Set(counts).size === 1, + `contagens: ${counts.join(', ')}`, + ) + check('o broadcast chegou (contagem > 0)', counts[0] > 0, `${counts[0]} eventos`) + check( + 'a quantidade de eventos cobre todas as mutacoes', + counts[0] >= mutations, + `${counts[0]} eventos para ${mutations} mutacoes`, + ) + + const reference = clients[0].raw.join('\n') + const divergent = clients + .slice(1) + .filter((c) => c.raw.join('\n') !== reference) + .map((c) => c.index) + check( + 'todos os clientes receberam a MESMA sequencia, na mesma ordem', + divergent.length === 0, + divergent.length ? `clientes divergentes: ${divergent.join(', ')}` : '', + ) + + // Se a ordem divergiu, o conjunto ainda pode ser igual -- vale distinguir os + // dois casos: conjunto diferente e evento perdido; ordem diferente e corrida. + if (divergent.length > 0) { + const asSet = (c) => [...c.raw].sort().join('\n') + const refSet = asSet(clients[0]) + check( + '...ao menos o CONJUNTO de eventos e o mesmo', + clients.every((c) => asSet(c) === refSet), + 'ha cliente com evento faltando ou sobrando, nao so ordem trocada', + ) + } + + const log = clients[0].events + const byType = log.reduce((acc, e) => ({ ...acc, [e.type]: (acc[e.type] ?? 0) + 1 }), {}) + console.log( + ` eventos: ${Object.entries(byType) + .map(([t, n]) => `${t}=${n}`) + .join(' ')}`, + ) + + // Todo card criado tem que ter aparecido no broadcast. + const created = new Set( + log.filter((e) => e.type === 'card.created').map((e) => e.payload.id), + ) + const missing = [...cardsA, ...cardsB].filter((c) => !created.has(c.id)).map((c) => c.id) + check( + 'todo card criado pelo HTTP apareceu como card.created no WS', + missing.length === 0, + missing.join(', '), + ) + check( + 'todo card.moved traz exatamente { id, listId, position }', + log + .filter((e) => e.type === 'card.moved') + .every((e) => Object.keys(e.payload).sort().join(',') === 'id,listId,position'), + ) + + // ------------------------------------------------------------------------- + section('idempotencia do reducer') + const once = replay(baseline, log) + const doubled = replayDoubled(baseline, log) + check( + 'aplicar cada evento duas vezes == aplicar uma vez', + same(snapshot(once), snapshot(doubled)), + firstDiff(snapshot(once), snapshot(doubled)) ?? '', + ) + + const twice = replay(once, log) + check( + 'reaplicar o log inteiro converge para o mesmo estado', + same(snapshot(once), snapshot(twice)), + firstDiff(snapshot(once), snapshot(twice)) ?? '', + ) + + // Ordem embaralhada NAO precisa convergir (card.moved depende do estado + // anterior do card), entao nao ha assercao aqui -- so a nota de que `stale` + // e o mecanismo que cobre esse caso no frontend. + check( + 'o reducer nao precisou pedir recarga (stale) para dar conta do log', + once.stale !== true, + 'algum evento falou de uma lista/card desconhecido: ver o `stale` em useBoard.ts', + ) + + // ------------------------------------------------------------------------- + section('convergencia: eventos vs GET /api/board') + const freshRes = await api('GET', '/api/board') + const fresh = freshRes.body + const derived = snapshot(once) + const server = snapshot(fresh) + check( + 'estado derivado so dos eventos == GET /api/board fresco', + same(derived, server), + firstDiff(derived, server) ?? '', + ) + check( + 'a contagem da lixeira derivada de trash.updated bate com o GET', + same(derived.trash, server.trash), + `derivado ${JSON.stringify(derived.trash)} vs servidor ${JSON.stringify(server.trash)}`, + ) + + // Empates de position sao tolerados (o servidor desempata por id), mas vale + // registrar quantos a rajada produziu -- e a metrica que diz se o POST + // paralelo esta correndo perigo. + const ties = server.lists.reduce((n, l) => { + const seen = new Map() + for (const c of l.cards) seen.set(c.position, (seen.get(c.position) ?? 0) + 1) + return n + [...seen.values()].filter((v) => v > 1).length + }, 0) + console.log(` ${ties} grupo(s) de position empatada apos a rajada (tolerado: desempate por id)`) + + // ------------------------------------------------------------------------- + // A rajada acima produziu empates de position. Empate sozinho o servidor + // tolera (desempata por id), mas ele nao e inofensivo: `POST /api/cards` + // calcula "ultima + 1" com um SELECT seguido de um INSERT fora de qualquer + // transacao, entao N criacoes concorrentes leem a MESMA "ultima" e gravam a + // MESMA position. E a partir de dois cards empatados o drag-and-drop deixa de + // funcionar: `positionFor` devolve (a+b)/2, que com a === b e o proprio a -- + // o cliente pede uma posicao que nao existe entre os vizinhos, o servidor + // grava 200 OK, e o card nao sai do lugar. + section('corrida de position em criacoes concorrentes') + const raceList = (await api('POST', '/api/lists', { title: 'ws-stress race' })).body + const racers = ( + await Promise.all( + Array.from({ length: 20 }, (_, i) => + api('POST', '/api/cards', { listId: raceList.id, title: `race ${i}` }), + ), + ) + ).map((r) => r.body) + const racePositions = racers.map((c) => c.position) + const distinct = new Set(racePositions).size + check( + '20 POST /api/cards concorrentes geram 20 positions distintas', + distinct === racePositions.length, + `so ${distinct} distintas: ${racePositions.join(',')} -- read-modify-write sem transacao em POST /api/cards`, + ) + + // A consequencia, medida em vez de deduzida: soltar um card entre dois + // vizinhos empatados nao muda a ordem. + const tied = [...racePositions].sort((x, y) => x - y).find((p, i, arr) => arr[i + 1] === p) + if (tied !== undefined) { + const neighbours = racers.filter((c) => c.position === tied) + const outsider = racers.find((c) => c.position !== tied) + const midpoint = (neighbours[0].position + neighbours[1].position) / 2 + const attempt = await api('PATCH', `/api/cards/${outsider.id}/move`, { + listId: raceList.id, + position: midpoint, + }) + check( + 'soltar um card ENTRE dois vizinhos empatados produz uma position distinta', + attempt.status === 200 && attempt.body.position !== neighbours[0].position, + `pedido ${midpoint}, gravado ${attempt.body?.position}, vizinhos em ${neighbours[0].position} -- o drag responde 200 e nao move nada`, + ) + } + await api('DELETE', `/api/lists/${raceList.id}`) + await api('DELETE', `/api/trash/lists/${raceList.id}`) + + // ------------------------------------------------------------------------- + section('limpeza') + await api('DELETE', `/api/lists/${listA.id}`) + await api('DELETE', `/api/lists/${listB.id}`) + // Purge item a item, nunca DELETE /api/trash: esvaziar a lixeira apagaria + // tambem o que ja estava la antes do teste. Purgar a lista cascateia nos + // cards dela, inclusive os que foram para a lixeira por conta propria. + const purgeA = await api('DELETE', `/api/trash/lists/${listA.id}`) + const purgeB = await api('DELETE', `/api/trash/lists/${listB.id}`) + check('listas de trabalho apagadas de vez', purgeA.status === 204 && purgeB.status === 204) + + await settle(clients, 400, 4000) + const trashAfter = (await api('GET', '/api/trash')).body.counts + check( + 'a lixeira voltou ao estado anterior ao teste', + same(trashBefore, trashAfter), + `antes ${JSON.stringify(trashBefore)} depois ${JSON.stringify(trashAfter)}`, + ) + const finalBoard = (await api('GET', '/api/board')).body + check( + 'o board voltou as listas originais', + same( + baseline.lists.map((l) => l.id).sort(), + finalBoard.lists.map((l) => l.id).sort(), + ), + ) + + for (const c of clients) c.socket.close() + console.log(failures === 0 ? '\nWS-STRESS OK' : `\nWS-STRESS FALHOU: ${failures} assercao(oes)`) + process.exit(failures === 0 ? 0 : 1) +} + +main().catch((e) => { + console.error('ws-stress explodiu:', e.message) + process.exit(1) +}) diff --git a/backend/src/activity.ts b/backend/src/activity.ts new file mode 100644 index 0000000..404d1aa --- /dev/null +++ b/backend/src/activity.ts @@ -0,0 +1,100 @@ +import { prisma } from './db.js' +import { broadcast } from './bus.js' + +/** + * O Visualizador de Eventos do Windows 95: um log append-only de tudo que muda + * no board. Uma linha por mutacao, com o texto ja formatado -- o cliente so + * imprime `summary`, nunca remonta a frase a partir de ids. + * + * ------------------------------------------------------------------------ + * ONDE LIGAR AS CHAMADAS `record(...)` + * ------------------------------------------------------------------------ + * As rotas de cards/lists/trash NAO estao instrumentadas -- esta sessao nao + * pode edita-las. A lista abaixo diz exatamente onde inserir cada chamada. + * As linhas sao do estado do repo em que este arquivo foi escrito; cada uma + * vai LOGO DEPOIS do `broadcast(...)` correspondente, para que o log so + * registre o que ja foi persistido e anunciado. + * + * Em todas elas basta acrescentar o import no topo do arquivo: + * import { record } from '../activity.js' + * + * src/routes/cards.ts + * ~L43 apos broadcast('card.created', card) + * await record('card.created', `Card "${card.title}" criado em "${list.title}"`) + * ~L76 apos broadcast('card.updated', card) + * await record('card.updated', card.title !== exists.title + * ? `Card "${exists.title}" renomeado para "${card.title}"` + * : `Card "${card.title}" editado`) + * ~L104 apos broadcast('card.moved', ...) + * await record('card.moved', `Card "${card.title}" movido para "${list.title}"`) + * ~L119 apos broadcast('card.deleted', { id }) + * await record('card.deleted', `Card "${exists.title}" foi para a lixeira`) + * + * src/routes/lists.ts + * ~L32 apos broadcast('list.created', list) + * await record('list.created', `Lista "${list.title}" criada`) + * ~L66 apos broadcast('list.updated', list) + * await record('list.updated', list.title !== exists.title + * ? `Lista "${exists.title}" renomeada para "${list.title}"` + * : `Lista "${list.title}" reordenada`) + * ~L81 apos broadcast('list.deleted', { id }) + * await record('list.deleted', `Lista "${exists.title}" foi para a lixeira`) + * + * src/routes/trash.ts + * ~L71 apos broadcast('card.created', restored) + * await record('card.restored', `Card "${card.title}" restaurado da lixeira`) + * ~L90 apos broadcast('list.created', list) + * await record('list.restored', `Lista "${list.title}" restaurada da lixeira`) + * ~L100 apos prisma.card.delete({ where: { id } }) + * await record('card.purged', `Card "${exists.title}" apagado definitivamente`) + * ~L113 apos prisma.list.delete({ where: { id } }) + * await record('list.purged', `Lista "${exists.title}" apagada definitivamente`) + * ~L135 apos os dois deleteMany de DELETE /api/trash + * await record('trash.emptied', + * `Lixeira esvaziada: ${cards} card(s) e ${lists} lista(s) apagados`) + * + * Cuidado nos dois `purged` e no `trash.emptied`: o titulo tem que ser lido da + * variavel `exists`/`card` capturada ANTES do delete -- depois dele a linha nao + * existe mais para consultar. + */ + +/** O que `record` aceita em `type`. String livre de proposito: o log registra + * acoes que nao sao eventos de WebSocket (`card.purged`, `trash.emptied`), e + * amarra-lo ao `EventType` do bus obrigaria a inventar evento para cada uma. */ +export type ActivityType = + | 'list.created' + | 'list.updated' + | 'list.deleted' + | 'list.restored' + | 'list.purged' + | 'card.created' + | 'card.updated' + | 'card.moved' + | 'card.deleted' + | 'card.restored' + | 'card.purged' + | 'trash.emptied' + | (string & {}) + +/** + * Grava uma linha no log e anuncia `activity.recorded`. + * + * NUNCA lanca. Log e efeito colateral: se o insert falhar, a mutacao que o + * chamou ja aconteceu e nao pode virar 500 por causa disso. O erro vai para o + * stderr e a vida segue -- por isso o `await record(...)` nas rotas e seguro. + * + * Retorna a linha gravada, ou `null` se a gravacao falhou. + */ +export async function record(type: ActivityType, summary: string) { + try { + const activity = await prisma.activity.create({ data: { type, summary } }) + broadcast('activity.recorded', activity) + return activity + } catch (err) { + // console, e nao o logger do Fastify: `record` e chamado de dentro das rotas + // mas nao recebe a instancia do app, e passar `app` so para logar o caso raro + // obrigaria a mudar a assinatura de toda chamada. + console.error('[activity] falha ao gravar log (mutacao NAO foi afetada):', err) + return null + } +} diff --git a/backend/src/bus.ts b/backend/src/bus.ts index 1cb46e9..3ddec4e 100644 --- a/backend/src/bus.ts +++ b/backend/src/bus.ts @@ -8,6 +8,13 @@ export type EventType = | 'card.updated' | 'card.moved' | 'card.deleted' + // Lixeira: o payload e so a contagem ({ cards, lists }), para o icone do desktop + // saber se ela esta cheia. O conteudo vem de GET /api/trash quando a janela abre. + | 'trash.updated' + // Visualizador de Eventos: o payload e a linha de log inteira + // ({ id, type, summary, createdAt }), ja com o texto formatado. E append-only, + // entao o cliente pode so empilhar -- nao ha upsert por id a fazer aqui. + | 'activity.recorded' const clients = new Set() diff --git a/backend/src/routes/activity.ts b/backend/src/routes/activity.ts new file mode 100644 index 0000000..65ce57d --- /dev/null +++ b/backend/src/routes/activity.ts @@ -0,0 +1,53 @@ +import type { FastifyInstance } from 'fastify' +import { prisma } from '../db.js' + +const DEFAULT_LIMIT = 50 +const MAX_LIMIT = 200 + +export async function activityRoutes(app: FastifyInstance) { + /** + * O log, mais recente primeiro -- e a ordem em que o Visualizador de Eventos + * mostra. O desempate por id existe porque o SQLite guarda o timestamp com + * precisao de milissegundo: duas gravacoes na mesma requisicao empatam em + * `createdAt` e sem o segundo criterio a paginacao ficaria instavel. + */ + app.get('/api/activity', async (req, reply) => { + const { limit: raw } = req.query as { limit?: unknown } + + let limit = DEFAULT_LIMIT + if (raw !== undefined) { + // Query string chega sempre como string; `Number('')` e 0 e `Number('x')` + // e NaN, entao os dois caem na validacao abaixo em vez de virar o default. + const parsed = typeof raw === 'string' || typeof raw === 'number' ? Number(raw) : NaN + if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_LIMIT) { + return reply + .code(400) + .send({ error: `limit deve ser um inteiro entre 1 e ${MAX_LIMIT}` }) + } + limit = parsed + } + + const items = await prisma.activity.findMany({ + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit, + }) + + // `total` vem junto para a janela poder dizer "mostrando 50 de 312" sem uma + // segunda chamada -- o log cresce sozinho e o usuario nao tem como saber + // que a lista esta truncada. + return { items, limit, total: await prisma.activity.count() } + }) + + /** + * Limpar o log. Nao ha lixeira para o log: ele e o registro de que a lixeira + * existiu, guardar um registro do registro nao acaba nunca. + * + * Sem broadcast: o cliente que apagou ja sabe, e um evento `activity.cleared` + * nao esta em docs/API.md -- inventa-lo aqui divergiria do contrato. A janela + * recarrega com GET /api/activity quando reabre. + */ + app.delete('/api/activity', async () => { + const { count } = await prisma.activity.deleteMany({}) + return { deleted: count } + }) +} diff --git a/backend/src/routes/board.ts b/backend/src/routes/board.ts index 3060492..2f254d3 100644 --- a/backend/src/routes/board.ts +++ b/backend/src/routes/board.ts @@ -1,16 +1,22 @@ import type { FastifyInstance } from 'fastify' import { prisma } from '../db.js' +import { ALIVE, trashCounts } from '../trash.js' export async function boardRoutes(app: FastifyInstance) { app.get('/api/health', async () => ({ status: 'ok' })) app.get('/api/board', async (_req, reply) => { + // O board so enxerga o que nao esta na lixeira. Os DOIS filtros importam: + // sem o de dentro, um card jogado fora continua aparecendo na lista dele. const board = await prisma.board.findUnique({ where: { slug: 'default' }, include: { lists: { + where: ALIVE, orderBy: [{ position: 'asc' }, { id: 'asc' }], - include: { cards: { orderBy: [{ position: 'asc' }, { id: 'asc' }] } }, + include: { + cards: { where: ALIVE, orderBy: [{ position: 'asc' }, { id: 'asc' }] }, + }, }, }, }) @@ -18,6 +24,8 @@ export async function boardRoutes(app: FastifyInstance) { if (!board) { return reply.code(404).send({ error: 'board default nao existe -- rode o seed' }) } - return board + // A contagem da lixeira vem junto para o icone ja nascer no estado certo, sem + // precisar de um GET /api/trash so para saber se ela esta vazia. + return { ...board, trash: await trashCounts() } }) } diff --git a/backend/src/routes/cards.ts b/backend/src/routes/cards.ts index 06ebf6c..59e96f7 100644 --- a/backend/src/routes/cards.ts +++ b/backend/src/routes/cards.ts @@ -1,6 +1,9 @@ import type { FastifyInstance } from 'fastify' import { prisma } from '../db.js' import { broadcast } from '../bus.js' +import { record } from '../activity.js' +import { serialize } from '../serialize.js' +import { broadcastTrash, findLiveCard, findLiveList } from '../trash.js' export async function cardRoutes(app: FastifyInstance) { app.post('/api/cards', async (req, reply) => { @@ -20,24 +23,32 @@ export async function cardRoutes(app: FastifyInstance) { return reply.code(400).send({ error: 'description deve ser uma string' }) } - const list = await prisma.list.findUnique({ where: { id: listId } }) + const list = await findLiveList(listId) if (!list) return reply.code(404).send({ error: 'lista nao encontrada' }) - const last = await prisma.card.findFirst({ - where: { listId }, - orderBy: { position: 'desc' }, - }) - - const card = await prisma.card.create({ - data: { - listId, - title: title.trim(), - description: description ?? '', - position: last ? last.position + 1 : 0, - }, + // Ler a ultima position e inserir precisa ser atomico: sem isso duas + // criacoes simultaneas leem a mesma "ultima" e nascem empatadas, e dois + // cards empatados tornam o drag entre eles inoperante (ver serialize.ts). + const card = await serialize(`cards:${listId}`, async () => { + // De proposito conta os cards na lixeira tambem: se um card com position 7 + // for restaurado depois, ele nao colide com um criado agora. + const last = await prisma.card.findFirst({ + where: { listId }, + orderBy: { position: 'desc' }, + }) + + return prisma.card.create({ + data: { + listId, + title: title.trim(), + description: description ?? '', + position: last ? last.position + 1 : 0, + }, + }) }) broadcast('card.created', card) + await record('card.created', `Card "${card.title}" criado em "${list.title}"`) return reply.code(201).send(card) }) @@ -65,12 +76,18 @@ export async function cardRoutes(app: FastifyInstance) { return reply.code(400).send({ error: 'nada para atualizar' }) } - const exists = await prisma.card.findUnique({ where: { id } }) + const exists = await findLiveCard(id) if (!exists) return reply.code(404).send({ error: 'card nao encontrado' }) const card = await prisma.card.update({ where: { id }, data }) broadcast('card.updated', card) + await record( + 'card.updated', + card.title !== exists.title + ? `Card "${exists.title}" renomeado para "${card.title}"` + : `Card "${card.title}" editado`, + ) return card }) @@ -87,10 +104,10 @@ export async function cardRoutes(app: FastifyInstance) { return reply.code(400).send({ error: 'position deve ser um numero finito' }) } - const exists = await prisma.card.findUnique({ where: { id } }) + const exists = await findLiveCard(id) if (!exists) return reply.code(404).send({ error: 'card nao encontrado' }) - const list = await prisma.list.findUnique({ where: { id: listId } }) + const list = await findLiveList(listId) if (!list) return reply.code(404).send({ error: 'lista de destino nao encontrada' }) const card = await prisma.card.update({ @@ -99,18 +116,24 @@ export async function cardRoutes(app: FastifyInstance) { }) broadcast('card.moved', { id: card.id, listId: card.listId, position: card.position }) + await record('card.moved', `Card "${card.title}" movido para "${list.title}"`) return card }) app.delete('/api/cards/:id', async (req, reply) => { const { id } = req.params as { id: string } - const exists = await prisma.card.findUnique({ where: { id } }) + const exists = await findLiveCard(id) if (!exists) return reply.code(404).send({ error: 'card nao encontrado' }) - await prisma.card.delete({ where: { id } }) + // Soft delete: o card vai para a lixeira em vez de sumir. Para o board o + // evento e o mesmo de sempre -- 'card.deleted' com { id } -- entao o cliente + // continua removendo por id sem saber que a linha ainda existe. + await prisma.card.update({ where: { id }, data: { deletedAt: new Date() } }) broadcast('card.deleted', { id }) + await record('card.deleted', `Card "${exists.title}" foi para a lixeira`) + await broadcastTrash() return reply.code(204).send() }) } diff --git a/backend/src/routes/lists.ts b/backend/src/routes/lists.ts index dce4761..0802eac 100644 --- a/backend/src/routes/lists.ts +++ b/backend/src/routes/lists.ts @@ -1,6 +1,9 @@ import type { FastifyInstance } from 'fastify' import { prisma } from '../db.js' import { broadcast } from '../bus.js' +import { record } from '../activity.js' +import { serialize } from '../serialize.js' +import { ALIVE, broadcastTrash, findLiveList } from '../trash.js' export async function listRoutes(app: FastifyInstance) { app.post('/api/lists', async (req, reply) => { @@ -12,21 +15,27 @@ export async function listRoutes(app: FastifyInstance) { const board = await prisma.board.findUnique({ where: { slug: 'default' } }) if (!board) return reply.code(404).send({ error: 'board default nao existe' }) - const last = await prisma.list.findFirst({ - where: { boardId: board.id }, - orderBy: { position: 'desc' }, - }) + // Mesmo race do POST /api/cards: ler-e-inserir tem que ser atomico. + const list = await serialize(`lists:${board.id}`, async () => { + // Inclui as listas na lixeira: uma lista restaurada nao deve colidir de + // position com uma criada enquanto ela estava fora. + const last = await prisma.list.findFirst({ + where: { boardId: board.id }, + orderBy: { position: 'desc' }, + }) - const list = await prisma.list.create({ - data: { - title: title.trim(), - position: last ? last.position + 1 : 0, - boardId: board.id, - }, - include: { cards: true }, + return prisma.list.create({ + data: { + title: title.trim(), + position: last ? last.position + 1 : 0, + boardId: board.id, + }, + include: { cards: true }, + }) }) broadcast('list.created', list) + await record('list.created', `Lista "${list.title}" criada`) return reply.code(201).send(list) }) @@ -51,28 +60,39 @@ export async function listRoutes(app: FastifyInstance) { return reply.code(400).send({ error: 'nada para atualizar' }) } - const exists = await prisma.list.findUnique({ where: { id } }) + const exists = await findLiveList(id) if (!exists) return reply.code(404).send({ error: 'lista nao encontrada' }) const list = await prisma.list.update({ where: { id }, data, - include: { cards: { orderBy: [{ position: 'asc' }, { id: 'asc' }] } }, + include: { cards: { where: ALIVE, orderBy: [{ position: 'asc' }, { id: 'asc' }] } }, }) broadcast('list.updated', list) + await record( + 'list.updated', + list.title !== exists.title + ? `Lista "${exists.title}" renomeada para "${list.title}"` + : `Lista "${list.title}" reordenada`, + ) return list }) app.delete('/api/lists/:id', async (req, reply) => { const { id } = req.params as { id: string } - const exists = await prisma.list.findUnique({ where: { id } }) + const exists = await findLiveList(id) if (!exists) return reply.code(404).send({ error: 'lista nao encontrada' }) - await prisma.list.delete({ where: { id } }) + // Soft delete. Os cards da lista NAO sao marcados: eles somem do board junto + // com ela e voltam junto na restauracao. Marcar em cascata faria um card que + // ja tinha ido para a lixeira sozinho ressuscitar quando a lista voltasse. + await prisma.list.update({ where: { id }, data: { deletedAt: new Date() } }) broadcast('list.deleted', { id }) + await record('list.deleted', `Lista "${exists.title}" foi para a lixeira`) + await broadcastTrash() return reply.code(204).send() }) } diff --git a/backend/src/routes/stats.ts b/backend/src/routes/stats.ts new file mode 100644 index 0000000..8d99e1c --- /dev/null +++ b/backend/src/routes/stats.ts @@ -0,0 +1,77 @@ +import type { FastifyInstance } from 'fastify' +import { prisma } from '../db.js' +import { ALIVE } from '../trash.js' + +const TRASHED = { deletedAt: { not: null } } as const + +/** So os campos que a janela de propriedades mostra -- devolver o card inteiro + * faria `description` (texto livre, potencialmente longo) viajar duas vezes + * dentro de um endpoint que e so contagem. */ +const CARD_SUMMARY = { id: true, title: true, listId: true, createdAt: true } as const + +export async function statsRoutes(app: FastifyInstance) { + /** + * Propriedades do board: as contagens que a caixa de dialogo do Windows 95 + * mostraria. Leitura pura, sem broadcast. + * + * "Vivo" aqui e o que o board mostra, que e mais estrito que `deletedAt: null` + * no card: um card sem `deletedAt` dentro de uma lista NA LIXEIRA sumiu da + * tela junto com ela (ver o comentario do soft delete em routes/lists.ts). + * Contar esse card em `cards` faria o total nao bater com a soma de + * `cardsPerList`, que so percorre listas vivas -- dois numeros na mesma tela + * discordando um do outro. Por isso o filtro de card e `{ ...ALIVE, list: ALIVE }`. + */ + app.get('/api/stats', async () => { + const liveCards = { ...ALIVE, list: ALIVE } + + const [lists, cards, trashedCards, trashedLists, listRows, oldestCard, newestCard] = + await Promise.all([ + prisma.list.count({ where: ALIVE }), + prisma.card.count({ where: liveCards }), + prisma.card.count({ where: TRASHED }), + prisma.list.count({ where: TRASHED }), + prisma.list.findMany({ + where: ALIVE, + orderBy: [{ position: 'asc' }, { id: 'asc' }], + select: { + id: true, + title: true, + // `_count` com filtro resolve no proprio SQL: sem ele seria um SELECT + // por lista, e a contagem viria errada de qualquer jeito, porque + // `_count` sem `where` inclui os cards da lixeira. + _count: { select: { cards: { where: ALIVE } } }, + }, + }), + prisma.card.findFirst({ + where: liveCards, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: CARD_SUMMARY, + }), + prisma.card.findFirst({ + where: liveCards, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + select: CARD_SUMMARY, + }), + ]) + + return { + lists, + cards, + trashedCards, + trashedLists, + cardsPerList: listRows.map((list) => ({ + listId: list.id, + title: list.title, + count: list._count.cards, + })), + // null, nao um objeto vazio: board sem card nenhum e um estado normal + // (logo depois de "Esvaziar Lixeira", por exemplo). + oldestCard, + newestCard, + // Board sem lista da divisao por zero -> Infinity, que o JSON.stringify + // transforma em `null` sem avisar ninguem. Duas casas porque a media de + // cards por lista nao tem significado alem disso. + avgCardsPerList: lists === 0 ? 0 : Math.round((cards / lists) * 100) / 100, + } + }) +} diff --git a/backend/src/routes/trash.ts b/backend/src/routes/trash.ts new file mode 100644 index 0000000..110d8f5 --- /dev/null +++ b/backend/src/routes/trash.ts @@ -0,0 +1,149 @@ +import type { FastifyInstance } from 'fastify' +import { prisma } from '../db.js' +import { broadcast } from '../bus.js' +import { record } from '../activity.js' +import { ALIVE, broadcastTrash, trashCounts } from '../trash.js' + +const CARD_ORDER = [{ position: 'asc' as const }, { id: 'asc' as const }] + +export async function trashRoutes(app: FastifyInstance) { + /** + * Conteudo da Lixeira, mais recente primeiro (e o que a janela mostra). + * + * Cada card carrega `originalList` com `deleted`: se a lista de origem tambem + * esta na lixeira, restaurar o card vai dar 409. O frontend consegue esmaecer + * o botao Restaurar antes de o usuario clicar, em vez de mostrar um erro. + */ + app.get('/api/trash', async () => { + const [cards, lists] = await Promise.all([ + prisma.card.findMany({ + where: { deletedAt: { not: null } }, + orderBy: [{ deletedAt: 'desc' }, { id: 'asc' }], + include: { list: { select: { id: true, title: true, deletedAt: true } } }, + }), + prisma.list.findMany({ + where: { deletedAt: { not: null } }, + orderBy: [{ deletedAt: 'desc' }, { id: 'asc' }], + include: { cards: { where: ALIVE, orderBy: CARD_ORDER } }, + }), + ]) + + return { + cards: cards.map(({ list, ...card }) => ({ + ...card, + originalList: { id: list.id, title: list.title, deleted: list.deletedAt !== null }, + })), + // `cardCount` e quantos cards voltam junto se a lista for restaurada -- + // os que ja estavam na lixeira sozinhos nao contam, eles ficam la. + lists: lists.map(({ cards: listCards, ...list }) => ({ + ...list, + cardCount: listCards.length, + })), + counts: await trashCounts(), + } + }) + + /** + * Restaurar volta o item para a posicao original -- e a semantica do Windows, + * e a position fracionaria aguenta: colisao de position empata por id, e a + * ordenacao continua determinista. + */ + app.post('/api/trash/cards/:id/restore', async (req, reply) => { + const { id } = req.params as { id: string } + + const card = await prisma.card.findFirst({ + where: { id, deletedAt: { not: null } }, + include: { list: { select: { id: true, title: true, deletedAt: true } } }, + }) + if (!card) return reply.code(404).send({ error: 'card nao esta na lixeira' }) + + if (card.list.deletedAt !== null) { + return reply.code(409).send({ + error: `a lista "${card.list.title}" tambem esta na lixeira -- restaure a lista primeiro`, + }) + } + + const { list: _list, ...rest } = card + const restored = { ...rest, deletedAt: null } + await prisma.card.update({ where: { id }, data: { deletedAt: null } }) + + // `card.created`, nao um `card.restored` novo: o cliente ja faz upsert por id + // com esse evento, entao restaurar aparece no board sem mudanca no frontend. + broadcast('card.created', restored) + await record('card.restored', `Card "${card.title}" restaurado da lixeira`) + await broadcastTrash() + return restored + }) + + app.post('/api/trash/lists/:id/restore', async (req, reply) => { + const { id } = req.params as { id: string } + + const exists = await prisma.list.findFirst({ where: { id, deletedAt: { not: null } } }) + if (!exists) return reply.code(404).send({ error: 'lista nao esta na lixeira' }) + + const list = await prisma.list.update({ + where: { id }, + data: { deletedAt: null }, + // Os cards vivos voltam junto: mandar a lista sem eles faria o cliente + // fazer upsert de uma lista vazia por cima da que tem conteudo. + include: { cards: { where: ALIVE, orderBy: CARD_ORDER } }, + }) + + broadcast('list.created', list) + await record('list.restored', `Lista "${list.title}" restaurada da lixeira`) + await broadcastTrash() + return list + }) + + app.delete('/api/trash/cards/:id', async (req, reply) => { + const { id } = req.params as { id: string } + const exists = await prisma.card.findFirst({ where: { id, deletedAt: { not: null } } }) + if (!exists) return reply.code(404).send({ error: 'card nao esta na lixeira' }) + + await prisma.card.delete({ where: { id } }) + // `exists` foi capturado antes do delete: depois dele nao ha linha para ler. + await record('card.purged', `Card "${exists.title}" apagado definitivamente`) + // Sem `card.deleted`: para o board o card ja tinha sumido no soft delete. + await broadcastTrash() + return reply.code(204).send() + }) + + app.delete('/api/trash/lists/:id', async (req, reply) => { + const { id } = req.params as { id: string } + const exists = await prisma.list.findFirst({ where: { id, deletedAt: { not: null } } }) + if (!exists) return reply.code(404).send({ error: 'lista nao esta na lixeira' }) + + // Cascade do schema leva os cards junto, inclusive os que estavam na lixeira + // sozinhos -- a lista de origem deles deixou de existir. + await prisma.list.delete({ where: { id } }) + await record('list.purged', `Lista "${exists.title}" apagada definitivamente`) + await broadcastTrash() + return reply.code(204).send() + }) + + /** Esvaziar Lixeira. Irreversivel, como manda o figurino. */ + app.delete('/api/trash', async (_req, reply) => { + const lists = await prisma.list.count({ where: { deletedAt: { not: null } } }) + + // Conta os cards que VAO SUMIR, nao os que estao na lixeira: um card vivo + // dentro de uma lista na lixeira tambem morre no cascade abaixo. Contar so + // `deletedAt != null` reportava menos linhas do que a operacao apagava. + const cards = await prisma.card.count({ + where: { + OR: [{ deletedAt: { not: null } }, { list: { deletedAt: { not: null } } }], + }, + }) + + // Listas primeiro: o cascade ja apaga os cards delas, inclusive os que + // estavam na lixeira por conta propria. + await prisma.list.deleteMany({ where: { deletedAt: { not: null } } }) + await prisma.card.deleteMany({ where: { deletedAt: { not: null } } }) + + await record( + 'trash.emptied', + `Lixeira esvaziada: ${cards} card(s) e ${lists} lista(s) apagados`, + ) + await broadcastTrash() + return reply.send({ deleted: { cards, lists } }) + }) +} diff --git a/backend/src/serialize.ts b/backend/src/serialize.ts new file mode 100644 index 0000000..8fc90ea --- /dev/null +++ b/backend/src/serialize.ts @@ -0,0 +1,50 @@ +/** + * Serializa operacoes por chave. + * + * Existe por causa de um bug real: `POST /api/cards` lia a maior `position` da + * lista e so depois inseria `last.position + 1`. Duas criacoes simultaneas leem + * a mesma "ultima" e nascem empatadas -- 15 requisicoes em paralelo produziam 5 + * posicoes distintas. + * + * O empate nao e cosmetico. A partir de dois cards na mesma position, + * `positionFor()` calcula `(a + b) / 2 === a` para o slot entre eles: o servidor + * responde 200, grava o valor que recebeu, e o card **nao sai do lugar**. O + * desempate por `id` no `orderBy` mantem a ordem estavel, entao nada parece + * quebrado -- o drag simplesmente nao funciona. + * + * Uma transacao do Prisma nao resolve sozinha: no SQLite o `BEGIN` e deferido, o + * SELECT nao pega lock de escrita, e as duas transacoes leem o mesmo valor antes + * de qualquer uma escrever. + * + * LIMITE: isto e um mutex em memoria, valido enquanto houver **um processo** de + * backend -- que e o modelo de implantacao aqui (um container no compose). Com + * mais de uma instancia o race volta, e a correcao passa a ser no banco: + * `INSERT ... SELECT MAX(position) + 1` numa unica declaracao, ou + * `UNIQUE(listId, position)` com retry. + */ + +const chains = new Map>() + +export function serialize(key: string, task: () => Promise): Promise { + const previous = chains.get(key) ?? Promise.resolve() + + // `previous.then(task, task)` roda a tarefa tenha a anterior falhado ou nao: + // um erro numa requisicao nao pode travar a fila das seguintes. + const next = previous.then(task, task) + + // O que fica no Map e a versao "silenciada": se ninguem tratar a rejeicao da + // corrente armazenada, o Node emite unhandledRejection. + const settled = next.then( + () => undefined, + () => undefined, + ) + chains.set(key, settled) + + // Libera a entrada quando esta e a ultima tarefa da chave -- sem isso o Map + // cresce indefinidamente conforme listas sao criadas e apagadas. + void settled.then(() => { + if (chains.get(key) === settled) chains.delete(key) + }) + + return next +} diff --git a/backend/src/server.ts b/backend/src/server.ts index f4057fb..91cf808 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,9 @@ import { addClient, clientCount } from './bus.js' import { boardRoutes } from './routes/board.js' import { listRoutes } from './routes/lists.js' import { cardRoutes } from './routes/cards.js' +import { trashRoutes } from './routes/trash.js' +import { activityRoutes } from './routes/activity.js' +import { statsRoutes } from './routes/stats.js' const PORT = Number(process.env.PORT ?? 3001) const HOST = process.env.HOST ?? '0.0.0.0' @@ -68,6 +71,9 @@ app.setNotFoundHandler((req, reply) => { await app.register(boardRoutes) await app.register(listRoutes) await app.register(cardRoutes) +await app.register(trashRoutes) +await app.register(activityRoutes) +await app.register(statsRoutes) for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, async () => { diff --git a/backend/src/trash.ts b/backend/src/trash.ts new file mode 100644 index 0000000..8eb324a --- /dev/null +++ b/backend/src/trash.ts @@ -0,0 +1,43 @@ +import { prisma } from './db.js' +import { broadcast } from './bus.js' + +/** + * A Lixeira do Windows 95. + * + * `deletedAt` transforma DELETE em soft delete: a linha continua no banco, some + * do `GET /api/board` e reaparece em `GET /api/trash`. So `DELETE /api/trash` + * (Esvaziar Lixeira) e `DELETE /api/trash/{cards,lists}/:id` apagam de verdade. + * + * A consequencia que pega desprevenido: `findUnique({ where: { id } })` continua + * achando linha na lixeira. Toda rota que opera sobre um recurso vivo precisa + * filtrar `deletedAt: null` explicitamente, senao da para renomear, mover e + * "apagar" (de novo) um card que o board nem mostra mais. Use os helpers abaixo + * em vez de escrever o where na mao. + */ + +export const ALIVE = { deletedAt: null } as const + +export function findLiveCard(id: string) { + return prisma.card.findFirst({ where: { id, ...ALIVE } }) +} + +export function findLiveList(id: string) { + return prisma.list.findFirst({ where: { id, ...ALIVE } }) +} + +export async function trashCounts() { + const [cards, lists] = await Promise.all([ + prisma.card.count({ where: { deletedAt: { not: null } } }), + prisma.list.count({ where: { deletedAt: { not: null } } }), + ]) + return { cards, lists } +} + +/** + * Avisa que a lixeira mudou -- e so a contagem, para o icone do desktop trocar + * de cheia para vazia. O conteudo vem de `GET /api/trash` quando a janela abre; + * mandar a lixeira inteira em cada delete violaria "payload nunca e o board todo". + */ +export async function broadcastTrash() { + broadcast('trash.updated', await trashCounts()) +} diff --git a/backend/test/position.test.mjs b/backend/test/position.test.mjs new file mode 100644 index 0000000..a3b64e4 --- /dev/null +++ b/backend/test/position.test.mjs @@ -0,0 +1,122 @@ +// Testes unitarios da matematica de position fracionaria. +// +// Runner: node:test nativo (`node --test test/`). Zero dependencia extra. +// +// ATENCAO -- ESPELHO DE CONTRATO +// A funcao real vive no frontend, em `frontend/src/api.ts` (export positionFor), +// porque o calculo da nova position e responsabilidade do CLIENTE: so ele conhece +// os vizinhos do destino; o servidor apenas persiste o float recebido em +// `PATCH /api/cards/:id/move` (ver CLAUDE.md e docs/API.md). +// +// O backend nao pode importar do frontend (a fronteira de posse entre as duas +// sessoes proibe), entao a logica esta REPLICADA aqui como referencia executavel +// do contrato. Se `frontend/src/api.ts` mudar, este arquivo passa a mentir -- +// atualize os dois juntos, ou o contrato divergiu e o codigo e que esta errado. + +import test from 'node:test' +import assert from 'node:assert/strict' + +/** + * Espelho de `positionFor` em frontend/src/api.ts (linhas ~63-71). + * + * `siblings` sao os cards ja ordenados da lista de DESTINO (sem o card que esta + * sendo arrastado) e `index` e o slot onde ele vai cair. + */ +function positionFor(siblings, index) { + const before = siblings[index - 1] + const after = siblings[index] + + if (!before && !after) return 0 + if (!before) return after.position - 1 + if (!after) return before.position + 1 + return (before.position + after.position) / 2 +} + +const at = (...positions) => positions.map((position, i) => ({ id: `c${i}`, position })) + +test('lista vazia: primeira position e 0', () => { + assert.equal(positionFor([], 0), 0) +}) + +test('topo: first.position - 1', () => { + assert.equal(positionFor(at(1, 2, 3), 0), 0) + assert.equal(positionFor(at(-5.5, 2), 0), -6.5) + // Nao ha piso: arrastar pro topo repetidamente so caminha para -Infinity. + assert.equal(positionFor(at(0), 0), -1) +}) + +test('fim: last.position + 1', () => { + assert.equal(positionFor(at(1, 2, 3), 3), 4) + // "ultima + 1" sobre o valor fracionario, igual ao POST do servidor: + // depois de um card em 7.125 o proximo nasce em 8.125 (docs/API.md). + assert.equal(positionFor(at(7.125), 1), 8.125) +}) + +test('meio: (a + b) / 2', () => { + assert.equal(positionFor(at(1, 2, 3), 1), 1.5) + assert.equal(positionFor(at(1, 2, 3), 2), 2.5) + assert.equal(positionFor(at(0, 1), 1), 0.5) + assert.equal(positionFor(at(1, 1.5), 1), 1.25) + // Vizinhos negativos e assimetricos continuam valendo. + assert.equal(positionFor(at(-4, 6), 1), 1) +}) + +test('um unico drag grava uma unica linha: os vizinhos nao mudam', () => { + const siblings = at(1, 2, 3) + const snapshot = JSON.stringify(siblings) + positionFor(siblings, 1) + assert.equal(JSON.stringify(siblings), snapshot, 'positionFor nao pode mutar os vizinhos') +}) + +test('a position nova cai estritamente entre os vizinhos (ordenacao preservada)', () => { + const siblings = at(2, 4, 8) + for (let index = 0; index <= siblings.length; index++) { + const p = positionFor(siblings, index) + const before = siblings[index - 1] + const after = siblings[index] + if (before) assert.ok(p > before.position, `${p} deveria ser > ${before.position}`) + if (after) assert.ok(p < after.position, `${p} deveria ser < ${after.position}`) + } +}) + +// --- Limite conhecido: ~53 divisoes pela metade ------------------------------- +// docs/API.md, "Limite conhecido da position fracionaria": entre dois inteiros +// vizinhos cabem ~53 divisoes pela metade antes de o double esgotar a precisao e +// os valores empatarem. Nao ha rebalanceamento -- o teste documenta o teto, nao +// pede conserto. +test('halving repetido no mesmo ponto empata em ~53 divisoes', () => { + const lo = 1 + let hi = 2 + let divisions = 0 + + // Sempre soltando no MESMO ponto -- logo depois de `lo` -- que e o pior caso + // real: o card novo vira o vizinho de cima da proxima insercao. + for (;;) { + const mid = positionFor(at(lo, hi), 1) + if (mid === lo || mid === hi) break // empatou: a precisao acabou + hi = mid + divisions++ + if (divisions > 200) assert.fail('nao empatou -- a matematica mudou?') + } + + // 52 divisoes uteis; a 53a arredonda de volta para `lo`. O ulp de 1.0 e 2^-52. + assert.equal(divisions, 52) + assert.equal(hi, 1 + Number.EPSILON, 'o ultimo valor util e o vizinho imediato de 1.0') + assert.equal(positionFor(at(lo, hi), 1), lo, 'a divisao seguinte empata com o vizinho de baixo') + assert.ok(divisions <= 53, 'docs/API.md promete ~53 -- se subir, o doc mente') +}) + +test('empate de position e resolvido por id, nao por position', () => { + // Quando o teto acima e atingido dois cards ficam com a MESMA position. O + // servidor usa orderBy: [{position}, {id}] justamente para isso (docs/API.md). + const tied = [ + { id: 'b', position: 1.5 }, + { id: 'a', position: 1.5 }, + ] + const ordered = [...tied].sort((x, y) => x.position - y.position || x.id.localeCompare(y.id)) + assert.deepEqual( + ordered.map((c) => c.id), + ['a', 'b'], + 'com position empatada a ordem tem que ser determinista pelo id', + ) +}) diff --git a/backend/test/trash-semantics.test.mjs b/backend/test/trash-semantics.test.mjs new file mode 100644 index 0000000..e07ffe2 --- /dev/null +++ b/backend/test/trash-semantics.test.mjs @@ -0,0 +1,270 @@ +// Testes de mesa da Lixeira -- funcoes puras, sem banco, sem servidor rodando. +// +// Runner: node:test nativo (`node --test test/`). Zero dependencia extra. +// +// ATENCAO -- ESPELHO DE CONTRATO +// As regras modeladas aqui vivem de verdade em `backend/src/routes/trash.ts`, +// `backend/src/routes/lists.ts` e `backend/src/routes/cards.ts` (e no +// `deletedAt` de `backend/prisma/schema.prisma`), sobre o Prisma. Este arquivo +// REPLICA as transicoes como funcoes puras de estado -> estado para poder testar +// as duas regras que mais surpreendem sem subir SQLite. A autoridade e a secao +// "A Lixeira" de docs/API.md; se um teste daqui falhar contra o backend real, o +// backend e que divergiu. +// +// As duas regras sob teste: +// (a) mandar uma LISTA para a lixeira NAO marca os cards dela. Restaurar a +// lista traz de volta so os cards que estavam vivos -- um card jogado fora +// sozinho continua na lixeira. +// (b) restaurar um CARD cuja lista de origem esta na lixeira e bloqueado (409). + +import test from 'node:test' +import assert from 'node:assert/strict' + +// --- Modelo puro -------------------------------------------------------------- +// Nada muta: toda transicao devolve `{ state, status, body? }`, como a rota HTTP. + +const AT = '2026-08-21T04:12:00.000Z' // timestamp fixo: deletedAt so precisa ser truthy + +const clone = (state) => ({ + lists: state.lists.map((l) => ({ ...l })), + cards: state.cards.map((c) => ({ ...c })), +}) + +const alive = (row) => row.deletedAt === null +const trashed = (row) => row.deletedAt !== null + +function makeState({ lists = [], cards = [] } = {}) { + return { + lists: lists.map((l) => ({ deletedAt: null, position: 0, ...l })), + cards: cards.map((c) => ({ deletedAt: null, position: 0, description: '', ...c })), + } +} + +const findList = (state, id) => state.lists.find((l) => l.id === id) +const findCard = (state, id) => state.cards.find((c) => c.id === id) + +/** GET /api/board -- nem listas nem cards da lixeira aparecem. */ +function board(state) { + return state.lists + .filter(alive) + .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) + .map((list) => ({ + id: list.id, + title: list.title, + // Um card so aparece se ELE esta vivo E a lista dele esta viva. + cards: state.cards + .filter((c) => c.listId === list.id && alive(c)) + .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) + .map((c) => c.id), + })) +} + +/** DELETE /api/lists/:id -- soft delete. NAO toca nos cards. */ +function trashList(state, id) { + const next = clone(state) + const list = findList(next, id) + if (!list || trashed(list)) return { state, status: 404 } + list.deletedAt = AT + return { state: next, status: 204 } +} + +/** DELETE /api/cards/:id -- soft delete do card sozinho. */ +function trashCard(state, id) { + const next = clone(state) + const card = findCard(next, id) + if (!card) return { state, status: 404 } + const list = findList(next, card.listId) + // Rota de recurso vivo: card em lista na lixeira responde 404, como se nao existisse. + if (trashed(card) || !list || trashed(list)) return { state, status: 404 } + card.deletedAt = AT + return { state: next, status: 204 } +} + +/** POST /api/trash/lists/:id/restore -- 200 List com cards[] SO dos vivos. */ +function restoreList(state, id) { + const next = clone(state) + const list = findList(next, id) + if (!list || alive(list)) return { state, status: 404 } + list.deletedAt = null + const cards = next.cards + .filter((c) => c.listId === id && alive(c)) + .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) + return { state: next, status: 200, body: { ...list, cards } } +} + +/** POST /api/trash/cards/:id/restore -- 409 se a lista de origem esta na lixeira. */ +function restoreCard(state, id) { + const next = clone(state) + const card = findCard(next, id) + if (!card || alive(card)) return { state, status: 404 } + const list = findList(next, card.listId) + if (!list || trashed(list)) return { state, status: 409 } + card.deletedAt = null + return { state: next, status: 200, body: { ...card } } +} + +/** GET /api/trash -- inclui originalList.deleted e cardCount. */ +function trash(state) { + const cards = state.cards.filter(trashed).map((c) => { + const list = findList(state, c.listId) + return { + id: c.id, + originalList: { id: list.id, title: list.title, deleted: trashed(list) }, + } + }) + const lists = state.lists.filter(trashed).map((l) => ({ + id: l.id, + // Quantos cards voltam junto se a lista for restaurada -- os que ja estavam + // na lixeira sozinhos NAO contam. + cardCount: state.cards.filter((c) => c.listId === l.id && alive(c)).length, + })) + return { cards, lists, counts: { cards: cards.length, lists: lists.length } } +} + +/** DELETE /api/trash/lists/:id -- apaga de verdade, cascateia nos cards. */ +function purgeList(state, id) { + const next = clone(state) + const list = findList(next, id) + if (!list || alive(list)) return { state, status: 404 } + next.lists = next.lists.filter((l) => l.id !== id) + // Cascade leva TODOS os cards da lista, inclusive os que estavam na lixeira sozinhos. + next.cards = next.cards.filter((c) => c.listId !== id) + return { state: next, status: 204 } +} + +/** Cenario base: lista "A Fazer" com tres cards vivos + uma lista vizinha. */ +const scenario = () => + makeState({ + lists: [ + { id: 'L1', title: 'A Fazer', position: 1 }, + { id: 'L2', title: 'Feito', position: 2 }, + ], + cards: [ + { id: 'C1', listId: 'L1', title: 'um', position: 1 }, + { id: 'C2', listId: 'L1', title: 'dois', position: 2 }, + { id: 'C3', listId: 'L1', title: 'tres', position: 3 }, + { id: 'C9', listId: 'L2', title: 'outro', position: 1 }, + ], + }) + +// --- Regra (a): lista na lixeira nao marca os cards --------------------------- + +test('(a) mandar a lista para a lixeira NAO marca os cards dela', () => { + const s0 = scenario() + const { state: s1, status } = trashList(s0, 'L1') + + assert.equal(status, 204) + // Os cards continuam com deletedAt null -- eles somem do board porque a LISTA sumiu. + for (const id of ['C1', 'C2', 'C3']) { + assert.equal(findCard(s1, id).deletedAt, null, `${id} nao pode ser marcado em cascata`) + } + assert.deepEqual(board(s1), [{ id: 'L2', title: 'Feito', cards: ['C9'] }]) + // E a lixeira mostra 1 lista e ZERO cards: cascata marcada apareceria aqui. + assert.deepEqual(trash(s1).counts, { cards: 0, lists: 1 }) + assert.equal(trash(s1).lists[0].cardCount, 3, 'cardCount = quantos voltam junto') +}) + +test('(a) restaurar a lista traz so os cards que estavam vivos', () => { + let s = scenario() + ;({ state: s } = trashCard(s, 'C2')) // C2 vai pra lixeira SOZINHO, antes da lista + ;({ state: s } = trashList(s, 'L1')) + + assert.deepEqual(trash(s).counts, { cards: 1, lists: 1 }) + + const { state: restored, status, body } = restoreList(s, 'L1') + assert.equal(status, 200) + assert.deepEqual( + body.cards.map((c) => c.id), + ['C1', 'C3'], + 'a resposta traz cards[] populado, so os vivos', + ) + // C2 continua na lixeira: quem foi jogado fora sozinho nao volta de carona. + assert.equal(findCard(restored, 'C2').deletedAt, AT) + assert.deepEqual(board(restored)[0], { id: 'L1', title: 'A Fazer', cards: ['C1', 'C3'] }) + assert.deepEqual(trash(restored).counts, { cards: 1, lists: 0 }) +}) + +test('(a) o ciclo lixeira->restore e idempotente para quem estava vivo', () => { + const s0 = scenario() + const s1 = restoreList(trashList(s0, 'L1').state, 'L1').state + assert.deepEqual(board(s1), board(s0), 'ida e volta sem cards proprios volta ao estado original') +}) + +// --- Regra (b): restore de card com lista de origem na lixeira = 409 ---------- + +test('(b) restaurar card cuja lista de origem esta na lixeira retorna 409', () => { + let s = scenario() + ;({ state: s } = trashCard(s, 'C2')) + ;({ state: s } = trashList(s, 'L1')) + + const attempt = restoreCard(s, 'C2') + assert.equal(attempt.status, 409) + // 409 nao pode alterar nada: o card segue na lixeira. + assert.equal(findCard(attempt.state, 'C2').deletedAt, AT) + assert.deepEqual(trash(attempt.state).counts, { cards: 1, lists: 1 }) +}) + +test('(b) GET /api/trash sinaliza o 409 antes do clique via originalList.deleted', () => { + let s = scenario() + ;({ state: s } = trashCard(s, 'C2')) + + // Lista de origem viva: o botao "Restaurar" fica ativo e o restore passa. + assert.equal(trash(s).cards[0].originalList.deleted, false) + assert.equal(restoreCard(s, 'C2').status, 200) + + // Mesma lixeira, agora com a lista de origem tambem la dentro (nao reatribui + // acima de proposito: C2 continua na lixeira em `s`). + ;({ state: s } = trashList(s, 'L1')) + assert.equal(trash(s).cards[0].originalList.deleted, true, 'lista morta -> botao esmaecido') + assert.equal(restoreCard(s, 'C2').status, 409) +}) + +test('(b) restaurar a lista primeiro desbloqueia o restore do card', () => { + let s = scenario() + ;({ state: s } = trashCard(s, 'C2')) + ;({ state: s } = trashList(s, 'L1')) + + assert.equal(restoreCard(s, 'C2').status, 409) + ;({ state: s } = restoreList(s, 'L1')) + + const done = restoreCard(s, 'C2') + assert.equal(done.status, 200) + assert.deepEqual(board(done.state)[0].cards, ['C1', 'C2', 'C3'], 'volta na position original') + assert.deepEqual(trash(done.state).counts, { cards: 0, lists: 0 }) +}) + +// --- Bordas que caem das duas regras ----------------------------------------- + +test('rota de recurso vivo trata item na lixeira como 404', () => { + let s = scenario() + ;({ state: s } = trashList(s, 'L1')) + + assert.equal(trashList(s, 'L1').status, 404, 'lista ja na lixeira') + assert.equal(trashCard(s, 'C1').status, 404, 'card vivo dentro de lista na lixeira') + assert.equal(restoreList(s, 'L2').status, 404, 'lista viva nao esta na lixeira') + assert.equal(restoreCard(s, 'C9').status, 404, 'card vivo nao esta na lixeira') +}) + +test('apagar a lista de vez cascateia inclusive nos cards que estavam na lixeira sozinhos', () => { + let s = scenario() + ;({ state: s } = trashCard(s, 'C2')) + ;({ state: s } = trashList(s, 'L1')) + + const { state: purged, status } = purgeList(s, 'L1') + assert.equal(status, 204) + assert.equal(findCard(purged, 'C2'), undefined, 'C2 vai junto -- a lista de origem sumiu') + assert.deepEqual( + purged.cards.map((c) => c.id), + ['C9'], + ) + assert.deepEqual(trash(purged).counts, { cards: 0, lists: 0 }) +}) + +test('as transicoes nao mutam o estado recebido', () => { + const s0 = scenario() + const snapshot = JSON.stringify(s0) + trashList(s0, 'L1') + trashCard(s0, 'C1') + restoreList(trashList(s0, 'L1').state, 'L1') + assert.equal(JSON.stringify(s0), snapshot) +}) diff --git a/docs/ACESSIBILIDADE.md b/docs/ACESSIBILIDADE.md new file mode 100644 index 0000000..572bbb1 --- /dev/null +++ b/docs/ACESSIBILIDADE.md @@ -0,0 +1,619 @@ +# Auditoria de acessibilidade — frontend + +Auditoria manual do código de `frontend/src/`, feita lendo `App.tsx`, `ListColumn.tsx`, +`TrashWindow.tsx`, `Modal.tsx`, `CardDialog.tsx`, `ConfirmDialog.tsx`, `Minesweeper.tsx`, +`ScreenSaver.tsx`, `useHotkeys.ts`, `icons.tsx`, `styles.css` e `index.html`. Referência: +WCAG 2.2 nível AA. + +Este documento é só o diagnóstico. As correções ficam com a sessão `frontend` — nada aqui +foi implementado. + +**O veredito curto:** o app tem *muita* coisa boa de acessibilidade em lugares periféricos +(o Campo Minado é o componente mais acessível do repositório) e quase nada na função +principal. **Não existe nenhuma forma de mover um card usando o teclado.** Um card nem +sequer recebe foco. Isso não é um detalhe a polir — é o app inteiro sendo inutilizável para +quem não usa mouse. + +## Resumo + +| # | Achado | Severidade | Arquivo | +|---|---|---|---| +| B1 | Card não é focável; mover/abrir card só por mouse | Bloqueante | `ListColumn.tsx:161` | +| B2 | Reordenar e renomear lista só por mouse | Bloqueante | `ListColumn.tsx:94`, `:109` | +| B3 | Ícone da Lixeira é um ` + )} + +
setNewList(e.target.value)} /> +
{board.lists.map((list, index) => ( setDrag({ kind: 'card', id })} onListDragStart={(id) => setDrag({ kind: 'list', id })} + matches={matches} onCardHover={setCardDropTarget} onListHover={setListDropSlot} onCardDrop={dropCard} onListDrop={dropList} onDragEnd={endDrag} - onOpenCard={(card) => setOpenCardId(card.id)} - onAddCard={(listId, title) => void run(() => api.createCard(listId, title))} + onOpenCard={(card) => { + // O seed cria um card "Jogar Campo Minado". Abrir o jogo em vez do + // dialogo de propriedades fecha a piada -- qualquer card com esse + // titulo funciona, nao ha id magico envolvido. + if (/campo minado/i.test(card.title)) return setMinesweeperOpen(true) + setOpenCardId(card.id) + }} + onAddCard={(listId, title) => runWithSound(() => api.createCard(listId, title))} onRename={(l, title) => void run(() => api.renameList(l.id, title))} onDelete={(l) => setConfirmation({ title: 'Apagar lista', - message: `Apagar "${l.title}" e seus ${l.cards.length} card(s)? Isso nao volta.`, - onYes: () => void run(() => api.deleteList(l.id)), + message: `Mandar "${l.title}" e seus ${l.cards.length} card(s) para a Lixeira?`, + onYes: () => runWithSound(() => api.deleteList(l.id), 'recycle'), }) } /> @@ -170,9 +351,9 @@ export default function App() { onDelete={() => setConfirmation({ title: 'Apagar card', - message: `Apagar o card "${openCard.title}"?`, + message: `Mandar o card "${openCard.title}" para a Lixeira?`, onYes: () => { - void run(() => api.deleteCard(openCard.id)) + runWithSound(() => api.deleteCard(openCard.id), 'recycle') setOpenCardId(null) }, }) @@ -180,6 +361,85 @@ export default function App() { /> )} + {minesweeperOpen && setMinesweeperOpen(false)} />} + + {notepadOpen && ( + { + try { + localStorage.setItem('teamapp95.notepad', text) + } catch { + /* modo privado: perde a nota, nao quebra a app */ + } + sounds.play('ding') + }} + onClose={() => setNotepadOpen(false)} + /> + )} + + {sysPropsOpen && ( + setSysPropsOpen(false)} + /> + )} + + {eventsOpen && ( + setEventsOpen(false)} onConfirm={setConfirmation} /> + )} + + {trashOpen && ( + setTrashOpen(false)} + onConfirm={setConfirmation} + counts={board.trash ?? { cards: 0, lists: 0 }} + /> + )} + + {helpOpen && ( + setHelpOpen(false)} width={300}> +
    +
  • + N Nova lista +
  • +
  • + / Buscar cards +
  • +
  • + T Abrir a Lixeira +
  • +
  • + R Atualizar o board +
  • +
  • + Esc Limpar a busca / fechar janela +
  • +
  • + ? Esta janela +
  • +
+

+ Os atalhos ficam desligados enquanto uma janela está aberta ou você está digitando + num campo. +

+
+ +
+
+ )} + {confirmation && ( setConfirmation(null)} /> )} @@ -203,6 +463,22 @@ export default function App() { )} + {undo.action && ( +
+
+ {undo.action.label} + + +
+
+ )} + + {menu && setMenu(null)} />} + + +
@@ -238,6 +515,99 @@ export default function App() {
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • +
  • + Esquema de cores +
      + {(Object.keys(themes) as (keyof typeof themes)[]).map((name) => ( +
    • + +
    • + ))} +
    +
  • diff --git a/frontend/src/ConfirmDialog.tsx b/frontend/src/ConfirmDialog.tsx index 8ee8b4b..1bc87b7 100644 --- a/frontend/src/ConfirmDialog.tsx +++ b/frontend/src/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import { Modal } from './Modal' +import { WarningIcon } from './icons' export type Confirmation = { title: string; message: string; onYes: () => void } @@ -13,8 +14,8 @@ export function ConfirmDialog({ return (
    -
    diff --git a/frontend/src/ContextMenu.tsx b/frontend/src/ContextMenu.tsx new file mode 100644 index 0000000..004699a --- /dev/null +++ b/frontend/src/ContextMenu.tsx @@ -0,0 +1,83 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' + +export type MenuItem = + | { kind: 'item'; label: string; onClick: () => void; disabled?: boolean; bold?: boolean } + | { kind: 'separator' } + +export type MenuState = { x: number; y: number; items: MenuItem[] } + +/** + * Menu de contexto do Windows 95: botao direito em qualquer lugar. + * + * O detalhe que faz parecer nativo e o reposicionamento -- um menu aberto perto + * da borda direita da tela abre para a ESQUERDA do cursor, nao para fora da + * viewport. Precisa medir depois de montar, por isso o useLayoutEffect: com + * useEffect normal o menu pisca na posicao errada antes de corrigir. + */ +export function ContextMenu({ state, onClose }: { state: MenuState; onClose: () => void }) { + const ref = useRef(null) + const [pos, setPos] = useState({ x: state.x, y: state.y }) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const box = el.getBoundingClientRect() + const margin = 4 + setPos({ + x: state.x + box.width > window.innerWidth ? Math.max(margin, state.x - box.width) : state.x, + y: + state.y + box.height > window.innerHeight + ? Math.max(margin, state.y - box.height) + : state.y, + }) + }, [state.x, state.y]) + + useEffect(() => { + // Qualquer clique, Escape ou scroll fecha -- e o comportamento do shell. + const close = () => onClose() + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('click', close) + window.addEventListener('scroll', close, true) + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('click', close) + window.removeEventListener('scroll', close, true) + window.removeEventListener('keydown', onKey) + } + }, [onClose]) + + return ( +
    e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > +
      + {state.items.map((item, i) => + item.kind === 'separator' ? ( +
    • + ) : ( +
    • + +
    • + ), + )} +
    +
    + ) +} diff --git a/frontend/src/DraggableWindow.tsx b/frontend/src/DraggableWindow.tsx new file mode 100644 index 0000000..e92f50e --- /dev/null +++ b/frontend/src/DraggableWindow.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode, type PointerEvent } from 'react' +import './draggablewindow.css' + +export interface DraggableWindowProps { + title: string + onClose: () => void + /** Chamado em qualquer clique na janela -- o dono usa para subir o z-index. */ + onFocus?: () => void + z?: number + children: ReactNode + width?: number + initialX?: number + initialY?: number + /** Se ausente, o botao de minimizar nao aparece (nao ha taskbar para receber). */ + onMinimize?: () => void + /** Sem isso a janela nao tem foco visual: title-bar cinza em vez de azul. */ + active?: boolean +} + +/** Quantos pixels da janela precisam continuar dentro da viewport. */ +const KEEP_VISIBLE = 60 +/** Altura aproximada da title-bar (3px de padding do `.window` + barra). */ +const TITLE_BAR_H = 22 + +interface Point { + x: number + y: number +} + +interface DragState { + pointerId: number + /** Distancia entre o ponto clicado e o canto da janela. */ + offsetX: number + offsetY: number +} + +/** + * Janela arrastavel pela barra de titulo, como no Win95 de verdade. + * + * **Por que pointer events e nao o drag-and-drop HTML5**: os cards do board ja + * usam `draggable` + `dragstart`/`drop`. Se a janela tambem fosse `draggable`, + * arrastar um card de dentro dela dispararia os dois: o navegador escolhe o + * ancestral arrastavel mais proximo, o `dataTransfer` acaba compartilhado e o + * drop cai no alvo errado. Alem disso o HTML5 drag nao da coordenadas uteis + * durante o arrasto (o `dragover` do documento e ruidoso e o `drag` reporta + * 0,0 no Firefox). `pointerdown` + `setPointerCapture` resolve os dois + * problemas: o movimento e continuo, as coordenadas sao exatas, e a captura + * garante que o `pointermove` continue chegando mesmo se o cursor sair da + * barra ou passar por cima de um iframe. + */ +export function DraggableWindow({ + title, + onClose, + onFocus, + z = 1, + children, + width = 320, + initialX = 80, + initialY = 80, + onMinimize, + active = true, +}: DraggableWindowProps) { + const [pos, setPos] = useState({ x: initialX, y: initialY }) + const [maximized, setMaximized] = useState(false) + const drag = useRef(null) + const barRef = useRef(null) + + /** Impede que a janela suma: a title-bar precisa continuar alcancavel. */ + const clamp = useCallback( + (p: Point): Point => { + const maxX = window.innerWidth - KEEP_VISIBLE + const minX = KEEP_VISIBLE - width + const maxY = window.innerHeight - TITLE_BAR_H + return { + x: Math.min(Math.max(p.x, minX), maxX), + // y nunca negativo: arrastar para cima esconderia a barra sob o topo + // da viewport e nao haveria como pegar a janela de volta. + y: Math.min(Math.max(p.y, 0), maxY), + } + }, + [width], + ) + + // Redimensionar a janela do navegador pode deixar a janelinha fora da tela. + useEffect(() => { + const onResize = () => setPos((p) => clamp(p)) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [clamp]) + + const onPointerDown = (e: PointerEvent) => { + onFocus?.() + if (maximized) return + // Clique nos botoes de controle nao arrasta. + if (e.target instanceof HTMLElement && e.target.closest('button')) return + if (e.button !== 0) return + + drag.current = { pointerId: e.pointerId, offsetX: e.clientX - pos.x, offsetY: e.clientY - pos.y } + barRef.current?.setPointerCapture(e.pointerId) + // Sem isso o navegador comeca a selecionar o texto do titulo durante o arrasto. + e.preventDefault() + } + + const onPointerMove = (e: PointerEvent) => { + const d = drag.current + if (!d || d.pointerId !== e.pointerId) return + setPos(clamp({ x: e.clientX - d.offsetX, y: e.clientY - d.offsetY })) + } + + const endDrag = (e: PointerEvent) => { + const d = drag.current + if (!d || d.pointerId !== e.pointerId) return + drag.current = null + if (barRef.current?.hasPointerCapture(e.pointerId)) { + barRef.current.releasePointerCapture(e.pointerId) + } + } + + const style = maximized + ? { left: 0, top: 0, width: '100%', height: '100%', zIndex: z } + : { left: pos.x, top: pos.y, width, zIndex: z } + + return ( +
    onFocus?.()} + role="dialog" + aria-label={title} + > +
    setMaximized((m) => !m)} + > +
    {title}
    +
    + {onMinimize ? ( +
    +
    +
    {children}
    +
    + ) +} diff --git a/frontend/src/EventViewer.tsx b/frontend/src/EventViewer.tsx new file mode 100644 index 0000000..18cc722 --- /dev/null +++ b/frontend/src/EventViewer.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react' +import { API_URL } from './api' +import { Modal } from './Modal' +import { DocumentIcon, FolderIcon, TrashFullIcon, WarningIcon } from './icons' + +type Activity = { id: string; type: string; summary: string; createdAt: string } + +/** Um icone por familia de evento -- o Visualizador de Eventos do Windows faz igual. */ +function iconFor(type: string) { + if (type.startsWith('list.')) return + if (type.includes('purged') || type.includes('emptied')) return + if (type.includes('deleted')) return + return +} + +function formatTime(iso: string) { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +export function EventViewer({ + onClose, + onConfirm, +}: { + onClose: () => void + onConfirm: (c: { title: string; message: string; onYes: () => void }) => void +}) { + const [items, setItems] = useState(null) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/api/activity?limit=100`) + if (!res.ok) throw new Error((await res.json()).error ?? `HTTP ${res.status}`) + const data = (await res.json()) as { items: Activity[] } + setItems(data.items) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + // O backend faz broadcast de `activity.recorded`, mas o board nao guarda o log + // (o payload nao pertence ao estado do board). Um listener proprio, so + // enquanto a janela esta aberta, mantem a lista viva sem poluir o reducer. + useEffect(() => { + const socket = new WebSocket(API_URL.replace(/^http/, 'ws') + '/ws') + socket.onmessage = (message) => { + const event = JSON.parse(message.data) as { type: string; payload: Activity } + if (event.type === 'activity.recorded') { + setItems((current) => (current ? [event.payload, ...current].slice(0, 100) : current)) + } + } + return () => socket.close() + }, []) + + const clear = async () => { + try { + const res = await fetch(`${API_URL}/api/activity`, { method: 'DELETE' }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + await load() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + return ( + + {error &&

    {error}

    } + +
    + {items === null &&

    Lendo o log...

    } + {items?.length === 0 &&

    Nenhum evento registrado.

    } + {items && items.length > 0 && ( + + + + + + + + + {items.map((item) => ( + + + + + + ))} + +
    + Data e horaDescrição
    {iconFor(item.type)}{formatTime(item.createdAt)}{item.summary}
    + )} +
    + +
    + {items ? `${items.length} evento(s)` : ''} + + + +
    +
    + ) +} diff --git a/frontend/src/ListColumn.tsx b/frontend/src/ListColumn.tsx index d919a49..3c2b8fc 100644 --- a/frontend/src/ListColumn.tsx +++ b/frontend/src/ListColumn.tsx @@ -11,6 +11,8 @@ type Props = { draggingCardId: string | null draggingListId: string | null cardDropTarget: CardDropTarget | null + /** Ids que casam com a busca, ou null quando nao ha busca ativa. */ + matches: Set | null /** Slot (indice no array de listas) onde a lista arrastada vai cair. */ listDropSlot: number | null isLast: boolean @@ -34,7 +36,8 @@ function cardSlot(event: DragEvent, index: number): number { } export function ListColumn(props: Props) { - const { list, index, draggingCardId, draggingListId, cardDropTarget, listDropSlot } = props + const { list, index, draggingCardId, draggingListId, cardDropTarget, listDropSlot, matches } = + props const [renaming, setRenaming] = useState(false) const [draftTitle, setDraftTitle] = useState(list.title) const [newCard, setNewCard] = useState('') @@ -161,7 +164,10 @@ export function ListColumn(props: Props) { className={ 'card' + (draggingCardId === card.id ? ' dragging' : '') + - (isCardTarget(i) ? ' drop-here' : '') + (isCardTarget(i) ? ' drop-here' : '') + + // Busca realca em vez de filtrar: esconder os que nao casam mudaria + // os vizinhos e quebraria o calculo de position no drop. + (matches === null ? '' : matches.has(card.id) ? ' card-match' : ' card-dimmed') } draggable onDragStart={(e) => { diff --git a/frontend/src/Minesweeper.tsx b/frontend/src/Minesweeper.tsx new file mode 100644 index 0000000..ba66761 --- /dev/null +++ b/frontend/src/Minesweeper.tsx @@ -0,0 +1,257 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Modal } from './Modal' +import { BombIcon, FlagIcon } from './icons' +import './minesweeper.css' + +/* Tabuleiro classico "iniciante": 9x9 com 10 minas. */ +const SIZE = 9 +const MINES = 10 +const TOTAL = SIZE * SIZE + +type CellState = 'hidden' | 'revealed' | 'flagged' + +type Cell = { + mine: boolean + /** Quantidade de minas nas 8 celulas vizinhas. So vale depois do 1o clique. */ + adjacent: number + state: CellState +} + +/** 'ready' = minas ainda nao foram sorteadas; o cronometro so anda em 'playing'. */ +type Status = 'ready' | 'playing' | 'won' | 'lost' + +type Game = { cells: Cell[]; status: Status } + +const FACES: Record = { + ready: '\u{1F600}', + playing: '\u{1F600}', + won: '\u{1F60E}', + lost: '\u{1F635}', +} + +function freshGame(): Game { + return { + cells: Array.from({ length: TOTAL }, () => ({ + mine: false, + adjacent: 0, + state: 'hidden' as CellState, + })), + status: 'ready', + } +} + +/** Indices das ate 8 celulas vizinhas. O clamp por linha evita o wrap de borda: + sem ele a celula da coluna 8 "vizinharia" a coluna 0 da linha seguinte. */ +function neighbors(index: number): number[] { + const row = Math.floor(index / SIZE) + const col = index % SIZE + const out: number[] = [] + for (let dr = -1; dr <= 1; dr++) { + for (let dc = -1; dc <= 1; dc++) { + if (dr === 0 && dc === 0) continue + const r = row + dr + const c = col + dc + if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) continue + out.push(r * SIZE + c) + } + } + return out +} + +/** As minas so sao sorteadas depois do primeiro clique, e a celula clicada mais + toda a sua vizinhanca ficam de fora do sorteio. Duas razoes: o primeiro clique + nunca pode matar (seria um jogo perdido sem jogada), e zerando a vizinhanca + garantimos `adjacent === 0` ali, o que dispara o flood fill e abre uma area + inicial em vez de um unico quadradinho com um numero. */ +function plantMines(cells: Cell[], safeIndex: number): Cell[] { + const forbidden = new Set([safeIndex, ...neighbors(safeIndex)]) + const pool: number[] = [] + for (let i = 0; i < TOTAL; i++) if (!forbidden.has(i)) pool.push(i) + + // Fisher-Yates parcial: embaralha so o suficiente para tirar MINES indices. + for (let i = 0; i < MINES; i++) { + const j = i + Math.floor(Math.random() * (pool.length - i)) + const tmp = pool[i] + pool[i] = pool[j] + pool[j] = tmp + } + + const next = cells.map((cell) => ({ ...cell, mine: false, adjacent: 0 })) + for (let i = 0; i < MINES; i++) next[pool[i]].mine = true + for (let i = 0; i < TOTAL; i++) { + if (next[i].mine) continue + next[i].adjacent = neighbors(i).filter((n) => next[n].mine).length + } + return next +} + +/** Flood fill iterativo a partir de `start`. + A pilha explicita (em vez de recursao) evita estourar o stack, e marcar a + celula como revelada ANTES de empilhar os vizinhos e o que impede o loop + infinito: cada celula so entra na parte "expande" uma unica vez, porque na + segunda visita ela ja esta em 'revealed' e o `continue` corta o caminho. */ +function revealFrom(cells: Cell[], start: number): Cell[] { + const next = cells.slice() + const stack: number[] = [start] + while (stack.length > 0) { + const i = stack.pop() as number + const cell = next[i] + if (cell.state === 'revealed' || cell.state === 'flagged') continue + next[i] = { ...cell, state: 'revealed' } + // Celula com vizinho minado e parede: revela, mas nao propaga. + if (!cell.mine && cell.adjacent === 0) { + for (const n of neighbors(i)) stack.push(n) + } + } + return next +} + +/** Vitoria = toda celula sem mina revelada. Nao exige bandeira nas minas — + e a regra do Campo Minado original. */ +function hasWon(cells: Cell[]): boolean { + return cells.every((c) => c.mine || c.state === 'revealed') +} + +function display(value: number): string { + const clamped = Math.max(-99, Math.min(999, value)) + const sign = clamped < 0 ? '-' : '' + return sign + String(Math.abs(clamped)).padStart(sign ? 2 : 3, '0') +} + +export function Minesweeper({ onClose }: { onClose: () => void }) { + const [game, setGame] = useState(freshGame) + const [seconds, setSeconds] = useState(0) + + const flags = useMemo( + () => game.cells.filter((c) => c.state === 'flagged').length, + [game.cells], + ) + + useEffect(() => { + if (game.status !== 'playing') return + const id = window.setInterval(() => setSeconds((s) => Math.min(999, s + 1)), 1000) + return () => window.clearInterval(id) + }, [game.status]) + + const restart = useCallback(() => { + setGame(freshGame()) + setSeconds(0) + }, []) + + const handleReveal = useCallback((index: number) => { + setGame((prev) => { + if (prev.status === 'won' || prev.status === 'lost') return prev + if (prev.cells[index].state !== 'hidden') return prev + + // O sorteio acontece aqui, dentro do updater, para que a celula clicada + // esteja sempre disponivel como semente do "primeiro clique seguro". + const cells = prev.status === 'ready' ? plantMines(prev.cells, index) : prev.cells + + if (cells[index].mine) { + return { + cells: cells.map((c) => (c.mine ? { ...c, state: 'revealed' as CellState } : c)), + status: 'lost', + } + } + + const opened = revealFrom(cells, index) + return { cells: opened, status: hasWon(opened) ? 'won' : 'playing' } + }) + }, []) + + const handleFlag = useCallback((index: number) => { + setGame((prev) => { + if (prev.status === 'won' || prev.status === 'lost') return prev + const cell = prev.cells[index] + if (cell.state === 'revealed') return prev + const cells = prev.cells.slice() + cells[index] = { ...cell, state: cell.state === 'flagged' ? 'hidden' : 'flagged' } + // Bandeira antes do primeiro clique nao inicia o cronometro: sem minas + // sorteadas ainda nao existe partida para cronometrar. + return { cells, status: prev.status } + }) + }, []) + + return ( + +
    +
    + + {display(MINES - flags)} + + + + {display(seconds)} + +
    + +
    + {game.cells.map((cell, i) => { + const row = Math.floor(i / SIZE) + 1 + const col = (i % SIZE) + 1 + const revealed = cell.state === 'revealed' + const isMine = revealed && cell.mine + const number = revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : 0 + + const label = + cell.state === 'flagged' + ? `Linha ${row}, coluna ${col}: marcada com bandeira` + : !revealed + ? `Linha ${row}, coluna ${col}: fechada` + : isMine + ? `Linha ${row}, coluna ${col}: mina` + : number > 0 + ? `Linha ${row}, coluna ${col}: ${number} minas por perto` + : `Linha ${row}, coluna ${col}: vazia` + + return ( + + ) + })} +
    + +

    + {game.status === 'won' + ? 'Voce venceu! Campo limpo.' + : game.status === 'lost' + ? 'Boom. Clique na carinha para jogar de novo.' + : 'Clique para abrir, botao direito para marcar bandeira.'} +

    +
    +
    + ) +} diff --git a/frontend/src/Notepad.tsx b/frontend/src/Notepad.tsx new file mode 100644 index 0000000..3058291 --- /dev/null +++ b/frontend/src/Notepad.tsx @@ -0,0 +1,271 @@ +import { useEffect, useRef, useState } from 'react' +import { Modal } from './Modal' +import { WarningIcon } from './icons' +import './notepad.css' + +type Props = { + initialText: string + title: string + onSave: (text: string) => void + onClose: () => void +} + +/** Qual menu esta aberto. `null` = nenhum; so um por vez, como no original. */ +type OpenMenu = 'arquivo' | 'editar' | 'formatar' | null + +/** + * Formato do carimbo do F5. O Notepad do Windows 95 usava a ordem do locale + * (hora antes da data), e nao um ISO -- manter isso e metade da ilusao. + */ +function stamp(): string { + const now = new Date() + const hora = now.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) + const data = now.toLocaleDateString('pt-BR') + return `${hora} ${data}` +} + +/** + * Linha e coluna a partir do offset do caret. O textarea so expoe `selectionStart` + * em caracteres, entao a conversao e nossa: linha = quantidade de \n antes, e a + * coluna e a distancia ate o \n anterior. Com quebra automatica ligada o Notepad + * original tambem contava linhas *logicas*, nao visuais -- e o que fazemos aqui. + */ +function lineCol(text: string, caret: number): { line: number; col: number } { + const before = text.slice(0, caret) + const lastBreak = before.lastIndexOf('\n') + return { line: before.split('\n').length, col: caret - lastBreak } +} + +export function Notepad({ initialText, title, onSave, onClose }: Props) { + const [text, setText] = useState(initialText) + /** Ultimo conteudo gravado. `dirty` e a comparacao com ele, nao um booleano solto: + desfazer ate voltar ao original deixa de ser "alteracao nao salva", como no Win95. */ + const [saved, setSaved] = useState(initialText) + const [wrap, setWrap] = useState(true) + const [menu, setMenu] = useState(null) + /** Segundo estado da janela: o aviso de saida. Nada de window.confirm. */ + const [asking, setAsking] = useState(false) + const [caret, setCaret] = useState(0) + /** Posicao para onde o caret deve ir depois que o React re-renderizar o texto. */ + const [pendingCaret, setPendingCaret] = useState(null) + + const areaRef = useRef(null) + const dirty = text !== saved + + // Reposicionar o caret so funciona depois que o novo valor chegou ao DOM; + // fazer isso dentro do handler colocaria o cursor no fim do texto. + useEffect(() => { + if (pendingCaret === null) return + const el = areaRef.current + if (el) { + el.focus() + el.setSelectionRange(pendingCaret, pendingCaret) + setCaret(pendingCaret) + } + setPendingCaret(null) + }, [pendingCaret]) + + const syncCaret = () => { + const el = areaRef.current + if (el) setCaret(el.selectionStart) + } + + const doSave = () => { + onSave(text) + setSaved(text) + setMenu(null) + } + + const insertStamp = () => { + const el = areaRef.current + if (!el) return + const { selectionStart: start, selectionEnd: end } = el + const s = stamp() + setText(text.slice(0, start) + s + text.slice(end)) + setPendingCaret(start + s.length) + setMenu(null) + } + + const selectAll = () => { + setMenu(null) + const el = areaRef.current + if (!el) return + el.focus() + el.select() + setCaret(el.selectionStart) + } + + /** Porta unica de saida: backdrop, Esc, X e "Sair" passam todos por aqui. */ + const requestClose = () => { + if (dirty && !asking) { + setMenu(null) + setAsking(true) + return + } + onClose() + } + + const { line, col } = lineCol(text, Math.min(caret, text.length)) + + return ( + + {/* Fechar o menu ao clicar em qualquer lugar da janela imita o comportamento + modal das barras de menu do Win95, sem listener global no document. */} +
    setMenu(null)} + onKeyDown={(e) => { + if (e.key === 'F5') { + e.preventDefault() + insertStamp() + } + }} + > + {asking ? ( +
    +
    + +

    As alteracoes nao foram salvas. Deseja salva-las?

    +
    +
    + + + +
    +
    + ) : ( + <> +
    e.stopPropagation()}> + setMenu(menu === 'arquivo' ? null : 'arquivo')} + onHover={() => menu !== null && setMenu('arquivo')} + > + +
    + + + + setMenu(menu === 'editar' ? null : 'editar')} + onHover={() => menu !== null && setMenu('editar')} + > + + + + + setMenu(menu === 'formatar' ? null : 'formatar')} + onHover={() => menu !== null && setMenu('formatar')} + > + { + setWrap(!wrap) + setMenu(null) + }} + /> + +
    + +