Compare commits

...

6 commits

Author SHA1 Message Date
le king fu
fe9ae0118c feat(reports): adopt shared FilterPanel on Compare and Budget
Mounts <FilterPanel> on ReportsComparePage (next to the existing
CompareReferenceMonthPicker) and BudgetPage (next to the existing
YearNavigator), keeping each page's temporal control unchanged. Threads
accountIds from useReportsPeriod through useCompare into
getCompareMonthOverMonth/getCompareYearOverYear, and through
CompareBudgetView into getBudgetVsActualData — closing the gap where
that sub-tab silently ignored the filter while the rest of the Compare
page respected it.

Budget: the issue named getBudgetVsActualData as the target for
useBudget, but that hook never calls it (it's exclusive to
CompareBudgetView/Dashboard/Cartes) — its only real actuals fetch is
getActualTotalsForYear(year - 1), the previous-year reference column.
Extended that function with the same optional accountIds pass-through
established by #273, rather than wiring in an unrelated call shape.

Both hooks fetch their account checkbox list via getAllImportSources,
mirroring the useTrends/#275 pattern. Empty selection = no filter,
byte-identical to pre-#276 output (regression-tested).

Resolves #276

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:52:21 -04:00
le king fu
30682004d3 feat(reports): adopt shared FilterPanel on the Trends page
Mounts <FilterPanel> (from #274) on ReportsTrendsPage next to the period
selector, and threads accountIds from useReportsPeriod through useTrends
into getMonthlyTrends/getCategoryOverTime (both already accountIds-aware
since #273). The account checkbox list reuses the same getAllImportSources
query as the Transactions single-source filter. Empty selection = no
filter, byte-identical to pre-#275 output.

Resolves #275
2026-07-11 15:41:13 -04:00
le king fu
70b2cd2e42 feat(reports): shared FilterPanel component (temporal slot + account multi-select)
Adds src/components/reports/FilterPanel.tsx, the shared filter bar for the
report pages. It renders the page's own temporal control (PeriodSelector /
CompareReferenceMonthPicker / YearNavigator / …) as-is via a `temporalControl`
ReactNode prop, plus a checkbox multi-select over `accounts: ImportSource[]`.
No `temporalMode` enum — that design was rejected to avoid mixing state
sources; the panel owns no temporal state at all.

Empty `accountIds` means no filter (all sources). Selection toggling is a
pure, exported `toggleAccountId` helper. Copy says "sources"/"import
sources" everywhere, never "comptes"/"accounts", so the filter reads as
distinct from the Bilan module's own "Compte" vocabulary.

This issue creates the standalone component only; wiring it into report
pages is left to follow-up issues (#275/#276).

Resolves #274
2026-07-11 15:31:58 -04:00
le king fu
b40381fb89 docs(reports): fix stale COMPARE_DELTA_SQL comment references
The constant became the compareDeltaSql() function in the previous
commit (Issue #273); three comments still named the old constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:17:48 -04:00
le king fu
4a93c60ea7 feat(reports): plumb accountIds[] account filter into 7 report services
Give every report service an optional accountIds?: number[] filter that
narrows results to a subset of import sources (transactions.source_id),
matched via a parameterized IN (...) clause — one bound placeholder per
id, never a joined string (CWE-89). New shared inPlaceholders() helper
(src/utils/sqlFilters.ts) generates the placeholder list so the pattern
lives in exactly one place.

New accountIds param: getCompareMonthOverMonth, getCompareYearOverYear,
getBudgetVsActualData, getCartesSnapshot (forwarded to its
getCompareMonthOverMonth + getBudgetVsActualData sub-calls, so the
Cartes dashboard's top-movers and budget-adherence cards respect an
active filter instead of silently ignoring it).

Signature change scalar -> plural: getMonthlyTrends, getCategoryOverTime,
getExpensesByCategory (sourceId?: number -> accountIds?: number[]). No
scalar production caller of these three passed sourceId today, so only
the test call-sites needed updating to the array shape.

Omitted/empty accountIds adds no clause at all, so every service stays
byte-identical to its pre-#273 SQL/results — pinned by a regression test
per service (new budgetService.test.ts / dashboardService.test.ts files,
extended reportService.test.ts / reportService.cartes.test.ts).

This is backend plumbing only: no report page exposes an account filter
control yet (follow-up issues #274-#276 add the shared <FilterPanel> and
wire it into Trends/Compare/Budget).

Resolves #273

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:15:37 -04:00
le king fu
65be6c7482 feat(reports): extend useReportsPeriod with account filter foundation
All checks were successful
PR Check / rust (pull_request) Successful in 21m28s
PR Check / frontend (pull_request) Successful in 2m22s
Add accountIds: number[] to the shared useReportsPeriod hook, URL-backed
via a dedicated `sources` query param (comma-separated, bookmarkable like
the existing period/from/to). Purely additive: the hook's existing shape
is unchanged, so all 6 consumers (useTrends, useCompare, useCategoryZoom,
ReportsCategoryPage, ReportsPage, ReportsComparePage) keep compiling as-is.

Also adds the shared ReportFilters { period, accountIds } type for
follow-up issues to consume, and exports pure parseAccountIds/
serializeAccountIds helpers (same hookless-testability pattern as
resolveReportsPeriod). URL parsing validates each token as a finite
integer via a strict regex + Number.isSafeInteger, dropping invalid
tokens individually rather than discarding the whole selection.

This is foundation only: no UI control exposes the filter yet, and no
service reads accountIds yet (both land in follow-up issues of the
"rapports uniformes" epic).

Resolves #272

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:01:43 -04:00
26 changed files with 1201 additions and 174 deletions

View file

@ -6,6 +6,11 @@
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254). - Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265). - Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
- Rapports → Tendances : le panneau de filtre partagé apparaît désormais sur ce rapport, à côté du sélecteur de période. Cocher une ou plusieurs sources d'import restreint à la fois la vue mensuelle globale et le tableau de résultat par catégorie à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#275).
- Rapports → Comparaison et Budget : le panneau de filtre partagé apparaît désormais aussi sur ces deux rapports, à côté de leur contrôle de période existant (la comparaison garde son sélecteur de mois de référence, la grille budget garde son navigateur d'année — inchangés). Cocher une ou plusieurs sources d'import restreint le rapport de comparaison — y compris son onglet budget-vs-réel — et la colonne de référence de l'année précédente de la grille budget à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#276).
### Corrigé ### Corrigé

View file

@ -6,6 +6,11 @@
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254). - Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265). - Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
- Reports → Trends: the shared filter panel now appears on this report, next to the period selector. Checking one or more import sources narrows both the global monthly view and the by-category result table to those sources; leaving none checked keeps showing every source, unchanged (#275).
- Reports → Compare & Budget: the shared filter panel now appears on both reports too, next to their existing period control (the compare report keeps its reference-month picker, the budget grid keeps its year navigator — unchanged). Checking one or more import sources narrows the compare report — including its budget-vs-actual tab — and the budget grid's previous-year reference column to those sources; leaving none checked keeps showing every source, unchanged (#276).
### Fixed ### Fixed

View file

@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `useDashboard` | Métriques du tableau de bord |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276) — reste Dashboard/Hub (M2) |
| `useHighlights` | Panneau de faits saillants du hub rapports | | `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |

View file

@ -7,9 +7,13 @@ import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps { export interface CompareBudgetViewProps {
year: number; year: number;
month: number; month: number;
/** Account (import source) filter forwarded to getBudgetVsActualData so this
* sub-tab respects the same FilterPanel selection as the rest of the Compare
* page (Issue #276). Empty/omitted = no filter. */
accountIds?: number[];
} }
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) { export default function CompareBudgetView({ year, month, accountIds }: CompareBudgetViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]); const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -17,7 +21,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
getBudgetVsActualData(year, month) getBudgetVsActualData(year, month, accountIds)
.then((data) => { .then((data) => {
if (!cancelled) setRows(data); if (!cancelled) setRows(data);
}) })
@ -27,7 +31,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [year, month]); }, [year, month, accountIds]);
if (error) { if (error) {
return ( return (

View file

@ -0,0 +1,199 @@
// FilterPanel — unit tests (Issue #274).
//
// NOTE: This project has no @testing-library/react / jsdom configured (see
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
// their own test file, has NO internal state or hooks besides `useTranslation`
// — mocked below into a plain function — so it can be invoked directly as a
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
// call site) and its return value (a plain React-element object tree, since
// JSX creation never touches the DOM) walked directly. This gives genuine
// coverage of the shipped render + selection-wiring code, not a
// re-implementation of its logic in the test.
import { describe, it, expect, vi } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
i18n: { language: "fr" },
})),
}));
vi.mock("lucide-react", () => ({
Inbox: () => null,
}));
import FilterPanel, { toggleAccountId } from "./FilterPanel";
import type { FilterPanelProps } from "./FilterPanel";
import type { ImportSource } from "../../shared/types";
// ---------------------------------------------------------------------------
// Minimal React-element tree walker (no renderer/DOM involved)
// ---------------------------------------------------------------------------
interface ElementNode {
type: unknown;
props: Record<string, unknown> & { children?: unknown };
}
type FlatNode = ElementNode | string | number;
function isElementNode(x: unknown): x is ElementNode {
return typeof x === "object" && x !== null && "props" in x;
}
function flatten(node: unknown): FlatNode[] {
if (Array.isArray(node)) return node.flatMap(flatten);
if (node === null || node === undefined || typeof node === "boolean") return [];
if (typeof node === "string" || typeof node === "number") return [node];
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
return [];
}
function findCheckboxes(root: unknown): ElementNode[] {
return flatten(root).filter(
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
);
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SOURCE_CHEQUING: ImportSource = {
id: 1,
name: "Compte chèque Desjardins",
date_format: "YYYY-MM-DD",
delimiter: ",",
encoding: "utf-8",
column_mapping: "{}",
skip_lines: 0,
has_header: true,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
const SOURCE_VISA: ImportSource = {
...SOURCE_CHEQUING,
id: 2,
name: "Visa Desjardins",
};
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
const onAccountIdsChange = vi.fn();
const props: FilterPanelProps = {
temporalControl: "TEMPORAL_CONTROL_MARKER",
accountIds: [],
onAccountIdsChange,
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
...overrides,
};
const element = FilterPanel(props);
return { element, onAccountIdsChange };
}
// ---------------------------------------------------------------------------
// toggleAccountId — pure selection logic
// ---------------------------------------------------------------------------
describe("toggleAccountId", () => {
it("adds an absent id to an empty list", () => {
expect(toggleAccountId([], 1)).toEqual([1]);
});
it("appends an absent id after the existing ones", () => {
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
});
it("removes a present id, preserving the order of the remaining ids", () => {
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
});
it("removing the only selected id returns an empty array (= all accounts)", () => {
expect(toggleAccountId([1], 1)).toEqual([]);
});
it("does not mutate the input array", () => {
const input = [1, 2];
toggleAccountId(input, 3);
expect(input).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// FilterPanel — render
// ---------------------------------------------------------------------------
describe("FilterPanel — render", () => {
it("renders the injected temporalControl as-is", () => {
const { element } = renderPanel();
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
});
it("renders one checkbox per account", () => {
const { element } = renderPanel();
expect(findCheckboxes(element)).toHaveLength(2);
});
it("renders each account's name as visible text", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("Compte chèque Desjardins");
expect(text).toContain("Visa Desjardins");
});
it("checks exactly the accounts present in accountIds", () => {
const { element } = renderPanel({ accountIds: [2] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
});
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
const { element } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
});
it("renders zero checkboxes when accounts is empty, without crashing", () => {
const { element } = renderPanel({ accounts: [] });
expect(findCheckboxes(element)).toHaveLength(0);
});
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("reports.filters.accounts.label");
expect(text).toContain("reports.filters.accounts.hint");
});
});
// ---------------------------------------------------------------------------
// FilterPanel — selection wiring
// ---------------------------------------------------------------------------
describe("FilterPanel — selection wiring", () => {
it("checking an unselected account calls onAccountIdsChange with it added", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[1] = SOURCE_VISA (id 2), currently unselected
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
});
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
(checkboxes[0].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
});
it("checking a second account preserves the first (multi-select)", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
(checkboxes[0].props.onChange as () => void)();
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
});
});

View file

@ -0,0 +1,93 @@
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
//
// Design: a "slot" component. Each report page owns its own temporal state
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
// via useReportsPeriod, Budget = a plain year) and renders its own control
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
// enum and does not know which control it is rendering — an earlier design
// unifying temporal state behind such an enum was rejected specifically to
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
// The only state this component actually owns/shares across report pages is
// the account (import source) filter.
//
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
// copy in this component deliberately says "sources" everywhere, never
// "comptes"/"accounts", so the two concepts never read as the same filter.
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react";
import type { ImportSource } from "../../shared/types";
export interface FilterPanelProps {
/** The page's own temporal control, rendered as-is. */
temporalControl: ReactNode;
/** Selected import source ids. Empty array = no filter (all sources). */
accountIds: number[];
onAccountIdsChange: (accountIds: number[]) => void;
/** Import sources available to filter on. */
accounts: ImportSource[];
}
/**
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
* preserving the relative order of the remaining ids. Pure and exported so
* the selection logic is unit-testable without rendering the component.
*/
export function toggleAccountId(accountIds: number[], id: number): number[] {
return accountIds.includes(id)
? accountIds.filter((existing) => existing !== id)
: [...accountIds, id];
}
export default function FilterPanel({
temporalControl,
accountIds,
onAccountIdsChange,
accounts,
}: FilterPanelProps) {
const { t } = useTranslation();
const accountsLabel = t("reports.filters.accounts.label");
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
<div>{temporalControl}</div>
<div className="flex flex-col gap-1.5">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
<Inbox size={14} />
{accountsLabel}
</span>
<div
className="flex flex-wrap gap-x-4 gap-y-1.5"
role="group"
aria-label={accountsLabel}
>
{accounts.map((account) => (
<label
key={account.id}
className="flex items-center gap-1.5 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={accountIds.includes(account.id)}
onChange={() =>
onAccountIdsChange(toggleAccountId(accountIds, account.id))
}
className="accent-[var(--primary)]"
/>
{account.name}
</label>
))}
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{t("reports.filters.accounts.hint")}
</span>
</div>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
@ -11,11 +11,14 @@ import {
applyTemplate as applyTemplateSvc, applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
interface BudgetState { interface BudgetState {
year: number; year: number;
rows: BudgetYearRow[]; rows: BudgetYearRow[];
templates: BudgetTemplate[]; templates: BudgetTemplate[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
@ -26,6 +29,7 @@ type BudgetAction =
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_YEAR"; payload: number }; | { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState { function initialState(): BudgetState {
@ -33,6 +37,7 @@ function initialState(): BudgetState {
year: new Date().getFullYear(), year: new Date().getFullYear(),
rows: [], rows: [],
templates: [], templates: [],
accounts: [],
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
error: null, error: null,
@ -54,6 +59,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates, templates: action.payload.templates,
isLoading: false, isLoading: false,
}; };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_YEAR": case "SET_YEAR":
return { ...state, year: action.payload }; return { ...state, year: action.payload };
default: default:
@ -64,10 +71,11 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number) => { const refreshData = useCallback(async (year: number, ids: number[]) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
@ -76,7 +84,7 @@ export function useBudget() {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1), getActualTotalsForYear(year - 1, ids),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -364,8 +372,21 @@ export function useBudget() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshData(state.year); refreshData(state.year, accountIds);
}, [state.year, refreshData]); }, [state.year, accountIds, refreshData]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Compare source filters — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
const navigateYear = useCallback((delta: -1 | 1) => { const navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta }); dispatch({ type: "SET_YEAR", payload: state.year + delta });
@ -376,7 +397,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await upsertBudgetEntry(categoryId, state.year, month, amount); await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -386,7 +407,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const splitEvenly = useCallback( const splitEvenly = useCallback(
@ -400,7 +421,7 @@ export function useBudget() {
amounts.push(m < remainder ? base + 0.01 : base); amounts.push(m < remainder ? base + 0.01 : base);
} }
await upsertBudgetEntriesForYear(categoryId, state.year, amounts); await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -410,7 +431,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const saveTemplate = useCallback( const saveTemplate = useCallback(
@ -423,7 +444,7 @@ export function useBudget() {
.filter((r) => !r.is_parent && r.months[0] !== 0) .filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] })); .map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries); await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -433,7 +454,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.rows, state.year, refreshData] [state.rows, state.year, accountIds, refreshData]
); );
const applyTemplate = useCallback( const applyTemplate = useCallback(
@ -441,7 +462,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await applyTemplateSvc(templateId, state.year, month); await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -451,7 +472,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const applyTemplateAllMonths = useCallback( const applyTemplateAllMonths = useCallback(
@ -461,7 +482,7 @@ export function useBudget() {
for (let m = 1; m <= 12; m++) { for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m); await applyTemplateSvc(templateId, state.year, m);
} }
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -471,7 +492,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const deleteTemplate = useCallback( const deleteTemplate = useCallback(
@ -479,7 +500,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await deleteTemplateSvc(templateId); await deleteTemplateSvc(templateId);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -489,7 +510,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
return { return {

View file

@ -1,6 +1,7 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta } from "../shared/types"; import type { CategoryDelta, ImportSource } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
@ -13,6 +14,7 @@ interface State {
year: number; year: number;
month: number; month: number;
rows: CategoryDelta[]; rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -23,6 +25,7 @@ type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
/** /**
@ -93,6 +96,7 @@ const initialState: State = {
year: defaultRef.year, year: defaultRef.year,
month: defaultRef.month, month: defaultRef.month,
rows: [], rows: [],
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -109,6 +113,8 @@ function reducer(state: State, action: Action): State {
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ROWS": case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null }; return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -117,20 +123,26 @@ function reducer(state: State, action: Action): State {
} }
export function useCompare() { export function useCompare() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback( const fetch = useCallback(
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => { async (
mode: CompareMode,
subMode: CompareSubMode,
year: number,
month: number,
ids: number[],
) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
const rows = const rows =
subMode === "mom" subMode === "mom"
? await getCompareMonthOverMonth(year, month) ? await getCompareMonthOverMonth(year, month, ids)
: await getCompareYearOverYear(year, month); : await getCompareYearOverYear(year, month, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows }); dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) { } catch (e) {
@ -142,8 +154,21 @@ export function useCompare() {
); );
useEffect(() => { useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month); fetch(state.mode, state.subMode, state.year, state.month, accountIds);
}, [fetch, state.mode, state.subMode, state.year, state.month]); }, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Transactions source filters — see useTrends.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
// Keep the reference month in sync with the URL period when the user navigates // Keep the reference month in sync with the URL period when the user navigates
// via PeriodSelector — but not on mount. The ref is seeded with the initial // via PeriodSelector — but not on mount. The ref is seeded with the initial

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { resolveReportsPeriod } from "./useReportsPeriod"; import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod";
describe("resolveReportsPeriod", () => { describe("resolveReportsPeriod", () => {
const fixedToday = new Date("2026-04-14T12:00:00Z"); const fixedToday = new Date("2026-04-14T12:00:00Z");
@ -51,3 +51,76 @@ describe("resolveReportsPeriod", () => {
expect(result.to).toBe("2026-12-31"); expect(result.to).toBe("2026-12-31");
}); });
}); });
describe("parseAccountIds", () => {
it("defaults to an empty array (no filter) when the sources param is absent", () => {
expect(parseAccountIds(null)).toEqual([]);
});
it("defaults to an empty array for an empty string", () => {
expect(parseAccountIds("")).toEqual([]);
});
it("parses a comma-separated list of ids", () => {
expect(parseAccountIds("3,7,12")).toEqual([3, 7, 12]);
});
it("trims whitespace around tokens", () => {
expect(parseAccountIds(" 3 , 7 ,12")).toEqual([3, 7, 12]);
});
it("parses a single id", () => {
expect(parseAccountIds("42")).toEqual([42]);
});
it("drops non-numeric tokens but keeps the valid ones (rejects non-numeric)", () => {
expect(parseAccountIds("3,abc,7")).toEqual([3, 7]);
});
it("drops decimal tokens (not a finite integer)", () => {
expect(parseAccountIds("3,4.5,7")).toEqual([3, 7]);
});
it("drops empty tokens produced by stray/trailing commas", () => {
expect(parseAccountIds("3,,7,")).toEqual([3, 7]);
});
it("drops exponential/hex-looking tokens that Number() would otherwise coerce", () => {
expect(parseAccountIds("3,1e3,0x10,7")).toEqual([3, 7]);
});
it("drops unsafe-integer tokens (too large to be a reliable id)", () => {
expect(parseAccountIds("3,99999999999999999999,7")).toEqual([3, 7]);
});
it("returns an empty array when every token is invalid", () => {
expect(parseAccountIds("abc,def")).toEqual([]);
});
});
describe("serializeAccountIds", () => {
it("joins ids into a comma-separated string", () => {
expect(serializeAccountIds([3, 7, 12])).toBe("3,7,12");
});
it("serializes a single id", () => {
expect(serializeAccountIds([42])).toBe("42");
});
it("returns null for an empty array so the param is removed (no filter)", () => {
expect(serializeAccountIds([])).toBeNull();
});
it("drops invalid entries (e.g. NaN) before serializing", () => {
expect(serializeAccountIds([3, NaN, 7])).toBe("3,7");
});
it("returns null when every entry is invalid", () => {
expect(serializeAccountIds([NaN, Infinity])).toBeNull();
});
it("round-trips through parseAccountIds", () => {
const ids = [1, 2, 3];
expect(parseAccountIds(serializeAccountIds(ids))).toEqual(ids);
});
});

View file

@ -26,6 +26,43 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string
return { from: `${year}-01-01`, to: `${year}-12-31` }; return { from: `${year}-01-01`, to: `${year}-12-31` };
} }
// Matches a bare (optionally negative) integer token, e.g. "3" or "-3", but
// not "3.5", "abc", "" or "1e3" — deliberately stricter than `Number(...)` so
// that hex/exponential-notation strings from a hand-edited URL don't sneak
// through as valid ids.
const INTEGER_TOKEN = /^-?\d+$/;
/**
* Pure parser for the `sources` query param (comma-separated account/import
* source ids), exposed for the same testability reason as
* `resolveReportsPeriod`. Invalid tokens (non-numeric, decimal, empty from a
* stray comma, or too large to be a safe integer) are dropped individually
* rather than invalidating the whole list a single corrupted token in a
* bookmarked/hand-edited URL should not silently discard the rest of an
* otherwise valid selection. Missing param or no valid token `[]`, which
* means "no filter" (all accounts).
*/
export function parseAccountIds(raw: string | null): number[] {
if (!raw) return [];
return raw
.split(",")
.map((token) => token.trim())
.filter((token) => INTEGER_TOKEN.test(token))
.map(Number)
.filter((n) => Number.isSafeInteger(n));
}
/**
* Inverse of `parseAccountIds`: serializes account ids back to the `sources`
* query param value, or `null` when the param should be removed from the URL
* entirely (empty/all-invalid selection = no filter mirrors how `from`/`to`
* are omitted for a non-custom period).
*/
export function serializeAccountIds(accountIds: number[]): string | null {
const valid = accountIds.filter((n) => Number.isSafeInteger(n));
return valid.length > 0 ? valid.join(",") : null;
}
/** /**
* Pure resolver used by the hook and unit tests. Exposed to keep the core * Pure resolver used by the hook and unit tests. Exposed to keep the core
* logic hookless and testable without rendering a router. * logic hookless and testable without rendering a router.
@ -57,15 +94,19 @@ export interface UseReportsPeriodResult {
from: string; from: string;
to: string; to: string;
period: DashboardPeriod; period: DashboardPeriod;
accountIds: number[];
setPeriod: (period: DashboardPeriod) => void; setPeriod: (period: DashboardPeriod) => void;
setCustomDates: (from: string, to: string) => void; setCustomDates: (from: string, to: string) => void;
setAccountIds: (accountIds: number[]) => void;
} }
/** /**
* Reads/writes the active reporting period via the URL query string so it is * Reads/writes the active reporting period and, additively, the account
* bookmarkable and shared across the four report sub-routes. * (import source) filter via the URL query string so both are bookmarkable
* and shared across the report sub-routes.
* *
* Defaults to the current civil year (Jan 1 Dec 31). * Defaults to the current civil year (Jan 1 Dec 31) with no account filter
* (`accountIds: []`, meaning all accounts).
*/ */
export function useReportsPeriod(): UseReportsPeriodResult { export function useReportsPeriod(): UseReportsPeriodResult {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -73,12 +114,15 @@ export function useReportsPeriod(): UseReportsPeriodResult {
const rawPeriod = searchParams.get("period"); const rawPeriod = searchParams.get("period");
const rawFrom = searchParams.get("from"); const rawFrom = searchParams.get("from");
const rawTo = searchParams.get("to"); const rawTo = searchParams.get("to");
const rawSources = searchParams.get("sources");
const { from, to, period } = useMemo( const { from, to, period } = useMemo(
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod), () => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
[rawPeriod, rawFrom, rawTo], [rawPeriod, rawFrom, rawTo],
); );
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
const setPeriod = useCallback( const setPeriod = useCallback(
(next: DashboardPeriod) => { (next: DashboardPeriod) => {
setSearchParams( setSearchParams(
@ -115,5 +159,24 @@ export function useReportsPeriod(): UseReportsPeriodResult {
[setSearchParams], [setSearchParams],
); );
return { from, to, period, setPeriod, setCustomDates }; const setAccountIds = useCallback(
(nextAccountIds: number[]) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
const serialized = serializeAccountIds(nextAccountIds);
if (serialized === null) {
params.delete("sources");
} else {
params.set("sources", serialized);
}
return params;
},
{ replace: true },
);
},
[setSearchParams],
);
return { from, to, period, accountIds, setPeriod, setCustomDates, setAccountIds };
} }

View file

@ -1,6 +1,7 @@
import { useReducer, useEffect, useRef, useCallback } from "react"; import { useReducer, useEffect, useRef, useCallback } from "react";
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types"; import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory"; export type TrendsSubView = "global" | "byCategory";
@ -9,6 +10,7 @@ interface State {
subView: TrendsSubView; subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[]; monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -18,12 +20,14 @@ type Action =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] } | { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const initialState: State = { const initialState: State = {
subView: "byCategory", subView: "byCategory",
monthlyTrends: [], monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -38,6 +42,8 @@ function reducer(state: State, action: Action): State {
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null }; return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME": case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null }; return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -46,32 +52,48 @@ function reducer(state: State, action: Action): State {
} }
export function useTrends() { export function useTrends() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => { const fetch = useCallback(
const id = ++fetchIdRef.current; async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
dispatch({ type: "SET_LOADING", payload: true }); const id = ++fetchIdRef.current;
try { dispatch({ type: "SET_LOADING", payload: true });
if (subView === "global") { try {
const data = await getMonthlyTrends(dateFrom, dateTo); if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
}
} catch (e) {
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
} }
} catch (e) { },
if (id !== fetchIdRef.current) return; []
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); );
}
}, []);
useEffect(() => { useEffect(() => {
fetch(state.subView, from, to); fetch(state.subView, from, to, accountIds);
}, [fetch, state.subView, from, to]); }, [fetch, state.subView, from, to, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Transactions single-source filter — see useTransactions.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
const setSubView = useCallback((sv: TrendsSubView) => { const setSubView = useCallback((sv: TrendsSubView) => {
dispatch({ type: "SET_SUBVIEW", payload: sv }); dispatch({ type: "SET_SUBVIEW", payload: sv });

View file

@ -392,7 +392,11 @@
"search": "Search...", "search": "Search...",
"all": "All", "all": "All",
"none": "None", "none": "None",
"allTypes": "All types" "allTypes": "All types",
"accounts": {
"label": "Import sources",
"hint": "No selection = all sources"
}
}, },
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",

View file

@ -392,7 +392,11 @@
"search": "Rechercher...", "search": "Rechercher...",
"all": "Toutes", "all": "Toutes",
"none": "Aucune", "none": "Aucune",
"allTypes": "Tous les types" "allTypes": "Tous les types",
"accounts": {
"label": "Sources d'import",
"hint": "Aucune sélection = toutes les sources"
}
}, },
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",

View file

@ -1,12 +1,15 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import FilterPanel from "../components/reports/FilterPanel";
import { useBudget } from "../hooks/useBudget"; import { useBudget } from "../hooks/useBudget";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import YearNavigator from "../components/budget/YearNavigator"; import YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions"; import TemplateActions from "../components/budget/TemplateActions";
export default function BudgetPage() { export default function BudgetPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { accountIds, setAccountIds } = useReportsPeriod();
const { const {
state, state,
navigateYear, navigateYear,
@ -18,28 +21,32 @@ export default function BudgetPage() {
deleteTemplate, deleteTemplate,
} = useBudget(); } = useBudget();
const { year, rows, templates, isLoading, isSaving, error } = state; const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
return ( return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1> <h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" /> <PageHelp helpKey="budget" />
</div> </div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> <TemplateActions
<TemplateActions templates={templates}
templates={templates} onApply={applyTemplate}
onApply={applyTemplate} onApplyAllMonths={applyTemplateAllMonths}
onApplyAllMonths={applyTemplateAllMonths} onSave={saveTemplate}
onSave={saveTemplate} onDelete={deleteTemplate}
onDelete={deleteTemplate} disabled={isSaving}
disabled={isSaving} />
/>
<YearNavigator year={year} onNavigate={navigateYear} />
</div>
</div> </div>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
{error && ( {error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20"> <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error} {error}

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import CompareModeTabs from "../components/reports/CompareModeTabs"; import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle"; import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
@ -25,7 +26,8 @@ function formatMonthLabel(year: number, month: number, language: string): string
export default function ReportsComparePage() { export default function ReportsComparePage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } =
useReportsPeriod();
const { const {
mode, mode,
subMode, subMode,
@ -35,6 +37,7 @@ export default function ReportsComparePage() {
year, year,
month, month,
rows, rows,
accounts,
isLoading, isLoading,
error, error,
} = useCompare(); } = useCompare();
@ -90,21 +93,25 @@ export default function ReportsComparePage() {
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap"> <FilterPanel
<div className="flex items-center gap-3 flex-wrap"> temporalControl={
<CompareReferenceMonthPicker <CompareReferenceMonthPicker
year={year} year={year}
month={month} month={month}
onChange={setReferencePeriod} onChange={setReferencePeriod}
/> />
{showActualControls && ( }
<CompareSubModeToggle value={subMode} onChange={setSubMode} /> accountIds={accountIds}
)} onAccountIdsChange={setAccountIds}
</div> accounts={accounts}
{showActualControls && ( />
{showActualControls && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} /> <ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)} </div>
</div> )}
{error && ( {error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6"> <div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
@ -113,7 +120,7 @@ export default function ReportsComparePage() {
)} )}
{mode === "budget" ? ( {mode === "budget" ? (
<CompareBudgetView year={year} month={month} /> <CompareBudgetView year={year} month={month} accountIds={accountIds} />
) : viewMode === "chart" ? ( ) : viewMode === "chart" ? (
<ComparePeriodChart <ComparePeriodChart
rows={rows} rows={rows}

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable"; import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
@ -21,8 +22,8 @@ const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() { export default function ReportsTrendsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends(); const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table")); const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType()); const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -55,50 +56,56 @@ export default function ReportsTrendsPage() {
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1> <h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap"> <FilterPanel
<PeriodSelector temporalControl={
value={period} <PeriodSelector
onChange={setPeriod} value={period}
customDateFrom={from} onChange={setPeriod}
customDateTo={to} customDateFrom={from}
onCustomDateChange={setCustomDates} customDateTo={to}
/> onCustomDateChange={setCustomDates}
<div className="flex gap-2 items-center flex-wrap"> />
<div className="inline-flex gap-1"> }
<button accountIds={accountIds}
type="button" onAccountIdsChange={setAccountIds}
onClick={() => setSubView("global")} accounts={accounts}
aria-pressed={subView === "global"} />
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "global" <div className="flex gap-2 items-center justify-end flex-wrap mb-6">
? "bg-[var(--primary)] text-white" <div className="inline-flex gap-1">
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]" <button
}`} type="button"
> onClick={() => setSubView("global")}
{t("reports.trends.subviewGlobal")} aria-pressed={subView === "global"}
</button> className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
<button subView === "global"
type="button" ? "bg-[var(--primary)] text-white"
onClick={() => setSubView("byCategory")} : "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
aria-pressed={subView === "byCategory"} }`}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ >
subView === "byCategory" {t("reports.trends.subviewGlobal")}
? "bg-[var(--primary)] text-white" </button>
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]" <button
}`} type="button"
> onClick={() => setSubView("byCategory")}
{t("reports.trends.subviewByCategory")} aria-pressed={subView === "byCategory"}
</button> className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
</div> subView === "byCategory"
{subView === "byCategory" && viewMode === "chart" && ( ? "bg-[var(--primary)] text-white"
<TrendsChartTypeToggle : "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
value={chartType} }`}
onChange={setChartType} >
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY} {t("reports.trends.subviewByCategory")}
/> </button>
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
{subView === "byCategory" && viewMode === "chart" && (
<TrendsChartTypeToggle
value={chartType}
onChange={setChartType}
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
/>
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
{error && ( {error && (

View file

@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
vi.mock("./db", () => {
const getDb = vi.fn();
return { getDb };
});
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
// getBudgetEntriesForYear, and getActualsByCategoryRange for the month + YTD
// windows). Only the two `getActualsByCategoryRange` calls query `transactions`
// and are eligible for the accountIds filter — budgets are not tied to an
// account, so the categories/entries queries must never carry the clause.
describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
it("without accountIds, the actuals queries carry no source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
expect(actualsCalls.length).toBe(2); // month + YTD
for (const [sql, params] of actualsCalls) {
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toHaveLength(2); // dateFrom, dateTo only
}
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, []);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
for (const [sql, params] of actualsCalls) {
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toHaveLength(2);
}
});
it("applies a parameterized accountIds IN clause to both the month and YTD actuals queries", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, [4, 9]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions"),
);
expect(actualsCalls.length).toBe(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(sql as string).not.toContain("4,9");
expect(params as unknown[]).toEqual(expect.arrayContaining([4, 9]));
}
});
it("never adds the filter to the categories/budget-entries queries (a budget is not tied to an account)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getBudgetVsActualData(2026, 3, [4]);
const categoriesCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("FROM categories"),
)!;
const budgetEntriesCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("FROM budget_entries"),
)!;
expect(categoriesCall[0]).not.toContain("source_id");
expect(budgetEntriesCall[0]).not.toContain("source_id");
});
});
// getActualTotalsForYear is the previous-year actuals reference column
// consumed by useBudget (Issue #276) — a thin wrapper over the same
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through.
describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => {
it("without accountIds, the query carries no source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025);
expect(mockSelect).toHaveBeenCalledTimes(1);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025, []);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).not.toContain("source_id");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getActualTotalsForYear(2025, [4, 9]);
const [sql, params] = mockSelect.mock.calls[0];
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
Category, Category,
BudgetEntry, BudgetEntry,
@ -181,34 +182,47 @@ export async function deleteTemplate(templateId: number): Promise<void> {
// --- Actuals helpers --- // --- Actuals helpers ---
export async function getActualTotalsForYear( export async function getActualTotalsForYear(
year: number year: number,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`; const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`; const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo); return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
} }
// --- Budget vs Actual --- // --- Budget vs Actual ---
async function getActualsByCategoryRange( async function getActualsByCategoryRange(
dateFrom: string, dateFrom: string,
dateTo: string dateTo: string,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, params.length + 1);
const accountFilter = accountPlaceholders ? ` AND source_id IN (${accountPlaceholders})` : "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<Array<{ category_id: number | null; actual: number }>>( return db.select<Array<{ category_id: number | null; actual: number }>>(
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual `SELECT category_id, COALESCE(SUM(amount), 0) AS actual
FROM transactions FROM transactions
WHERE date BETWEEN $1 AND $2 WHERE date BETWEEN $1 AND $2${accountFilter}
GROUP BY category_id`, GROUP BY category_id`,
[dateFrom, dateTo] params
); );
} }
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
/**
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
* (Issue #273) optionally scopes both the month and YTD actuals to a subset of
* import sources (`transactions.source_id`) budgeted amounts are unaffected
* by the filter (a budget is not tied to any account).
*/
export async function getBudgetVsActualData( export async function getBudgetVsActualData(
year: number, year: number,
month: number month: number,
accountIds?: number[]
): Promise<BudgetVsActualRow[]> { ): Promise<BudgetVsActualRow[]> {
// Date ranges // Date ranges
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month); const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
@ -219,8 +233,8 @@ export async function getBudgetVsActualData(
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([ const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualsByCategoryRange(monthFrom, monthTo), getActualsByCategoryRange(monthFrom, monthTo, accountIds),
getActualsByCategoryRange(ytdFrom, ytdTo), getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
]); ]);
// Build maps // Build maps

