Skip to content

[arquitetura] Um único conjunto de totais serve duas superfícies com períodos diferentes no /panorama — a linha "Total" da tabela não soma a própria coluna, e a "Receita Acumulada" divide 12 meses por 8 #125

Description

@Guiroos

Onde

  • app/(app)/panorama/page.tsx:57-63 — o conjunto híbrido de totais, calculado uma vez na page
  • app/(app)/panorama/page.tsx:119-125 — o mesmo conjunto entregue à PanoramaTable (rodapé "Total")
  • app/(app)/panorama/page.tsx:98-104 — e à AnnualSummaryCards (cards "Acumulada")
  • components/panorama/PanoramaTable.tsx:68-89 (tfoot desktop) e :131-163 (resumo mobile) — consomem como soma de coluna
  • components/panorama/AnnualSummaryCards.tsx:73-96 — consome como YTD, e recalcula a perna de despesa por conta própria
  • lib/queries/panorama.ts:49 (getAnnualOverview) — devolve sempre os 12 meses do ano; nenhum total sai de lib/

Evidência

A page calcula um conjunto de totais e entrega o mesmo conjunto a duas superfícies que precisam de períodos diferentes:

// app/(app)/panorama/page.tsx:57-63
const nowYM = currentYearMonth()
const activeOverview = overview.filter((m) => m.month <= nowYM)

const totalIncomes = overview.reduce((sum, m) => sum + m.totalIncomes, 0)        // 12 meses
const totalExpensesYTD = activeOverview.reduce((sum, m) => sum + m.totalExpenses, 0)  // YTD
const totalInvested = overview.reduce((sum, m) => sum + m.totalInvested, 0)      // 12 meses
const finalBalance = totalIncomes - totalExpensesYTD - totalInvested             // híbrido

Nenhuma das duas superfícies recebe um conjunto coerente:

  • A tabela mensal precisa da soma da própria coluna — é uma linha rotulada "Total" embaixo de 12 linhas. Recebe totalExpensesYTD na coluna "Gastos" e finalBalance na coluna "Saldo".
  • Os cards precisam de YTD nas três pernas — o rótulo é "Acumulada", o divisor dos rodapés é monthsElapsed (AnnualSummaryCards.tsx:87-88, :165) e a comparação com o ano anterior é montada de propósito sobre os meses decorridos do ano corrente (:75, :81). Recebe receita e investimento do ano inteiro.

Rodando os dois predicados sobre a mesma fixtura (salário jan–ago, 13º lançado para novembro, gastos fixos já rolados set–dez, aportes jan–ago; mês corrente 2026-08):

A) /panorama?year=2026
TABELA MENSAL — linha "Total" vs. soma da própria coluna
  Gastos ....... tfoot R$ 40.000,00    coluna R$ 52.000,00
  Saldo ........ tfoot R$ 24.000,00    coluna R$ 12.000,00
CARD "Acumulada" — valor exibido vs. YTD coerente
  Receita ...... card  R$ 72.000,00    YTD    R$ 64.000,00
  média/mês .... card  R$ 9.000,00     YTD    R$ 8.000,00   (divisor = 8)
  proj. anual .. card  R$ 108.000,00   YTD    R$ 96.000,00
  vs. ano ant .. card  29%             YTD    14%

média/mês e proj. anual são o caso mais direto: numerador de 12 meses sobre divisor de 8 meses decorridos. Não há leitura em que isso esteja certo — se totalIncomes é do ano inteiro, projetar não faz sentido; se é YTD, o numerador está errado. O mesmo vale para incomeChangePct (:93): o código constrói activeMonthKeys em :75 e filtra prevOverview em :81 justamente para comparar o mesmo período, e então compara os 8 meses do ano anterior contra os 12 do ano corrente.

Segunda manifestação — ano futuro, alcançável pelo próprio seletor. createInstallmentPurchase (lib/actions/transactions.ts:291-307) grava uma transaction por parcela com referenceMonth futuro; getAvailableYears (lib/queries/panorama.ts:21-43) extrai os anos distintos desses referenceMonth e YearSelector (components/panorama/YearSelector.tsx:16-24) renderiza um chip por ano. Uma compra em 12x feita em agosto/2026 faz o chip 2027 existir — e ele existe exatamente porque há dados lá:

B) /panorama?year=2027 (7 parcelas de R$ 500 caem em 2027)
  Gastos ....... tfoot R$ 0,00         coluna R$ 3.500,00
  Saldo ........ tfoot R$ 0,00         coluna R$ -3.500,00
  card "Despesa Acumulada" ........... R$ 0,00

A tabela lista sete meses com R$ 500,00 e o rodapé "Total" da mesma coluna diz R$ 0,00.

