fix(reports): thread accountIds into Cartes KPI + seasonality series
All checks were successful
PR Check / rust (pull_request) Successful in 21m29s
PR Check / frontend (pull_request) Successful in 2m24s

getCartesSnapshot forwarded the account filter only to the top-movers and
budget sub-reports, leaving fetchMonthlyFlows (KPIs, sparklines, 12-month
overlay) and fetchSeasonality unfiltered. The Dashboard is the first page to
expose the account filter over these series, so its KPI cards showed
unfiltered totals while the category bars / trend / top-movers respected the
filter — figures that did not reconcile on the same screen.

Thread an optional accountIds through both fetchers (parameterized
`source_id IN (...)` via inPlaceholders) and pass it from getCartesSnapshot,
so the whole dashboard honours the filter — which is what the #279 CHANGELOG
entry already promises. No CHANGELOG change: the code now matches the note.

Resolves #279

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-11 21:16:09 -04:00
parent 193ef1d9dd
commit 9ee5ad353f
2 changed files with 58 additions and 17 deletions

View file

@ -470,13 +470,14 @@ describe("getCartesSnapshot", () => {
expect(worst.overrunPct).toBeCloseTo(75, 5); expect(worst.overrunPct).toBeCloseTo(75, 5);
}); });
// --- accountIds filter (Issue #273) --- // --- accountIds filter (Issues #273 / #279) ---
// //
// getCartesSnapshot must forward accountIds to its two sub-reports that // getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
// dashboard honours an active account filter: the two sub-reports that
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData // support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
// for budget adherence) — the review caveat that flagged this: omitting it // for budget adherence) AND the KPI/sparkline/seasonality series
// would let this dashboard silently ignore an active account filter even // (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
// though its own building blocks respect it. // dashboard silently mix filtered and unfiltered figures on the same screen.
it("forwards accountIds to the compare (top movers) sub-report", async () => { it("forwards accountIds to the compare (top movers) sub-report", async () => {
mockSelect.mockImplementation(() => Promise.resolve([])); mockSelect.mockImplementation(() => Promise.resolve([]));
@ -506,6 +507,32 @@ describe("getCartesSnapshot", () => {
} }
}); });
it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
const flowCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%Y-%m', date) AS month"),
)!;
expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("forwards accountIds to the seasonality series", async () => {
mockSelect.mockImplementation(() => Promise.resolve([]));
await getCartesSnapshot(2026, 3, "month", [3, 8]);
// fetchSeasonality is the only snapshot query filtering on a "%m" month.
const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
(sql as string).includes("strftime('%m', date) = $1"),
)!;
expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
});
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => { it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
mockSelect.mockImplementation(() => Promise.resolve([])); mockSelect.mockImplementation(() => Promise.resolve([]));

View file

@ -1186,18 +1186,25 @@ interface RawMonthFlow {
async function fetchMonthlyFlows( async function fetchMonthlyFlows(
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
accountIds?: number[],
): Promise<RawMonthFlow[]> { ): Promise<RawMonthFlow[]> {
const db = await getDb(); const db = await getDb();
const params: unknown[] = [dateFrom, dateTo];
const accountPlaceholders = inPlaceholders(accountIds, 3);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawMonthFlow[]>( return db.select<RawMonthFlow[]>(
`SELECT `SELECT
strftime('%Y-%m', date) AS month, strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions FROM transactions
WHERE date >= $1 AND date <= $2 WHERE date >= $1 AND date <= $2${accountClause}
GROUP BY month GROUP BY month
ORDER BY month ASC`, ORDER BY month ASC`,
[dateFrom, dateTo], params,
); );
} }
@ -1210,9 +1217,16 @@ async function fetchSeasonality(
month: number, month: number,
yearFrom: number, yearFrom: number,
yearTo: number, yearTo: number,
accountIds?: number[],
): Promise<RawSeasonalityRow[]> { ): Promise<RawSeasonalityRow[]> {
const db = await getDb(); const db = await getDb();
const mm = String(month).padStart(2, "0"); const mm = String(month).padStart(2, "0");
const params: unknown[] = [mm, yearFrom, yearTo];
const accountPlaceholders = inPlaceholders(accountIds, 4);
const accountClause = accountPlaceholders
? ` AND source_id IN (${accountPlaceholders})`
: "";
if (accountPlaceholders) params.push(...accountIds!);
return db.select<RawSeasonalityRow[]>( return db.select<RawSeasonalityRow[]>(
`SELECT `SELECT
CAST(strftime('%Y', date) AS INTEGER) AS year, CAST(strftime('%Y', date) AS INTEGER) AS year,
@ -1220,10 +1234,10 @@ async function fetchSeasonality(
FROM transactions FROM transactions
WHERE strftime('%m', date) = $1 WHERE strftime('%m', date) = $1
AND CAST(strftime('%Y', date) AS INTEGER) >= $2 AND CAST(strftime('%Y', date) AS INTEGER) >= $2
AND CAST(strftime('%Y', date) AS INTEGER) <= $3 AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
GROUP BY year GROUP BY year
ORDER BY year DESC`, ORDER BY year DESC`,
[mm, yearFrom, yearTo], params,
); );
} }
@ -1240,12 +1254,12 @@ async function fetchSeasonality(
* 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 * `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
* support it (`getCompareMonthOverMonth` for top movers, `getBudgetVsActualData` * dashboard honours an active account filter: the top-movers
* for budget adherence) omitting it here would let this dashboard silently * (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
* ignore an active account filter even though its building blocks respect it. * and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
* The KPI/sparkline/seasonality series (`fetchMonthlyFlows`, `fetchSeasonality`) * `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
* do not take an account filter yet; that is out of scope for this issue. * filtered and unfiltered figures on the same screen.
*/ */
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
@ -1261,8 +1275,8 @@ export async function getCartesSnapshot(
// Seasonality range: previous 2 years for the same calendar month. // Seasonality range: previous 2 years for the same calendar month.
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, accountIds),
fetchMonthlyFlows(windowStartIso, refEnd), fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds), getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
getBudgetVsActualData(referenceYear, referenceMonth, accountIds), getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
]); ]);