View file

@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getExpensesByCategory } from "./dashboardService";
vi.mock("./db", () => {
const getDb = vi.fn();
return { getDb };
});
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
it("without accountIds, the query carries no source_id clause (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory("2025-01-01", "2025-12-31", [6, 11]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("t.source_id IN ($3, $4)");
expect(sql).not.toContain("6,11");
expect(params).toEqual(["2025-01-01", "2025-12-31", 6, 11]);
});
it("applies the accountIds filter alone (no dates) alongside the fixed expense-type clause", async () => {
mockSelect.mockResolvedValueOnce([]);
await getExpensesByCategory(undefined, undefined, [2]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("t.source_id IN ($1)");
expect(sql).toContain("COALESCE(c.type, 'expense') = 'expense'");
expect(params).toEqual([2]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
DashboardSummary, DashboardSummary,
CategoryBreakdownItem, CategoryBreakdownItem,
@ -54,7 +55,7 @@ export async function getDashboardSummary(
export async function getExpensesByCategory( export async function getExpensesByCategory(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
sourceId?: number, accountIds?: number[],
): Promise<CategoryBreakdownItem[]> { ): Promise<CategoryBreakdownItem[]> {
const db = await getDb(); const db = await getDb();
@ -72,10 +73,11 @@ export async function getExpensesByCategory(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`t.source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;

View file

@ -469,4 +469,50 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunAbs).toBe(150); expect(worst.overrunAbs).toBe(150);
expect(worst.overrunPct).toBeCloseTo(75, 5); expect(worst.overrunPct).toBeCloseTo(75, 5);
}); });
// --- accountIds filter (Issue #273) ---
//
// getCartesSnapshot must forward accountIds to its two sub-reports that
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
// for budget adherence) — the review caveat that flagged this: omitting it
// would let this dashboard silently ignore an active account filter even
// though its own building blocks respect it.
it("forwards accountIds to the compare (top movers) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const momCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("ORDER BY ABS(month_current_total - month_previous_total) DESC"),
)!;
expect(momCall[0]).toContain("AND t.source_id IN ($9, $10)");
expect(momCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the budget-vs-actual (budget adherence) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
(sql as string).includes("FROM transactions\n WHERE date BETWEEN"),
);
// Both the month and YTD actuals queries (getBudgetVsActualData) receive it.
expect(actualsCalls.length).toBeGreaterThanOrEqual(2);
for (const [sql, params] of actualsCalls) {
expect(sql as string).toContain("source_id IN ($3, $4)");
expect(params as unknown[]).toEqual(expect.arrayContaining([3, 8]));
}
});
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3);
for (const [sql] of mockSelect.mock.calls) {
expect(sql as string).not.toContain("source_id");
}
});
}); });

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { import {
getMonthlyTrends,
getCategoryOverTime, getCategoryOverTime,
getHighlights, getHighlights,
getCompareMonthOverMonth, getCompareMonthOverMonth,
@ -29,6 +30,66 @@ beforeEach(() => {
mockSelect.mockReset(); mockSelect.mockReset();
}); });
describe("getMonthlyTrends", () => {
it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends();
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("WHERE");
expect(params).toEqual([]);
});
it("applies date range filters without an accountIds argument (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31");
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("date >= $1");
expect(sql).toContain("date <= $2");
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("an empty accountIds array behaves exactly like no filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
});
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("source_id IN ($3, $4)");
expect(sql).not.toContain("3,7");
expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
});
it("applies the accountIds filter alone (no dates)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getMonthlyTrends(undefined, undefined, [5]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("WHERE source_id IN ($1)");
expect(params).toEqual([5]);
});
});
describe("getCategoryOverTime", () => { describe("getCategoryOverTime", () => {
it("builds query without WHERE clause when no filters are provided", async () => { it("builds query without WHERE clause when no filters are provided", async () => {
// First call: top categories, second call: monthly breakdown // First call: top categories, second call: monthly breakdown
@ -87,32 +148,60 @@ describe("getCategoryOverTime", () => {
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]); expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
}); });
it("applies sourceId filter", async () => { it("applies accountIds filter (single id)", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime(undefined, undefined, 50, 3); await getCategoryOverTime(undefined, undefined, 50, [3]);
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id"); expect(topCatSQL).toContain("t.source_id IN ($1)");
expect(topCatParams[0]).toBe(3); expect(topCatParams[0]).toBe(3);
}); });
it("combines typeFilter, date range, and sourceId", async () => { it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
mockSelect mockSelect
.mockResolvedValueOnce([]) // topCategories .mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows .mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense"); await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
expect(topCatSQL).not.toContain("3,7,12");
// topN ($4) follows the 3 account placeholders.
expect(topCatParams).toEqual([3, 7, 12, 50]);
});
it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);
const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).not.toContain("source_id");
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
});
it("combines typeFilter, date range, and accountIds", async () => {
mockSelect
.mockResolvedValueOnce([]) // topCategories
.mockResolvedValueOnce([]); // monthlyRows
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");
const topCatSQL = mockSelect.mock.calls[0][0] as string; const topCatSQL = mockSelect.mock.calls[0][0] as string;
const topCatParams = mockSelect.mock.calls[0][1] as unknown[]; const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1"); expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
expect(topCatSQL).toContain("t.date >= $2"); expect(topCatSQL).toContain("t.date >= $2");
expect(topCatSQL).toContain("t.date <= $3"); expect(topCatSQL).toContain("t.date <= $3");
expect(topCatSQL).toContain("t.source_id = $4"); expect(topCatSQL).toContain("t.source_id IN ($4)");
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]); expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
}); });
@ -492,6 +581,46 @@ describe("getCompareMonthOverMonth", () => {
expect(result[0].deltaAbs).toBe(50); expect(result[0].deltaAbs).toBe(50);
expect(result[0].cumulativeDeltaAbs).toBe(80); expect(result[0].cumulativeDeltaAbs).toBe(80);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, []);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareMonthOverMonth(2026, 4, [3, 7]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9, $10)");
expect(sql).not.toContain("3,7");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2026-03-01", "2026-03-31",
"2026-01-01", "2026-04-30",
"2026-01-01", "2026-03-31",
3, 7,
]);
});
}); });
describe("getCompareYearOverYear", () => { describe("getCompareYearOverYear", () => {
@ -549,6 +678,34 @@ describe("getCompareYearOverYear", () => {
expect(result[0].deltaPct).toBeCloseTo(50, 4); expect(result[0].deltaPct).toBeCloseTo(50, 4);
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4); expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
}); });
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).not.toContain("source_id");
expect(params).toHaveLength(8);
});
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getCompareYearOverYear(2026, 4, [9]);
const sql = mockSelect.mock.calls[0][0] as string;
const params = mockSelect.mock.calls[0][1] as unknown[];
expect(sql).toContain("AND t.source_id IN ($9)");
expect(params).toEqual([
"2026-04-01", "2026-04-30",
"2025-04-01", "2025-04-30",
"2026-01-01", "2026-04-30",
"2025-01-01", "2025-04-30",
9,
]);
});
}); });
describe("real-vs-real compare — transfer netting (Issue #243)", () => { describe("real-vs-real compare — transfer netting (Issue #243)", () => {

View file

@ -1,5 +1,6 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService"; import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
MonthlyTrendItem, MonthlyTrendItem,
CategoryBreakdownItem, CategoryBreakdownItem,
@ -29,7 +30,7 @@ import type {
export async function getMonthlyTrends( export async function getMonthlyTrends(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
sourceId?: number, accountIds?: number[],
): Promise<MonthlyTrendItem[]> { ): Promise<MonthlyTrendItem[]> {
const db = await getDb(); const db = await getDb();
@ -47,10 +48,11 @@ export async function getMonthlyTrends(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = const whereSQL =
@ -73,7 +75,7 @@ export async function getCategoryOverTime(
dateFrom?: string, dateFrom?: string,
dateTo?: string, dateTo?: string,
topN: number = 50, topN: number = 50,
sourceId?: number, accountIds?: number[],
typeFilter?: "expense" | "income" | "transfer", typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> { ): Promise<CategoryOverTimeData> {
const db = await getDb(); const db = await getDb();
@ -98,10 +100,11 @@ export async function getCategoryOverTime(
params.push(dateTo); params.push(dateTo);
paramIndex++; paramIndex++;
} }
if (sourceId != null) { const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
whereClauses.push(`t.source_id = $${paramIndex}`); if (accountPlaceholders) {
params.push(sourceId); whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
paramIndex++; params.push(...accountIds!);
paramIndex += accountIds!.length;
} }
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
@ -688,7 +691,7 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
* Minimal category metadata for building the compare tree. Fetched WITHOUT an * Minimal category metadata for building the compare tree. Fetched WITHOUT an
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry * `is_active` filter so soft-deleted categories (is_active = 0) that still carry
* historic transactions keep their place in the hierarchy matching the raw * historic transactions keep their place in the hierarchy matching the raw
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown). * LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
*/ */
type CompareCatMeta = TreeCatMeta; type CompareCatMeta = TreeCatMeta;
@ -699,7 +702,7 @@ const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM cat
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 }; const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/** /**
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a * Turns the flat per-category deltas returned by compareDeltaSql() into a
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of * parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
* getBudgetVsActualData. * getBudgetVsActualData.
* *
@ -883,8 +886,16 @@ function previousMonth(year: number, month: number): { year: number; month: numb
* rows still contribute only their outflows, byte-identical to before. The two * rows still contribute only their outflows, byte-identical to before. The two
* result lines (before/after transfers) are computed in ComparePeriodTable from * result lines (before/after transfers) are computed in ComparePeriodTable from
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'. * the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'.
*
* `accountIds` (Issue #273) adds an optional `t.source_id IN (...)` clause
* one bound placeholder per id, starting at $9 (the 8 date bounds occupy
* $1-$8). Omitted/empty no clause at all, so the query text and results stay
* byte-identical to the pre-#273 behavior.
*/ */
const COMPARE_DELTA_SQL = `SELECT function compareDeltaSql(accountIds?: number[]): string {
const accountPlaceholders = inPlaceholders(accountIds, 9);
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
return `SELECT
t.category_id, t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(c.color, '#9ca3af') AS category_color,
@ -900,20 +911,23 @@ const COMPARE_DELTA_SQL = `SELECT
OR (t.date >= $3 AND t.date <= $4) OR (t.date >= $3 AND t.date <= $4)
OR (t.date >= $5 AND t.date <= $6) OR (t.date >= $5 AND t.date <= $6)
OR (t.date >= $7 AND t.date <= $8) OR (t.date >= $7 AND t.date <= $8)
) )${accountFilter}
GROUP BY t.category_id, category_name, category_color GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(month_current_total - month_previous_total) DESC`; ORDER BY ABS(month_current_total - month_previous_total) DESC`;
}
/** /**
* Month-over-month expense delta by category. Returns both a monthly view * Month-over-month expense delta by category. Returns both a monthly view
* (reference month vs immediately-previous month) and a cumulative YTD view * (reference month vs immediately-previous month) and a cumulative YTD view
* (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative * (JanrefMonth of refYear vs JanprevMonth of refYear i.e. "cumulative
* progress through end of last month" vs "cumulative progress through end of * progress through end of last month" vs "cumulative progress through end of
* this month"). All SQL parameterised. * this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareMonthOverMonth( export async function getCompareMonthOverMonth(
year: number, year: number,
month: number, month: number,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curStart, end: curEnd } = monthBoundaries(year, month); const { start: curStart, end: curEnd } = monthBoundaries(year, month);
@ -931,16 +945,19 @@ export async function getCompareMonthOverMonth(
const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousStart = `${prev.year}-01-01`;
const cumPreviousEnd = prevEnd; const cumPreviousEnd = prevEnd;
const deltaParams: unknown[] = [
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the // Delta select stays first so its params/SQL remain `mock.calls[0]`; the
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards // category metadata (for the hierarchy) is fetched alongside. `?? []` guards
// under-specified mocks — db.select never returns undefined in production. // under-specified mocks — db.select never returns undefined in production.
const [rows, cats] = await Promise.all([ const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [ db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
curStart, curEnd,
prevStart, prevEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
]),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL), db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -952,11 +969,13 @@ export async function getCompareMonthOverMonth(
* view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the * view (JanrefMonth of refYear vs JanrefMonth of refYear - 1). Uses the
* reference year's December as the "current month" when no explicit * reference year's December as the "current month" when no explicit
* reference month is provided; callers typically pass the user's chosen * reference month is provided; callers typically pass the user's chosen
* reference month. All SQL parameterised. * reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
* scopes the whole report to a subset of import sources (`transactions.source_id`).
*/ */
export async function getCompareYearOverYear( export async function getCompareYearOverYear(
year: number, year: number,
month: number = 12, month: number = 12,
accountIds?: number[],
): Promise<CategoryDelta[]> { ): Promise<CategoryDelta[]> {
const db = await getDb(); const db = await getDb();
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month); const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
@ -967,14 +986,17 @@ export async function getCompareYearOverYear(
const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousStart = `${year - 1}-01-01`;
const cumPreviousEnd = prevMonthEnd; const cumPreviousEnd = prevMonthEnd;
const deltaParams: unknown[] = [
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
];
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
// See getCompareMonthOverMonth: delta select first, categories alongside. // See getCompareMonthOverMonth: delta select first, categories alongside.
const [rows, cats] = await Promise.all([ const [rows, cats] = await Promise.all([
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [ db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
]),
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL), db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
]); ]);
return buildCompareTree(rowsToDeltas(rows), cats ?? []); return buildCompareTree(rowsToDeltas(rows), cats ?? []);
@ -1217,11 +1239,19 @@ async function fetchSeasonality(
* via the monthly series instead of re-querying. * via the monthly series instead of re-querying.
* 4. Budget vs actual for the reference month. * 4. Budget vs actual for the reference month.
* 5. Seasonality: same calendar month across the two prior years. * 5. Seasonality: same calendar month across the two prior years.
*
* `accountIds` (Issue #273) is forwarded to the two sub-reports that already
* support it (`getCompareMonthOverMonth` for top movers, `getBudgetVsActualData`
* for budget adherence) omitting it here would let this dashboard silently
* ignore an active account filter even though its building blocks respect it.
* The KPI/sparkline/seasonality series (`fetchMonthlyFlows`, `fetchSeasonality`)
* do not take an account filter yet; that is out of scope for this issue.
*/ */
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
referenceMonth: number, referenceMonth: number,
mode: CartesKpiPeriodMode = "month", mode: CartesKpiPeriodMode = "month",
accountIds?: number[],
): Promise<CartesSnapshot> { ): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month // Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24. // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
@ -1233,8 +1263,8 @@ export async function getCartesSnapshot(
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([ const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1), fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
fetchMonthlyFlows(windowStartIso, refEnd), fetchMonthlyFlows(windowStartIso, refEnd),
getCompareMonthOverMonth(referenceYear, referenceMonth), getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth), getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]); ]);
// Index the flow rows by month for O(1) lookup, then fill missing months // Index the flow rows by month for O(1) lookup, then fill missing months
@ -1388,7 +1418,7 @@ export async function getCartesSnapshot(
// Top movers: biggest MoM increases / decreases. `momRows` now carries the // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. Keep only EXPENSE // parent group can't double-count against its own leaves. Keep only EXPENSE
// leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and // leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
// net transfers), momRows now also carries revenue rows — and this card is a // net transfers), momRows now also carries revenue rows — and this card is a
// spending view whose colours read "up = red" (more spending is bad), so a // spending view whose colours read "up = red" (more spending is bad), so a
// salary rise must not appear under "biggest increases" in red. Mirror the // salary rise must not appear under "biggest increases" in red. Mirror the

View file

@ -285,6 +285,17 @@ export interface RecentTransaction {
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual"; export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
/**
* Shared filter state for the report pages: the active period plus the
* account (import source) subset. `accountIds` is a list of `ImportSource.id`
* not to be confused with the Bilan module's `balance_accounts` ids, a
* disjoint id space. An empty list means "no filter" (all accounts).
*/
export interface ReportFilters {
period: DashboardPeriod;
accountIds: number[];
}
export interface CategoryDelta { export interface CategoryDelta {
categoryId: number | null; categoryId: number | null;
categoryName: string; categoryName: string;

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { inPlaceholders } from "./sqlFilters";
describe("inPlaceholders", () => {
it("returns null for undefined ids (no filter)", () => {
expect(inPlaceholders(undefined, 1)).toBeNull();
});
it("returns null for an empty array (no filter)", () => {
expect(inPlaceholders([], 1)).toBeNull();
});
it("builds a single placeholder for one id", () => {
expect(inPlaceholders([7], 1)).toBe("$1");
});
it("builds one placeholder per id, starting at startIndex", () => {
expect(inPlaceholders([3, 7, 12], 4)).toBe("$4, $5, $6");
});
it("never joins the raw id values into the string (CWE-89 guard)", () => {
const result = inPlaceholders([3, 7], 1);
expect(result).not.toContain("3");
expect(result).not.toContain("7");
expect(result).toBe("$1, $2");
});
});

16
src/utils/sqlFilters.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Shared helper for building a parameterized SQL `IN (...)` clause one
* bound placeholder per id, never a joined/interpolated value list (CWE-89).
* First introduced for the report services' optional account
* (`transactions.source_id`) filter (Issue #273); reused by every service
* that accepts an `accountIds?: number[]` filter so the placeholder
* bookkeeping (start index, one `$N` per id) lives in exactly one place.
*
* Returns `null` when `ids` is empty/undefined so callers can skip adding the
* clause entirely that is the "no filter" case, and the query must stay
* byte-identical to how it read before the filter existed.
*/
export function inPlaceholders(ids: number[] | undefined, startIndex: number): string | null {
if (!ids || ids.length === 0) return null;
return ids.map((_, i) => `$${startIndex + i}`).join(", ");
}