Enumeração do predicado "quais meses de overview entram num agregado do ano" — 7 sites, 4 recortam por mês decorrido e 3 somam os 12:

# Site Recorte
1 app/(app)/panorama/page.tsx:58 (activeOverview, despesas) meses decorridos
2 components/panorama/AnnualSummaryCards.tsx:73 (active, despesas + monthsElapsed) meses decorridos
3 components/panorama/AnnualSummaryCards.tsx:81 (prevSamePeriod, ano anterior) meses decorridos
4 components/charts/PatrimonyEvolutionChart.tsx:142 (activeMonths, melhor/pior mês e hasData) meses decorridos
5 app/(app)/panorama/page.tsx:60 (totalIncomes) 12 meses
6 app/(app)/panorama/page.tsx:62 (totalInvested) 12 meses
7 components/panorama/AnnualSummaryCards.tsx:91 (monthsWithInvestment, divisor) 12 meses

O placar 4×3 é apertado demais para sustentar sozinho um argumento de convenção, e o achado não depende dele: as duas falhas acima são normativas. Uma linha "Total" tem de somar a coluna que encabeça; um numerador e seu divisor têm de cobrir o mesmo período. Os sites 5–7 ficam registrados porque são o que a correção precisa alcançar, não como votação.

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

  1. Rodei o código. Script com os predicados copiados de page.tsx:57-63 e AnnualSummaryCards.tsx:72-96, sobre as fixturas A e B — são as saídas acima. Controle: a mesma varredura em ?year=2025 (ano encerrado) bate em todas as oito linhas, porque activeOverview passa a ser os 12 meses. O defeito está no recorte de período, não na aritmética. Achado sobreviveu.

  2. Foi decisão deliberada? git log --oneline -S "const totalIncomes = overview.reduce" -- 'app/(app)/panorama/page.tsx' devolve só 8e45b9b, que é o commit-base do histórico esmagado (adiciona .claude/ inteiro). git log -- components/panorama/AnnualSummaryCards.tsx devolve um único commit, 2229d75, que é o de criação do arquivo. Nenhum commit escolhe o ano inteiro para receita.

    O que existe é o oposto: docs/panorama/01-bugs-calculo-anual.md ("Bug 1 — Dois valores de saldo divergentes na mesma tela", status implementado) é a origem direta desta linha. Ele trocou totalExpenses por totalExpensesYTD no tfoot para fazer o rodapé concordar com o card — e o snippet "Correção aplicada" mostra totalIncomes e totalInvested seguindo em overview.reduce. O doc comparou os dois números de saldo entre si e não comparou nenhum dos dois com a coluna que o rodapé encabeça; a troca eliminou a divergência card↔tfoot criando a divergência tfoot↔coluna. Achado sobreviveu, e o doc é a evidência de que a decisão registrada foi outra.

  3. É a convenção do repo? .claude/domain.md (seção Panorama) registra a intenção de recorte: "activeMonths: overview.filter(m => m.month <= currentYearMonth())" e "Comparações YTD: filtrar prevOverview pelos meses ativos do ano atual". O código faz as duas coisas — e depois compara o resultado contra um numerador de 12 meses. A convenção documentada é a que o achado defende. Achado sobreviveu.

  4. Já é coberto indiretamente? Não. __tests__/integration/queries-panorama.test.ts tem um it, sobre exclusão de conta de crédito em modo fatura (:31-32); nada sobre totais. grep -rn "totalExpensesYTD\|projectedIncome\|AnnualSummaryCards" __tests__/ devolve zero. Os totais vivem dentro do arquivo de page (não exportados) e dentro de um Client Component, então nenhum teste consegue alcançá-los hoje. Achado sobreviveu.

Superfície tem consumidor

PanoramaTable é importado em app/(app)/panorama/page.tsx:19 e renderizado em :119; AnnualSummaryCards em :18 e :98; YearSelector em :17 e :81. As três são renderizadas em /panorama, que é rota do (app) com item próprio na navegação.

Impacto

Manifestação B atinge qualquer usuário com parcelamento que cruze o ano — uma compra em 12x, ou uma em 6x feita em agosto. O chip do ano seguinte aparece no seletor porque as parcelas existem; ao clicar, a tabela mostra os compromissos mês a mês e a linha "Total" das colunas Gastos e Saldo diz R$ 0,00, junto com o card "Despesa Acumulada" em R$ 0,00. É o número que o usuário procura ("quanto já devo em 2027") exibido como zero sobre a evidência do contrário.

