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