From af914a769e1d26adca49593592c0c985f5357ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 13:58:42 +0000 Subject: [PATCH 1/4] fix: ancorar termino de parcelas em nextChargeMonth, nao no mes atual (closes #114) /parcelas calculava "termina em" com aritmetica manual ancorada no mes corrente. Para parcelamentos cuja 1a parcela cai no mes seguinte (compra apos o fechamento do cartao), isso projetava as parcelas restantes a partir de hoje em vez de a partir de quando elas de fato comecam, adiantando o "termina em" exibido nos cards e nos dois KPIs do topo. Extrai addMonthsToYearMonth para lib/utils/date.ts e ancora o calculo em g.nextChargeMonth (com fallback para o mes atual quando nao ha parcela futura pendente). --- __tests__/unit/date.test.ts | 22 ++++++++++++++++++++++ app/(app)/parcelas/page.tsx | 14 ++++---------- lib/utils/date.ts | 5 +++++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/__tests__/unit/date.test.ts b/__tests__/unit/date.test.ts index 9c7e5da..db95ab0 100644 --- a/__tests__/unit/date.test.ts +++ b/__tests__/unit/date.test.ts @@ -29,6 +29,7 @@ import { calcBaseReferenceMonth, calcInstallmentDate, uniqueMonthsFromDates, + addMonthsToYearMonth, } from '@/lib/utils/date' describe('yearMonthToReferenceMonth', () => { @@ -153,6 +154,27 @@ describe('nextMonth', () => { }) }) +describe('addMonthsToYearMonth', () => { + it('adds n months within the same year', () => { + expect(addMonthsToYearMonth('2025-03', 2)).toBe('2025-05') + }) + + it('n=0 returns the same month', () => { + expect(addMonthsToYearMonth('2025-06', 0)).toBe('2025-06') + }) + + it('crosses the year boundary', () => { + expect(addMonthsToYearMonth('2025-11', 3)).toBe('2026-02') + }) + + it('issue #114: nextChargeMonth 2026-09 + 11 restantes termina em 2027-08, não 2027-07', () => { + // Âncora errada (mês atual 2026-08) daria 2027-07 — a correção precisa ancorar + // em nextChargeMonth, não no mês corrente. + expect(addMonthsToYearMonth('2026-09', 11)).toBe('2027-08') + expect(addMonthsToYearMonth('2026-08', 11)).toBe('2027-07') + }) +}) + describe('parseDate', () => { it('parses YYYY-MM-DD without UTC offset shifting the day', () => { const d = parseDate('2025-03-15') diff --git a/app/(app)/parcelas/page.tsx b/app/(app)/parcelas/page.tsx index fa49e6c..f52916b 100644 --- a/app/(app)/parcelas/page.tsx +++ b/app/(app)/parcelas/page.tsx @@ -10,15 +10,7 @@ import { Section } from '@/components/ui/section' import { PageHeader } from '@/components/ui/page-header' import { PageLayout } from '@/components/ui/page-layout' import { formatCurrency } from '@/lib/utils/currency' -import { currentYearMonth, formatMonthShort } from '@/lib/utils/date' - -function calcEndLabel(currentYM: string, remainingInstallments: number): string { - const [year, month] = currentYM.split('-').map(Number) - const totalMonths = year * 12 + (month - 1) + (remainingInstallments - 1) - const endYear = Math.floor(totalMonths / 12) - const endMonth = String((totalMonths % 12) + 1).padStart(2, '0') - return formatMonthShort(`${endYear}-${endMonth}`) -} +import { addMonthsToYearMonth, currentYearMonth, formatMonthShort } from '@/lib/utils/date' export default async function ParcelasPage() { const session = await auth() @@ -40,7 +32,9 @@ export default async function ParcelasPage() { const groupsWithEnd = groups.map((g) => ({ ...g, - endLabel: calcEndLabel(currentYM, g.remainingInstallments), + endLabel: formatMonthShort( + addMonthsToYearMonth(g.nextChargeMonth ?? currentYM, g.remainingInstallments - 1) + ), })) const endLabels = groupsWithEnd.map((g) => g.endLabel).sort() diff --git a/lib/utils/date.ts b/lib/utils/date.ts index 634f36e..3f1c49b 100644 --- a/lib/utils/date.ts +++ b/lib/utils/date.ts @@ -97,6 +97,11 @@ export function nextMonth(yearMonth: string): string { return format(addMonths(parseISO(`${yearMonth}-01`), 1), 'yyyy-MM') } +/** Returns yearMonth plus n months, as YYYY-MM. Generalization of nextMonth. */ +export function addMonthsToYearMonth(yearMonth: string, n: number): string { + return format(addMonths(parseISO(`${yearMonth}-01`), n), 'yyyy-MM') +} + /** Formats a YYYY-MM as "janeiro de 2025" (pt-BR). */ export function formatMonthName(yearMonth: string): string { return fmt(parseISO(`${yearMonth}-01`), "MMMM 'de' yyyy") From de435b31bfc45ffe9ab722677214fdd7607d615a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:50:55 +0000 Subject: [PATCH 2/4] fix: cobrir ancora de nextChargeMonth e ordenar lastEnd por YYYY-MM Endereca dois achados bloqueantes da revisao da PR #132 (issue #114): 1. Os testes anteriores exercitavam so addMonthsToYearMonth (aritmetica pura), nunca a logica de ancora que era o proprio bug -- revertendo page.tsx para ancorar em currentYM a suite continuava verde. Extrai installmentEndYearMonth para lib/utils/date.ts, unica fonte usada por page.tsx, e testa os dois casos que a issue #114 pedia (ancora em nextChargeMonth e fallback nextChargeMonth=null) sob fake timers. 2. lastEnd ordenava os rotulos ja formatados por formatMonthShort ('set 26', 'jan 27'), que e ordem alfabetica (month-major), nao cronologica -- os dois KPIs de topo continuavam errados mesmo apos a correcao do endLabel por card. Passa a ordenar por endYM (YYYY-MM, ano-major) e so formata o maximo no final. --- __tests__/unit/date.test.ts | 25 ++++++++++++++++++++----- app/(app)/parcelas/page.tsx | 22 ++++++++++++---------- lib/utils/date.ts | 12 ++++++++++++ 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/__tests__/unit/date.test.ts b/__tests__/unit/date.test.ts index db95ab0..85fb7b3 100644 --- a/__tests__/unit/date.test.ts +++ b/__tests__/unit/date.test.ts @@ -30,6 +30,7 @@ import { calcInstallmentDate, uniqueMonthsFromDates, addMonthsToYearMonth, + installmentEndYearMonth, } from '@/lib/utils/date' describe('yearMonthToReferenceMonth', () => { @@ -166,12 +167,26 @@ describe('addMonthsToYearMonth', () => { it('crosses the year boundary', () => { expect(addMonthsToYearMonth('2025-11', 3)).toBe('2026-02') }) +}) + +describe('installmentEndYearMonth', () => { + afterEach(() => { + vi.useRealTimers() + }) - it('issue #114: nextChargeMonth 2026-09 + 11 restantes termina em 2027-08, não 2027-07', () => { - // Âncora errada (mês atual 2026-08) daria 2027-07 — a correção precisa ancorar - // em nextChargeMonth, não no mês corrente. - expect(addMonthsToYearMonth('2026-09', 11)).toBe('2027-08') - expect(addMonthsToYearMonth('2026-08', 11)).toBe('2027-07') + it('issue #114: ancora em nextChargeMonth, não no mês corrente', () => { + // Mês corrente 2026-08, mas a 1a parcela pendente só cai em 2026-09 (compra feita + // depois do fechamento do cartão). Ancorar no mês corrente daria 2027-07 — a + // correção precisa ancorar em nextChargeMonth e devolver 2027-08. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-08-15T12:00:00')) + expect(installmentEndYearMonth('2026-09', 12)).toBe('2027-08') + }) + + it('sem parcela pendente (nextChargeMonth null): cai no fallback do mês corrente', () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-08-15T12:00:00')) + expect(installmentEndYearMonth(null, 12)).toBe('2027-07') }) }) diff --git a/app/(app)/parcelas/page.tsx b/app/(app)/parcelas/page.tsx index f52916b..46873bc 100644 --- a/app/(app)/parcelas/page.tsx +++ b/app/(app)/parcelas/page.tsx @@ -10,7 +10,7 @@ import { Section } from '@/components/ui/section' import { PageHeader } from '@/components/ui/page-header' import { PageLayout } from '@/components/ui/page-layout' import { formatCurrency } from '@/lib/utils/currency' -import { addMonthsToYearMonth, currentYearMonth, formatMonthShort } from '@/lib/utils/date' +import { currentYearMonth, formatMonthShort, installmentEndYearMonth } from '@/lib/utils/date' export default async function ParcelasPage() { const session = await auth() @@ -30,15 +30,17 @@ export default async function ParcelasPage() { const totalAll = groups.reduce((sum, g) => sum + g.totalAmount, 0) const paidPct = totalAll > 0 ? Math.round((totalPago / totalAll) * 100) : 0 - const groupsWithEnd = groups.map((g) => ({ - ...g, - endLabel: formatMonthShort( - addMonthsToYearMonth(g.nextChargeMonth ?? currentYM, g.remainingInstallments - 1) - ), - })) - - const endLabels = groupsWithEnd.map((g) => g.endLabel).sort() - const lastEnd = endLabels[endLabels.length - 1] ?? null + const groupsWithEnd = groups.map((g) => { + const endYM = installmentEndYearMonth(g.nextChargeMonth, g.remainingInstallments) + return { ...g, endYM, endLabel: formatMonthShort(endYM) } + }) + + const lastEndYM = + groupsWithEnd + .map((g) => g.endYM) + .sort() + .at(-1) ?? null + const lastEnd = lastEndYM ? formatMonthShort(lastEndYM) : null const categoryData = Object.values( groups.reduce>((acc, g) => { diff --git a/lib/utils/date.ts b/lib/utils/date.ts index 3f1c49b..913eb49 100644 --- a/lib/utils/date.ts +++ b/lib/utils/date.ts @@ -102,6 +102,18 @@ export function addMonthsToYearMonth(yearMonth: string, n: number): string { return format(addMonths(parseISO(`${yearMonth}-01`), n), 'yyyy-MM') } +/** + * Returns the YYYY-MM in which the last of `remainingInstallments` installments lands, + * anchored on `nextChargeMonth` (the month of the next pending installment). Falls back to + * the current month when there is no pending installment (`nextChargeMonth === null`). + */ +export function installmentEndYearMonth( + nextChargeMonth: string | null, + remainingInstallments: number +): string { + return addMonthsToYearMonth(nextChargeMonth ?? currentYearMonth(), remainingInstallments - 1) +} + /** Formats a YYYY-MM as "janeiro de 2025" (pt-BR). */ export function formatMonthName(yearMonth: string): string { return fmt(parseISO(`${yearMonth}-01`), "MMMM 'de' yyyy") From a26955cc7ca1043606f007c1a6465b162d8cad93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:21:02 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20ordenar=20"Termina=20mais=20cedo"=20?= =?UTF-8?q?por=20endYM,=20n=C3=A3o=20por=20remainingInstallments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achado bloqueante da revisao da PR #132 (issue #114): a correcao da ancora por grupo (nextChargeMonth) quebrou o pressuposto que o sort 'soonest-end' de ParcelasToolbar.tsx dependia -- antes todos os grupos compartilhavam a mesma ancora (mes atual), entao remainingInstallments era uma proxy valida para "termina em". Com ancora por grupo, as duas ordens divergem, e a tela passou a mostrar cards ordenados por "Termina mais cedo" que contradizem o proprio rotulo "termina" de cada card. Ordena por endYM (YYYY-MM, ano-major) via localeCompare, mesmo dado que page.tsx ja produz para o lastEnd. applySort exportada e testada em __tests__/unit/parcelas-toolbar.test.ts com o caso que so a correcao certa passa: dois grupos cujo remainingInstallments e endYM apontam para ordens opostas. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uc5r53ktBJD1ZZL2oDEyPG --- __tests__/unit/parcelas-toolbar.test.ts | 63 +++++++++++++++++++++++++ components/parcelas/ParcelasToolbar.tsx | 5 +- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 __tests__/unit/parcelas-toolbar.test.ts diff --git a/__tests__/unit/parcelas-toolbar.test.ts b/__tests__/unit/parcelas-toolbar.test.ts new file mode 100644 index 0000000..978e8e3 --- /dev/null +++ b/__tests__/unit/parcelas-toolbar.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { applySort } from '@/components/parcelas/ParcelasToolbar' + +type Group = Parameters[0][number] + +const group = (id: string, overrides: Partial = {}): Group => ({ + id, + name: `Grupo ${id}`, + categoryId: 'cat-1', + accountId: 'acc-1', + accountName: 'Conta', + categoryName: 'Categoria', + startDate: '2026-01-01', + nextChargeMonth: null, + nextChargeDate: null, + totalAmount: 0, + totalInstallments: 1, + paidInstallments: 0, + remainingInstallments: 1, + installmentAmount: 0, + remainingAmount: 0, + ...overrides, +}) + +describe('applySort', () => { + it('soonest-end ordena por endYM (data real de término), não por remainingInstallments', () => { + // A: termina antes (out/26) mas tem mais parcelas restantes que B. + // B: termina depois (nov/26) mas tem menos parcelas restantes que A — a 1a + // parcela de B só cai em 2026-10, então "menos parcelas restantes" não significa + // "termina mais cedo" quando cada grupo parte de um nextChargeMonth diferente. + const a = group('A', { remainingInstallments: 3, endYM: '2026-10' }) + const b = group('B', { remainingInstallments: 2, endYM: '2026-11' }) + + // Ordenar por remainingInstallments (o bug) devolveria [B, A] — B tem menos + // parcelas restantes (2 < 3) e apareceria primeiro, embora termine depois. + const sorted = applySort([b, a], 'soonest-end') + + expect(sorted.map((g) => g.id)).toEqual(['A', 'B']) + }) + + it('soonest-end com endYM ausente cai para string vazia (não quebra o sort)', () => { + const a = group('A', { endYM: undefined }) + const b = group('B', { endYM: '2026-01' }) + + const sorted = applySort([a, b], 'soonest-end') + + expect(sorted.map((g) => g.id)).toEqual(['A', 'B']) + }) + + it('expensive ordena por installmentAmount decrescente', () => { + const a = group('A', { installmentAmount: 50 }) + const b = group('B', { installmentAmount: 100 }) + + expect(applySort([a, b], 'expensive').map((g) => g.id)).toEqual(['B', 'A']) + }) + + it('highest-balance ordena por remainingAmount decrescente', () => { + const a = group('A', { remainingAmount: 200 }) + const b = group('B', { remainingAmount: 500 }) + + expect(applySort([a, b], 'highest-balance').map((g) => g.id)).toEqual(['B', 'A']) + }) +}) diff --git a/components/parcelas/ParcelasToolbar.tsx b/components/parcelas/ParcelasToolbar.tsx index 9601d7a..69ed038 100644 --- a/components/parcelas/ParcelasToolbar.tsx +++ b/components/parcelas/ParcelasToolbar.tsx @@ -30,11 +30,12 @@ type Group = { installmentAmount: number remainingAmount: number endLabel?: string + endYM?: string } type Sort = 'expensive' | 'highest-balance' | 'soonest-end' -function applySort(groups: Group[], sort: Sort): Group[] { +export function applySort(groups: Group[], sort: Sort): Group[] { const copy = [...groups] switch (sort) { case 'expensive': @@ -42,7 +43,7 @@ function applySort(groups: Group[], sort: Sort): Group[] { case 'highest-balance': return copy.sort((a, b) => b.remainingAmount - a.remainingAmount) case 'soonest-end': - return copy.sort((a, b) => a.remainingInstallments - b.remainingInstallments) + return copy.sort((a, b) => (a.endYM ?? '').localeCompare(b.endYM ?? '')) default: return copy } From 71d59eae4d10a8921701671105ca5f0e1bb66aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 19:22:38 +0000 Subject: [PATCH 4/4] fix: entrada ja ordenada deixava o 2o teste de applySort passar com o bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nit da revisao da PR #132 (issue #114): a entrada [a, b] do teste de endYM ausente ja estava na ordem esperada pela asserção, e Array.sort e estavel -- entao qualquer comparador que empate (ou vire no-op) devolve ['A', 'B'] sem exercitar nada. Invertida para [b, a], igual ao primeiro it() do arquivo, que ja usava essa entrada e de fato falha se soonest-end voltar a ordenar por remainingInstallments. Verificado revertendo o comparador do PR e rodando so este arquivo: os dois it()s de soonest-end falham de forma independente agora. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uc5r53ktBJD1ZZL2oDEyPG --- __tests__/unit/parcelas-toolbar.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/unit/parcelas-toolbar.test.ts b/__tests__/unit/parcelas-toolbar.test.ts index 978e8e3..a4cdba4 100644 --- a/__tests__/unit/parcelas-toolbar.test.ts +++ b/__tests__/unit/parcelas-toolbar.test.ts @@ -42,7 +42,7 @@ describe('applySort', () => { const a = group('A', { endYM: undefined }) const b = group('B', { endYM: '2026-01' }) - const sorted = applySort([a, b], 'soonest-end') + const sorted = applySort([b, a], 'soonest-end') expect(sorted.map((g) => g.id)).toEqual(['A', 'B']) })