Nexus — Fase 2 5 episódios

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.

01 - Visão GeralVISÃO GERAL

Panorama da Fase 2 — o que entrega, ordem das decisões e onde estamos

0:00 / 0:00

Resumo

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).

tsvectorN-Npaleta fechadaURL stateTipTapProseMirror
CÓDIGO COMPLETO
02 - Busca full-texttsvector

Postgres tsvector + GIN + unaccent + prefix matching, type-as-you-search

0:00 / 0:00

Resumo

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.

Flyway V5+V6GIN indexunaccentprefix matchingdebounce
CÓDIGO COMPLETO
03 - Tags backendN-N

Migration V7, pacote io.nexus.tags, attach/detach, filtro ?tag=

0:00 / 0:00

Resumo

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).

Flyway V7@ManyToManynotes_tagscase-insensitiveJPQL JOIN
CÓDIGO COMPLETO
04 - Cor + Tags frontendSvelteKit

Paleta fechada de 8 cores, página /tags, anexar no detalhe, chips na home, filtro por URL

0:00 / 0:00

Resumo

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.

Flyway V8enum TagColorpaleta fechada$state runesURL statechip clicável
CÓDIGO COMPLETO
05 - OrdenaçãoSort

Enum NoteSort + ORDER BY dinâmico em native query, sync com URL ?sort=

0:00 / 0:00

Resumo

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: updatedAtn.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.

enum NoteSortEntityManagernative query$derivedURL state
CÓDIGO COMPLETO
06 - Editor rich-textTipTap

TipTap + JSON ProseMirror + coluna derivada content_text pra busca full-text

0:00 / 0:00

Resumo

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.

TipTapProseMirrorFlyway V9content_textJacksonRichTextExtractor
CÓDIGO COMPLETO