Compare commits
No commits in common. "fe9ae0118c8bf66e2a5ad5e45715acfd3d552299" and "a8e3775b8b778dd35db3ab57420046ae06ab466d" have entirely different histories.
fe9ae0118c
...
a8e3775b8b
26 changed files with 175 additions and 1202 deletions
|
|
@ -6,11 +6,6 @@
|
|||
|
||||
- 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 : 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é
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,6 @@
|
|||
|
||||
- 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: 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
|
||||
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
| `useDashboard` | Métriques du tableau de bord |
|
||||
| `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) |
|
||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
|
||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -7,13 +7,9 @@ import type { BudgetVsActualRow } from "../../shared/types";
|
|||
export interface CompareBudgetViewProps {
|
||||
year: 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, accountIds }: CompareBudgetViewProps) {
|
||||
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -21,7 +17,7 @@ export default function CompareBudgetView({ year, month, accountIds }: CompareBu
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
getBudgetVsActualData(year, month, accountIds)
|
||||
getBudgetVsActualData(year, month)
|
||||
.then((data) => {
|
||||
if (!cancelled) setRows(data);
|
||||
})
|
||||
|
|
@ -31,7 +27,7 @@ export default function CompareBudgetView({ year, month, accountIds }: CompareBu
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [year, month, accountIds]);
|
||||
}, [year, month]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
// 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
// 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
|
||||
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
|
||||
import {
|
||||
getAllActiveCategories,
|
||||
getBudgetEntriesForYear,
|
||||
|
|
@ -11,14 +11,11 @@ import {
|
|||
applyTemplate as applyTemplateSvc,
|
||||
deleteTemplate as deleteTemplateSvc,
|
||||
} from "../services/budgetService";
|
||||
import { getAllImportSources } from "../services/transactionService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
interface BudgetState {
|
||||
year: number;
|
||||
rows: BudgetYearRow[];
|
||||
templates: BudgetTemplate[];
|
||||
accounts: ImportSource[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -29,7 +26,6 @@ type BudgetAction =
|
|||
| { type: "SET_SAVING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||
| { type: "SET_YEAR"; payload: number };
|
||||
|
||||
function initialState(): BudgetState {
|
||||
|
|
@ -37,7 +33,6 @@ function initialState(): BudgetState {
|
|||
year: new Date().getFullYear(),
|
||||
rows: [],
|
||||
templates: [],
|
||||
accounts: [],
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
|
|
@ -59,8 +54,6 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
|||
templates: action.payload.templates,
|
||||
isLoading: false,
|
||||
};
|
||||
case "SET_ACCOUNTS":
|
||||
return { ...state, accounts: action.payload };
|
||||
case "SET_YEAR":
|
||||
return { ...state, year: action.payload };
|
||||
default:
|
||||
|
|
@ -71,11 +64,10 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
|||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||
|
||||
export function useBudget() {
|
||||
const { accountIds } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
||||
const refreshData = useCallback(async (year: number) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
|
@ -84,7 +76,7 @@ export function useBudget() {
|
|||
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
|
||||
getAllActiveCategories(),
|
||||
getBudgetEntriesForYear(year),
|
||||
getActualTotalsForYear(year - 1, ids),
|
||||
getActualTotalsForYear(year - 1),
|
||||
getAllTemplates(),
|
||||
]);
|
||||
|
||||
|
|
@ -372,21 +364,8 @@ export function useBudget() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshData(state.year, accountIds);
|
||||
}, [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) });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
refreshData(state.year);
|
||||
}, [state.year, refreshData]);
|
||||
|
||||
const navigateYear = useCallback((delta: -1 | 1) => {
|
||||
dispatch({ type: "SET_YEAR", payload: state.year + delta });
|
||||
|
|
@ -397,7 +376,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await upsertBudgetEntry(categoryId, state.year, month, amount);
|
||||
await refreshData(state.year, accountIds);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -407,7 +386,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, accountIds, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const splitEvenly = useCallback(
|
||||
|
|
@ -421,7 +400,7 @@ export function useBudget() {
|
|||
amounts.push(m < remainder ? base + 0.01 : base);
|
||||
}
|
||||
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
|
||||
await refreshData(state.year, accountIds);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -431,7 +410,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, accountIds, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const saveTemplate = useCallback(
|
||||
|
|
@ -444,7 +423,7 @@ export function useBudget() {
|
|||
.filter((r) => !r.is_parent && r.months[0] !== 0)
|
||||
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
|
||||
await saveAsTemplateSvc(name, description, entries);
|
||||
await refreshData(state.year, accountIds);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -454,7 +433,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.rows, state.year, accountIds, refreshData]
|
||||
[state.rows, state.year, refreshData]
|
||||
);
|
||||
|
||||
const applyTemplate = useCallback(
|
||||
|
|
@ -462,7 +441,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await applyTemplateSvc(templateId, state.year, month);
|
||||
await refreshData(state.year, accountIds);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -472,7 +451,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, accountIds, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const applyTemplateAllMonths = useCallback(
|
||||
|
|
@ -482,7 +461,7 @@ export function useBudget() {
|
|||
for (let m = 1; m <= 12; m++) {
|
||||
await applyTemplateSvc(templateId, state.year, m);
|
||||
}
|
||||
await refreshData(state.year, accountIds);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -492,7 +471,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, accountIds, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
const deleteTemplate = useCallback(
|
||||
|
|
@ -500,7 +479,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: true });
|
||||
try {
|
||||
await deleteTemplateSvc(templateId);
|
||||
await refreshData(state.year, accountIds);
|
||||
await refreshData(state.year);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
|
|
@ -510,7 +489,7 @@ export function useBudget() {
|
|||
dispatch({ type: "SET_SAVING", payload: false });
|
||||
}
|
||||
},
|
||||
[state.year, accountIds, refreshData]
|
||||
[state.year, refreshData]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type { CategoryDelta, ImportSource } from "../shared/types";
|
||||
import type { CategoryDelta } from "../shared/types";
|
||||
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
||||
import { getAllImportSources } from "../services/transactionService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
|
||||
|
||||
|
|
@ -14,7 +13,6 @@ interface State {
|
|||
year: number;
|
||||
month: number;
|
||||
rows: CategoryDelta[];
|
||||
accounts: ImportSource[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -25,7 +23,6 @@ type Action =
|
|||
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||
| { type: "SET_ERROR"; payload: string };
|
||||
|
||||
/**
|
||||
|
|
@ -96,7 +93,6 @@ const initialState: State = {
|
|||
year: defaultRef.year,
|
||||
month: defaultRef.month,
|
||||
rows: [],
|
||||
accounts: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -113,8 +109,6 @@ function reducer(state: State, action: Action): State {
|
|||
return { ...state, isLoading: action.payload };
|
||||
case "SET_ROWS":
|
||||
return { ...state, rows: action.payload, isLoading: false, error: null };
|
||||
case "SET_ACCOUNTS":
|
||||
return { ...state, accounts: action.payload };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
default:
|
||||
|
|
@ -123,26 +117,20 @@ function reducer(state: State, action: Action): State {
|
|||
}
|
||||
|
||||
export function useCompare() {
|
||||
const { from, to, accountIds } = useReportsPeriod();
|
||||
const { from, to } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(
|
||||
async (
|
||||
mode: CompareMode,
|
||||
subMode: CompareSubMode,
|
||||
year: number,
|
||||
month: number,
|
||||
ids: number[],
|
||||
) => {
|
||||
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
|
||||
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
const rows =
|
||||
subMode === "mom"
|
||||
? await getCompareMonthOverMonth(year, month, ids)
|
||||
: await getCompareYearOverYear(year, month, ids);
|
||||
? await getCompareMonthOverMonth(year, month)
|
||||
: await getCompareYearOverYear(year, month);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ROWS", payload: rows });
|
||||
} catch (e) {
|
||||
|
|
@ -154,21 +142,8 @@ export function useCompare() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.mode, state.subMode, state.year, state.month, accountIds);
|
||||
}, [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) });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
fetch(state.mode, state.subMode, state.year, state.month);
|
||||
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod";
|
||||
import { resolveReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
describe("resolveReportsPeriod", () => {
|
||||
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
||||
|
|
@ -51,76 +51,3 @@ describe("resolveReportsPeriod", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,43 +26,6 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string
|
|||
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
|
||||
* logic hookless and testable without rendering a router.
|
||||
|
|
@ -94,19 +57,15 @@ export interface UseReportsPeriodResult {
|
|||
from: string;
|
||||
to: string;
|
||||
period: DashboardPeriod;
|
||||
accountIds: number[];
|
||||
setPeriod: (period: DashboardPeriod) => void;
|
||||
setCustomDates: (from: string, to: string) => void;
|
||||
setAccountIds: (accountIds: number[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads/writes the active reporting period — and, additively, the account
|
||||
* (import source) filter — via the URL query string so both are bookmarkable
|
||||
* and shared across the report sub-routes.
|
||||
* Reads/writes the active reporting period via the URL query string so it is
|
||||
* bookmarkable and shared across the four report sub-routes.
|
||||
*
|
||||
* Defaults to the current civil year (Jan 1 → Dec 31) with no account filter
|
||||
* (`accountIds: []`, meaning all accounts).
|
||||
* Defaults to the current civil year (Jan 1 → Dec 31).
|
||||
*/
|
||||
export function useReportsPeriod(): UseReportsPeriodResult {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -114,15 +73,12 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
|||
const rawPeriod = searchParams.get("period");
|
||||
const rawFrom = searchParams.get("from");
|
||||
const rawTo = searchParams.get("to");
|
||||
const rawSources = searchParams.get("sources");
|
||||
|
||||
const { from, to, period } = useMemo(
|
||||
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
||||
[rawPeriod, rawFrom, rawTo],
|
||||
);
|
||||
|
||||
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
|
||||
|
||||
const setPeriod = useCallback(
|
||||
(next: DashboardPeriod) => {
|
||||
setSearchParams(
|
||||
|
|
@ -159,24 +115,5 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
|||
[setSearchParams],
|
||||
);
|
||||
|
||||
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 };
|
||||
return { from, to, period, setPeriod, setCustomDates };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||
import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
|
||||
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
|
||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||
import { getAllImportSources } from "../services/transactionService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
export type TrendsSubView = "global" | "byCategory";
|
||||
|
|
@ -10,7 +9,6 @@ interface State {
|
|||
subView: TrendsSubView;
|
||||
monthlyTrends: MonthlyTrendItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
accounts: ImportSource[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -20,14 +18,12 @@ type Action =
|
|||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||
| { type: "SET_ERROR"; payload: string };
|
||||
|
||||
const initialState: State = {
|
||||
subView: "byCategory",
|
||||
monthlyTrends: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
||||
accounts: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -42,8 +38,6 @@ function reducer(state: State, action: Action): State {
|
|||
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
||||
case "SET_CATEGORY_OVER_TIME":
|
||||
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
|
||||
case "SET_ACCOUNTS":
|
||||
return { ...state, accounts: action.payload };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
default:
|
||||
|
|
@ -52,21 +46,20 @@ function reducer(state: State, action: Action): State {
|
|||
}
|
||||
|
||||
export function useTrends() {
|
||||
const { from, to, accountIds } = useReportsPeriod();
|
||||
const { from, to } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(
|
||||
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
|
||||
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
|
||||
const id = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
try {
|
||||
if (subView === "global") {
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
|
||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_TRENDS", payload: data });
|
||||
} else {
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
|
||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||
}
|
||||
|
|
@ -74,27 +67,12 @@ export function useTrends() {
|
|||
if (id !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.subView, from, to, accountIds);
|
||||
}, [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) });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(state.subView, from, to);
|
||||
}, [fetch, state.subView, from, to]);
|
||||
|
||||
const setSubView = useCallback((sv: TrendsSubView) => {
|
||||
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -392,11 +392,7 @@
|
|||
"search": "Search...",
|
||||
"all": "All",
|
||||
"none": "None",
|
||||
"allTypes": "All types",
|
||||
"accounts": {
|
||||
"label": "Import sources",
|
||||
"hint": "No selection = all sources"
|
||||
}
|
||||
"allTypes": "All types"
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Monthly",
|
||||
|
|
|
|||
|
|
@ -392,11 +392,7 @@
|
|||
"search": "Rechercher...",
|
||||
"all": "Toutes",
|
||||
"none": "Aucune",
|
||||
"allTypes": "Tous les types",
|
||||
"accounts": {
|
||||
"label": "Sources d'import",
|
||||
"hint": "Aucune sélection = toutes les sources"
|
||||
}
|
||||
"allTypes": "Tous les types"
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Mensuel",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import FilterPanel from "../components/reports/FilterPanel";
|
||||
import { useBudget } from "../hooks/useBudget";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
import YearNavigator from "../components/budget/YearNavigator";
|
||||
import BudgetTable from "../components/budget/BudgetTable";
|
||||
import TemplateActions from "../components/budget/TemplateActions";
|
||||
|
||||
export default function BudgetPage() {
|
||||
const { t } = useTranslation();
|
||||
const { accountIds, setAccountIds } = useReportsPeriod();
|
||||
const {
|
||||
state,
|
||||
navigateYear,
|
||||
|
|
@ -21,15 +18,16 @@ export default function BudgetPage() {
|
|||
deleteTemplate,
|
||||
} = useBudget();
|
||||
|
||||
const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
|
||||
const { year, rows, templates, isLoading, isSaving, error } = state;
|
||||
|
||||
return (
|
||||
<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-4">
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
|
||||
<PageHelp helpKey="budget" />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<TemplateActions
|
||||
templates={templates}
|
||||
onApply={applyTemplate}
|
||||
|
|
@ -38,14 +36,9 @@ export default function BudgetPage() {
|
|||
onDelete={deleteTemplate}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<YearNavigator year={year} onNavigate={navigateYear} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import FilterPanel from "../components/reports/FilterPanel";
|
||||
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
||||
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
|
||||
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||
|
|
@ -26,8 +25,7 @@ function formatMonthLabel(year: number, month: number, language: string): string
|
|||
|
||||
export default function ReportsComparePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } =
|
||||
useReportsPeriod();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const {
|
||||
mode,
|
||||
subMode,
|
||||
|
|
@ -37,7 +35,6 @@ export default function ReportsComparePage() {
|
|||
year,
|
||||
month,
|
||||
rows,
|
||||
accounts,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCompare();
|
||||
|
|
@ -93,25 +90,21 @@ export default function ReportsComparePage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
temporalControl={
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<CompareReferenceMonthPicker
|
||||
year={year}
|
||||
month={month}
|
||||
onChange={setReferencePeriod}
|
||||
/>
|
||||
}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showActualControls && (
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
|
|
@ -120,7 +113,7 @@ export default function ReportsComparePage() {
|
|||
)}
|
||||
|
||||
{mode === "budget" ? (
|
||||
<CompareBudgetView year={year} month={month} accountIds={accountIds} />
|
||||
<CompareBudgetView year={year} month={month} />
|
||||
) : viewMode === "chart" ? (
|
||||
<ComparePeriodChart
|
||||
rows={rows}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import FilterPanel from "../components/reports/FilterPanel";
|
||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
|
|
@ -22,8 +21,8 @@ const STORAGE_KEY = "reports-viewmode-trends";
|
|||
|
||||
export default function ReportsTrendsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
|
||||
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
|
||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
|
||||
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
|
|
@ -56,8 +55,7 @@ export default function ReportsTrendsPage() {
|
|||
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
temporalControl={
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
|
|
@ -65,13 +63,7 @@ export default function ReportsTrendsPage() {
|
|||
customDateTo={to}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 items-center justify-end flex-wrap mb-6">
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<div className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -107,6 +99,7 @@ export default function ReportsTrendsPage() {
|
|||
)}
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { getDb } from "./db";
|
||||
import { inPlaceholders } from "../utils/sqlFilters";
|
||||
import type {
|
||||
Category,
|
||||
BudgetEntry,
|
||||
|
|
@ -182,47 +181,34 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
|||
// --- Actuals helpers ---
|
||||
|
||||
export async function getActualTotalsForYear(
|
||||
year: number,
|
||||
accountIds?: number[]
|
||||
year: number
|
||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||
const dateFrom = `${year}-01-01`;
|
||||
const dateTo = `${year}-12-31`;
|
||||
return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
|
||||
return getActualsByCategoryRange(dateFrom, dateTo);
|
||||
}
|
||||
|
||||
// --- Budget vs Actual ---
|
||||
|
||||
async function getActualsByCategoryRange(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
accountIds?: number[]
|
||||
dateTo: string
|
||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||
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 }>>(
|
||||
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
||||
FROM transactions
|
||||
WHERE date BETWEEN $1 AND $2${accountFilter}
|
||||
WHERE date BETWEEN $1 AND $2
|
||||
GROUP BY category_id`,
|
||||
params
|
||||
[dateFrom, dateTo]
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
year: number,
|
||||
month: number,
|
||||
accountIds?: number[]
|
||||
month: number
|
||||
): Promise<BudgetVsActualRow[]> {
|
||||
// Date ranges
|
||||
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
||||
|
|
@ -233,8 +219,8 @@ export async function getBudgetVsActualData(
|
|||
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
||||
getAllActiveCategories(),
|
||||
getBudgetEntriesForYear(year),
|
||||
getActualsByCategoryRange(monthFrom, monthTo, accountIds),
|
||||
getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
|
||||
getActualsByCategoryRange(monthFrom, monthTo),
|
||||
getActualsByCategoryRange(ytdFrom, ytdTo),
|
||||
]);
|
||||
|
||||
// Build maps
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { getDb } from "./db";
|
||||
import { inPlaceholders } from "../utils/sqlFilters";
|
||||
import type {
|
||||
DashboardSummary,
|
||||
CategoryBreakdownItem,
|
||||
|
|
@ -55,7 +54,7 @@ export async function getDashboardSummary(
|
|||
export async function getExpensesByCategory(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
accountIds?: number[],
|
||||
sourceId?: number,
|
||||
): Promise<CategoryBreakdownItem[]> {
|
||||
const db = await getDb();
|
||||
|
||||
|
|
@ -73,11 +72,10 @@ export async function getExpensesByCategory(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||
if (accountPlaceholders) {
|
||||
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
||||
params.push(...accountIds!);
|
||||
paramIndex += accountIds!.length;
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||
|
|
|
|||
|
|
@ -469,50 +469,4 @@ describe("getCartesSnapshot", () => {
|
|||
expect(worst.overrunAbs).toBe(150);
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getMonthlyTrends,
|
||||
getCategoryOverTime,
|
||||
getHighlights,
|
||||
getCompareMonthOverMonth,
|
||||
|
|
@ -30,66 +29,6 @@ beforeEach(() => {
|
|||
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", () => {
|
||||
it("builds query without WHERE clause when no filters are provided", async () => {
|
||||
// First call: top categories, second call: monthly breakdown
|
||||
|
|
@ -148,60 +87,32 @@ describe("getCategoryOverTime", () => {
|
|||
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
||||
});
|
||||
|
||||
it("applies accountIds filter (single id)", async () => {
|
||||
it("applies sourceId filter", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([]) // topCategories
|
||||
.mockResolvedValueOnce([]); // monthlyRows
|
||||
|
||||
await getCategoryOverTime(undefined, undefined, 50, [3]);
|
||||
await getCategoryOverTime(undefined, undefined, 50, 3);
|
||||
|
||||
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)");
|
||||
expect(topCatSQL).toContain("t.source_id");
|
||||
expect(topCatParams[0]).toBe(3);
|
||||
});
|
||||
|
||||
it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
|
||||
it("combines typeFilter, date range, and sourceId", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([]) // topCategories
|
||||
.mockResolvedValueOnce([]); // monthlyRows
|
||||
|
||||
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");
|
||||
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense");
|
||||
|
||||
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
|
||||
expect(topCatSQL).toContain("t.date >= $2");
|
||||
expect(topCatSQL).toContain("t.date <= $3");
|
||||
expect(topCatSQL).toContain("t.source_id IN ($4)");
|
||||
expect(topCatSQL).toContain("t.source_id = $4");
|
||||
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
||||
});
|
||||
|
||||
|
|
@ -581,46 +492,6 @@ describe("getCompareMonthOverMonth", () => {
|
|||
expect(result[0].deltaAbs).toBe(50);
|
||||
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", () => {
|
||||
|
|
@ -678,34 +549,6 @@ describe("getCompareYearOverYear", () => {
|
|||
expect(result[0].deltaPct).toBeCloseTo(50, 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)", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { getDb } from "./db";
|
||||
import { getBudgetVsActualData } from "./budgetService";
|
||||
import { inPlaceholders } from "../utils/sqlFilters";
|
||||
import type {
|
||||
MonthlyTrendItem,
|
||||
CategoryBreakdownItem,
|
||||
|
|
@ -30,7 +29,7 @@ import type {
|
|||
export async function getMonthlyTrends(
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
accountIds?: number[],
|
||||
sourceId?: number,
|
||||
): Promise<MonthlyTrendItem[]> {
|
||||
const db = await getDb();
|
||||
|
||||
|
|
@ -48,11 +47,10 @@ export async function getMonthlyTrends(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||
if (accountPlaceholders) {
|
||||
whereClauses.push(`source_id IN (${accountPlaceholders})`);
|
||||
params.push(...accountIds!);
|
||||
paramIndex += accountIds!.length;
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
|
|
@ -75,7 +73,7 @@ export async function getCategoryOverTime(
|
|||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
topN: number = 50,
|
||||
accountIds?: number[],
|
||||
sourceId?: number,
|
||||
typeFilter?: "expense" | "income" | "transfer",
|
||||
): Promise<CategoryOverTimeData> {
|
||||
const db = await getDb();
|
||||
|
|
@ -100,11 +98,10 @@ export async function getCategoryOverTime(
|
|||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||
if (accountPlaceholders) {
|
||||
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
||||
params.push(...accountIds!);
|
||||
paramIndex += accountIds!.length;
|
||||
if (sourceId != null) {
|
||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||
params.push(sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
|
@ -691,7 +688,7 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
|
|||
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
|
||||
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
|
||||
* historic transactions keep their place in the hierarchy — matching the raw
|
||||
* LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
|
||||
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
|
||||
*/
|
||||
type CompareCatMeta = TreeCatMeta;
|
||||
|
||||
|
|
@ -702,7 +699,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 };
|
||||
|
||||
/**
|
||||
* Turns the flat per-category deltas returned by compareDeltaSql() into a
|
||||
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
|
||||
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
||||
* getBudgetVsActualData.
|
||||
*
|
||||
|
|
@ -886,16 +883,8 @@ function previousMonth(year: number, month: number): { year: number; month: numb
|
|||
* rows still contribute only their outflows, byte-identical to before. The two
|
||||
* result lines (before/after transfers) are computed in ComparePeriodTable from
|
||||
* 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.
|
||||
*/
|
||||
function compareDeltaSql(accountIds?: number[]): string {
|
||||
const accountPlaceholders = inPlaceholders(accountIds, 9);
|
||||
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
|
||||
return `SELECT
|
||||
const COMPARE_DELTA_SQL = `SELECT
|
||||
t.category_id,
|
||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||
|
|
@ -911,23 +900,20 @@ function compareDeltaSql(accountIds?: number[]): string {
|
|||
OR (t.date >= $3 AND t.date <= $4)
|
||||
OR (t.date >= $5 AND t.date <= $6)
|
||||
OR (t.date >= $7 AND t.date <= $8)
|
||||
)${accountFilter}
|
||||
)
|
||||
GROUP BY t.category_id, category_name, category_color
|
||||
ORDER BY ABS(month_current_total - month_previous_total) DESC`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Month-over-month expense delta by category. Returns both a monthly view
|
||||
* (reference month vs immediately-previous month) and a cumulative YTD view
|
||||
* (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative
|
||||
* progress through end of last month" vs "cumulative progress through end of
|
||||
* this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
|
||||
* scopes the whole report to a subset of import sources (`transactions.source_id`).
|
||||
* this month"). All SQL parameterised.
|
||||
*/
|
||||
export async function getCompareMonthOverMonth(
|
||||
year: number,
|
||||
month: number,
|
||||
accountIds?: number[],
|
||||
): Promise<CategoryDelta[]> {
|
||||
const db = await getDb();
|
||||
const { start: curStart, end: curEnd } = monthBoundaries(year, month);
|
||||
|
|
@ -945,19 +931,16 @@ export async function getCompareMonthOverMonth(
|
|||
const cumPreviousStart = `${prev.year}-01-01`;
|
||||
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
|
||||
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
|
||||
// under-specified mocks — db.select never returns undefined in production.
|
||||
const [rows, cats] = await Promise.all([
|
||||
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
|
||||
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
|
||||
curStart, curEnd,
|
||||
prevStart, prevEnd,
|
||||
cumCurrentStart, cumCurrentEnd,
|
||||
cumPreviousStart, cumPreviousEnd,
|
||||
]),
|
||||
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
||||
]);
|
||||
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
||||
|
|
@ -969,13 +952,11 @@ export async function getCompareMonthOverMonth(
|
|||
* view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the
|
||||
* reference year's December as the "current month" when no explicit
|
||||
* reference month is provided; callers typically pass the user's chosen
|
||||
* reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
|
||||
* scopes the whole report to a subset of import sources (`transactions.source_id`).
|
||||
* reference month. All SQL parameterised.
|
||||
*/
|
||||
export async function getCompareYearOverYear(
|
||||
year: number,
|
||||
month: number = 12,
|
||||
accountIds?: number[],
|
||||
): Promise<CategoryDelta[]> {
|
||||
const db = await getDb();
|
||||
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
|
||||
|
|
@ -986,17 +967,14 @@ export async function getCompareYearOverYear(
|
|||
const cumPreviousStart = `${year - 1}-01-01`;
|
||||
const cumPreviousEnd = prevMonthEnd;
|
||||
|
||||
const deltaParams: unknown[] = [
|
||||
// See getCompareMonthOverMonth: delta select first, categories alongside.
|
||||
const [rows, cats] = await Promise.all([
|
||||
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
|
||||
curMonthStart, curMonthEnd,
|
||||
prevMonthStart, prevMonthEnd,
|
||||
cumCurrentStart, cumCurrentEnd,
|
||||
cumPreviousStart, cumPreviousEnd,
|
||||
];
|
||||
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
|
||||
|
||||
// See getCompareMonthOverMonth: delta select first, categories alongside.
|
||||
const [rows, cats] = await Promise.all([
|
||||
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
|
||||
]),
|
||||
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
||||
]);
|
||||
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
||||
|
|
@ -1239,19 +1217,11 @@ async function fetchSeasonality(
|
|||
* via the monthly series instead of re-querying.
|
||||
* 4. Budget vs actual for the reference month.
|
||||
* 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(
|
||||
referenceYear: number,
|
||||
referenceMonth: number,
|
||||
mode: CartesKpiPeriodMode = "month",
|
||||
accountIds?: number[],
|
||||
): Promise<CartesSnapshot> {
|
||||
// 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.
|
||||
|
|
@ -1263,8 +1233,8 @@ export async function getCartesSnapshot(
|
|||
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
|
||||
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
|
||||
fetchMonthlyFlows(windowStartIso, refEnd),
|
||||
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
|
||||
getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
|
||||
getCompareMonthOverMonth(referenceYear, referenceMonth),
|
||||
getBudgetVsActualData(referenceYear, referenceMonth),
|
||||
]);
|
||||
|
||||
// Index the flow rows by month for O(1) lookup, then fill missing months
|
||||
|
|
@ -1418,7 +1388,7 @@ export async function getCartesSnapshot(
|
|||
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
|
||||
// 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
|
||||
// leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
|
||||
// leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and
|
||||
// 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
|
||||
// salary rise must not appear under "biggest increases" in red. Mirror the
|
||||
|
|
|
|||
|
|
@ -285,17 +285,6 @@ export interface RecentTransaction {
|
|||
|
||||
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 {
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/**
|
||||
* 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(", ");
|
||||
}
|
||||
Loading…
Reference in a new issue