Manifestação A atinge quem lança receita ou aporte em mês futuro do ano correnteEntradaFields e InvestimentoFields usam MonthSelect com o forward = 12 default (components/ui/month-select.tsx:30) e yearMonthSchema é só um regex (lib/validations/utils.ts:48), sem teto. Um 13º salário lançado em novembro infla a "Receita Acumulada" do ano inteiro, a média mensal, a projeção anual, o "% poupado" e a comparação com o ano anterior — todos para cima, e todos plausíveis o bastante para não chamar atenção.

Em ambas, o erro se corrige sozinho quando o ano fecha, o que o torna difícil de reproduzir depois.

Cobertura

Não existe teste (item 4 acima), e nenhum é possível sem extrair os totais da page — o que é parte da proposta, não um pré-requisito separado.

Casos que só a correção certa passa, com currentYearMonth() fixado em 2026-08 (vi.useFakeTimers({ toFake: ['Date'] }), conforme .claude/testing.md). São três, e cada um mata uma correção errada diferente:

  1. Ano futuro. overview de 2027 com R$ 500 de despesa em jan–jul: os totais da tabela devem dar totalExpenses = 3500 e balance = -3500. Hoje dão 0.
  2. Ano corrente, receita futura. overview de 2026 com receita em jan–ago e em novembro: o total do card deve contar só jan–ago. Hoje conta novembro junto.
  3. Ano corrente, despesa fixa futura (regressão do doc 01-bugs-calculo-anual.md). overview de 2026 com despesa fixa em set–dez: o total do card deve continuar parando em agosto.

O caso 2 sozinho passa com a correção errada mais provável — aplicar activeOverview também a totalIncomes/totalInvested, uma linha na page — que deixa o ano futuro inteiro em zero e reprova no caso 1. O caso 1 sozinho passa com a correção errada oposta — remover o filtro YTD e somar 12 meses em tudo — que reintroduz o Bug 1 do doc e reprova no caso 3. Os três juntos só passam se os dois períodos existirem separados.

Proposta

Fazer lib/queries/panorama.ts devolver os dois conjuntos, em vez de a page calcular um híbrido:

// lib/queries/panorama.ts, ao lado de getAnnualOverview
export type AnnualTotals = { totalIncomes: number; totalExpenses: number; totalInvested: number; balance: number }

export function annualTotals(overview: OverviewMonth[]): AnnualTotals          // os 12 meses — tabela
export function annualTotalsToDate(overview: OverviewMonth[]): AnnualTotals & { monthsElapsed: number }  // meses decorridos — cards
  1. page.tsx chama as duas e passa annualTotals(overview) para PanoramaTable e annualTotalsToDate(overview) para AnnualSummaryCards. As duas superfícies deixam de compartilhar números.
  2. AnnualSummaryCards para de recalcular active/totalExpensesYTD/balance (:73, :77, :79) e passa a receber monthsElapsed junto com os totais — é o mesmo divisor que já usa, agora vindo do mesmo lugar que o numerador. prevSamePeriod (:81) continua como está: ele já recorta pelos meses decorridos, e passa a comparar contra um numerador do mesmo período. monthsWithInvestment (:91) passa a contar sobre os meses decorridos, pelo mesmo motivo.
  3. PatrimonyEvolutionChart:142 (activeMonths) passa a usar o mesmo recorte de annualTotalsToDate em vez da terceira cópia do filtro. O isPastYear que ele carrega vira desnecessário: para ano encerrado o filtro já é no-op.

Por que em lib/queries/panorama.ts e não um helper em lib/utils/date.ts: o helper de data óbvio seria algo como isElapsed(month), e é o errado aqui — ele deixaria o recorte espalhado pelos mesmos 7 sites, só que atrás de uma função. O que precisa ser único é o total, não o predicado: hoje a page já é o único lugar que soma, e é justamente por somar uma vez para dois consumidores que o híbrido nasceu. Devolvendo dois conjuntos nomeados pelo período, cada superfície pede o que precisa e a recaída exige passar o conjunto errado de propósito. Também é o que torna os três casos de teste escrevíveis — hoje nada disso é exportável.

Não mexer em getAnnualOverview: ele devolver os 12 meses está certo, é o que a tabela renderiza linha a linha.

Ao corrigir, atualizar a linha de .claude/domain.md que hoje diz "tfoot usa totalExpensesYTD" — ela descreve exatamente a metade do Bug 1 que virou este defeito — e acrescentar uma nota ao final de docs/panorama/01-bugs-calculo-anual.md registrando o que a correção de lá não alcançou.

Custo estimado

M (4 arquivos: lib/queries/panorama.ts, app/(app)/panorama/page.tsx, components/panorama/AnnualSummaryCards.tsx, components/charts/PatrimonyEvolutionChart.tsx + 1 de teste)

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