Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
]);

Expand Down Expand Up @@ -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 (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>

<div className="flex items-start justify-between gap-2">
<ReportFilterBar accounts={accountOptions} categories={allCategories} />
<ReportFilterBar accounts={filterAccounts} categories={allCategories} />
<SavedReportPicker reports={savedReports} activeTab={tab} />
</div>

Expand Down
34 changes: 31 additions & 3 deletions src/components/organisms/report-filter-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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");
Expand Down Expand Up @@ -107,8 +113,8 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps)
<CommandInput placeholder="Search accounts..." className="h-8" />
<CommandList>
<CommandEmpty>No accounts found.</CommandEmpty>
<CommandGroup>
{accounts.map((a) => (
<CommandGroup heading="Active">
{activeAccounts.map((a) => (
<CommandItem key={a.id} onSelect={() => toggleAccount(a.id)}>
<Checkbox
checked={selectedAccountIds.includes(a.id)}
Expand All @@ -118,6 +124,28 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps)
</CommandItem>
))}
</CommandGroup>
{disconnectedAccounts.length > 0 && (
<CommandGroup heading="Disconnected">
{disconnectedAccounts.map((a) => (
<CommandItem key={a.id} onSelect={() => toggleAccount(a.id)}>
<Checkbox
checked={selectedAccountIds.includes(a.id)}
className="mr-2"
/>
<span className="flex min-w-0 flex-col">
<span className="truncate text-muted-foreground">{a.name}</span>
{/* The span is what tells a superseded account apart from
a duplicated one — it stops where its replacement starts. */}
{a.firstTxnDate && a.lastTxnDate && (
<span className="text-[10px] tabular-nums text-muted-foreground/70">
{formatTxnSpan(a.firstTxnDate, a.lastTxnDate)}
</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
Expand Down
16 changes: 15 additions & 1 deletion src/lib/date-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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");
});
});
24 changes: 24 additions & 0 deletions src/lib/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
}
77 changes: 74 additions & 3 deletions src/queries/accounts.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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<ReportFilterAccount[]> {
const scoped = scopedQuery(householdId, db);

const rows = await db
.select({
id: accounts.id,
name: accounts.name,
deletedAt: accounts.deletedAt,
txnCount: countRows(),
firstTxnDate: sql<string | null>`MIN(${transactions.date})`,
lastTxnDate: sql<string | null>`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);
});
}
71 changes: 71 additions & 0 deletions tests/integration/accounts-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading