Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
Expand All @@ -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;

Expand Down
13 changes: 9 additions & 4 deletions src/components/organisms/report-filter-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
66 changes: 65 additions & 1 deletion src/lib/date-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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");
Expand Down
34 changes: 28 additions & 6 deletions src/lib/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -70,17 +81,30 @@ 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,
direction: "back" | "forward",
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;
Expand All @@ -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);
Expand Down
46 changes: 46 additions & 0 deletions src/lib/report-date-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 39 additions & 0 deletions src/lib/report-date-selection.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading