From b11c4c8e257604c1511d41df0b79ead3d37bd79f Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:37:35 -0300 Subject: [PATCH 01/19] feat(backend): Lixeira do Windows 95 com soft delete e restauracao DELETE de card e de lista passa a marcar `deletedAt` em vez de apagar. O item some do board, aparece em GET /api/trash e pode voltar. So as rotas /api/trash apagam de verdade. O contrato antigo continua valendo: DELETE responde 204 e emite card.deleted / list.deleted com { id }, entao o reducer do cliente nao muda. Restaurar reusa card.created / list.created em vez de eventos novos -- o upsert idempotente por id ja faz a coisa certa com eles. A armadilha do soft delete e que findUnique por id continua achando a linha: toda rota de recurso vivo passa a filtrar deletedAt: null via findLiveCard() / findLiveList(), senao da para renomear, mover e criar cards dentro de coisas que o board nao mostra mais. Duas semanticas deliberadas: apagar uma lista nao marca os cards dela (senao um card jogado fora sozinho ressuscitaria junto com ela), e restaurar um card cuja lista esta na lixeira retorna 409, sinalizado antes pelo originalList.deleted. Smoke: 45 -> 82 assercoes, incluindo preflight de CORS das rotas novas. Co-Authored-By: Claude Opus 5 (1M context) --- .../20260821040639_lixeira/migration.sql | 11 ++ backend/prisma/schema.prisma | 19 ++- backend/prisma/seed.ts | 15 ++ backend/scripts/smoke.mjs | 151 ++++++++++++++++++ backend/src/bus.ts | 3 + backend/src/routes/board.ts | 12 +- backend/src/routes/cards.ts | 19 ++- backend/src/routes/lists.ts | 15 +- backend/src/routes/trash.ts | 139 ++++++++++++++++ backend/src/server.ts | 2 + backend/src/trash.ts | 43 +++++ 11 files changed, 412 insertions(+), 17 deletions(-) create mode 100644 backend/prisma/migrations/20260821040639_lixeira/migration.sql create mode 100644 backend/src/routes/trash.ts create mode 100644 backend/src/trash.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/schema.prisma b/backend/prisma/schema.prisma index 89f21d9..a8d7a24 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,23 +15,32 @@ 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) + // 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]) } 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/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/src/bus.ts b/backend/src/bus.ts index 1cb46e9..895fb59 100644 --- a/backend/src/bus.ts +++ b/backend/src/bus.ts @@ -8,6 +8,9 @@ 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' const clients = new Set() 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..7a52124 100644 --- a/backend/src/routes/cards.ts +++ b/backend/src/routes/cards.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify' import { prisma } from '../db.js' import { broadcast } from '../bus.js' +import { broadcastTrash, findLiveCard, findLiveList } from '../trash.js' export async function cardRoutes(app: FastifyInstance) { app.post('/api/cards', async (req, reply) => { @@ -20,9 +21,11 @@ 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' }) + // 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' }, @@ -65,7 +68,7 @@ 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 }) @@ -87,10 +90,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({ @@ -105,12 +108,16 @@ export async function cardRoutes(app: FastifyInstance) { 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 broadcastTrash() return reply.code(204).send() }) } diff --git a/backend/src/routes/lists.ts b/backend/src/routes/lists.ts index dce4761..c54ccb0 100644 --- a/backend/src/routes/lists.ts +++ b/backend/src/routes/lists.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify' import { prisma } from '../db.js' import { broadcast } from '../bus.js' +import { ALIVE, broadcastTrash, findLiveList } from '../trash.js' export async function listRoutes(app: FastifyInstance) { app.post('/api/lists', async (req, reply) => { @@ -12,6 +13,8 @@ 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' }) + // 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' }, @@ -51,13 +54,13 @@ 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) @@ -67,12 +70,16 @@ export async function listRoutes(app: FastifyInstance) { 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 broadcastTrash() return reply.code(204).send() }) } diff --git a/backend/src/routes/trash.ts b/backend/src/routes/trash.ts new file mode 100644 index 0000000..18dacb9 --- /dev/null +++ b/backend/src/routes/trash.ts @@ -0,0 +1,139 @@ +import type { FastifyInstance } from 'fastify' +import { prisma } from '../db.js' +import { broadcast } from '../bus.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 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 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 } }) + // 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 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 broadcastTrash() + return reply.send({ deleted: { cards, lists } }) + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index f4057fb..e434514 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ 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' const PORT = Number(process.env.PORT ?? 3001) const HOST = process.env.HOST ?? '0.0.0.0' @@ -68,6 +69,7 @@ app.setNotFoundHandler((req, reply) => { await app.register(boardRoutes) await app.register(listRoutes) await app.register(cardRoutes) +await app.register(trashRoutes) 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()) +} From 117721987192537b9bede9b288ca0b14d593f59f Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:37:56 -0300 Subject: [PATCH 02/19] feat(frontend): janela da Lixeira e correcoes do reducer A Lixeira ganha UI: janela com Restaurar/Excluir por item, Esvaziar no rodape, e o botao Restaurar ja nasce desabilitado quando originalList.deleted avisa que o restore vai dar 409 -- evita o erro em vez de exibi-lo. Junto, os tres achados de QA que estavam abertos no useBoard.ts: - race no load(): um GET /api/board em voo podia sobrescrever eventos WS mais novos. Resolvido com numero de geracao que descarta resposta obsoleta. - `incoming.cards ?? l.cards` nunca usava o fallback ([] nao e nullish), entao um list.updated com cards vazio zerava a lista. Agora testa a presenca da chave, o que tambem e o que faz o restore de lista trazer os cards de volta. - evento sobre card ou lista desconhecida sumia em silencio ate o proximo reload. Agora marca o board como stale e o efeito recarrega. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/TrashWindow.tsx | 186 +++++++++++++++++++++++++++++++++++ frontend/src/api.ts | 13 ++- frontend/src/types.ts | 26 +++++ frontend/src/useBoard.ts | 45 +++++++-- 4 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 frontend/src/TrashWindow.tsx diff --git a/frontend/src/TrashWindow.tsx b/frontend/src/TrashWindow.tsx new file mode 100644 index 0000000..90260f2 --- /dev/null +++ b/frontend/src/TrashWindow.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from './api' +import { Modal } from './Modal' +import { DocumentIcon, FolderIcon } from './icons' +import type { Trash, TrashedCard, TrashedList } from './types' + +type Props = { + onClose: () => void + /** Pede confirmacao antes de uma acao irreversivel. Reusa o ConfirmDialog do App. */ + onConfirm: (c: { title: string; message: string; onYes: () => void }) => void + /** Contagem vinda do board/WS: muda quando alguem apaga algo em outra aba. */ + counts: { cards: number; lists: number } +} + +/** "21/08/2026 01:47" -- sem segundos, que nao acrescentam nada aqui. */ +function formatDate(iso: string) { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function TrashWindow({ onClose, onConfirm, counts }: Props) { + const [trash, setTrash] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + + const load = useCallback(async () => { + try { + setTrash(await api.getTrash()) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + // A contagem chega por `trash.updated` no WebSocket, mas o conteudo nao -- + // o payload e so { cards, lists }. Quando ela muda, alguem mexeu na lixeira + // (talvez em outra aba) e a lista aberta aqui esta velha: recarrega. + const total = counts.cards + counts.lists + useEffect(() => { + void load() + }, [total, load]) + + /** Toda acao recarrega a lixeira; o board se atualiza sozinho pelo WebSocket. */ + const act = async (key: string, action: () => Promise) => { + setBusy(key) + try { + await action() + await load() + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(null) + } + } + + const empty = trash !== null && trash.cards.length === 0 && trash.lists.length === 0 + + const cardRow = (card: TrashedCard) => { + // O backend ja diz que o restore vai dar 409 -- da para desabilitar o botao + // antes do clique em vez de mostrar o erro depois. + const blocked = card.originalList.deleted + return ( +
  • + + + +
    + {card.title} + + card de "{card.originalList.title}" · {formatDate(card.deletedAt)} + + {blocked && ( + + a lista de origem também está na lixeira — restaure a lista primeiro + + )} +
    +
    + + +
    +
  • + ) + } + + const listRow = (list: TrashedList) => ( +
  • + + + +
    + {list.title} + + lista com {list.cardCount} card(s) · {formatDate(list.deletedAt)} + +
    +
    + + +
    +
  • + ) + + return ( + + {error &&

    {error}

    } + +
    + {trash === null &&

    Abrindo...

    } + {empty &&

    A Lixeira está vazia.

    } + {trash !== null && ( +
      + {trash.lists.map(listRow)} + {trash.cards.map(cardRow)} +
    + )} +
    + +
    + + {trash ? `${trash.counts.lists} lista(s), ${trash.counts.cards} card(s)` : ''} + + + +
    +
    + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 37530de..428e238 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { Board, Card, List } from './types' +import type { Board, Card, List, Trash, TrashCounts } from './types' export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3001' export const WS_URL = API_URL.replace(/^http/, 'ws') + '/ws' @@ -40,7 +40,18 @@ export const api = { request('PATCH', `/api/cards/${id}`, patch), moveCard: (id: string, listId: string, position: number) => request('PATCH', `/api/cards/${id}/move`, { listId, position }), + // Manda para a lixeira -- nao apaga. O evento continua sendo card.deleted { id }. deleteCard: (id: string) => request('DELETE', `/api/cards/${id}`), + + // --- Lixeira --- + getTrash: () => request('GET', '/api/trash'), + // Restaurar reusa card.created/list.created: o reducer ja faz upsert por id, + // entao o item reaparece no board sem tratamento especial. + restoreCard: (id: string) => request('POST', `/api/trash/cards/${id}/restore`), + restoreList: (id: string) => request('POST', `/api/trash/lists/${id}/restore`), + purgeCard: (id: string) => request('DELETE', `/api/trash/cards/${id}`), + purgeList: (id: string) => request('DELETE', `/api/trash/lists/${id}`), + emptyTrash: () => request<{ deleted: TrashCounts }>('DELETE', '/api/trash'), } /** diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 92700d9..cc28a03 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -20,6 +20,10 @@ export type Board = { slug: string title: string lists: List[] + /** Contagem da lixeira, para o icone nascer no estado certo sem um GET extra. */ + trash: TrashCounts + /** Marcado pelo reducer quando chega evento sobre algo que ele nao conhece: pede reload. */ + stale?: boolean } export type ServerEvent = @@ -30,3 +34,25 @@ export type ServerEvent = | { type: 'card.updated'; payload: Card } | { type: 'card.moved'; payload: { id: string; listId: string; position: number } } | { type: 'card.deleted'; payload: { id: string } } + // So a contagem: o conteudo vem de GET /api/trash quando a janela abre. + | { type: 'trash.updated'; payload: TrashCounts } + +// --- Lixeira --- +// DELETE de card/lista e soft delete: o item vai para a lixeira, de onde volta. +// Ver "A Lixeira" em docs/API.md. + +export type TrashCounts = { cards: number; lists: number } + +export type TrashedCard = Card & { + deletedAt: string + /** `deleted: true` => restaurar este card retorna 409: a lista de origem tambem foi para a lixeira. */ + originalList: { id: string; title: string; deleted: boolean } +} + +export type TrashedList = Omit & { + deletedAt: string + /** Quantos cards voltam junto se a lista for restaurada. Os que foram jogados fora sozinhos nao contam. */ + cardCount: number +} + +export type Trash = { cards: TrashedCard[]; lists: TrashedList[]; counts: TrashCounts } diff --git a/frontend/src/useBoard.ts b/frontend/src/useBoard.ts index f8d25c1..91647e0 100644 --- a/frontend/src/useBoard.ts +++ b/frontend/src/useBoard.ts @@ -17,11 +17,16 @@ function reduce(board: Board, event: ServerEvent): Board { const incoming = event.payload const exists = board.lists.some((l) => l.id === incoming.id) const lists = exists - ? board.lists.map((l) => - // Preserva os cards locais: list.updated pode nao trazer os mesmos. - l.id === incoming.id ? { ...l, ...incoming, cards: incoming.cards ?? l.cards } : l, - ) - : [...board.lists, { ...incoming, cards: incoming.cards ?? [] }] + ? board.lists.map((l) => { + if (l.id !== incoming.id) return l + // `?? l.cards` nao servia: [] nao e nullish, entao um list.updated com + // cards vazio zerava a lista. Um payload SEM a chave preserva o local; + // com a chave (mesmo vazia) manda -- e o caso do restore, que traz os + // cards de volta de proposito. + 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) } } @@ -31,6 +36,9 @@ function reduce(board: Board, event: ServerEvent): Board { case 'card.created': case 'card.updated': { const card = event.payload + // Lista desconhecida: mesma situacao do card.moved orfao. O card nao tem + // onde entrar, entao marca para recarregar em vez de sumir. + 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) { @@ -45,7 +53,10 @@ function reduce(board: Board, event: ServerEvent): Board { 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 + // Card que este cliente nao conhece: aconteceu fora da nossa visao (criado + // e movido entre dois GETs, ou restaurado da lixeira). Descartar em silencio + // deixava o board errado ate o proximo reload -- sinaliza para recarregar. + if (!existing) return { ...board, stale: true } const moved: Card = { ...existing, listId, position } const lists = board.lists.map((list) => { const without = list.cards.filter((c) => c.id !== id) @@ -57,6 +68,11 @@ function reduce(board: Board, event: ServerEvent): Board { return { ...board, lists } } + // So a contagem muda -- o conteudo da lixeira vem de GET /api/trash quando + // a janela abre. Nao ha nada de board para reduzir aqui. + case 'trash.updated': + return { ...board, trash: event.payload } + case 'card.deleted': { const { id } = event.payload return { @@ -79,13 +95,23 @@ export function useBoard() { const [connected, setConnected] = useState(false) const socketRef = useRef(null) + // Geracao do load: um GET /api/board em voo pode terminar DEPOIS de eventos WS + // que ja aplicamos, e sobrescrever o estado mais novo com o mais velho. A janela + // e pequena mas real justamente na reconexao, que e quando o load() roda. + const generation = useRef(0) + const load = useCallback(async () => { + const mine = ++generation.current try { const fresh = await api.getBoard() + // Outro load comecou enquanto este estava em voo: a resposta dele e mais + // nova que a nossa, entao esta aqui esta obsoleta. + if (mine !== generation.current) return fresh.lists = byPosition(fresh.lists).map((l: List) => ({ ...l, cards: byPosition(l.cards) })) setBoard(fresh) setError(null) } catch (e) { + if (mine !== generation.current) return setError(e instanceof Error ? e.message : String(e)) } }, []) @@ -94,6 +120,13 @@ export function useBoard() { void load() }, [load]) + // O reducer marca `stale` quando recebe um evento sobre algo que ele nao + // conhece (ver card.moved/card.created orfaos). Ele nao pode chamar load() + // sozinho -- e uma funcao pura -- entao o efeito faz a recarga. + useEffect(() => { + if (board?.stale) void load() + }, [board?.stale, load]) + useEffect(() => { let closedByUnmount = false let retry: ReturnType From 62361daa41dd49f187eaeb5d4a2a7781cb1b4f0d Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:37:56 -0300 Subject: [PATCH 03/19] feat(frontend): icones do Windows 95 em pixel art Emoji entregava na hora que nao e Windows 95. Os icones agora sao SVG desenhados numa grade de 16x16 com a paleta VGA de 16 cores: lixeira vazia e cheia, bandeira do Iniciar, pasta, documento, aviso, bomba e bandeira do Campo Minado. Sao recriacoes proprias, nao os arquivos da Microsoft -- os originais sao proprietarios e este repositorio e publico. O que da a aparencia certa e a disciplina do formato, nao o arquivo. shapeRendering="crispEdges" e obrigatorio: sem ele o navegador aplica antialias e o resultado parece um icone moderno borrado. Escalar so em multiplos inteiros do tamanho base -- 16px renderizado em 20px mostra linhas de espessura irregular, que e o detalhe que denuncia a imitacao mais rapido que qualquer outro. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/ConfirmDialog.tsx | 5 +- frontend/src/icons.tsx | 274 +++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 frontend/src/icons.tsx 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/icons.tsx b/frontend/src/icons.tsx new file mode 100644 index 0000000..e2650f1 --- /dev/null +++ b/frontend/src/icons.tsx @@ -0,0 +1,274 @@ +/** + * Icones do Windows 95 em pixel art. + * + * Sao recriacoes proprias em SVG, nao os arquivos originais da Microsoft: os + * icones do Windows 95 sao material proprietario e nao podem ser redistribuidos + * num repositorio publico. O que da a aparencia certa nao e o arquivo, e a + * disciplina do formato -- grade de 16x16 ou 32x32, um pixel por unidade, e a + * paleta VGA de 16 cores. + * + * Cada icone e um `` com `shapeRendering="crispEdges"`, + * que desliga o antialias: sem isso o navegador borra as bordas e o resultado + * parece um icone moderno desfocado em vez de pixel art. + * + * Escalar so em multiplos inteiros (16, 32, 48). Um icone de 16px desenhado em + * 20px mostra linhas de espessura irregular -- e o detalhe que denuncia a + * imitacao mais rapido que qualquer outro. + */ + +type IconProps = { size?: number; className?: string; title?: string } + +/** Paleta VGA de 16 cores -- a mesma que o Windows 95 usava para os icones do shell. */ +const C = { + black: '#000000', + gray: '#808080', + silver: '#c0c0c0', + white: '#ffffff', + navy: '#000080', + blue: '#0000ff', + teal: '#008080', + cyan: '#00ffff', + green: '#008000', + lime: '#00ff00', + maroon: '#800000', + red: '#ff0000', + olive: '#808000', + yellow: '#ffff00', + purple: '#800080', + magenta: '#ff00ff', +} as const + +function Svg({ + size = 16, + className, + title, + children, +}: IconProps & { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +/** Atalho para um pixel/bloco: `p(x, y, w, h, cor)`. */ +const p = (x: number, y: number, w: number, h: number, fill: string, key?: string | number) => ( + +) + +/** + * Lixeira vazia: cesto de tela metalica, tampa por cima. + * O Windows 95 diferencia vazia de cheia so pelo conteudo saindo da boca -- + * o cesto e identico nos dois estados. + */ +export function TrashEmptyIcon({ size = 32, className, title = 'Lixeira vazia' }: IconProps) { + return ( + + {/* tampa */} + {p(3, 3, 10, 1, C.gray)} + {p(4, 2, 8, 1, C.silver)} + {p(6, 1, 4, 1, C.gray)} + {/* corpo, levemente conico */} + {p(4, 4, 8, 1, C.white)} + {p(4, 5, 8, 8, C.silver)} + {p(4, 5, 1, 8, C.white)} + {p(11, 5, 1, 8, C.gray)} + {p(5, 13, 6, 1, C.gray)} + {/* ripas verticais da tela */} + {p(6, 6, 1, 7, C.gray)} + {p(8, 6, 1, 7, C.gray)} + {p(10, 6, 1, 7, C.gray)} + {/* contorno */} + {p(3, 4, 1, 9, C.black)} + {p(12, 4, 1, 9, C.black)} + {p(4, 14, 8, 1, C.black)} + + ) +} + +/** Lixeira cheia: mesmo cesto, com papel amassado transbordando. */ +export function TrashFullIcon({ size = 32, className, title = 'Lixeira cheia' }: IconProps) { + return ( + + {/* papel saindo da boca, antes da tampa para ficar por tras */} + {p(5, 1, 3, 2, C.white)} + {p(8, 0, 3, 2, C.silver)} + {p(6, 0, 2, 1, C.silver)} + {p(9, 2, 2, 1, C.white)} + {p(4, 2, 1, 1, C.white)} + {/* tampa, encostada de lado porque o cesto esta cheio */} + {p(3, 3, 10, 1, C.gray)} + {p(4, 3, 8, 1, C.silver)} + {/* corpo */} + {p(4, 4, 8, 1, C.white)} + {p(4, 5, 8, 8, C.silver)} + {p(4, 5, 1, 8, C.white)} + {p(11, 5, 1, 8, C.gray)} + {p(5, 13, 6, 1, C.gray)} + {p(6, 6, 1, 7, C.gray)} + {p(8, 6, 1, 7, C.gray)} + {p(10, 6, 1, 7, C.gray)} + {p(3, 4, 1, 9, C.black)} + {p(12, 4, 1, 9, C.black)} + {p(4, 14, 8, 1, C.black)} + + ) +} + +/** A bandeira de quatro cores do botao Iniciar, ondulada. */ +export function WindowsFlagIcon({ size = 16, className, title }: IconProps) { + return ( + + {/* Cada quadrante e desenhado como duas colunas com alturas diferentes: + e o que cria a ondulacao da bandeira sem curva de verdade. */} + {/* vermelho, superior esquerdo */} + {p(1, 3, 3, 5, C.red)} + {p(4, 2, 3, 5, C.red)} + {/* verde, superior direito */} + {p(8, 1, 3, 5, C.green)} + {p(11, 1, 3, 5, C.green)} + {/* azul, inferior esquerdo */} + {p(1, 9, 3, 5, C.blue)} + {p(4, 8, 3, 5, C.blue)} + {/* amarelo, inferior direito */} + {p(8, 7, 3, 5, C.yellow)} + {p(11, 7, 3, 5, C.yellow)} + + ) +} + +/** Meu Computador: monitor CRT sobre a base, com a tela em ciano. */ +export function ComputerIcon({ size = 32, className, title = 'Meu Computador' }: IconProps) { + return ( + + {p(2, 2, 12, 9, C.black)} + {p(3, 3, 10, 7, C.silver)} + {p(4, 4, 8, 5, C.teal)} + {p(4, 4, 8, 1, C.cyan)} + {p(3, 9, 10, 1, C.gray)} + {/* pescoco e base */} + {p(6, 11, 4, 1, C.gray)} + {p(4, 12, 8, 2, C.silver)} + {p(4, 12, 8, 1, C.white)} + {p(4, 14, 8, 1, C.black)} + {p(10, 13, 1, 1, C.green)} + + ) +} + +/** Pasta amarela fechada -- a lista, na janela da Lixeira. */ +export function FolderIcon({ size = 16, className, title = 'Pasta' }: IconProps) { + return ( + + {/* aba */} + {p(1, 3, 5, 1, C.black)} + {p(6, 4, 2, 1, C.black)} + {/* corpo */} + {p(1, 4, 14, 9, C.yellow)} + {p(1, 4, 14, 1, C.white)} + {p(2, 5, 12, 7, C.olive)} + {p(2, 5, 12, 6, C.yellow)} + {p(1, 13, 14, 1, C.black)} + {p(0, 3, 1, 11, C.black)} + {p(15, 4, 1, 10, C.black)} + + ) +} + +/** Documento de texto -- o card, na janela da Lixeira. */ +export function DocumentIcon({ size = 16, className, title = 'Documento' }: IconProps) { + return ( + + {p(3, 1, 8, 14, C.black)} + {p(4, 2, 7, 12, C.white)} + {/* orelha dobrada no canto superior direito */} + {p(9, 1, 2, 1, C.black)} + {p(10, 2, 1, 3, C.black)} + {p(9, 2, 1, 2, C.silver)} + {/* linhas de texto */} + {[5, 7, 9, 11].map((y) => p(5, y, 5, 1, C.gray, `l${y}`))} + + ) +} + +/** Ampulheta -- estado de carregando. */ +export function HourglassIcon({ size = 16, className, title = 'Aguarde' }: IconProps) { + return ( + + {p(4, 2, 8, 1, C.black)} + {p(4, 13, 8, 1, C.black)} + {p(5, 3, 6, 2, C.white)} + {p(6, 5, 4, 2, C.silver)} + {p(7, 7, 2, 2, C.gray)} + {p(6, 9, 4, 2, C.silver)} + {p(5, 11, 6, 2, C.white)} + {/* areia acumulada embaixo */} + {p(6, 11, 4, 2, C.yellow)} + {p(5, 3, 1, 10, C.black)} + {p(10, 3, 1, 10, C.black)} + + ) +} + +/** Aviso amarelo -- usado no ConfirmDialog. */ +export function WarningIcon({ size = 32, className, title = 'Atencao' }: IconProps) { + return ( + + {/* triangulo em degraus: cada linha uma unidade mais larga */} + {p(7, 1, 2, 1, C.black)} + {Array.from({ length: 11 }, (_, i) => p(7 - i, 2 + i, 2 + i * 2, 1, C.black, `t${i}`))} + {p(7, 2, 2, 1, C.yellow)} + {Array.from({ length: 9 }, (_, i) => p(6 - i, 3 + i, 4 + i * 2, 1, C.yellow, `f${i}`))} + {/* exclamacao */} + {p(7, 5, 2, 5, C.black)} + {p(7, 11, 2, 2, C.black)} + + ) +} + +/** Bomba do Campo Minado -- tambem o icone de erro fatal do Win95. */ +export function BombIcon({ size = 16, className, title = 'Mina' }: IconProps) { + return ( + + {p(6, 4, 5, 1, C.black)} + {p(4, 5, 9, 2, C.black)} + {p(3, 7, 11, 5, C.black)} + {p(4, 12, 9, 1, C.black)} + {p(6, 13, 5, 1, C.black)} + {/* brilho especular: e o que faz a esfera parecer redonda */} + {p(5, 6, 2, 1, C.white)} + {p(5, 7, 1, 1, C.white)} + {/* pavio */} + {p(9, 2, 1, 2, C.gray)} + {p(10, 1, 2, 1, C.gray)} + {p(12, 0, 1, 2, C.red)} + + ) +} + +/** Bandeira vermelha do Campo Minado. */ +export function FlagIcon({ size = 16, className, title = 'Bandeira' }: IconProps) { + return ( + + {p(7, 2, 1, 7, C.black)} + {p(3, 3, 4, 1, C.red)} + {p(2, 4, 5, 1, C.red)} + {p(3, 5, 4, 1, C.red)} + {p(4, 6, 3, 1, C.red)} + {p(5, 9, 5, 1, C.black)} + {p(4, 10, 7, 1, C.black)} + {p(3, 11, 9, 2, C.black)} + + ) +} From 75f76413e01ef072710bdad368e1de5222e57640 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:38:15 -0300 Subject: [PATCH 04/19] feat(frontend): Campo Minado jogavel e protetor de tela O seed sempre teve um card "Jogar Campo Minado". Agora ele abre o jogo: 9x9 com 10 minas, flood fill, bandeira com botao direito, cronometro e a carinha que reinicia. Qualquer card com esse titulo funciona -- nao ha id magico. As minas so sao sorteadas depois do primeiro clique, excluindo a celula clicada e a vizinhanca dela: garante que a primeira jogada sempre abre uma area em vez de um quadradinho, e torna impossivel perder de cara. O protetor de tela aparece apos 2 minutos. So input humano conta como atividade: se trafego de WebSocket resetasse o timer, ele nunca apareceria com duas abas abertas. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/Minesweeper.tsx | 257 +++++++++++++++++++++++++++++++++++ frontend/src/ScreenSaver.tsx | 53 ++++++++ frontend/src/minesweeper.css | 144 ++++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 frontend/src/Minesweeper.tsx create mode 100644 frontend/src/ScreenSaver.tsx create mode 100644 frontend/src/minesweeper.css 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/ScreenSaver.tsx b/frontend/src/ScreenSaver.tsx new file mode 100644 index 0000000..f4fc29d --- /dev/null +++ b/frontend/src/ScreenSaver.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' + +const IDLE_MS = 120_000 + +/** + * O protetor de tela "Tubos"/logotipo do Windows 95: aparece apos 2 minutos de + * ociosidade e some ao primeiro sinal de vida. + * + * A parte que importa e o que conta como ociosidade. Um board com WebSocket + * recebe eventos o tempo todo -- se o timer resetasse com atividade de rede, o + * protetor nunca apareceria com duas abas abertas. So input humano conta. + */ +export function ScreenSaver() { + const [idle, setIdle] = useState(false) + const timer = useRef>(undefined) + + useEffect(() => { + const arm = () => { + clearTimeout(timer.current) + timer.current = setTimeout(() => setIdle(true), IDLE_MS) + } + + const wake = () => { + setIdle((on) => { + if (on) return false + return on + }) + arm() + } + + // `pointermove` cobre mouse, caneta e toque numa assinatura so. + const events = ['pointermove', 'pointerdown', 'keydown', 'wheel'] as const + for (const e of events) window.addEventListener(e, wake, { passive: true }) + arm() + + return () => { + clearTimeout(timer.current) + for (const e of events) window.removeEventListener(e, wake) + } + }, []) + + if (!idle) return null + + return ( +
    +
    + TeamApp + 95 +
    +

    mova o mouse para voltar

    +
    + ) +} diff --git a/frontend/src/minesweeper.css b/frontend/src/minesweeper.css new file mode 100644 index 0000000..3322d1a --- /dev/null +++ b/frontend/src/minesweeper.css @@ -0,0 +1,144 @@ +/* O 98.css nao tem componente de grade nem display de 7 segmentos, entao aqui o + CSS proprio e legitimo. Tudo fica no prefixo .ms- para nao vazar no styles.css + compartilhado. */ + +.ms-frame { + display: flex; + flex-direction: column; + gap: 6px; + /* Relevo afundado do "campo" do Campo Minado original. */ + padding: 6px; + box-shadow: + inset 2px 2px 0 #808080, + inset -2px -2px 0 #fff; + background: #c0c0c0; +} + +.ms-hud { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + box-shadow: + inset 2px 2px 0 #808080, + inset -2px -2px 0 #fff; +} + +/* Display digital: numeros vermelhos em fundo preto. */ +.ms-display { + min-width: 44px; + padding: 1px 4px; + background: #000; + color: #f00; + font-family: 'Courier New', monospace; + font-size: 16px; + font-weight: bold; + line-height: 1.15; + letter-spacing: 1px; + text-align: center; + border: 1px solid #808080; +} + +/* Botao da carinha: o 98.css ja da o relevo de botao, so ajustamos o tamanho. */ +.ms-face { + min-width: 28px; + width: 28px; + height: 28px; + padding: 0; + font-size: 15px; + line-height: 1; +} + +.ms-board { + display: grid; + grid-template-columns: repeat(9, 22px); + grid-auto-rows: 22px; + gap: 0; + box-shadow: + inset 2px 2px 0 #808080, + inset -2px -2px 0 #fff; + padding: 3px; + width: max-content; + margin: 0 auto; +} + +/* Celula fechada: relevo com claro em cima/esquerda e escuro embaixo/direita. + Usamos box-shadow inset em vez de border porque a troca fechada -> aberta + mudaria a espessura da borda e o grid inteiro tremeria a cada clique. */ +.ms-board .ms-cell { + width: 22px; + height: 22px; + min-width: 0; + padding: 0; + margin: 0; + background: #c0c0c0; + border: none; + border-radius: 0; + box-shadow: + inset 2px 2px 0 #fff, + inset -2px -2px 0 #808080; + font-family: 'Courier New', monospace; + font-size: 13px; + font-weight: bold; + line-height: 1; + text-align: center; + cursor: default; + user-select: none; +} + +.ms-board .ms-cell:focus-visible { + outline: 1px dotted #000; + outline-offset: -4px; +} + +/* Celula aberta: afundada, so o fio cinza em cima/esquerda. */ +.ms-board .ms-cell.ms-revealed, +.ms-board .ms-cell.ms-revealed:active { + box-shadow: inset 1px 1px 0 #808080; +} + +.ms-board .ms-cell.ms-mine { + background: #f00; +} + +/* Cores classicas dos numeros. */ +.ms-n1 { + color: #0000ff; +} +.ms-n2 { + color: #008000; +} +.ms-n3 { + color: #ff0000; +} +.ms-n4 { + color: #000080; +} +.ms-n5 { + color: #800000; +} +.ms-n6 { + color: #008080; +} +.ms-n7 { + color: #000000; +} +.ms-n8 { + color: #808080; +} + +.ms-hint { + margin: 0; + font-size: 11px; + text-align: center; + color: #333; +} + +/* A celula alterna entre numero (texto) e icone (SVG); sem line-height 0 no + caso do SVG o glifo empurra a grade e as celulas ficam de alturas diferentes. */ +.ms-glyph { + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} From 6b1dd99b81a89d04d884f91f2a3766b50c70b8dd Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:38:15 -0300 Subject: [PATCH 05/19] feat(frontend): busca, atalhos de teclado e relogio na bandeja Busca por titulo e descricao com N/T/R/? e / como atalhos, janela de ajuda e relogio na bandeja. A busca realca os cards que casam e esmaece o resto, em vez de filtrar. Esconder os que nao casam mudaria os vizinhos de cada card, e como positionFor() calcula a posicao a partir dos vizinhos do destino, arrastar durante uma busca gravaria a posicao errada. Os atalhos ficam desligados enquanto ha janela aberta ou o foco esta num campo: senao "n" digitado num dialogo criaria uma lista no board por tras dele. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 203 ++++++++++++++++++++++++- frontend/src/ListColumn.tsx | 10 +- frontend/src/styles.css | 285 +++++++++++++++++++++++++++++++++++- frontend/src/useHotkeys.ts | 44 ++++++ 4 files changed, 527 insertions(+), 15 deletions(-) create mode 100644 frontend/src/useHotkeys.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b03769..18152c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { FormEvent, ReactNode } from 'react' import { api, API_URL, positionFor } from './api' import { useBoard } from './useBoard' @@ -8,6 +8,11 @@ import type { Confirmation } from './ConfirmDialog' import { ListColumn } from './ListColumn' import type { CardDropTarget } from './ListColumn' import { Modal } from './Modal' +import { TrashWindow } from './TrashWindow' +import { Minesweeper } from './Minesweeper' +import { useHotkeys } from './useHotkeys' +import { ScreenSaver } from './ScreenSaver' +import { TrashEmptyIcon, TrashFullIcon, WindowsFlagIcon } from './icons' import type { List } from './types' /** O que esta sendo arrastado. Card e lista compartilham a tela, nao o alvo. */ @@ -34,6 +39,36 @@ export default function App() { const [newList, setNewList] = useState('') const [startOpen, setStartOpen] = useState(false) const [aboutOpen, setAboutOpen] = useState(false) + const [trashOpen, setTrashOpen] = useState(false) + const [minesweeperOpen, setMinesweeperOpen] = useState(false) + const [search, setSearch] = useState('') + const [helpOpen, setHelpOpen] = useState(false) + const [clock, setClock] = useState(() => new Date()) + const searchRef = useRef(null) + const newListRef = useRef(null) + + // Relogio da bandeja. 10s em vez de 1s: o display so mostra hora e minuto, + // entao segundo a segundo seria render descartado. + useEffect(() => { + const tick = setInterval(() => setClock(new Date()), 10_000) + return () => clearInterval(tick) + }, []) + + // Qualquer janela aberta desliga os atalhos: senao "n" digitado num dialogo + // criaria uma lista no board por tras dele. + const modalOpen = minesweeperOpen || trashOpen || aboutOpen || helpOpen || confirmation !== null + useHotkeys( + { + n: () => newListRef.current?.focus(), + '/': () => searchRef.current?.focus(), + 'ctrl+f': () => searchRef.current?.focus(), + t: () => setTrashOpen(true), + r: () => void reload(), + '?': () => setHelpOpen(true), + escape: () => setSearch(''), + }, + !modalOpen && openCardId === null, + ) // Um clique em qualquer lugar fecha o menu Iniciar, como no Windows 95. useEffect(() => { @@ -97,6 +132,30 @@ export default function App() { const cardCount = board.lists.reduce((n, l) => n + l.cards.length, 0) + // Busca por titulo e descricao. Devolve o conjunto de ids que casam em vez de + // filtrar as listas: esconder os cards que nao casam quebraria o drag-and-drop + // (as posicoes dos vizinhos mudariam) -- realcar preserva o board inteiro. + const query = search.trim().toLowerCase() + const matches = useMemo(() => { + if (!query) return null + const ids = new Set() + for (const list of board.lists) { + for (const card of list.cards) { + if ( + card.title.toLowerCase().includes(query) || + card.description.toLowerCase().includes(query) + ) { + ids.add(card.id) + } + } + } + return ids + }, [query, board.lists]) + // `board.trash` vem do GET /api/board e e atualizado pelo evento trash.updated: + // o icone troca de estado sem precisar abrir a janela. + const trashCount = (board.trash?.cards ?? 0) + (board.trash?.lists ?? 0) + const trashFull = trashCount > 0 + return (
    @@ -104,11 +163,33 @@ export default function App() { {connected ? 'conectado' : 'reconectando...'} +
    + setSearch(e.target.value)} + /> + {query && ( + + {matches?.size ?? 0} de {cardCount} + + )} + {query && ( + + )} +
    +
    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)} + 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) => void run(() => 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.`, + message: `Mandar "${l.title}" e seus ${l.cards.length} card(s) para a Lixeira?`, onYes: () => void run(() => api.deleteList(l.id)), }) } @@ -170,7 +277,7 @@ 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)) setOpenCardId(null) @@ -180,6 +287,48 @@ export default function App() { /> )} + {minesweeperOpen && setMinesweeperOpen(false)} />} + + {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 +352,8 @@ export default function App() { )} + +
    @@ -238,6 +390,37 @@ export default function App() {
  • +
  • + +
  • +
  • + +
  • +
  • +
  • + +
  • 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/styles.css b/frontend/src/styles.css index 9477cd8..51f57d0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -145,6 +145,9 @@ body { .taskbar .start { font-weight: bold; + display: inline-flex; + align-items: center; + gap: 4px; } .taskbar-item { @@ -321,14 +324,6 @@ body { .confirm-icon { flex: 0 0 auto; - width: 28px; - height: 28px; - border-radius: 50%; - background: #ffd700; - border: 1px solid #808000; - color: #000; - font-weight: bold; - font-size: 18px; display: flex; align-items: center; justify-content: center; @@ -383,3 +378,277 @@ body { background: #808080; border-bottom: 1px solid #fff; } + +/* --- Lixeira --- */ +/* Icone de desktop: o unico icone da tela, e tem funcao (abre a janela) e estado + (vazia vs. cheia). Fica no canto inferior esquerdo, acima da taskbar. */ +.desktop-icon { + position: fixed; + left: 12px; + bottom: 44px; + width: 76px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 2px; + background: transparent; + border: 1px dotted transparent; + box-shadow: none; + cursor: pointer; + z-index: 5; +} + +.desktop-icon:hover, +.desktop-icon:focus-visible { + border-color: #fff; + outline: none; +} + +.desktop-icon:active { + /* Sem o box-shadow de botao do 98.css: icone de desktop nao afunda. */ + box-shadow: none; +} + +.desktop-icon-glyph { + display: block; + line-height: 0; +} + +.desktop-icon-label { + font-size: 11px; + line-height: 1.25; + color: #fff; + text-align: center; + text-shadow: 1px 1px 0 #000; +} + +.trash-list { + height: 240px; + overflow-y: auto; + background: #fff; + border: 1px solid #808080; + box-shadow: inset 1px 1px 0 #000, inset -1px -1px 0 #dfdfdf; + padding: 2px; +} + +.trash-list ul { + margin: 0; + padding: 0; + list-style: none; +} + +.trash-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 4px; + border-bottom: 1px solid #dfdfdf; +} + +.trash-item:last-child { + border-bottom: none; +} + +.trash-icon { + display: block; + line-height: 0; + margin-top: 2px; + flex-shrink: 0; +} + +.trash-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.trash-info strong { + overflow-wrap: anywhere; +} + +.trash-info small { + color: #555; +} + +/* O aviso de que Restaurar vai falhar (409): a lista de origem tambem foi para + a lixeira. O backend manda originalList.deleted justamente para isso. */ +.trash-blocked { + color: #a00; +} + +.trash-actions { + display: flex; + flex-direction: column; + gap: 3px; + flex-shrink: 0; +} + +.trash-actions button { + min-width: 76px; +} + +.trash-footer { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; +} + +.trash-status { + flex: 1; + font-size: 11px; + color: #555; +} + +.trash-empty { + margin: 0; + padding: 12px; + text-align: center; + color: #555; +} + +.trash-error { + margin: 0 0 6px; + padding: 4px 6px; + background: #ffdcdc; + border: 1px solid #a00; + overflow-wrap: anywhere; +} + +/* Icone na bandeja: alinhado a linha de texto ao lado dele. */ +.tray-icon { + vertical-align: -3px; + margin-right: 2px; +} + +/* --- Busca, atalhos e relogio --- */ +.search-box { + display: flex; + align-items: center; + gap: 4px; +} + +.search-box input { + width: 170px; +} + +.search-count { + font-size: 11px; + color: #333; + white-space: nowrap; +} + +/* A busca realca em vez de filtrar -- ver o comentario no ListColumn. */ +.card-match { + outline: 2px solid #000080; + outline-offset: -2px; + background: #ffffcc; +} + +.card-dimmed { + opacity: 0.35; +} + +.shortcut-list { + margin: 4px 0 10px; + padding: 0; + list-style: none; +} + +.shortcut-list li { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; +} + +.shortcut-list kbd { + flex: 0 0 46px; + text-align: center; + padding: 1px 4px; + background: #c0c0c0; + border: 1px solid #000; + box-shadow: inset 1px 1px 0 #fff, inset -1px -1px 0 #808080; + font-family: inherit; + font-size: 11px; +} + +.shortcut-note { + margin: 0 0 8px; + font-size: 11px; + color: #555; +} + +/* Relogio da bandeja: afundado e separado, como no Windows 95. */ +.tray-clock { + margin-left: 8px; + padding-left: 8px; + border-left: 1px solid #808080; +} + +/* --- Protetor de tela --- */ +.screensaver { + position: fixed; + inset: 0; + z-index: 100; + background: #000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + cursor: none; +} + +.screensaver-logo { + display: flex; + flex-direction: column; + align-items: center; + /* Deriva devagar pela tela: um logo parado queimaria o fosforo de um CRT -- + que e exatamente o problema que o protetor de tela existia para resolver. */ + animation: ss-drift 24s ease-in-out infinite alternate; +} + +.ss-line { + font-size: 44px; + font-weight: bold; + line-height: 1; + letter-spacing: 2px; + background: linear-gradient(180deg, #00ffff, #0000ff); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.ss-2 { + font-size: 66px; + background: linear-gradient(180deg, #ffff00, #ff0000); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.screensaver-hint { + color: #444; + font-size: 11px; + margin: 0; +} + +@keyframes ss-drift { + from { + transform: translate(-14vw, -12vh); + } + to { + transform: translate(14vw, 12vh); + } +} + +/* Respeita quem pediu menos movimento: a animacao para, o protetor continua. */ +@media (prefers-reduced-motion: reduce) { + .screensaver-logo { + animation: none; + } +} diff --git a/frontend/src/useHotkeys.ts b/frontend/src/useHotkeys.ts new file mode 100644 index 0000000..a1982aa --- /dev/null +++ b/frontend/src/useHotkeys.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' + +type Handlers = Record void> + +/** Campos de texto engolem o atalho: digitar "n" num input nao pode criar lista. */ +function isTyping(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + const tag = target.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable +} + +/** + * Atalhos globais de teclado. + * + * A chave e a tecla em minusculo, opcionalmente com prefixo de modificador: + * `'n'`, `'ctrl+f'`, `'?'`. O handler roda com `preventDefault()` ja aplicado. + * + * `enabled: false` desliga tudo -- e como um modal aberto evita que a tecla + * dispare uma acao no board por tras dele. + */ +export function useHotkeys(handlers: Handlers, enabled = true) { + useEffect(() => { + if (!enabled) return + + const onKey = (e: KeyboardEvent) => { + // Escape e o unico que funciona dentro de um campo -- e como se sai dele. + if (isTyping(e.target) && e.key !== 'Escape') return + + const key = e.key.toLowerCase() + const combo = e.ctrlKey || e.metaKey ? `ctrl+${key}` : key + const handler = handlers[combo] ?? handlers[key] + if (!handler) return + + e.preventDefault() + handler() + } + + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // `handlers` e recriado a cada render pelo chamador; depender do objeto + // reinstalaria o listener toda vez. As teclas e o enabled bastam. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, Object.keys(handlers).join(',')]) +} From 9af69e2774c3361979bb074b8c9c5f9c6e3535a3 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:38:33 -0300 Subject: [PATCH 06/19] test(backend): testes unitarios sem dependencia nova O unico teste era o smoke, que e integracao e exige o stack de pe. Agora ha `npm test` com o runner nativo do Node (node:test), sem dependencia adicional: - position.test.mjs: a matematica da position fracionaria, incluindo o limite de ~53 divisoes pela metade antes do float empatar. - trash-semantics.test.mjs: as duas regras da Lixeira que mais surpreendem, como funcoes puras estado -> estado. Junto: .env.example com os defaults reais lidos do server.ts, e reset-db.mjs, que e dry-run por padrao e so destroi com --yes. Co-Authored-By: Claude Opus 5 (1M context) --- backend/.env.example | 21 ++ backend/package.json | 3 +- backend/scripts/reset-db.mjs | 127 ++++++++++++ backend/test/position.test.mjs | 122 ++++++++++++ backend/test/trash-semantics.test.mjs | 270 ++++++++++++++++++++++++++ 5 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 backend/.env.example create mode 100644 backend/scripts/reset-db.mjs create mode 100644 backend/test/position.test.mjs create mode 100644 backend/test/trash-semantics.test.mjs 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/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/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) +}) From 66db4fa67b6cc8634daa699202e199993b0621a2 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:38:33 -0300 Subject: [PATCH 07/19] chore: padronizacao do repositorio README, LICENSE (MIT), CONTRIBUTING, templates de issue e PR, e CI no GitHub Actions com tres jobs: typecheck do backend, build do frontend, e o smoke test contra o stack Docker de verdade. O .editorconfig e o .gitattributes forcam LF: este repo ja teve um arquivo gravado em UTF-16 CRLF por redirecionamento do PowerShell, que o bash nao consegue interpretar. Co-Authored-By: Claude Opus 5 (1M context) --- .editorconfig | 23 +++ .gitattributes | 35 +++++ .github/ISSUE_TEMPLATE/bug_report.md | 32 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 +++ .github/PULL_REQUEST_TEMPLATE.md | 28 ++++ .github/workflows/ci.yml | 112 ++++++++++++++ CONTRIBUTING.md | 122 +++++++++++++++ LICENSE | 21 +++ README.md | 178 ++++++++++++++++++++++ 9 files changed, 574 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md 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..3237f20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +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 + + 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 + + - 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/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). From 91a4c00e5a16798450062fe0c7b34348f38cbf4c Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:38:33 -0300 Subject: [PATCH 08/19] docs: contrato da Lixeira, icones e busca CLAUDE.md e docs/API.md documentam a Lixeira por inteiro, as regras dos icones em pixel art e por que a busca realca em vez de filtrar. FRONTEND-REQUESTS.md registra o handoff. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 53 +++++++++++++++++++++++-- docs/API.md | 81 +++++++++++++++++++++++++++++++++++++-- docs/FRONTEND-REQUESTS.md | 73 +++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2338887..ac8e99e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,27 @@ 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`. + +### 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. ### API REST @@ -87,11 +107,18 @@ 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/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 +128,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/docs/API.md b/docs/API.md index 87c9a10..6e39c0b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -20,7 +20,11 @@ type Card = { id: string; listId: string; title: string; description: string; p ### `GET /api/board` Retorna o board `default` com `lists` ordenadas por `position` asc e, dentro de cada uma, `cards` ordenados por `position` asc. É a única chamada de leitura que o frontend precisa fazer. -`200 Board` +Itens na lixeira **não aparecem aqui** — nem listas, nem cards. A resposta traz também +`trash: { cards, lists }`, a contagem do que está na lixeira, para o ícone do desktop +nascer no estado certo sem um `GET /api/trash` extra. + +`200 Board & { trash: { cards, lists } }` ### `POST /api/lists` `{ title: string }` — `position` é calculada pelo servidor (última + 1). @@ -31,8 +35,16 @@ Retorna o board `default` com `lists` ordenadas por `position` asc e, dentro de `200 List` · evento `list.updated` ### `DELETE /api/lists/:id` -Apaga a lista e seus cards em cascata. -`204` · evento `list.deleted` com payload `{ id }` +**Manda a lista para a lixeira** (soft delete) — não apaga. Ela some do `GET /api/board` +junto com todos os seus cards e passa a aparecer em `GET /api/trash`. + +Os cards da lista **não** são marcados individualmente: eles somem porque a lista sumiu e +voltam junto na restauração. Um card que já tinha ido para a lixeira sozinho continua lá +mesmo depois de a lista voltar. + +`204` · eventos `list.deleted` com payload `{ id }` **e** `trash.updated` + +Do ponto de vista do board o evento é o mesmo de antes: remova a lista por `id`. ### `POST /api/cards` `{ listId: string, title: string, description?: string }` — `position` é calculada pelo servidor (última da lista + 1). @@ -47,7 +59,62 @@ Apaga a lista e seus cards em cascata. `200 Card` · evento `card.moved` com payload `{ id, listId, position }` ### `DELETE /api/cards/:id` -`204` · evento `card.deleted` com payload `{ id }` +**Manda o card para a lixeira** (soft delete) — não apaga. + +`204` · eventos `card.deleted` com payload `{ id }` **e** `trash.updated` + +### A Lixeira + +Soft delete muda o significado de "existe": a linha continua no banco, mas invisível para +o board. **Toda rota que opera sobre um recurso vivo filtra `deletedAt: null`** — renomear, +mover, ou criar um card dentro de uma lista que está na lixeira retorna `404`, como se o +recurso não existisse. É o comportamento que você quer no cliente: um item na lixeira não +é um item editável. + +#### `GET /api/trash` +```json +{ + "cards": [{ "id": "...", "title": "...", "description": "...", "position": 3, + "listId": "...", "deletedAt": "2026-08-21T04:12:00.000Z", + "originalList": { "id": "...", "title": "A Fazer", "deleted": false } }], + "lists": [{ "id": "...", "title": "...", "position": 1, "boardId": "...", + "deletedAt": "...", "cardCount": 4 }], + "counts": { "cards": 1, "lists": 0 } +} +``` +Mais recente primeiro. `originalList.deleted` diz que a lista de origem também está na +lixeira — nesse caso o restore do card retorna `409`, então dá para esmaecer o botão +"Restaurar" antes do clique em vez de mostrar um erro. `cardCount` é quantos cards voltam +junto se a lista for restaurada. + +`200` + +#### `POST /api/trash/cards/:id/restore` +Devolve o card à posição original. `200 Card` · eventos **`card.created`** e `trash.updated`. + +O evento é `card.created`, não um tipo novo: o cliente já aplica esse evento como upsert +por `id`, então restaurar aparece no board sem nenhuma mudança no reducer. + +`404` se o card não está na lixeira · `409` se a lista de origem está na lixeira +(restaure a lista primeiro). + +#### `POST /api/trash/lists/:id/restore` +`200 List` **com `cards[]` populado** (só os cards vivos) · eventos **`list.created`** e +`trash.updated`. Os cards vêm junto de propósito — uma lista sem eles faria o cliente +fazer upsert de uma lista vazia por cima da que tem conteúdo. + +`404` se a lista não está na lixeira. + +#### `DELETE /api/trash/cards/:id` · `DELETE /api/trash/lists/:id` +Apaga **de verdade**, irreversível. `204` · evento `trash.updated`. + +Não emite `card.deleted`/`list.deleted`: para o board o item já tinha sumido no soft delete. +Apagar uma lista de vez leva os cards dela em cascata, inclusive os que estavam na lixeira +por conta própria. `404` se o item não está na lixeira (apagar um item **vivo** por aqui +não funciona — use `DELETE /api/cards/:id`). + +#### `DELETE /api/trash` +Esvaziar Lixeira. Apaga tudo de vez. `200 { deleted: { cards, lists } }` · evento `trash.updated`. ## WebSocket @@ -55,6 +122,11 @@ Apaga a lista e seus cards em cascata. Toda mensagem é `{ type, payload }`. O broadcast vai para **todos** os clientes, incluindo o que originou a mutação — aplique os eventos como upsert idempotente por `id`. +`trash.updated` é o único evento que não fala de um recurso específico: o payload é só a +contagem, para o ícone do desktop saber se a lixeira está cheia. O conteúdo vem de +`GET /api/trash` quando a janela abre — mandar a lixeira inteira em cada delete violaria +a regra de que o payload nunca é o estado todo. + | `type` | `payload` | |---|---| | `list.created` | `List` | @@ -64,6 +136,7 @@ Toda mensagem é `{ type, payload }`. O broadcast vai para **todos** os clientes | `card.updated` | `Card` | | `card.moved` | `{ id, listId, position }` | | `card.deleted` | `{ id }` | +| `trash.updated` | `{ cards, lists }` — só a contagem | Se a conexão cair, o cliente deve reconectar com backoff e refazer `GET /api/board` ao reconectar — eventos perdidos durante a queda não são reenviados. diff --git a/docs/FRONTEND-REQUESTS.md b/docs/FRONTEND-REQUESTS.md index b0ba462..8ab5922 100644 --- a/docs/FRONTEND-REQUESTS.md +++ b/docs/FRONTEND-REQUESTS.md @@ -106,3 +106,76 @@ Nenhum é bloqueante, todos vêm de leitura de código: - [ ] **`card.moved` de card desconhecido é descartado em silêncio**, sem recuperação até reload. Idem `card.created` cujo `listId` não está no board. Um `void load()` nesses casos resolveria. Detalhes completos, incluindo os buracos de contrato que fechei, no fim de `docs/API.md`. + +--- + +## Nova feature no backend: a Lixeira (backend, 2026-08-21) + +Implementei **soft delete com restauração** — a Lixeira do Windows 95. Contrato completo em +`docs/API.md` (seção "A Lixeira"); resumo do que muda para você: + +### O que muda no que você já tem: nada quebra + +`DELETE /api/cards/:id` e `DELETE /api/lists/:id` continuam retornando `204` e continuam +emitindo `card.deleted`/`list.deleted` com `{ id }`. O item só não é mais apagado — vai +para a lixeira. **Seu reducer não precisa de nenhuma mudança para continuar funcionando.** + +Restaurar também reusa `card.created`/`list.created` de propósito, em vez de eventos novos: +seu upsert idempotente por `id` já faz a coisa certa com eles. Um item restaurado reaparece +no board sem você escrever uma linha. + +O único evento novo é `trash.updated`, com payload `{ cards, lists }` — **só a contagem**, +para o ícone saber se a lixeira está cheia. Um `type` desconhecido no seu switch deve cair +no default e ser ignorado; se ele estiver logando erro, vale silenciar. + +`GET /api/board` agora traz um campo a mais: `trash: { cards, lists }`. Assim o ícone +nasce no estado certo sem um GET extra. + +### Endpoints novos + +``` +GET /api/trash { cards, lists, counts } +POST /api/trash/cards/:id/restore -> 200 Card, evento card.created +POST /api/trash/lists/:id/restore -> 200 List (com cards[]), evento list.created +DELETE /api/trash/cards/:id apaga de vez, 204 +DELETE /api/trash/lists/:id apaga de vez, 204 +DELETE /api/trash esvaziar, 200 { deleted: { cards, lists } } +``` + +Preflight de CORS testado para todos (o P0 de `methods` não se repete). + +### Pedidos + +- [ ] **Ícone da Lixeira no desktop.** Você tinha deixado "Ícone de Meu Computador" de fora + por achar que polui um board que ocupa a tela toda — concordo, e por isso este é + diferente: ele tem função. Duplo-clique abre a janela; e ele **muda de estado** — + vazia vs. cheia — conforme `trash.updated` e o `board.trash` inicial. +- [ ] **Janela da Lixeira** (`.window` com `.title-bar`): lista o que `GET /api/trash` + devolve, cards e listas juntos, mais recente primeiro. Por item, botões + **Restaurar** e **Excluir** (esse é o irreversível). Rodapé com "Esvaziar Lixeira" + atrás do seu `ConfirmDialog` — é a única ação destrutiva de verdade aqui. +- [ ] **Esmaeça "Restaurar" quando `originalList.deleted === true`.** Esse é o único ponto + onde o backend te dá informação para evitar um erro em vez de mostrar um: o restore + desse card retorna `409` porque a lista de origem também está na lixeira. O campo + existe para isso; a mensagem do `409` também é exibível se você preferir deixar + clicar. +- [ ] **Menu Iniciar → "Lixeira".** Mesma janela, para quem não achar o ícone. + +### Duas coisas que valem saber antes de implementar + +1. **Restaurar uma lista traz junto só os cards que estavam vivos quando ela foi apagada.** + Um card que você jogou fora *individualmente* antes de apagar a lista **continua na + lixeira** depois que a lista volta — não ressuscita junto. É intencional (o oposto + desfaria uma ação que o usuário tomou de propósito), mas é o comportamento que mais + surpreende na hora de testar. `cardCount` na lista diz quantos vão voltar. +2. **Item na lixeira não é editável.** Renomear, mover ou criar card dentro de uma lista + que está na lixeira retorna `404`, como se não existisse. Se sua UI tem algum caminho + que segura um `id` antigo (um dialog aberto quando o card foi apagado em outra aba), + ele vai receber `404` — o mesmo que já acontecia antes com delete real. + +O seed agora nasce com um card já na lixeira ("Trabalho de Estagio.doc") para você ter o +que ver sem precisar apagar nada primeiro. `docker compose down -v && docker compose up +--build` para pegar a migration. + +`backend/scripts/smoke.mjs` está em **80/80** contra o stack Docker, com 35 asserções novas +só de lixeira (incluindo os dois casos acima). Roda de novo depois de mexer na API. From f9d24029c73380819803330cf8478a03b59c803d Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:41:11 -0300 Subject: [PATCH 09/19] fix(frontend): hook depois de return condicional deixava a tela em branco O useMemo da busca tinha ficado abaixo do `if (!board) return `. Isso muda a quantidade de hooks entre a primeira renderizacao e a seguinte, e o React derruba a arvore inteira -- a pagina servia o bundle certo e nao desenhava nada. Movido para junto dos outros hooks, com `!board` tratado dentro do memo. Todos os 13 hooks do App agora ficam acima de qualquer return condicional. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18152c3..daa39f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,30 @@ export default function App() { return () => clearInterval(tick) }, []) + // Busca por titulo e descricao. Devolve o conjunto de ids que casam em vez de + // filtrar as listas: esconder os cards que nao casam quebraria o drag-and-drop + // (as posicoes dos vizinhos mudariam) -- realcar preserva o board inteiro. + // + // Fica aqui em cima, junto dos outros hooks, e NAO depois do `if (!board)`: + // hook depois de return condicional muda a quantidade de hooks entre renders + // e o React derruba a arvore inteira (tela em branco). + const query = search.trim().toLowerCase() + const matches = useMemo(() => { + if (!query || !board) return null + const ids = new Set() + for (const list of board.lists) { + for (const card of list.cards) { + if ( + card.title.toLowerCase().includes(query) || + card.description.toLowerCase().includes(query) + ) { + ids.add(card.id) + } + } + } + return ids + }, [query, board]) + // Qualquer janela aberta desliga os atalhos: senao "n" digitado num dialogo // criaria uma lista no board por tras dele. const modalOpen = minesweeperOpen || trashOpen || aboutOpen || helpOpen || confirmation !== null @@ -132,25 +156,6 @@ export default function App() { const cardCount = board.lists.reduce((n, l) => n + l.cards.length, 0) - // Busca por titulo e descricao. Devolve o conjunto de ids que casam em vez de - // filtrar as listas: esconder os cards que nao casam quebraria o drag-and-drop - // (as posicoes dos vizinhos mudariam) -- realcar preserva o board inteiro. - const query = search.trim().toLowerCase() - const matches = useMemo(() => { - if (!query) return null - const ids = new Set() - for (const list of board.lists) { - for (const card of list.cards) { - if ( - card.title.toLowerCase().includes(query) || - card.description.toLowerCase().includes(query) - ) { - ids.add(card.id) - } - } - } - return ids - }, [query, board.lists]) // `board.trash` vem do GET /api/board e e atualizado pelo evento trash.updated: // o icone troca de estado sem precisar abrir a janela. const trashCount = (board.trash?.cards ?? 0) + (board.trash?.lists ?? 0) From 4d3c25808f0af74b18eb8f47b55c92e3f2be2019 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:44:20 -0300 Subject: [PATCH 10/19] ci: roda os testes unitarios no job do backend O workflow foi escrito antes de `npm test` existir, entao os 17 testes unitarios nao rodavam no CI -- so o smoke, que precisa do stack de pe. Agora o job de backend faz typecheck e testes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3237f20..013c1a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,11 @@ jobs: - 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 From 8e5256a1e9af5010caeec9e33553ebe434ed5958 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:51:16 -0300 Subject: [PATCH 11/19] feat(frontend): desfazer de movimento e menu de contexto Arrastar um card passa a oferecer "Desfazer" por 8 segundos, tambem em Ctrl+Z. A origem e capturada ANTES do move: depois dele o eco do WebSocket ja reescreveu o card e nao ha mais de onde tirar o lugar antigo. E so uma acao, com validade curta, e nao ha refazer. O estado aqui e compartilhado entre abas: desfazer tres passos atras pode reverter algo que outra pessoa mudou no meio tempo, e refazer depois disso e pedir conflito. Botao direito no fundo do desktop abre menu de contexto. Ele se reposiciona sozinho perto das bordas -- abre para a esquerda em vez de vazar da viewport -- e isso exige medir depois de montar, dai o useLayoutEffect: com useEffect o menu pisca na posicao errada antes de corrigir. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 53 ++++++++++++++++++++++- frontend/src/ContextMenu.tsx | 83 ++++++++++++++++++++++++++++++++++++ frontend/src/styles.css | 69 ++++++++++++++++++++++++++++++ frontend/src/useUndo.ts | 47 ++++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 frontend/src/ContextMenu.tsx create mode 100644 frontend/src/useUndo.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index daa39f4..b424cc8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,9 @@ import { TrashWindow } from './TrashWindow' import { Minesweeper } from './Minesweeper' import { useHotkeys } from './useHotkeys' import { ScreenSaver } from './ScreenSaver' +import { useUndo } from './useUndo' +import { ContextMenu } from './ContextMenu' +import type { MenuState } from './ContextMenu' import { TrashEmptyIcon, TrashFullIcon, WindowsFlagIcon } from './icons' import type { List } from './types' @@ -46,6 +49,8 @@ export default function App() { const [clock, setClock] = useState(() => new Date()) const searchRef = useRef(null) const newListRef = useRef(null) + const [menu, setMenu] = useState(null) + const undo = useUndo() // Relogio da bandeja. 10s em vez de 1s: o display so mostra hora e minuto, // entao segundo a segundo seria render descartado. @@ -90,6 +95,7 @@ export default function App() { r: () => void reload(), '?': () => setHelpOpen(true), escape: () => setSearch(''), + 'ctrl+z': () => void undo.run(), }, !modalOpen && openCardId === null, ) @@ -134,11 +140,22 @@ export default function App() { const dropCard = (list: List, index: number) => { if (drag?.kind !== 'card') return const cardId = drag.id + // Origem capturada ANTES do move: depois dele o eco do WebSocket ja + // reescreveu o card e nao ha mais de onde tirar o lugar antigo. + const origin = board.lists + .flatMap((l) => l.cards) + .find((c) => c.id === cardId) // Tirar o proprio card antes de calcular evita que ele vire vizinho de si mesmo // -- sem isso, arrastar um slot para baixo cai de volta no lugar de origem. const neighbours = list.cards.filter((c) => c.id !== cardId) const slot = Math.min(index, neighbours.length) void run(() => api.moveCard(cardId, list.id, positionFor(neighbours, slot))) + if (origin && (origin.listId !== list.id || origin.position !== positionFor(neighbours, slot))) { + undo.offer({ + label: `Card "${origin.title}" movido`, + undo: () => api.moveCard(cardId, origin.listId, origin.position), + }) + } endDrag() } @@ -162,7 +179,27 @@ export default function App() { const trashFull = trashCount > 0 return ( -
    +
    { + // So o fundo: um clique direito sobre um card ou lista tem menu proprio + // (ou deve cair no menu nativo, se nao tiver). + if (e.target !== e.currentTarget) return + e.preventDefault() + setMenu({ + x: e.clientX, + y: e.clientY, + items: [ + { kind: 'item', label: 'Nova lista', bold: true, onClick: () => newListRef.current?.focus() }, + { kind: 'separator' }, + { kind: 'item', label: 'Atualizar', onClick: () => void reload() }, + { kind: 'item', label: 'Abrir a Lixeira', onClick: () => setTrashOpen(true) }, + { kind: 'separator' }, + { kind: 'item', label: 'Propriedades', onClick: () => setAboutOpen(true) }, + ], + }) + }} + >

    {board.title}

    @@ -357,6 +394,20 @@ export default function App() { )} + {undo.action && ( +
    +
    + {undo.action.label} + + +
    +
    + )} + + {menu && setMenu(null)} />} +
    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/styles.css b/frontend/src/styles.css index 51f57d0..c201dba 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -652,3 +652,72 @@ body { animation: none; } } + +/* --- Desfazer e menu de contexto --- */ +.undo-bar { + position: fixed; + left: 50%; + bottom: 44px; + transform: translateX(-50%); + z-index: 40; + min-width: 300px; +} + +.undo-bar .window-body { + display: flex; + align-items: center; + gap: 8px; + margin: 6px; +} + +.undo-bar span { + flex: 1; + overflow-wrap: anywhere; +} + +.context-menu { + position: fixed; + z-index: 60; + min-width: 160px; + padding: 2px; +} + +.context-menu ul { + margin: 0; + padding: 0; + list-style: none; +} + +.context-menu button { + display: block; + width: 100%; + text-align: left; + padding: 3px 18px; + background: transparent; + border: none; + box-shadow: none; +} + +/* Item destacado do menu do Win95: azul com texto branco, sem relevo. */ +.context-menu button:hover:not(:disabled), +.context-menu button:focus-visible { + background: #000080; + color: #fff; + outline: none; +} + +.context-menu button:disabled { + color: #808080; + text-shadow: 1px 1px 0 #fff; +} + +.context-default { + font-weight: bold; +} + +.context-separator { + height: 0; + margin: 3px 2px; + border-top: 1px solid #808080; + border-bottom: 1px solid #fff; +} diff --git a/frontend/src/useUndo.ts b/frontend/src/useUndo.ts new file mode 100644 index 0000000..6daccad --- /dev/null +++ b/frontend/src/useUndo.ts @@ -0,0 +1,47 @@ +import { useCallback, useRef, useState } from 'react' + +export type UndoAction = { + /** O que aparece na barra: "Card 'Comprar CD-R' movido". */ + label: string + /** Desfaz. Pode falhar -- o item pode ter sido apagado por outra aba nesse meio tempo. */ + undo: () => Promise +} + +const TIMEOUT_MS = 8000 + +/** + * Desfazer de uma acao so, com validade. + * + * A pilha de undo tem um problema especifico neste app: o estado e compartilhado + * entre abas por WebSocket, entao um "desfazer" de tres passos atras pode estar + * revertendo algo que outra pessoa ja mudou. Guardar uma acao so, valida por + * poucos segundos, mantem a janela em que a reversao ainda faz sentido. + * + * Por isso tambem nao existe redo: refazer o que voce desfez, depois de outra + * pessoa ter mexido, e pedir conflito. + */ +export function useUndo() { + const [action, setAction] = useState(null) + const timer = useRef>(undefined) + + const clear = useCallback(() => { + clearTimeout(timer.current) + setAction(null) + }, []) + + const offer = useCallback((next: UndoAction) => { + clearTimeout(timer.current) + setAction(next) + timer.current = setTimeout(() => setAction(null), TIMEOUT_MS) + }, []) + + const run = useCallback(async () => { + if (!action) return + // Limpa antes de executar: se o undo falhar, oferecer "desfazer" de novo + // sobre um estado que ja mudou e pior do que so sumir com o botao. + clear() + await action.undo() + }, [action, clear]) + + return { action, offer, run, clear } +} From 86d6d67dde6cd4ab57ee8111858cc1b6d28201b9 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:51:17 -0300 Subject: [PATCH 12/19] build(frontend): guarda contra hook depois de return condicional O bug que deixou a tela em branco nao e pego por nada: tsc compila, o Vite builda, o bundle e servido, e o React derruba a arvore em silencio porque a quantidade de hooks mudou entre renders. `check-hooks.mjs` varre os componentes e falha se achar chamada de hook depois de um return de nivel de componente. Roda no `npm run build`, entao o CI pega antes do deploy. Verificado dos dois lados: injetando a violacao ele acusa arquivo e linha e sai 1; sem ela, passa. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/package.json | 5 ++- frontend/scripts/check-hooks.mjs | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 frontend/scripts/check-hooks.mjs diff --git a/frontend/package.json b/frontend/package.json index 8c22730..77c8619 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 5173", - "build": "tsc -b && vite build", - "preview": "vite preview --host 0.0.0.0 --port 8080" + "build": "node scripts/check-hooks.mjs && tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0 --port 8080", + "check-hooks": "node scripts/check-hooks.mjs" }, "dependencies": { "98.css": "^0.1.20", diff --git a/frontend/scripts/check-hooks.mjs b/frontend/scripts/check-hooks.mjs new file mode 100644 index 0000000..d34eb54 --- /dev/null +++ b/frontend/scripts/check-hooks.mjs @@ -0,0 +1,75 @@ +/** + * Guarda contra hook depois de return condicional. + * + * Este bug ja derrubou a app uma vez: um useMemo abaixo de `if (!board) return` + * muda a quantidade de hooks entre a primeira renderizacao e a seguinte, o React + * derruba a arvore inteira, e o sintoma e uma tela em branco com o bundle certo + * sendo servido e nenhum erro no build. `tsc` nao pega, o Vite nao pega. + * + * Uso: node scripts/check-hooks.mjs (sai 1 se achar violacao) + * + * E uma heuristica de texto, nao um parser: procura o primeiro `return` no nivel + * de indentacao do corpo do componente e reclama de qualquer chamada de hook + * depois dele. Prefere falso positivo a falso negativo -- se acusar algo + * legitimo, o jeito e reorganizar o componente, que provavelmente esta grande + * demais de qualquer forma. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const SRC = new URL('../src/', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1') + +const HOOK = /\buse[A-Z]\w*\s*\(/ +// Um return de nivel 2 (dois espacos) dentro do componente: ` return (` ou +// ` if (x) return y`. Returns mais indentados estao dentro de callbacks. +const EARLY_RETURN = /^ {2}(if\s*\(.*\)\s*return\b|return\b)/ +const COMPONENT = /^export (default )?function [A-Z]\w*/ + +let violations = 0 + +function check(file, source) { + const lines = source.split('\n') + let inComponent = false + let sawReturn = null + + lines.forEach((line, i) => { + if (COMPONENT.test(line)) { + inComponent = true + sawReturn = null + return + } + if (!inComponent) return + + // Fim do componente: fecha-chaves na coluna 0. + if (line === '}') { + inComponent = false + return + } + + if (sawReturn === null && EARLY_RETURN.test(line)) { + sawReturn = i + 1 + return + } + + const trimmed = line.trim() + if (trimmed.startsWith('//') || trimmed.startsWith('*')) return + + if (sawReturn !== null && HOOK.test(line)) { + violations++ + console.log( + ` ${file}:${i + 1} hook depois do return da linha ${sawReturn}\n ${trimmed.slice(0, 80)}`, + ) + } + }) +} + +const files = readdirSync(SRC).filter((f) => f.endsWith('.tsx') || f.endsWith('.ts')) +for (const f of files) check(f, readFileSync(join(SRC, f), 'utf8')) + +if (violations > 0) { + console.log(`\nFALHOU: ${violations} hook(s) depois de return condicional.`) + console.log('Mova a chamada para junto dos outros hooks, antes de qualquer return.') + process.exit(1) +} + +console.log(`ok ${files.length} arquivos, nenhum hook depois de return condicional`) From 6891b26d14ae74c70299f1492a30cf1bfdff9e14 Mon Sep 17 00:00:00 2001 From: Renan Locatiz Fernandes Date: Fri, 21 Aug 2026 01:55:26 -0300 Subject: [PATCH 13/19] feat(frontend): Bloco de Notas, Propriedades do Sistema e sons Tres janelas classicas do shell, mais o audio. Os sons sao sintetizados na Web Audio API, nao arquivos .wav: o container builda offline e binario no repo e peso morto. Comecam DESLIGADOS e ficam no localStorage -- audio automatico sem o usuario pedir e hostil. O AudioContext e criado preguicosamente no primeiro play() porque navegador bloqueia audio antes da primeira interacao, e nenhuma excecao de audio escapa: som e enfeite, falha dele nao pode derrubar a UI. O `runWithSound` envolve o `run` que ja centraliza toda mutacao, entao nenhuma chamada individual precisa lembrar de tocar, e o som de erro acompanha qualquer falha de API automaticamente. O Bloco de Notas salva no localStorage, nao no servidor: nao existe recurso de "nota" no contrato da API e inventar um por conta propria seria mudanca de contrato unilateral -- exatamente o que docs/BACKEND-REQUESTS.md existe para evitar. Junto vao useWindowManager e DraggableWindow (arrastar pela title bar com pointer events, nao HTML5 drag, que ja e usado pelos cards e os dois brigariam), ainda nao ligados na UI. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 104 +++++++++++- frontend/src/DraggableWindow.tsx | 158 +++++++++++++++++ frontend/src/Notepad.tsx | 271 ++++++++++++++++++++++++++++++ frontend/src/SystemProperties.tsx | 169 +++++++++++++++++++ frontend/src/draggablewindow.css | 43 +++++ frontend/src/notepad.css | 163 ++++++++++++++++++ frontend/src/systemproperties.css | 97 +++++++++++ frontend/src/useSounds.ts | 249 +++++++++++++++++++++++++++ frontend/src/useWindowManager.ts | 116 +++++++++++++ 9 files changed, 1364 insertions(+), 6 deletions(-) create mode 100644 frontend/src/DraggableWindow.tsx create mode 100644 frontend/src/Notepad.tsx create mode 100644 frontend/src/SystemProperties.tsx create mode 100644 frontend/src/draggablewindow.css create mode 100644 frontend/src/notepad.css create mode 100644 frontend/src/systemproperties.css create mode 100644 frontend/src/useSounds.ts create mode 100644 frontend/src/useWindowManager.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b424cc8..27d97bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,9 @@ import { ScreenSaver } from './ScreenSaver' import { useUndo } from './useUndo' import { ContextMenu } from './ContextMenu' import type { MenuState } from './ContextMenu' +import { Notepad } from './Notepad' +import { SystemProperties } from './SystemProperties' +import { useSounds } from './useSounds' import { TrashEmptyIcon, TrashFullIcon, WindowsFlagIcon } from './icons' import type { List } from './types' @@ -51,6 +54,9 @@ export default function App() { const newListRef = useRef(null) const [menu, setMenu] = useState(null) const undo = useUndo() + const [notepadOpen, setNotepadOpen] = useState(false) + const [sysPropsOpen, setSysPropsOpen] = useState(false) + const sounds = useSounds() // Relogio da bandeja. 10s em vez de 1s: o display so mostra hora e minuto, // entao segundo a segundo seria render descartado. @@ -85,7 +91,14 @@ export default function App() { // Qualquer janela aberta desliga os atalhos: senao "n" digitado num dialogo // criaria uma lista no board por tras dele. - const modalOpen = minesweeperOpen || trashOpen || aboutOpen || helpOpen || confirmation !== null + const modalOpen = + minesweeperOpen || + trashOpen || + aboutOpen || + helpOpen || + notepadOpen || + sysPropsOpen || + confirmation !== null useHotkeys( { n: () => newListRef.current?.focus(), @@ -123,6 +136,20 @@ export default function App() { // Derivado do board, nao copiado: o eco do WebSocket atualiza o dialogo aberto. const openCard = board.lists.flatMap((l) => l.cards).find((c) => c.id === openCardId) ?? null + // Envelope unico em volta do `run`: assim nenhuma chamada individual precisa + // lembrar de tocar som, e o som de erro acompanha qualquer falha de API. + const runWithSound = (action: () => Promise, ok: 'ding' | 'recycle' = 'ding') => + void run(async () => { + try { + const result = await action() + sounds.play(ok) + return result + } catch (e) { + sounds.play('error') + throw e + } + }) + const endDrag = () => { setDrag(null) setCardDropTarget(null) @@ -133,7 +160,7 @@ export default function App() { e.preventDefault() const title = newList.trim() if (!title) return - void run(() => api.createList(title)) + runWithSound(() => api.createList(title)) setNewList('') } @@ -195,7 +222,7 @@ export default function App() { { kind: 'item', label: 'Atualizar', onClick: () => void reload() }, { kind: 'item', label: 'Abrir a Lixeira', onClick: () => setTrashOpen(true) }, { kind: 'separator' }, - { kind: 'item', label: 'Propriedades', onClick: () => setAboutOpen(true) }, + { kind: 'item', label: 'Propriedades', onClick: () => setSysPropsOpen(true) }, ], }) }} @@ -294,13 +321,13 @@ export default function App() { if (/campo minado/i.test(card.title)) return setMinesweeperOpen(true) setOpenCardId(card.id) }} - onAddCard={(listId, title) => void run(() => api.createCard(listId, title))} + 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: `Mandar "${l.title}" e seus ${l.cards.length} card(s) para a Lixeira?`, - onYes: () => void run(() => api.deleteList(l.id)), + onYes: () => runWithSound(() => api.deleteList(l.id), 'recycle'), }) } /> @@ -321,7 +348,7 @@ export default function App() { title: 'Apagar card', message: `Mandar o card "${openCard.title}" para a Lixeira?`, onYes: () => { - void run(() => api.deleteCard(openCard.id)) + runWithSound(() => api.deleteCard(openCard.id), 'recycle') setOpenCardId(null) }, }) @@ -331,6 +358,39 @@ 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)} + /> + )} + {trashOpen && ( setTrashOpen(false)} @@ -451,11 +511,23 @@ export default function App() { onClick={() => { setStartOpen(false) setMinesweeperOpen(true) + sounds.play('chord') }} > Campo Minado +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +
    {children}
    +
    + ) +} 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) + }} + /> + +
    + +