Skip to content

[dados] As setas de reordenar grupos em /categorias não movem nada — sortOrder é gravado e nenhuma das 4 leituras de categoryGroups o respeita #133

Description

@Guiroos

Onde

Escrita (a única):

  • lib/actions/categories.ts:58-70reorderCategoryGroups(orderedIds) grava sortOrder = index para cada grupo e revalida /categorias.

Leituras de categoryGroups (4 sites, enumerados por conteúdo — exigência 8). Nenhuma respeita sortOrder:

# Onde O que faz Consumidor (renderizado?)
1 lib/queries/categories.ts:15-22 getCategoriesWithGroups orderBy: [categoryGroups.sortOrder] no SQL e, na linha seguinte, .sort((a,b) => decryptField(a.name).localeCompare(decryptField(b.name),'pt-BR')) — descarta o ORDER BY inteiro /categoriasapp/(app)/categorias/page.tsx:21. É a própria tela que renderiza as setas (:8 importa, :60 renderiza <ReorderButtons>)
2 lib/queries/categories.ts:73-88 getCategoriesWithBudgets idêntico: orderBy no SQL (:84), re-sort por nome logo abaixo (:88) /configuracao-mesapp/(app)/configuracao-mes/page.tsx:47
3 lib/queries/dashboard.ts:55-56 getCategoryGroupProgress db.query.categoryGroups.findMany sem orderBy nenhum e sem sort em JS → ordem arbitrária do Postgres /dashboard (via getDashboardData)
4 lib/queries/panorama.ts:196-199 getAnnualExpensesByGroup findMany sem orderBy /panoramaapp/(app)/panorama/page.tsx:36

Evidência

lib/queries/categories.ts:15-22:

const groups = await db.query.categoryGroups.findMany({
  where: eq(categoryGroups.userId, userId),
  with: { categories: true },
  orderBy: [categoryGroups.sortOrder],      // ← pedido ao Postgres
})

return groups
  .sort((a, b) => decryptField(a.name, dek).localeCompare(decryptField(b.name, dek), 'pt-BR'))
  //  ↑ re-ordena tudo por nome; o ORDER BY acima não sobrevive a esta linha

O ciclo fecha em nada, e o no-op é total, para toda entrada — não é "às vezes não funciona":

  1. app/(app)/categorias/page.tsx:24 monta groupIds a partir de groups, que já veio ordenado alfabeticamente.
  2. ReorderButtons.tsx:20-23 troca dois ids adjacentes dessa lista e chama reorderCategoryGroups(newOrder).
  3. A action grava sortOrder = 0..n-1 na ordem pedida e chama revalidatePath('/categorias').
  4. A página re-lê por getCategoriesWithGroups, que re-ordena por nome. A saída é byte a byte a mesma de antes do clique.

Não há estado otimista mascarando: ReorderButtons é startTransition(() => reorderCategoryGroups(newOrder)), sem useOptimistic nem estado local de ordem. O usuário clica, o botão pisca (isPending), o servidor grava, a tela volta idêntica.

Agravante para quem for corrigir: createCategoryGroup (lib/actions/categories.ts:31-37) não atribui sortOrder, e o schema tem default(0) (lib/db/schema.ts:82). Todo grupo criado pela UI nasce com sortOrder = 0 — só os dois grupos do seed (lib/actions/reset-account.ts:37,51) têm 0 e 1. Ver a Proposta.

