From ae51a6b1a1758b7c72576cc2d8c978d6ff47c76b Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sat, 29 Aug 2026 17:24:25 -0700 Subject: [PATCH] fix(reports): surface disconnected accounts in the account filter Disconnecting an account soft-deletes it but leaves its transactions live, and every aggregate keeps counting them. The Reports account filter was built from notDeleted(accounts), so those transactions had no entry to filter by and no way to be drilled into -- silent inclusion with no control, which the issue calls the worst of both. Keep the history in the totals. It is real spending that happened, and on the reporting household the disconnected accounts cover Feb-May with their live replacements starting May 31, so excluding them would drop a contiguous period rather than remove a duplicate. What was missing was visibility, so: - getReportFilterAccounts() returns live AND soft-deleted accounts, each flagged, with its transaction count and date span. - The account popover splits into "Active" and "Disconnected" groups. Disconnected rows carry their span, which is what distinguishes a superseded account from a duplicated one. - formatTxnSpan() states the year once within a year and on both ends across one, so a range is never ambiguous. Aggregates are unchanged -- they already honour the accountIds filter, so ticking a box now does the right thing. Everything is ticked by default, so existing totals do not move. Closes #87. --- src/app/(dashboard)/reports/page.tsx | 9 +-- .../organisms/report-filter-bar.tsx | 34 +++++++- src/lib/date-utils.test.ts | 16 +++- src/lib/date-utils.ts | 24 ++++++ src/queries/accounts.ts | 77 ++++++++++++++++++- tests/integration/accounts-queries.test.ts | 71 +++++++++++++++++ 6 files changed, 219 insertions(+), 12 deletions(-) diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index a4be412c..dc3723a1 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -1,7 +1,7 @@ import { getHouseholdId } from "@/lib/auth/session"; import { withHousehold } from "@/lib/household-context"; import { getCategories } from "@/queries/categories"; -import { getAccounts } from "@/queries/accounts"; +import { getReportFilterAccounts } from "@/queries/accounts"; import { getSpendingByCategory, getIncomeVsExpense, @@ -64,7 +64,7 @@ export default async function ReportsPage({ // they run concurrently with the tab-specific query below. const sharedPromise = Promise.all([ getCategories(householdId), - getAccounts(householdId), + getReportFilterAccounts(householdId), getSavedReportsByHousehold(householdId), ]); @@ -114,15 +114,14 @@ export default async function ReportsPage({ const currentMonth = getCurrentMonth(); const isCurrentMonth = dateFrom <= `${currentMonth}-01` && dateTo >= `${currentMonth}-01`; - const [allCategories, allAccounts, savedReports] = await sharedPromise; - const accountOptions = allAccounts.map((a) => ({ id: a.id, name: a.name })); + const [allCategories, filterAccounts, savedReports] = await sharedPromise; return (

Reports

