diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index dc3723a..c447b07 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -13,6 +13,7 @@ import { type ReportFilters, } from "@/queries/reports"; import { rangeToDateBounds, shiftDateRange, comparisonLabel, getCurrentMonth } from "@/lib/date-utils"; +import { resolveReportDateSelection, DEFAULT_REPORT_PRESET } from "@/lib/report-date-selection"; import { ReportFilterBar } from "@/components/organisms/report-filter-bar"; import { ReportTabs } from "@/components/organisms/report-tabs"; import { SavedReportPicker } from "@/components/organisms/saved-report-picker"; @@ -30,15 +31,21 @@ export default async function ReportsPage({ const tab = typeof params.tab === "string" && VALID_TABS.has(params.tab) ? params.tab : "spending"; const preset = typeof params.preset === "string" ? params.preset : null; + const from = typeof params.from === "string" ? params.from : null; + const to = typeof params.to === "string" ? params.to : null; + + // Shared with ReportFilterBar so the page and the chip above it can never + // describe the same URL differently. + const { effectivePreset, isAllTime, isPreset } = resolveReportDateSelection({ from, to, preset }); let dateFrom: string; let dateTo: string; - if (typeof params.from === "string" && typeof params.to === "string") { - dateFrom = params.from; - dateTo = params.to; + if (from && to) { + dateFrom = from; + dateTo = to; } else { - const bounds = rangeToDateBounds(preset ?? "3M"); + const bounds = rangeToDateBounds(effectivePreset ?? DEFAULT_REPORT_PRESET); dateFrom = bounds.from ?? "2000-01-01"; dateTo = bounds.to; } @@ -49,8 +56,6 @@ export default async function ReportsPage({ const filters: ReportFilters = { dateFrom, dateTo, accountIds, categoryIds }; // Comparison period - const isPreset = preset !== null; - const isAllTime = preset === "all" || (!params.from && !params.to && !preset); let compLabel: string | null = null; let compPeriod: { dateFrom: string; dateTo: string } | undefined; diff --git a/src/components/organisms/report-filter-bar.tsx b/src/components/organisms/report-filter-bar.tsx index cc132f3..2bad4c3 100644 --- a/src/components/organisms/report-filter-bar.tsx +++ b/src/components/organisms/report-filter-bar.tsx @@ -12,6 +12,7 @@ import { } from "@/components/molecules/date-range-popover"; import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; import { rangeToDateBounds, formatDateShort, formatTxnSpan } from "@/lib/date-utils"; +import { resolveReportDateSelection } from "@/lib/report-date-selection"; import type { CategoryGroup } from "@/queries/categories"; // Reports keeps its own preset ids (mapped to rangeToDateBounds + the server's @@ -51,10 +52,14 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps) const fromParam = searchParams.get("from"); const toParam = searchParams.get("to"); const presetParam = searchParams.get("preset"); - // No preset + a manual from/to = custom; otherwise fall back to the 3M default. - const hasCustom = !!(fromParam && toParam && !presetParam); - const effectivePreset = presetParam ?? (hasCustom ? null : "3M"); - const dateActive = effectivePreset !== "all"; + // Shared with the Reports page so the chip and the report below it always + // describe the same range. + const { effectivePreset, isAllTime } = resolveReportDateSelection({ + from: fromParam, + to: toParam, + preset: presetParam, + }); + const dateActive = !isAllTime; const dateValue = (() => { if (!dateActive) return null; if (effectivePreset) return REPORT_DATE_OPTIONS.find((o) => o.id === effectivePreset)?.label ?? null; diff --git a/src/lib/date-utils.test.ts b/src/lib/date-utils.test.ts index b79dfa9..4b83577 100644 --- a/src/lib/date-utils.test.ts +++ b/src/lib/date-utils.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, vi, afterEach } from "vitest"; import { test as fcTest } from "@fast-check/vitest"; import { fc } from "@fast-check/vitest"; -import { rangeToDateBounds, monthBounds, shiftDateRange, comparisonLabel, formatTxnSpan, todayDateString } from "./date-utils"; +import { rangeToDateBounds, monthBounds, shiftDateRange, comparisonLabel, formatTxnSpan, todayDateString, formatDateShort } from "./date-utils"; describe("todayDateString", () => { afterEach(() => { @@ -114,6 +114,70 @@ describe("shiftDateRange", () => { }); }); +describe("shiftDateRange on rolling preset ranges", () => { + // rangeToDateBounds("3M") produces a *rolling* window (e.g. Jun 2 - Sep 2), + // which spans four calendar months. The old month-span arithmetic added one + // to that span and snapped to end-of-month, producing a 118-day baseline for + // a 92-day window — so every comparison percentage was measured against a + // window 28% longer than the one it described. + const daysBetween = (a: string, b: string) => + Math.round((new Date(b + "T12:00:00").getTime() - new Date(a + "T12:00:00").getTime()) / 86400000); + + test("a rolling 3M window shifts to the immediately preceding window of equal length", () => { + const result = shiftDateRange("2026-06-02", "2026-09-02", "back", true); + expect(result).toEqual({ from: "2026-03-02", to: "2026-06-02" }); + }); + + test("the baseline is the same length as the window it compares against", () => { + const result = shiftDateRange("2026-06-02", "2026-09-02", "back", true); + expect(daysBetween(result.from, result.to)).toBe(daysBetween("2026-06-02", "2026-09-02")); + }); + + test("month-aligned presets still shift by whole calendar months", () => { + // Apr 1 - Jun 30 is three whole months, so the baseline is Jan 1 - Mar 31 + // rather than an equal-day-count window landing mid-month. + expect(shiftDateRange("2026-04-01", "2026-06-30", "back", true)).toEqual({ + from: "2026-01-01", + to: "2026-03-31", + }); + }); + + test("forward shifts are symmetric with back shifts", () => { + const back = shiftDateRange("2026-06-02", "2026-09-02", "back", true); + expect(shiftDateRange(back.from, back.to, "forward", true)).toEqual({ + from: "2026-06-02", + to: "2026-09-02", + }); + }); + + fcTest.prop([fc.integer({ min: 1, max: 200 })])( + "a rolling preset baseline always matches the window length", + (daySpan) => { + const fromDate = new Date("2026-06-02T12:00:00"); + const toDate = new Date(fromDate); + toDate.setDate(toDate.getDate() + daySpan); + const to = `${toDate.getFullYear()}-${String(toDate.getMonth() + 1).padStart(2, "0")}-${String(toDate.getDate()).padStart(2, "0")}`; + const result = shiftDateRange("2026-06-02", to, "back", true); + expect(daysBetween(result.from, result.to)).toBe(daySpan); + }, + ); +}); + +describe("formatDateShort", () => { + const thisYear = new Date().getFullYear(); + + test("omits the year for dates in the current year", () => { + expect(formatDateShort(`${thisYear}-03-05`)).toBe("Mar 5"); + }); + + test("includes the year for dates outside it", () => { + // A custom 2019 range used to render as "Jan 1 - Mar 31" with nothing + // saying which year, and a year-over-year comparison label was + // indistinguishable from the current year's. + expect(formatDateShort(`${thisYear - 7}-01-01`)).toContain(String(thisYear - 7)); + }); +}); + describe("comparisonLabel", () => { test("formats date range as vs label", () => { const result = comparisonLabel("2026-01-01", "2026-03-31"); diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index 5421250..dda6a5b 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -12,7 +12,18 @@ export function formatMonthShort(month: string): string { } export function formatDateShort(date: string): string { - return new Date(date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const d = new Date(date + "T00:00:00"); + // The year is only shown when it is not the current one. Most dates on screen + // are recent, where a year is noise (and would crowd chart axis ticks), but + // without it an older range reads as "Jan 1 - Mar 31" with nothing saying + // which year, and a year-over-year comparison label is indistinguishable + // from this year's. + const showYear = d.getFullYear() !== new Date().getFullYear(); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + ...(showYear ? { year: "numeric" } : {}), + }); } export function formatMonthLong(month: string): string { @@ -70,6 +81,12 @@ export function monthBounds(monthStr: string): { from: string; to: string } { }; } +/** True when the range covers whole calendar months end to end (1st -> last). */ +function isWholeMonthSpan(fromDate: Date, toDate: Date): boolean { + const lastDayOfToMonth = new Date(toDate.getFullYear(), toDate.getMonth() + 1, 0).getDate(); + return fromDate.getDate() === 1 && toDate.getDate() === lastDayOfToMonth; +} + export function shiftDateRange( from: string, to: string, @@ -77,10 +94,17 @@ export function shiftDateRange( isPreset: boolean, ): { from: string; to: string } { const sign = direction === "back" ? -1 : 1; + const fromDate = new Date(from + "T12:00:00"); + const toDate = new Date(to + "T12:00:00"); - if (isPreset) { - const fromDate = new Date(from + "T12:00:00"); - const toDate = new Date(to + "T12:00:00"); + // Calendar-month arithmetic only makes sense for a range that actually spans + // whole months (Apr 1 - Jun 30). `rangeToDateBounds` returns a *rolling* + // window instead (Jun 2 - Sep 2), which touches four calendar months: the + // month-span arithmetic counted 4, and the end-of-month snap then stretched + // the baseline to 118 days against a 92-day window. A rolling window shifts + // by its own length, so the baseline is always the period immediately before + // it and exactly as long. + if (isPreset && isWholeMonthSpan(fromDate, toDate)) { const monthSpan = (toDate.getFullYear() - fromDate.getFullYear()) * 12 + (toDate.getMonth() - fromDate.getMonth()) + 1; @@ -97,8 +121,6 @@ export function shiftDateRange( }; } - const fromDate = new Date(from + "T12:00:00"); - const toDate = new Date(to + "T12:00:00"); const daySpan = Math.round((toDate.getTime() - fromDate.getTime()) / 86400000); const newFrom = new Date(fromDate); newFrom.setDate(newFrom.getDate() + sign * daySpan); diff --git a/src/lib/report-date-selection.test.ts b/src/lib/report-date-selection.test.ts new file mode 100644 index 0000000..e8eaa57 --- /dev/null +++ b/src/lib/report-date-selection.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "vitest"; +import { resolveReportDateSelection, DEFAULT_REPORT_PRESET } from "./report-date-selection"; + +/** + * The Reports page and its filter bar both have to answer "which range is + * selected?". They used to answer separately, and disagreed: the bar labelled a + * bare /reports "Last 3 months" while the page treated it as all-time and + * silently dropped the comparison column. + */ +describe("resolveReportDateSelection", () => { + test("a bare /reports is the 3M default, not all time", () => { + const s = resolveReportDateSelection({}); + expect(s.effectivePreset).toBe(DEFAULT_REPORT_PRESET); + expect(s.isAllTime).toBe(false); + expect(s.isPreset).toBe(true); + }); + + test("?preset=3M resolves identically to the bare default", () => { + expect(resolveReportDateSelection({ preset: "3M" })).toEqual( + resolveReportDateSelection({}), + ); + }); + + test("only an explicit all-time preset is all time", () => { + const s = resolveReportDateSelection({ preset: "all" }); + expect(s.isAllTime).toBe(true); + }); + + test("a custom from/to range is neither a preset nor all time", () => { + const s = resolveReportDateSelection({ from: "2019-01-01", to: "2019-03-31" }); + expect(s.effectivePreset).toBeNull(); + expect(s.isPreset).toBe(false); + expect(s.isAllTime).toBe(false); + }); + + test("a preset wins over from/to, which the filter bar sets alongside it", () => { + const s = resolveReportDateSelection({ from: "2026-06-02", to: "2026-09-02", preset: "6M" }); + expect(s.effectivePreset).toBe("6M"); + expect(s.isPreset).toBe(true); + }); + + test("a half-specified custom range falls back to the default", () => { + expect(resolveReportDateSelection({ from: "2026-06-02" }).effectivePreset).toBe(DEFAULT_REPORT_PRESET); + expect(resolveReportDateSelection({ to: "2026-09-02" }).effectivePreset).toBe(DEFAULT_REPORT_PRESET); + }); +}); diff --git a/src/lib/report-date-selection.ts b/src/lib/report-date-selection.ts new file mode 100644 index 0000000..5c9e394 --- /dev/null +++ b/src/lib/report-date-selection.ts @@ -0,0 +1,39 @@ +/** + * Which date range the Reports page is showing. + * + * The page and its filter bar both need this answer, and they used to derive it + * separately. They disagreed on a bare `/reports`: the bar defaulted to the 3M + * preset and rendered "Last 3 months", while the page read the absent params as + * all-time and suppressed the comparison column. Same URL, same data, two + * different UIs — so the resolution lives here and both read it. + */ + +/** Applied when the URL carries no preset and no explicit from/to pair. */ +export const DEFAULT_REPORT_PRESET = "3M"; + +export interface ReportDateSelection { + /** A preset id, or `null` when the user picked an explicit from/to range. */ + effectivePreset: string | null; + /** No lower bound, so there is no previous period to compare against. */ + isAllTime: boolean; + /** Presets shift by calendar months; custom ranges shift by day count. */ + isPreset: boolean; +} + +export function resolveReportDateSelection(params: { + from?: string | null; + to?: string | null; + preset?: string | null; +}): ReportDateSelection { + const preset = params.preset ?? null; + // Both bounds are required: a lone `from` is not a range, so it falls back to + // the default rather than resolving to a half-open custom selection. + const hasCustomRange = Boolean(params.from && params.to && !preset); + const effectivePreset = preset ?? (hasCustomRange ? null : DEFAULT_REPORT_PRESET); + + return { + effectivePreset, + isAllTime: effectivePreset === "all", + isPreset: effectivePreset !== null, + }; +}