Sequência da Fase 2 do Nexus — encontrar e organizar nota. Busca full-text com Postgres tsvector + GIN + unaccent, tags em relação N-N com paleta fechada de cores, ordenação dinâmica sincronizada com URL e editor rich-text com TipTap. Fase fechada — 5 episódios entregues, cada um com áudio + resumo.
Panorama da Fase 2 — o que entrega, ordem das decisões e onde estamos
A Fase 2 é sobre encontrar e organizar nota. A Fase 1 entregou o MVP: criar, listar, editar, apagar. Funciona — mas com 50 notas você não acha mais nada. Fase 2 ataca isso em cinco frentes: busca full-text (digitar palavra → filtra na hora), tags (rótulos pra agrupar notas por tema), cor de tag (visual rápido), ordenação (por data, título) e editor rich-text (negrito, títulos, listas).
Ordem das entregas: busca primeiro (2026-05-01) → tags backend (2026-05-02) → cor de tag, tags frontend, ordenação e rich-text (2026-05-04). Cada uma é independente — dá pra ter busca sem tags, tags sem cor, e assim por diante.
Decisões guiando: tudo no Postgres (sem serviço de busca externo tipo Meilisearch — o tsvector basta), tags em tabela normalizada (não array — escala melhor pra rename e filtro), paleta fechada de cor (8 cores enforced no enum Java — sem hex livre, sem CHECK no banco), TipTap em vez de BlockNote (mais leve, headless, controle total da UI), JSON ProseMirror em content + coluna derivada content_text (texto plano extraído pra alimentar o índice GIN).
Postgres tsvector + GIN + unaccent + prefix matching, type-as-you-search
Busca full-text dentro do Postgres. V5 cria índice GIN sobre setweight(to_tsvector('portuguese', title), 'A') || setweight(..., content, 'B') — title pesa mais que content no rank. V6 resolve acentos: extensão unaccent + função wrapper immutable_unaccent marcada IMMUTABLE pra poder usar em índice funcional (o unaccent nativo é STABLE).
Prefix matching em vez de websearch_to_tsquery: o usuário digita "r" e quero filtrar notas com "reunião". NoteService.toPrefixTsQuery sanitiza o input e converte em palavra:* & outra:* que vai pro to_tsquery. Trade-off: perde a sintaxe Google (frase, OR, NOT), mas ganha filtro instantâneo — mais valioso pra segundo cérebro.
UI: input no topo da home com debounce de 300ms. Cada tecla cancela o timer anterior. Sem debounce, bateria no backend a cada caractere.
Migration V7, pacote io.nexus.tags, attach/detach, filtro ?tag=
Tag é entidade de primeira classe — não array de string. V7 cria tags (id + user_id + name + created_at) e notes_tags (PK composta com FK CASCADE em ambos). Unicidade case-insensitive: UNIQUE INDEX ON tags(user_id, lower(name)) — preserva o case digitado mas evita "Trabalho"/"trabalho" duplicadas.
Razão de tabela vs array: rename é UPDATE de 1 linha (na tag) em vez de N notas; tag pode ganhar metadata depois (cor, ordem, contagem); @ManyToMany JPA é caminho batido (array Postgres com Hibernate exige converter custom). Custo extra é marginal — 1 entidade + 1 repo.
Endpoints: GET/POST/PUT/DELETE /tags pra CRUD, POST/DELETE /notes/{id}/tags/{id} pra anexar/desanexar, GET /notes?tag={id} pra filtrar (JPQL com JOIN). Filtro não combina com ?q=: se ambos vierem, tag ganha (UI típica clica tag e zera busca).
Paleta fechada de 8 cores, página /tags, anexar no detalhe, chips na home, filtro por URL
Backend de cor (V8 + enum TagColor): paleta fechada de 8 cores — gray, red, orange, yellow, green, blue, purple, pink. Validação no DTO + service. Sem CHECK no DB — adicionar cor nova exige só alterar enum + frontend, sem migration. API+DTO são única porta de entrada, valor inválido nunca chega no banco.
Frontend completo: página /tags com CRUD (input + paleta de bolinhas + edit inline + confirm delete); link no header. Detalhe da nota: chips coloridos atuais com × pra desanexar + <select> com tags disponíveis (= todas menos as já anexadas). Home: chips coloridos abaixo de cada card (botões fora do <a> pra não navegar) + filtro sincronizado com URL ?tag=ID (sobrevive F5 e back/forward) + banner "Filtrando por: X · limpar" substitui input de busca quando filtro ativo.
Mapa de cores em lib/tagColors.ts (tons pastel estilo Notion: bg + fg) — espelha o enum Java. Frontend só sabe a paleta como string lowercase.
Enum NoteSort + ORDER BY dinâmico em native query, sync com URL ?sort=
Enum NoteSort (UPDATED_DESC/ASC, CREATED_DESC/ASC, TITLE_ASC/DESC) traduz string da API ("updatedAt:desc") pra Sort do Spring Data. NoteService.list/listByTag/search aceitam NoteSort. Repository: findByUser(User, Sort) substitui derived query antiga; findByUserAndTagId perde ORDER BY hard-coded e ganha Sort parameter (Spring Data appenda).
Custom repository pattern pra busca: NoteRepositoryCustom + NoteRepositoryImpl usa EntityManager.createNativeQuery e monta ORDER BY string a partir do Sort (mapeamento JPA→SQL: updatedAt→n.updated_at, etc.). Sem risco de SQL injection — valores vêm do enum, controle estático.
Comportamento na busca: por padrão usa ts_rank_cd DESC (relevância) com sort como tiebreaker; quando o usuário escolhe sort manual (?sort= na URL), rank é descartado e ORDER BY é estritamente o sort. Frontend: <select> na toolbar; changeSort atualiza URL preservando ?tag=; sortExplicit derivado de searchParams.has('sort') decide se passa sort pra searchNotes.
TipTap + JSON ProseMirror + coluna derivada content_text pra busca full-text
Stack: TipTap (vs BlockNote) — wrapper headless sobre ProseMirror, ~50-80kb, permite UI custom, comunidade Svelte boa. Persistência: campo content em notes passa a guardar JSON ProseMirror.
Migration V9 adiciona content_text TEXT NOT NULL DEFAULT ''. Backend (RichTextExtractor com Jackson) percorre o JSON ao salvar e popula content_text. O índice GIN da V5/V6 é dropado e recriado apontando pra essa coluna em vez de content direto. Notas legadas (plain text antes do editor): se o JSON-parse falha, retorna a string como está — fallback transparente, sem perda de dado.
Frontend: lib/components/TipTapEditor.svelte (StarterKit + Placeholder), toolbar com B/I/H1/H2/listas/code; parseContent no mount detecta JSON vs plain text e envolve plain em paragraph (migração transparente do client). Util lib/contentPreview.ts (contentToPlainText) espelha o RichTextExtractor pra preview na home.