- +
diff --git a/src/components/organisms/report-filter-bar.tsx b/src/components/organisms/report-filter-bar.tsx index dfb32509..cc132f30 100644 --- a/src/components/organisms/report-filter-bar.tsx +++ b/src/components/organisms/report-filter-bar.tsx @@ -11,7 +11,7 @@ import { type DatePresetOption, } from "@/components/molecules/date-range-popover"; import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; -import { rangeToDateBounds, formatDateShort } from "@/lib/date-utils"; +import { rangeToDateBounds, formatDateShort, formatTxnSpan } from "@/lib/date-utils"; import type { CategoryGroup } from "@/queries/categories"; // Reports keeps its own preset ids (mapped to rangeToDateBounds + the server's @@ -27,6 +27,10 @@ const REPORT_DATE_OPTIONS: DatePresetOption[] = [ interface AccountOption { id: string; name: string; + /** Soft-deleted: the connection was removed, but its transactions remain. */ + disconnected: boolean; + firstTxnDate: string | null; + lastTxnDate: string | null; } interface ReportFilterBarProps { @@ -40,6 +44,8 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps) const [categoriesOpen, setCategoriesOpen] = useState(false); const selectedAccountIds = searchParams.get("accounts")?.split(",").filter(Boolean) ?? []; + const activeAccounts = accounts.filter((a) => !a.disconnected); + const disconnectedAccounts = accounts.filter((a) => a.disconnected); const selectedCategoryIds = searchParams.get("categories")?.split(",").filter(Boolean) ?? []; const fromParam = searchParams.get("from"); @@ -107,8 +113,8 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps) No accounts found. - - {accounts.map((a) => ( + + {activeAccounts.map((a) => ( toggleAccount(a.id)}> ))} + {disconnectedAccounts.length > 0 && ( + + {disconnectedAccounts.map((a) => ( + toggleAccount(a.id)}> + + + {a.name} + {/* The span is what tells a superseded account apart from + a duplicated one — it stops where its replacement starts. */} + {a.firstTxnDate && a.lastTxnDate && ( + + {formatTxnSpan(a.firstTxnDate, a.lastTxnDate)} + + )} + + + ))} + + )} diff --git a/src/lib/date-utils.test.ts b/src/lib/date-utils.test.ts index 58470e0e..b79dfa98 100644 --- a/src/lib/date-utils.test.ts +++ b/src/lib/date-utils.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, vi, afterEach } from "vitest"; import { test as fcTest } from "@fast-check/vitest"; import { fc } from "@fast-check/vitest"; -import { rangeToDateBounds, monthBounds, shiftDateRange, comparisonLabel, todayDateString } from "./date-utils"; +import { rangeToDateBounds, monthBounds, shiftDateRange, comparisonLabel, formatTxnSpan, todayDateString } from "./date-utils"; describe("todayDateString", () => { afterEach(() => { @@ -122,3 +122,17 @@ describe("comparisonLabel", () => { expect(result).toContain("Mar"); }); }); + +describe("formatTxnSpan", () => { + test("formats a span inside one year without repeating the year", () => { + expect(formatTxnSpan("2026-02-11", "2026-05-11")).toBe("Feb 11 – May 11, 2026"); + }); + + test("shows both years when the span crosses a year boundary", () => { + expect(formatTxnSpan("2025-11-20", "2026-01-08")).toBe("Nov 20, 2025 – Jan 8, 2026"); + }); + + test("collapses a single-day span to one date", () => { + expect(formatTxnSpan("2026-03-04", "2026-03-04")).toBe("Mar 4, 2026"); + }); +}); diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index ee559978..54212507 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -147,3 +147,27 @@ export function relativeDateLabel(dateStr: string): string { if (diffDays <= 7) return `in ${diffDays} days`; return target.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } + +/** + * Label the span of dates an account's transactions cover, e.g. + * "Feb 11 – May 11, 2026". Used on disconnected accounts in the Reports + * filter, where the span is what distinguishes a superseded account from a + * duplicated one — the old account stops where its replacement begins. + * + * The year is stated once when the span sits inside a single year, and on + * both ends when it crosses one, so the range is never ambiguous. + */ +export function formatTxnSpan(from: string, to: string): string { + const fromDate = new Date(from + "T00:00:00"); + const toDate = new Date(to + "T00:00:00"); + const md = (d: Date) => + d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const mdy = (d: Date) => + d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + + if (from === to) return mdy(fromDate); + if (fromDate.getFullYear() !== toDate.getFullYear()) { + return `${mdy(fromDate)} – ${mdy(toDate)}`; + } + return `${md(fromDate)} – ${mdy(toDate)}`; +} diff --git a/src/queries/accounts.ts b/src/queries/accounts.ts index 5f9e5859..29948b38 100644 --- a/src/queries/accounts.ts +++ b/src/queries/accounts.ts @@ -1,8 +1,8 @@ -import { inArray } from "drizzle-orm"; +import { inArray, and, eq, sql } from "drizzle-orm"; import { db as defaultDb, type LedgrDb } from "@/db"; -import { accounts, bankConnections, institutionLogos, ACCOUNT_TYPES, type ConnectionStatus, type BankProvider } from "@/db/schema"; +import { accounts, bankConnections, institutionLogos, transactions, ACCOUNT_TYPES, type ConnectionStatus, type BankProvider } from "@/db/schema"; import { scopedQuery } from "@/lib/scoped-query"; -import { notDeleted } from "@/lib/query-helpers"; +import { notDeleted, countRows } from "@/lib/query-helpers"; import { classifyAccountType } from "@/lib/account-utils"; export async function getAccounts(householdId: string, db: LedgrDb = defaultDb) { @@ -142,3 +142,74 @@ export async function getAccountSummary( netWorth: totalAssets + totalLiabilities, }; } + +export interface ReportFilterAccount { + id: string; + name: string; + disconnected: boolean; + txnCount: number; + firstTxnDate: string | null; + lastTxnDate: string | null; +} + +/** + * Accounts for the Reports filter bar — live accounts AND soft-deleted ones. + * + * Disconnecting an account leaves its transactions live and still counted in + * every aggregate (see #87). Building the filter from `notDeleted(accounts)` + * alone meant those transactions had no entry to filter by and no way to be + * drilled into: silent inclusion with no control. The history is real spending, + * so it stays in the totals; this query is what makes it visible and tickable. + * + * The per-account transaction span is what distinguishes a superseded account + * from a duplicated one, so the popover can show that the old account stops + * where its replacement begins. + */ +export async function getReportFilterAccounts( + householdId: string, + db: LedgrDb = defaultDb, +): Promise { + const scoped = scopedQuery(householdId, db); + + const rows = await db + .select({ + id: accounts.id, + name: accounts.name, + deletedAt: accounts.deletedAt, + txnCount: countRows(), + firstTxnDate: sql`MIN(${transactions.date})`, + lastTxnDate: sql`MAX(${transactions.date})`, + }) + .from(accounts) + // LEFT JOIN so an account with no transactions still appears, and the + // household predicate is repeated on the joined side: scopedQuery only + // constrains the driving table. + .leftJoin( + transactions, + and( + eq(transactions.accountId, accounts.id), + eq(transactions.householdId, householdId), + notDeleted(transactions), + ), + ) + .where(scoped.where(accounts)) + .groupBy(accounts.id, accounts.name, accounts.deletedAt); + + return rows + .map((r) => ({ + id: r.id, + name: r.name, + disconnected: r.deletedAt !== null, + // count(*) over a LEFT JOIN counts the null-filled row, so an account + // with no transactions would report 1 rather than 0. + txnCount: r.firstTxnDate === null ? 0 : Number(r.txnCount), + firstTxnDate: r.firstTxnDate, + lastTxnDate: r.lastTxnDate, + })) + .sort((a, b) => { + // Live accounts first — the popover renders in this order, and a + // disconnected account should never outrank one the user still holds. + if (a.disconnected !== b.disconnected) return a.disconnected ? 1 : -1; + return a.name.localeCompare(b.name); + }); +} diff --git a/tests/integration/accounts-queries.test.ts b/tests/integration/accounts-queries.test.ts index f5b5fbf2..7a4835af 100644 --- a/tests/integration/accounts-queries.test.ts +++ b/tests/integration/accounts-queries.test.ts @@ -6,8 +6,10 @@ import { getAccounts, getAccountsByInstitution, getAccountSummary, + getReportFilterAccounts, } from "@/queries/accounts"; import { accounts, bankConnections } from "@/db/schema"; +import { insertTransaction } from "./helpers"; import type { LedgrDb } from "@/db"; describe("account queries", () => { @@ -64,6 +66,75 @@ describe("account queries", () => { expect(result[0].name).toBe("Active"); }); + describe("getReportFilterAccounts", () => { + it("returns disconnected accounts alongside live ones, flagged", async () => { + const hh = await provisionHousehold("user-filter-1", db); + await insertAccount(db, hh, { name: "Live Checking" }); + await insertAccount(db, hh, { name: "Old Checking", deletedAt: new Date("2026-05-20") }); + + const result = await getReportFilterAccounts(hh, db); + + expect(result.map((a) => a.name).sort()).toEqual(["Live Checking", "Old Checking"]); + expect(result.find((a) => a.name === "Live Checking")!.disconnected).toBe(false); + expect(result.find((a) => a.name === "Old Checking")!.disconnected).toBe(true); + }); + + it("sorts live accounts ahead of disconnected ones", async () => { + const hh = await provisionHousehold("user-filter-2", db); + // "Zeta" sorts last alphabetically but is live, so it must still come + // before the disconnected "Alpha" -- the popover renders in this order. + await insertAccount(db, hh, { name: "Alpha", deletedAt: new Date("2026-05-20") }); + await insertAccount(db, hh, { name: "Zeta" }); + + const result = await getReportFilterAccounts(hh, db); + + expect(result.map((a) => a.name)).toEqual(["Zeta", "Alpha"]); + }); + + it("reports the transaction date range of a disconnected account", async () => { + const hh = await provisionHousehold("user-filter-3", db); + const accountId = await insertAccount(db, hh, { + name: "Old Checking", + deletedAt: new Date("2026-05-20"), + }); + await insertTransaction(db, hh, accountId, { date: "2026-02-11" }); + await insertTransaction(db, hh, accountId, { date: "2026-05-11" }); + await insertTransaction(db, hh, accountId, { date: "2026-03-02" }); + + const [account] = await getReportFilterAccounts(hh, db); + + expect(account.txnCount).toBe(3); + expect(account.firstTxnDate).toBe("2026-02-11"); + expect(account.lastTxnDate).toBe("2026-05-11"); + }); + + it("reports a zero count and null range for an account with no transactions", async () => { + const hh = await provisionHousehold("user-filter-4", db); + await insertAccount(db, hh, { name: "Empty" }); + + const [account] = await getReportFilterAccounts(hh, db); + + expect(account.txnCount).toBe(0); + expect(account.firstTxnDate).toBeNull(); + expect(account.lastTxnDate).toBeNull(); + }); + + it("does not leak accounts or counts across households", async () => { + const mine = await provisionHousehold("user-filter-5", db); + const theirs = await provisionHousehold("user-filter-6", db); + const accountId = await insertAccount(db, mine, { name: "Mine" }); + await insertTransaction(db, mine, accountId, { date: "2026-04-01" }); + const otherId = await insertAccount(db, theirs, { name: "Theirs" }); + await insertTransaction(db, theirs, otherId, { date: "2026-04-02" }); + + const result = await getReportFilterAccounts(mine, db); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Mine"); + expect(result[0].txnCount).toBe(1); + }); + }); + it("getAccountsByInstitution groups Plaid accounts under institution, manual under 'Manual Accounts'", async () => { const hh = await provisionHousehold("user-2", db); const itemId = await insertPlaidItem(db, hh);