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