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
30 changes: 14 additions & 16 deletions src/actions/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { scopedQuery } from "@/lib/scoped-query";
import { authorizeAction } from "@/lib/auth/authorize-action";
import { getHouseholdId } from "@/lib/auth/session";
import { withHousehold } from "@/lib/household-context";
import { getTransactions, type TransactionRow } from "@/queries/transactions";
import {
getDrillDownTransactions as queryDrillDownTransactions,
type DrillDownFilters,
type DrillDownResult,
} from "@/queries/reports";

const saveReportSchema = z.object({
name: z.string().min(1).max(100),
Expand Down Expand Up @@ -83,21 +87,15 @@ export async function deleteReport(

const DRILL_DOWN_LIMIT = 50;

export async function getDrillDownTransactions(filters: {
/** A category id, `null` for uncategorized, `undefined` for no category filter. */
categoryId?: string | null;
dateFrom: string;
dateTo: string;
type?: "income" | "expense";
}): Promise<{ rows: TransactionRow[]; hasMore: boolean }> {
export async function getDrillDownTransactions(
filters: DrillDownFilters,
db: LedgrDb = defaultDb,
): Promise<DrillDownResult> {
const householdId = await getHouseholdId();

const page = await withHousehold(householdId, (tx) =>
getTransactions(householdId, {
categoryId: filters.categoryId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
}, DRILL_DOWN_LIMIT, undefined, tx));

return { rows: page.rows, hasMore: page.nextCursor !== null };
return withHousehold(
householdId,
(tx) => queryDrillDownTransactions(householdId, filters, DRILL_DOWN_LIMIT, tx),
db,
);
}
3 changes: 3 additions & 0 deletions src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ export default async function ReportsPage({
safeToSpendData={safeToSpendData}
isCurrentMonth={isCurrentMonth}
comparisonLabel={compLabel}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
/>
</div>
);
Expand Down
27 changes: 19 additions & 8 deletions src/components/organisms/drill-down-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,19 @@ interface DrillDownSheetProps {
filter: DrillDownFilter | null;
dateFrom: string;
dateTo: string;
accountIds?: string[];
onClose: () => void;
}

export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownSheetProps) {
export function DrillDownSheet({ filter, dateFrom, dateTo, accountIds, onClose }: DrillDownSheetProps) {
// Depend on the contents, not the array identity: the parent re-renders on
// every drill-down open and would otherwise hand us a fresh array each time.
const accountKey = accountIds?.join(",") ?? "";
const [isPending, startTransition] = useTransition();
const [rows, setRows] = useState<TransactionRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [total, setTotal] = useState(0);
const [matchCount, setMatchCount] = useState(0);

const effectiveDateFrom = filter?.month ? `${filter.month}-01` : dateFrom;
const effectiveDateTo = filter?.month
Expand All @@ -51,18 +57,20 @@ export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownS
categoryId: filter.categoryId,
dateFrom: effectiveDateFrom,
dateTo: effectiveDateTo,
type: filter.type,
accountIds: accountKey ? accountKey.split(",") : undefined,
// The Spending tab has one side only, so an absent type means expense.
type: filter.type ?? "expense",
});
setRows(result.rows);
setHasMore(result.hasMore);
setTotal(result.total);
setMatchCount(result.matchCount);
});
// Depend on the filter object itself, not its fields: an uncategorized
// drill-down carries `categoryId: null` alongside undefined month/type, which
// is field-for-field identical to the closed (null filter) state, so a field
// dependency list would never fire and the sheet would open empty.
}, [filter, effectiveDateFrom, effectiveDateTo]);

const totalAmount = rows.reduce((s, r) => s + r.normalizedAmount, 0);
}, [filter, effectiveDateFrom, effectiveDateTo, accountKey]);

const txnPageUrl = filter
? drillDownTransactionsUrl({
Expand All @@ -81,9 +89,12 @@ export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownS
<SheetTitle className="text-base">
{filter?.categoryName}
</SheetTitle>
{!isPending && rows.length > 0 && (
{!isPending && matchCount > 0 && (
<div className="text-sm text-muted-foreground tabular-nums">
{centsToDisplay(Math.abs(totalAmount))}
{centsToDisplay(total)}
<span className="ml-2 tabular-nums">
{matchCount} {matchCount === 1 ? "transaction" : "transactions"}
</span>
</div>
)}
</SheetHeader>
Expand All @@ -99,7 +110,7 @@ export function DrillDownSheet({ filter, dateFrom, dateTo, onClose }: DrillDownS
<>
{hasMore && (
<div className="text-xs text-muted-foreground px-2 pb-2">
Showing first {rows.length} transactions
Showing the most recent {rows.length} of {matchCount}
</div>
)}
<TransactionListPanel rows={rows} absoluteAmounts={filter?.tabContext === "Spending"} />
Expand Down
13 changes: 9 additions & 4 deletions src/components/organisms/report-cash-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { SankeyChart, type SankeyNode, type SankeyLink } from "@/components/orga
import { CashFlowBarChart } from "@/components/atoms/cash-flow-bar-chart";
import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar";
import { DrillDownSheet, type DrillDownFilter } from "@/components/organisms/drill-down-sheet";
import { useSearchParamFilters } from "@/hooks/use-search-param-filters";
import { resolvedCategoryLabel } from "@/lib/labels";
import type { IncomeExpenseRow, SafeToSpendResult } from "@/queries/reports";

Expand All @@ -16,6 +15,9 @@ interface ReportCashFlowProps {
barData: IncomeExpenseRow[];
safeToSpend: SafeToSpendResult;
isCurrentMonth: boolean;
dateFrom: string;
dateTo: string;
accountIds?: string[];
}

export function ReportCashFlow({
Expand All @@ -24,9 +26,11 @@ export function ReportCashFlow({
barData,
safeToSpend,
isCurrentMonth,
dateFrom,
dateTo,
accountIds,
}: ReportCashFlowProps) {
const [drillDown, setDrillDown] = useState<DrillDownFilter | null>(null);
const { dateRange } = useSearchParamFilters();

const safeColor: SummaryItem["color"] = (() => {
if (safeToSpend.monthlyIncome === 0) return "default";
Expand Down Expand Up @@ -89,8 +93,9 @@ export function ReportCashFlow({

<DrillDownSheet
filter={drillDown}
dateFrom={dateRange.from}
dateTo={dateRange.to}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
onClose={() => setDrillDown(null)}
/>
</div>
Expand Down
18 changes: 13 additions & 5 deletions src/components/organisms/report-income-expense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@ import { CashFlowBarChart } from "@/components/atoms/cash-flow-bar-chart";
import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar";
import { IncomeExpenseCategoryTable } from "@/components/molecules/income-expense-category-table";
import { DrillDownSheet, type DrillDownFilter } from "@/components/organisms/drill-down-sheet";
import { useSearchParamFilters } from "@/hooks/use-search-param-filters";
import { resolvedCategoryLabel } from "@/lib/labels";
import type { IncomeExpenseRow, IncomeExpenseCategoryRow } from "@/queries/reports";

interface ReportIncomeExpenseProps {
data: IncomeExpenseRow[];
categoryData?: IncomeExpenseCategoryRow[];
dateFrom: string;
dateTo: string;
accountIds?: string[];
}

export function ReportIncomeExpense({ data, categoryData }: ReportIncomeExpenseProps) {
export function ReportIncomeExpense({
data,
categoryData,
dateFrom,
dateTo,
accountIds,
}: ReportIncomeExpenseProps) {
const [drillDown, setDrillDown] = useState<DrillDownFilter | null>(null);
const { dateRange } = useSearchParamFilters();

const chartData = data.map((r) => ({
month: r.period,
Expand Down Expand Up @@ -61,8 +68,9 @@ export function ReportIncomeExpense({ data, categoryData }: ReportIncomeExpenseP
)}
<DrillDownSheet
filter={drillDown}
dateFrom={dateRange.from}
dateTo={dateRange.to}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
onClose={() => setDrillDown(null)}
/>
</div>
Expand Down
18 changes: 13 additions & 5 deletions src/components/organisms/report-spending.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,25 @@ import {
} from "@/components/ui/table";
import { centsToDisplay } from "@/lib/money";
import { CHART_COLORS } from "@/lib/chart-colors";
import { useSearchParamFilters } from "@/hooks/use-search-param-filters";
import type { SpendingRow } from "@/queries/reports";

interface ReportSpendingProps {
data: SpendingRow[];
comparisonLabel: string | null;
dateFrom: string;
dateTo: string;
accountIds?: string[];
}

export function ReportSpending({ data, comparisonLabel: compLabel }: ReportSpendingProps) {
export function ReportSpending({
data,
comparisonLabel: compLabel,
dateFrom,
dateTo,
accountIds,
}: ReportSpendingProps) {
const [view, setView] = useState<"donut" | "bar">("donut");
const [drillDown, setDrillDown] = useState<DrillDownFilter | null>(null);
const { dateRange } = useSearchParamFilters();

const chartData = data.map((r) => ({
id: r.categoryId,
Expand Down Expand Up @@ -126,8 +133,9 @@ export function ReportSpending({ data, comparisonLabel: compLabel }: ReportSpend

<DrillDownSheet
filter={drillDown}
dateFrom={dateRange.from}
dateTo={dateRange.to}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
onClose={() => setDrillDown(null)}
/>
</div>
Expand Down
33 changes: 31 additions & 2 deletions src/components/organisms/report-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ interface ReportTabsProps {
safeToSpendData?: SafeToSpendResult;
isCurrentMonth?: boolean;
comparisonLabel: string | null;
/**
* The range the figures on screen were actually computed over. Passed down
* rather than re-read from the URL, so a drill-down can never query a
* different period than the row that opened it — on a bare `/reports` the
* URL carries no dates at all, and the client-side fallback was all-time.
*/
dateFrom: string;
dateTo: string;
accountIds?: string[];
}

export function ReportTabs({
Expand All @@ -62,6 +71,9 @@ export function ReportTabs({
safeToSpendData,
isCurrentMonth,
comparisonLabel,
dateFrom,
dateTo,
accountIds,
}: ReportTabsProps) {
const { updateFilter } = useSearchParamFilters();

Expand Down Expand Up @@ -89,11 +101,25 @@ export function ReportTabs({
</TabsList>

<TabsContent value="spending" className="mt-4">
{spendingData && <ReportSpending data={spendingData} comparisonLabel={comparisonLabel} />}
{spendingData && (
<ReportSpending
data={spendingData}
comparisonLabel={comparisonLabel}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
/>
)}
</TabsContent>
<TabsContent value="income-expense" className="mt-4">
{incomeExpenseData && (
<ReportIncomeExpense data={incomeExpenseData} categoryData={incomeExpenseCategoryData} />
<ReportIncomeExpense
data={incomeExpenseData}
categoryData={incomeExpenseCategoryData}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
/>
)}
</TabsContent>
<TabsContent value="cash-flow" className="mt-4">
Expand All @@ -104,6 +130,9 @@ export function ReportTabs({
barData={cashFlowBarData}
safeToSpend={safeToSpendData}
isCurrentMonth={isCurrentMonth ?? false}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
/>
)}
</TabsContent>
Expand Down
38 changes: 35 additions & 3 deletions src/lib/spending-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, lt, gte, lte, inArray, notInArray, isNull } from "drizzle-orm";
import { eq, lt, gte, lte, inArray, notInArray, isNull, sql } from "drizzle-orm";
import { db as defaultDb, type LedgrDb } from "@/db";
import {
transactions,
Expand All @@ -9,7 +9,7 @@ import {
import { scopedQuery } from "@/lib/scoped-query";
import { notDeleted, sumAbs, sumCol } from "@/lib/query-helpers";
import { UNCATEGORIZED, resolvedCategoryLabel } from "@/lib/labels";
import { notIncome } from "@/queries/shared-conditions";
import { notIncome, getIncomeCategoryIds } from "@/queries/shared-conditions";
import type { ReportFilters } from "@/queries/reports";

export interface SpendingChartItem {
Expand All @@ -22,7 +22,13 @@ export interface SpendingChartItem {
}


async function spendingBaseConditions(householdId: string, filters: ReportFilters, db: LedgrDb) {
/**
* The one definition of spending. Every Reports tab that answers "how much did
* I spend?" builds on this, and so does the drill-down that explains any of
* those figures: a settled, non-transfer, negative charge outside an income
* category.
*/
export async function spendingBaseConditions(householdId: string, filters: ReportFilters, db: LedgrDb) {
const conditions = [
notDeleted(transactions),
lt(transactions.normalizedAmount, 0),
Expand All @@ -39,6 +45,32 @@ async function spendingBaseConditions(householdId: string, filters: ReportFilter
return conditions;
}

/**
* The income counterpart, matching the Sankey's income nodes and the Income vs
* Expense category table: settled, non-transfer rows in an income category,
* summed by magnitude. Note this is *not* the Total Income tile's definition,
* which also counts uncategorized credits — that gap is the open gross-vs-net
* product question, deliberately left alone here.
*/
export async function incomeBaseConditions(householdId: string, filters: ReportFilters, db: LedgrDb) {
const incomeCatIds = await getIncomeCategoryIds(householdId, db);
const conditions = [
notDeleted(transactions),
eq(transactions.pending, false),
eq(transactions.isTransfer, false),
isNull(transactions.transferPairId),
gte(transactions.date, filters.dateFrom),
lte(transactions.date, filters.dateTo),
incomeCatIds.size > 0
? inArray(transactions.categoryId, [...incomeCatIds])
: sql`false`,
];
if (filters.accountIds?.length) {
conditions.push(inArray(transactions.accountId, filters.accountIds));
}
return conditions;
}


async function findSplitParentIds(
scoped: ReturnType<typeof scopedQuery>,
Expand Down
Loading
Loading