feat(reports/highlights): default reference month to previous month + YTD current year, user-changeable (#106)
All checks were successful
PR Check / rust (push) Successful in 21m14s
PR Check / frontend (push) Successful in 2m16s
PR Check / rust (pull_request) Successful in 21m31s
PR Check / frontend (pull_request) Successful in 2m14s

- 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
This commit is contained in:
le king fu 2026-04-19 08:28:30 -04:00
parent 3842a1102a
commit 8b90cb6489
11 changed files with 286 additions and 99 deletions

View file

@ -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 `<select>` natif (#103)
- **Rapport Comparables — Réel vs réel** (`/reports/compare`) : le tableau reprend maintenant la structure riche à 8 colonnes du tableau Réel vs budget, en scindant chaque comparaison en un bloc *Mensuel* (mois de référence vs mois de comparaison) et un bloc *Cumulatif YTD* (progression jusqu'au mois de référence vs progression jusqu'à la fenêtre précédente). En mode MoM, le cumulatif précédent couvre janvier → fin du mois précédent de la même année ; en mode YoY, il couvre janvier → même mois de l'année précédente. Le graphique reste uniquement mensuel (#104)
- **Rapport Faits saillants** (`/reports/highlights`) : les tuiles mensuelles (solde du mois courant, top mouvements vs mois précédent) s'ouvrent désormais sur le **mois précédent** au lieu du mois courant, en cohérence avec les rapports Cartes et Comparables. La tuile Cumul annuel reste ancrée au 1er janvier de l'année civile en cours. Un nouveau sélecteur de mois de référence permet de faire pivoter le solde mensuel et la comparaison des top mouvements vers n'importe quel mois passé ; le choix est mémorisé dans l'URL via `?refY=YYYY&refM=MM` pour que la vue soit bookmarkable. Le panneau de faits saillants du hub suit la même valeur par défaut (#106)
### Corrigé
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)

View file

@ -9,6 +9,7 @@
### Changed
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#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)

View file

@ -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,

View file

@ -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);
}
/**

View file

@ -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,
});
});
});

View file

@ -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,
};
}

View file

@ -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<ViewMode>(() => readViewMode(STORAGE_KEY));
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
const [pending, setPending] = useState<RecentTransaction | null>(null);
@ -44,14 +51,8 @@ export default function ReportsHighlightsPage() {
<h1 className="text-2xl font-bold">{t("reports.hub.highlights")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>

View file

@ -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);

View file

@ -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<HighlightsData> {
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<Array<{ net: number | null }>>(
`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<Array<{ net: number | null }>>(
`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<Array<{ month: string; net: number | null }>>(
`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<RecentTransaction[]>(
`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 {

View file

@ -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 });
});
});

View file

@ -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 };
}