Verificações feitas (tentativa de falsificar o achado)

  1. A superfície tem consumidor? (exigência 7) Sim — grep -rn "ReorderButtons" devolve app/(app)/categorias/page.tsx:8 (import) e :60 (render), dentro do groups.map. Não é código morto, ao contrário do caso da [arquitetura] getMonthlyEvolution roda em todo load do dashboard e o resultado é descartado — MonthlyEvolutionChart não é importado em lugar nenhum #58.
  2. Alguma leitura honra sortOrder? grep -rn "sortOrder\|sort_order" lib/ app/ components/ — 4 leituras (tabela acima) e nenhuma. As duas que passam orderBy o anulam na linha seguinte; as outras duas nem pedem ordem.
  3. É decisão deliberada documentada? Não. .claude/crypto.md prescreve "ordenar em JS após decrypt" porque ORDER BY sobre coluna cifrada ordena ciphertext — isso justifica ordenar por nome em JS, e é claramente de onde a linha veio. Mas nenhum .claude/*.md diz que grupo deve aparecer em ordem alfabética, e a existência de sortOrder + reorderCategoryGroups + as setas diz o contrário. O remédio da criptografia foi aplicado sobre o critério errado de ordenação.
  4. git log: lib/queries/categories.ts e components/categorias/ReorderButtons.tsx entram no mesmo commit (98c85f4, import inicial do repo), então não há commit isolado defendendo a escolha — não é uma decisão registrada, é uma colisão que nasceu junto.
  5. Já existe issue? Não. search_issues por sortOrder OR reorder OR reordenar no repo devolve 0 resultados; a listagem completa das 45 issues claude-audit também não tem nada da área.

Impacto

Qualquer usuário com dois ou mais grupos de categoria que tente colocá-los na ordem que faz sentido para ele ("Essencial" antes de "Estilo de Vida", "Fixos" no topo) — a ordenação alfabética é imposta em /categorias e /configuracao-mes, e no /dashboard e /panorama a ordem é a arbitrária do Postgres, que pode inclusive mudar entre dois loads sem que nada tenha sido editado.

O modo de falha é o pior tipo para suporte: não há erro, não há toast, o clique "funciona" (o disabled de isFirst/isLast muda, o isPending pisca) e o banco realmente é atualizado. Só o resultado nunca aparece. Não há como o usuário distinguir isso de "o app é assim".

Cobertura

Hoje: nenhum teste toca o caminho. grep -rn "getCategoriesWithGroups\|reorderCategoryGroups\|sortOrder" __tests__/ devolve uma linha: __tests__/integration/actions-reset-account.test.ts:132, que usa .orderBy(schema.categoryGroups.sortOrder) como ORDER BY próprio para conferir os grupos semeados. Ela não exercita nenhuma leitura de lib/queries/ — a correção certa a deixa verde, sem surpresa para quem implementar.

Caso proposto (integração, __tests__/integration/categories.test.ts), com a entrada que só a correção certa aceita:

  • Criar dois grupos cuja ordem alfabética seja o inverso do sortOrder: ('Zebra', sortOrder 0) e ('Alimentação', sortOrder 1).
  • getCategoriesWithGroups(userId) → esperar ['Zebra', 'Alimentação'].

O inverso alfabético é o que dá o poder discriminante: a implementação atual devolve ['Alimentação','Zebra'] e falha; qualquer correção que continue ordenando por nome também falha. Um par em ordem alfabética coincidente passaria nos dois mundos e não cobriria nada.

  • Segundo caso, no nível da action (__tests__/integration/actions-categories.test.ts): reorderCategoryGroups([idB, idA]) e em seguida getCategoriesWithGroups → esperar [B, A]. É o que amarra escrita e leitura; hoje nada garante que a coluna gravada seja lida por alguém.
  • Terceiro caso, o que protege a Proposta: criar dois grupos pela action createCategoryGroup e assertar que os sortOrder resultantes são distintos. Sem ele, a correção "só apagar o .sort()" passa nos dois casos acima (que inserem sortOrder explícito) e mesmo assim entrega ordem arbitrária a todo usuário real.

Proposta

Duas partes. A primeira sozinha não resolve — é justamente a armadilha desta issue.

(a) lib/queries/categories.ts — ordenar por sortOrder, com o nome só como desempate:

return groups
  .map((g) => ({ ...g, name: decryptField(g.name, dek), /* ... */ }))
  .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, 'pt-BR'))

nas duas funções (getCategoriesWithGroups:21 e getCategoriesWithBudgets:87). O orderBy: [categoryGroups.sortOrder] do SQL pode ficar ou sair — ele já é redundante hoje; o que não pode é continuar sendo anulado em silêncio.

Por que o comparador em JS e não simplesmente apagar o .sort() deixando o ORDER BY do SQL trabalhar (exigência 6): o desempate compara nome decifrado, e o Postgres não tem a DEK — ORDER BY name ordenaria ciphertext (.claude/crypto.md, "ORDER BY quebrado"). E o desempate não é detalhe cosmético aqui: como createCategoryGroup:35 não atribui sortOrder e o schema tem default(0) (schema.ts:82), hoje todo grupo criado pela UI está empatado em 0. Apagar o .sort() — que é o reflexo natural de quem lê o diagnóstico — troca "ordem alfabética teimosa" por "ordem arbitrária que muda entre loads", que é pior, e o teste alfabético-invertido acima passaria nessa versão errada porque ele insere sortOrder explícito. Daí o terceiro caso de cobertura.

(b) lib/actions/categories.ts:31-37createCategoryGroup atribui sortOrder no fim da lista (MAX(sortOrder) + 1 do usuário, ou a contagem atual) em vez de deixar o default(0). Sem isso, o empate em 0 faz o desempate alfabético carregar a ordenação inteira e as setas continuam parecendo quebradas até o primeiro clique, que é o único momento em que alguém grava uma sequência 0..n-1 completa.

Fora do escopo desta correção, declarado (exigência 8): os sites 3 e 4 da tabela (getCategoryGroupProgress e getAnnualExpensesByGroup) alimentam /dashboard e /panorama e também ignoram sortOrder. Alinhá-los é a mesma ideia, mas mexe em dois arquivos a mais e no shape de saída do /panorama (que remonta os grupos a partir de um Map), então cabe fatiar. Um PR que feche só os sites 1 e 2 deve usar refs #<n>, não closes.

Custo estimado

P para os sites 1 e 2 — lib/queries/categories.ts e lib/actions/categories.ts (2 arquivos com a parte (b), 1 sem ela). M se /dashboard e /panorama entrarem no mesmo PR.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions