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)
-
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.
-
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.
-
É 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.
-
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 corrente — EntradaFields 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:
- 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.
- 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.
- 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
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.
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.
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)
Onde
app/(app)/panorama/page.tsx:57-63— o conjunto híbrido de totais, calculado uma vez na pageapp/(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 colunacomponents/panorama/AnnualSummaryCards.tsx:73-96— consome como YTD, e recalcula a perna de despesa por conta próprialib/queries/panorama.ts:49(getAnnualOverview) — devolve sempre os 12 meses do ano; nenhum total sai delib/Evidência
A page calcula um conjunto de totais e entrega o mesmo conjunto a duas superfícies que precisam de períodos diferentes:
Nenhuma das duas superfícies recebe um conjunto coerente:
totalExpensesYTDna coluna "Gastos" efinalBalancena coluna "Saldo".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):média/mêseproj. anualsão o caso mais direto: numerador de 12 meses sobre divisor de 8 meses decorridos. Não há leitura em que isso esteja certo — setotalIncomesé do ano inteiro, projetar não faz sentido; se é YTD, o numerador está errado. O mesmo vale paraincomeChangePct(:93): o código constróiactiveMonthKeysem:75e filtraprevOverviewem:81justamente 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 umatransactionpor parcela comreferenceMonthfuturo;getAvailableYears(lib/queries/panorama.ts:21-43) extrai os anos distintos dessesreferenceMontheYearSelector(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á: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
overviewentram num agregado do ano" — 7 sites, 4 recortam por mês decorrido e 3 somam os 12:app/(app)/panorama/page.tsx:58(activeOverview, despesas)components/panorama/AnnualSummaryCards.tsx:73(active, despesas +monthsElapsed)components/panorama/AnnualSummaryCards.tsx:81(prevSamePeriod, ano anterior)components/charts/PatrimonyEvolutionChart.tsx:142(activeMonths, melhor/pior mês ehasData)app/(app)/panorama/page.tsx:60(totalIncomes)app/(app)/panorama/page.tsx:62(totalInvested)components/panorama/AnnualSummaryCards.tsx:91(monthsWithInvestment, divisor)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)
Rodei o código. Script com os predicados copiados de
page.tsx:57-63eAnnualSummaryCards.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, porqueactiveOverviewpassa a ser os 12 meses. O defeito está no recorte de período, não na aritmética. Achado sobreviveu.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.tsxdevolve 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 trocoutotalExpensesportotalExpensesYTDno tfoot para fazer o rodapé concordar com o card — e o snippet "Correção aplicada" mostratotalIncomesetotalInvestedseguindo emoverview.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.É 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: filtrarprevOverviewpelos 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.Já é coberto indiretamente? Não.
__tests__/integration/queries-panorama.test.tstem umit, 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 emapp/(app)/panorama/page.tsx:19e renderizado em:119;AnnualSummaryCardsem:18e:98;YearSelectorem:17e: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 corrente —
EntradaFieldseInvestimentoFieldsusamMonthSelectcom oforward = 12default (components/ui/month-select.tsx:30) eyearMonthSchemaé 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 em2026-08(vi.useFakeTimers({ toFake: ['Date'] }), conforme.claude/testing.md). São três, e cada um mata uma correção errada diferente:overviewde 2027 com R$ 500 de despesa em jan–jul: os totais da tabela devem dartotalExpenses = 3500ebalance = -3500. Hoje dão 0.overviewde 2026 com receita em jan–ago e em novembro: o total do card deve contar só jan–ago. Hoje conta novembro junto.01-bugs-calculo-anual.md).overviewde 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
activeOverviewtambém atotalIncomes/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.tsdevolver os dois conjuntos, em vez de a page calcular um híbrido:page.tsxchama as duas e passaannualTotals(overview)paraPanoramaTableeannualTotalsToDate(overview)paraAnnualSummaryCards. As duas superfícies deixam de compartilhar números.AnnualSummaryCardspara de recalcularactive/totalExpensesYTD/balance(:73,:77,:79) e passa a recebermonthsElapsedjunto 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.PatrimonyEvolutionChart:142(activeMonths) passa a usar o mesmo recorte deannualTotalsToDateem vez da terceira cópia do filtro. OisPastYearque ele carrega vira desnecessário: para ano encerrado o filtro já é no-op.Por que em
lib/queries/panorama.tse não um helper emlib/utils/date.ts: o helper de data óbvio seria algo comoisElapsed(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 apagejá é 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.mdque hoje diz "tfoot usatotalExpensesYTD" — ela descreve exatamente a metade do Bug 1 que virou este defeito — e acrescentar uma nota ao final dedocs/panorama/01-bugs-calculo-anual.mdregistrando 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)