From 8b90cb64892f04bc36d81126d044d2b228f54973 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 19 Apr 2026 08:28:30 -0400 Subject: [PATCH] feat(reports/highlights): default reference month to previous month + YTD current year, user-changeable (#106) - Extract shared defaultReferencePeriod helper (src/utils/referencePeriod.ts) - useHighlights now reads ?refY=YYYY&refM=MM, defaults to previous month - getHighlights signature: (referenceYear, referenceMonth, ytdYear, windowDays, ...) - YTD tile pinned to Jan 1 of current civil year, independent of reference month - CompareReferenceMonthPicker surfaced on /reports/highlights - Hub highlights panel inherits the same default via useHighlights - useCartes and useCompare now delegate their default-period helpers to the shared util --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/hooks/useCartes.ts | 16 ++--- src/hooks/useCompare.ts | 6 +- src/hooks/useHighlights.test.ts | 35 +++++++++++ src/hooks/useHighlights.ts | 98 +++++++++++++++++++++++------ src/pages/ReportsHighlightsPage.tsx | 25 ++++---- src/services/reportService.test.ts | 84 ++++++++++++++++++------- src/services/reportService.ts | 87 ++++++++++++++----------- src/utils/referencePeriod.test.ts | 16 +++++ src/utils/referencePeriod.ts | 16 +++++ 11 files changed, 286 insertions(+), 99 deletions(-) create mode 100644 src/hooks/useHighlights.test.ts create mode 100644 src/utils/referencePeriod.test.ts create mode 100644 src/utils/referencePeriod.ts diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 1b4f42f..f1f4f4e 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,7 @@ ### Modifié - **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `` (#103) - **Compare report — Actual vs. actual** (`/reports/compare`): the table now mirrors the rich 8-column structure of the Actual vs. budget table, splitting each comparison into a *Monthly* block (reference month vs. comparison month) and a *Cumulative YTD* block (progress through the reference month vs. progress through the previous window). MoM cumulative-previous uses Jan → end-of-previous-month of the same year; YoY cumulative-previous uses Jan → same-month of the previous year. The chart remains a monthly-only view (#104) +- **Highlights report** (`/reports/highlights`): the monthly tiles (current-month balance, top movers vs. previous month) now default to the **previous calendar month** instead of the current one, matching the Cartes and Compare reports. The YTD tile stays pinned to Jan 1st of the current civil year. A new reference-month picker lets you pivot both the monthly balance and the top-movers comparison to any past month; the selection is persisted in the URL via `?refY=YYYY&refM=MM` so the view is bookmarkable. The hub highlights panel follows the same default (#106) ### Fixed - **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101) diff --git a/src/hooks/useCartes.ts b/src/hooks/useCartes.ts index 7d82eda..8f9dd7d 100644 --- a/src/hooks/useCartes.ts +++ b/src/hooks/useCartes.ts @@ -1,6 +1,7 @@ import { useReducer, useCallback, useEffect, useRef } from "react"; import type { CartesSnapshot } from "../shared/types"; import { getCartesSnapshot } from "../services/reportService"; +import { defaultReferencePeriod } from "../utils/referencePeriod"; interface State { year: number; @@ -17,19 +18,12 @@ type Action = | { type: "SET_ERROR"; payload: string }; /** - * Default reference period for the Cartes report: the month preceding `today`. - * January wraps around to December of the previous year. Exported for tests. + * Re-exported so older imports keep working. New code should import + * `defaultReferencePeriod` from `../utils/referencePeriod`. */ -export function defaultCartesReferencePeriod( - today: Date = new Date(), -): { year: number; month: number } { - const y = today.getFullYear(); - const m = today.getMonth() + 1; - if (m === 1) return { year: y - 1, month: 12 }; - return { year: y, month: m - 1 }; -} +export const defaultCartesReferencePeriod = defaultReferencePeriod; -const defaultRef = defaultCartesReferencePeriod(); +const defaultRef = defaultReferencePeriod(); const initialState: State = { year: defaultRef.year, month: defaultRef.month, diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts index 8cc6a2c..965dd81 100644 --- a/src/hooks/useCompare.ts +++ b/src/hooks/useCompare.ts @@ -2,6 +2,7 @@ import { useReducer, useCallback, useEffect, useRef } from "react"; import type { CategoryDelta } from "../shared/types"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { useReportsPeriod } from "./useReportsPeriod"; +import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; export type CompareMode = "actual" | "budget"; export type CompareSubMode = "mom" | "yoy"; @@ -35,10 +36,11 @@ export function previousMonth(year: number, month: number): { year: number; mont /** * Default reference period for the Compare report: the month preceding `today`. - * Exported for unit tests. + * Thin wrapper around the shared helper — kept as a named export so existing + * imports (and tests) keep working. */ export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } { - return previousMonth(today.getFullYear(), today.getMonth() + 1); + return sharedDefaultReferencePeriod(today); } /** diff --git a/src/hooks/useHighlights.test.ts b/src/hooks/useHighlights.test.ts new file mode 100644 index 0000000..989b98c --- /dev/null +++ b/src/hooks/useHighlights.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { resolveHighlightsReference } from "./useHighlights"; + +describe("resolveHighlightsReference", () => { + const TODAY = new Date(2026, 3, 14); // April 14, 2026 + + it("falls back to the previous month when no params are provided", () => { + expect(resolveHighlightsReference(null, null, TODAY)).toEqual({ year: 2026, month: 3 }); + }); + + it("accepts a valid (year, month) pair from the URL", () => { + expect(resolveHighlightsReference("2025", "11", TODAY)).toEqual({ year: 2025, month: 11 }); + }); + + it("rejects non-integer values and falls back to the default", () => { + expect(resolveHighlightsReference("abc", "3", TODAY)).toEqual({ year: 2026, month: 3 }); + expect(resolveHighlightsReference("2026", "foo", TODAY)).toEqual({ year: 2026, month: 3 }); + }); + + it("rejects out-of-range months and falls back to the default", () => { + expect(resolveHighlightsReference("2026", "0", TODAY)).toEqual({ year: 2026, month: 3 }); + expect(resolveHighlightsReference("2026", "13", TODAY)).toEqual({ year: 2026, month: 3 }); + }); + + it("rejects absurd years and falls back to the default", () => { + expect(resolveHighlightsReference("999", "6", TODAY)).toEqual({ year: 2026, month: 3 }); + }); + + it("wraps January back to December of the previous year for the default", () => { + expect(resolveHighlightsReference(null, null, new Date(2026, 0, 10))).toEqual({ + year: 2025, + month: 12, + }); + }); +}); diff --git a/src/hooks/useHighlights.ts b/src/hooks/useHighlights.ts index 671e8bd..ded08e1 100644 --- a/src/hooks/useHighlights.ts +++ b/src/hooks/useHighlights.ts @@ -1,7 +1,8 @@ -import { useReducer, useEffect, useRef, useCallback } from "react"; +import { useReducer, useEffect, useRef, useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; import type { HighlightsData } from "../shared/types"; import { getHighlights } from "../services/reportService"; -import { useReportsPeriod } from "./useReportsPeriod"; +import { defaultReferencePeriod } from "../utils/referencePeriod"; interface State { data: HighlightsData | null; @@ -38,31 +39,92 @@ function reducer(state: State, action: Action): State { } } +/** + * Parses `?refY=YYYY&refM=MM` from the search string. Falls back to the + * previous-month default when either is missing or invalid. Exposed for + * unit tests. + */ +export function resolveHighlightsReference( + rawYear: string | null, + rawMonth: string | null, + today: Date = new Date(), +): { year: number; month: number } { + const y = rawYear !== null ? Number(rawYear) : NaN; + const m = rawMonth !== null ? Number(rawMonth) : NaN; + if ( + Number.isInteger(y) && + Number.isInteger(m) && + y >= 1970 && + y <= 9999 && + m >= 1 && + m <= 12 + ) { + return { year: y, month: m }; + } + return defaultReferencePeriod(today); +} + export function useHighlights() { - const { from, to } = useReportsPeriod(); + const [searchParams, setSearchParams] = useSearchParams(); + + const rawRefY = searchParams.get("refY"); + const rawRefM = searchParams.get("refM"); + const { year: referenceYear, month: referenceMonth } = useMemo( + () => resolveHighlightsReference(rawRefY, rawRefM), + [rawRefY, rawRefM], + ); + // YTD is always anchored on the current civil year — independent of the + // user-picked reference month. + const ytdYear = useMemo(() => new Date().getFullYear(), []); + const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); - const fetch = useCallback(async (windowDays: 30 | 60 | 90, referenceDate: string) => { - const id = ++fetchIdRef.current; - dispatch({ type: "SET_LOADING", payload: true }); - try { - const data = await getHighlights(windowDays, referenceDate); - if (id !== fetchIdRef.current) return; - dispatch({ type: "SET_DATA", payload: data }); - } catch (e) { - if (id !== fetchIdRef.current) return; - dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); - } - }, []); + const fetch = useCallback( + async (windowDays: 30 | 60 | 90, year: number, month: number, ytd: number) => { + const id = ++fetchIdRef.current; + dispatch({ type: "SET_LOADING", payload: true }); + try { + const data = await getHighlights(year, month, ytd, windowDays); + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_DATA", payload: data }); + } catch (e) { + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + }, + [], + ); useEffect(() => { - fetch(state.windowDays, to); - }, [fetch, state.windowDays, to]); + fetch(state.windowDays, referenceYear, referenceMonth, ytdYear); + }, [fetch, state.windowDays, referenceYear, referenceMonth, ytdYear]); const setWindowDays = useCallback((d: 30 | 60 | 90) => { dispatch({ type: "SET_WINDOW_DAYS", payload: d }); }, []); - return { ...state, setWindowDays, from, to }; + const setReferencePeriod = useCallback( + (year: number, month: number) => { + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + params.set("refY", String(year)); + params.set("refM", String(month)); + return params; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + return { + ...state, + setWindowDays, + year: referenceYear, + month: referenceMonth, + ytdYear, + setReferencePeriod, + }; } diff --git a/src/pages/ReportsHighlightsPage.tsx b/src/pages/ReportsHighlightsPage.tsx index b84b58d..2c321f1 100644 --- a/src/pages/ReportsHighlightsPage.tsx +++ b/src/pages/ReportsHighlightsPage.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { ArrowLeft, Tag } from "lucide-react"; -import PeriodSelector from "../components/dashboard/PeriodSelector"; +import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import HubNetBalanceTile from "../components/reports/HubNetBalanceTile"; import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable"; import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart"; @@ -11,15 +11,22 @@ import ViewModeToggle, { readViewMode, type ViewMode } from "../components/repor import ContextMenu from "../components/shared/ContextMenu"; import AddKeywordDialog from "../components/categories/AddKeywordDialog"; import { useHighlights } from "../hooks/useHighlights"; -import { useReportsPeriod } from "../hooks/useReportsPeriod"; import type { RecentTransaction } from "../shared/types"; const STORAGE_KEY = "reports-viewmode-highlights"; export default function ReportsHighlightsPage() { const { t } = useTranslation(); - const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); - const { data, isLoading, error, windowDays, setWindowDays } = useHighlights(); + const { + data, + isLoading, + error, + windowDays, + setWindowDays, + year, + month, + setReferencePeriod, + } = useHighlights(); const [viewMode, setViewMode] = useState(() => readViewMode(STORAGE_KEY)); const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null); const [pending, setPending] = useState(null); @@ -44,14 +51,8 @@ export default function ReportsHighlightsPage() {

{t("reports.hub.highlights")}

-
- +
+
diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts index 8f24367..34199a7 100644 --- a/src/services/reportService.test.ts +++ b/src/services/reportService.test.ts @@ -152,7 +152,12 @@ describe("getCategoryOverTime", () => { }); describe("getHighlights", () => { - const REF = "2026-04-14"; + // Reference month = March 2026, YTD year = 2026, today = 2026-04-14. + const REF_YEAR = 2026; + const REF_MONTH = 3; + const YTD_YEAR = 2026; + const TODAY = new Date(2026, 3, 14); // April 14, 2026 (month is 0-based here) + const TODAY_STR = "2026-04-14"; function queueEmpty(n: number) { for (let i = 0; i < n; i++) { @@ -163,14 +168,15 @@ describe("getHighlights", () => { it("computes windows and returns zeroed data on an empty profile", async () => { queueEmpty(5); // currentBalance, ytd, series, movers, recent - const result = await getHighlights(30, REF); + const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY); - expect(result.currentMonth).toBe("2026-04"); + expect(result.currentMonth).toBe("2026-03"); expect(result.netBalanceCurrent).toBe(0); expect(result.netBalanceYtd).toBe(0); expect(result.monthlyBalanceSeries).toHaveLength(12); - expect(result.monthlyBalanceSeries[11].month).toBe("2026-04"); - expect(result.monthlyBalanceSeries[0].month).toBe("2025-05"); + // 12 months ending at the reference month (March 2026), inclusive. + expect(result.monthlyBalanceSeries[11].month).toBe("2026-03"); + expect(result.monthlyBalanceSeries[0].month).toBe("2025-04"); expect(result.topMovers).toEqual([]); expect(result.topTransactions).toEqual([]); }); @@ -178,43 +184,79 @@ describe("getHighlights", () => { it("parameterises every query with no inlined strings", async () => { queueEmpty(5); - await getHighlights(60, REF); + await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 60, 5, 10, TODAY); for (const call of mockSelect.mock.calls) { const sql = call[0] as string; const params = call[1] as unknown[]; - expect(sql).not.toContain(`'${REF}'`); + expect(sql).not.toContain(`'${TODAY_STR}'`); expect(Array.isArray(params)).toBe(true); } - // First call uses current month range parameters - const firstParams = mockSelect.mock.calls[0][1] as unknown[]; - expect(firstParams[0]).toBe("2026-04-01"); - expect(firstParams[1]).toBe("2026-04-30"); - // YTD call uses year start + // Reference month range + const currentParams = mockSelect.mock.calls[0][1] as unknown[]; + expect(currentParams[0]).toBe("2026-03-01"); + expect(currentParams[1]).toBe("2026-03-31"); + // YTD spans Jan 1 of ytdYear → today (independent of reference month). const ytdParams = mockSelect.mock.calls[1][1] as unknown[]; expect(ytdParams[0]).toBe("2026-01-01"); - expect(ytdParams[1]).toBe(REF); + expect(ytdParams[1]).toBe(TODAY_STR); }); - it("uses a 60-day window for top transactions when requested", async () => { + it("uses a 60-day window ending today for top transactions when requested", async () => { queueEmpty(5); - await getHighlights(60, REF); + await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 60, 5, 10, TODAY); const recentParams = mockSelect.mock.calls[4][1] as unknown[]; - // 60-day window ending at REF: start = 2026-04-14 - 59 days = 2026-02-14 + // 60-day window ending today (2026-04-14): start = 2026-04-14 - 59 days = 2026-02-14. expect(recentParams[0]).toBe("2026-02-14"); - expect(recentParams[1]).toBe(REF); + expect(recentParams[1]).toBe(TODAY_STR); expect(recentParams[2]).toBe(10); }); + it("computes top movers against the reference month's previous month", async () => { + queueEmpty(3); // current balance, ytd, series + mockSelect + .mockResolvedValueOnce([ + { + category_id: 1, + category_name: "Restaurants", + category_color: "#f97316", + current_total: 240, + previous_total: 200, + }, + ]) + .mockResolvedValueOnce([]); // recent + + await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY); + + const moversParams = mockSelect.mock.calls[3][1] as unknown[]; + // Reference month = March 2026, previous = February 2026. + expect(moversParams[0]).toBe("2026-03-01"); + expect(moversParams[1]).toBe("2026-03-31"); + expect(moversParams[2]).toBe("2026-02-01"); + expect(moversParams[3]).toBe("2026-02-28"); + }); + + it("wraps January reference month back to December of the previous year for top movers", async () => { + queueEmpty(5); + + await getHighlights(2026, 1, 2026, 30, 5, 10, new Date(2026, 0, 10)); + + const moversParams = mockSelect.mock.calls[3][1] as unknown[]; + expect(moversParams[0]).toBe("2026-01-01"); + expect(moversParams[1]).toBe("2026-01-31"); + expect(moversParams[2]).toBe("2025-12-01"); + expect(moversParams[3]).toBe("2025-12-31"); + }); + it("computes deltaAbs and deltaPct from movers rows", async () => { mockSelect .mockResolvedValueOnce([{ net: -500 }]) // current balance .mockResolvedValueOnce([{ net: -1800 }]) // ytd .mockResolvedValueOnce([ - { month: "2026-04", net: -500 }, - { month: "2026-03", net: -400 }, + { month: "2026-03", net: -500 }, + { month: "2026-02", net: -400 }, ]) // series .mockResolvedValueOnce([ { @@ -234,7 +276,7 @@ describe("getHighlights", () => { ]) .mockResolvedValueOnce([]); // recent - const result = await getHighlights(30, REF); + const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY); expect(result.netBalanceCurrent).toBe(-500); expect(result.netBalanceYtd).toBe(-1800); @@ -266,7 +308,7 @@ describe("getHighlights", () => { ]) .mockResolvedValueOnce([]); - const result = await getHighlights(30, REF); + const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY); expect(result.topMovers[0].deltaPct).toBeNull(); expect(result.topMovers[0].deltaAbs).toBe(120); diff --git a/src/services/reportService.ts b/src/services/reportService.ts index a678a37..76bccbc 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -209,48 +209,64 @@ function shiftDate(refIso: string, days: number): string { return `${yy}-${mm}-${dd}`; } -function monthEnd(yyyyMm: string): string { - const [y, m] = yyyyMm.split("-").map(Number); - const d = new Date(Date.UTC(y, m, 0)); // day 0 of next month = last day of this month - const dd = String(d.getUTCDate()).padStart(2, "0"); - return `${yyyyMm}-${dd}`; +function todayIso(today: Date): string { + const y = today.getFullYear(); + const m = String(today.getMonth() + 1).padStart(2, "0"); + const d = String(today.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; } /** * Returns the dashboard "highlights" snapshot for the reports hub: - * - net balance for the reference month - * - YTD net balance - * - last 12 months of net balances (for sparkline) - * - top movers (biggest change in spending vs previous month) - * - top transactions (biggest absolute amounts in last `windowDays` days) + * - monthly tile — net balance of the REFERENCE month + * - YTD tile — net balance from Jan 1st of `ytdYear` through today + * - 12-month series — last 12 months ending at the reference month + * - top movers — reference month vs the month immediately before it + * - top transactions — biggest absolute amounts in the last `windowDays` + * days ending today * - * All SQL is parameterised. `referenceDate` defaults to today and is overridable - * from tests for deterministic fixtures. + * `today` is an optional injection point so unit tests can pin both the YTD + * window and the "recent transactions" window without depending on wall-clock + * time. All SQL is parameterised. */ export async function getHighlights( + referenceYear: number, + referenceMonth: number, + ytdYear: number, windowDays: number = 30, - referenceDate?: string, topMoversLimit: number = 5, topTransactionsLimit: number = 10, + today: Date = new Date(), ): Promise { const db = await getDb(); - const refIso = referenceDate ?? (() => { - const t = new Date(); - return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}-${String(t.getDate()).padStart(2, "0")}`; - })(); - const currentMonth = refIso.slice(0, 7); // YYYY-MM - const currentYear = refIso.slice(0, 4); - const yearStart = `${currentYear}-01-01`; - const currentMonthStart = `${currentMonth}-01`; - const currentMonthEnd = monthEnd(currentMonth); - const previousMonthStart = shiftMonthStart(refIso, -1); - const previousMonth = previousMonthStart.slice(0, 7); - const previousMonthEnd = monthEnd(previousMonth); - const sparklineStart = shiftMonthStart(refIso, -11); // 11 months back + current = 12 - const recentWindowStart = shiftDate(refIso, -(windowDays - 1)); + const currentMonth = `${referenceYear}-${String(referenceMonth).padStart(2, "0")}`; // YYYY-MM + const { start: currentMonthStart, end: currentMonthEnd } = monthBoundaries( + referenceYear, + referenceMonth, + ); + const prev = previousMonth(referenceYear, referenceMonth); + const { start: previousMonthStart, end: previousMonthEnd } = monthBoundaries( + prev.year, + prev.month, + ); - // 1. Net balance for current month + // Sparkline anchor = reference month; shift 11 back for a 12-month series. + const sparklineStart = shiftMonthStart(`${currentMonthStart}`, -11); + + // YTD window: Jan 1 of `ytdYear` → today. The reference month does not + // move this window — the YTD tile is pinned to the current civil year. + const ytdStart = `${ytdYear}-01-01`; + const todayStr = todayIso(today); + // Guard: if the user picks a reference month in the future (unlikely) or if + // `today` is clamped below Jan 1 (fixtures), cap the YTD end at `ytdStart` + // so SQL never inverts its bounds. + const ytdEnd = todayStr >= ytdStart ? todayStr : ytdStart; + + // Recent-transactions window ends today (not at the reference month). + const recentWindowStart = shiftDate(todayStr, -(windowDays - 1)); + + // 1. Net balance for the reference month const currentBalanceRows = await db.select>( `SELECT COALESCE(SUM(amount), 0) AS net FROM transactions @@ -259,16 +275,16 @@ export async function getHighlights( ); const netBalanceCurrent = Number(currentBalanceRows[0]?.net ?? 0); - // 2. YTD balance + // 2. YTD balance — independent of the reference month. const ytdRows = await db.select>( `SELECT COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2`, - [yearStart, refIso], + [ytdStart, ytdEnd], ); const netBalanceYtd = Number(ytdRows[0]?.net ?? 0); - // 3. 12-month sparkline series + // 3. 12-month sparkline series, ending at the reference month. const seriesRows = await db.select>( `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(amount), 0) AS net FROM transactions @@ -280,11 +296,12 @@ export async function getHighlights( const seriesMap = new Map(seriesRows.map((r) => [r.month, Number(r.net ?? 0)])); const monthlyBalanceSeries: MonthBalance[] = []; for (let i = 11; i >= 0; i--) { - const monthKey = shiftMonthStart(refIso, -i).slice(0, 7); + const monthKey = shiftMonthStart(currentMonthStart, -i).slice(0, 7); monthlyBalanceSeries.push({ month: monthKey, netBalance: seriesMap.get(monthKey) ?? 0 }); } - // 4. Top movers — expense-side only (amount < 0), compare current vs previous month + // 4. Top movers — expense-side only (amount < 0), compare reference month + // vs the immediately-previous month. const moversRows = await db.select< Array<{ category_id: number | null; @@ -335,7 +352,7 @@ export async function getHighlights( }; }); - // 5. Top transactions within the recent window + // 5. Top transactions within the recent window ending today. const recentRows = await db.select( `SELECT t.id, @@ -349,7 +366,7 @@ export async function getHighlights( WHERE t.date >= $1 AND t.date <= $2 ORDER BY ABS(t.amount) DESC LIMIT $3`, - [recentWindowStart, refIso, topTransactionsLimit], + [recentWindowStart, todayStr, topTransactionsLimit], ); return { diff --git a/src/utils/referencePeriod.test.ts b/src/utils/referencePeriod.test.ts new file mode 100644 index 0000000..4e6d096 --- /dev/null +++ b/src/utils/referencePeriod.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { defaultReferencePeriod } from "./referencePeriod"; + +describe("defaultReferencePeriod", () => { + it("returns the month before the given date", () => { + expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 }); + }); + + it("wraps January back to December of the previous year", () => { + expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 }); + }); + + it("handles the last day of a month", () => { + expect(defaultReferencePeriod(new Date(2026, 5, 30))).toEqual({ year: 2026, month: 5 }); + }); +}); diff --git a/src/utils/referencePeriod.ts b/src/utils/referencePeriod.ts new file mode 100644 index 0000000..f89157d --- /dev/null +++ b/src/utils/referencePeriod.ts @@ -0,0 +1,16 @@ +/** + * Shared helper used by reports that pivot on a reference month + * (Highlights, Compare, Cartes). Returns the calendar month immediately + * preceding `today` — January wraps to December of the previous year. + * + * Kept as a pure function so every consumer can unit-test its own wiring + * with a deterministic `today` override. + */ +export function defaultReferencePeriod( + today: Date = new Date(), +): { year: number; month: number } { + const y = today.getFullYear(); + const m = today.getMonth() + 1; + if (m === 1) return { year: y - 1, month: 12 }; + return { year: y, month: m - 1 }; +}