feat: add Budget vs Actual report tab with monthly and YTD comparison
Some checks failed
Release / build (windows-latest) (push) Has been cancelled

New tabular report showing actual vs budgeted amounts per category,
with dollar and percentage variations for both the selected month
and year-to-date. Includes parent/child hierarchy, type grouping,
variation coloring, and month navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-15 18:01:10 +00:00
parent 32dae2b7b2
commit 5e7c7e6609
10 changed files with 500 additions and 15 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.2.10", "version": "0.2.11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.2.10" version = "0.2.11"
description = "Personal finance management app" description = "Personal finance management app"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Résultat", "productName": "Simpl Résultat",
"version": "0.2.10", "version": "0.2.11",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -0,0 +1,192 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { BudgetVsActualRow } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(value);
const pctFormatter = (value: number | null) =>
value == null ? "—" : `${(value * 100).toFixed(1)}%`;
function variationColor(value: number): string {
if (value > 0) return "text-[var(--positive)]";
if (value < 0) return "text-[var(--negative)]";
return "";
}
interface BudgetVsActualTableProps {
data: BudgetVsActualRow[];
}
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.bva.noData")}
</div>
);
}
// Group rows by type for section headers
type SectionType = "expense" | "income" | "transfer";
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
const typeLabels: Record<SectionType, string> = {
expense: t("budget.expenses"),
income: t("budget.income"),
transfer: t("budget.transfers"),
};
let currentType: SectionType | null = null;
for (const row of data) {
if (row.category_type !== currentType) {
currentType = row.category_type;
sections.push({ type: currentType, label: typeLabels[currentType], rows: [] });
}
sections[sections.length - 1].rows.push(row);
}
// Grand totals (leaf rows only)
const leaves = data.filter((r) => !r.is_parent);
const totals = leaves.reduce(
(acc, r) => ({
monthActual: acc.monthActual + r.monthActual,
monthBudget: acc.monthBudget + r.monthBudget,
monthVariation: acc.monthVariation + r.monthVariation,
ytdActual: acc.ytdActual + r.ytdActual,
ytdBudget: acc.ytdBudget + r.ytdBudget,
ytdVariation: acc.ytdVariation + r.ytdVariation,
}),
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
);
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom">
{t("budget.category")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("reports.bva.monthly")}
</th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{t("budget.actual")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("budget.planned")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{sections.map((section) => (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]/50">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
{section.label}
</td>
</tr>
{section.rows.map((row) => {
const isParent = row.is_parent;
const isChild = row.parent_id !== null && !row.is_parent;
return (
<tr
key={`${row.category_id}-${row.is_parent}`}
className={`border-b border-[var(--border)]/50 ${
isParent ? "bg-[var(--muted)]/30 font-semibold" : ""
}`}
>
<td className={`px-3 py-1.5 ${isChild ? "pl-8" : ""}`}>
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
{row.category_name}
</span>
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.monthActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{cadFormatter(row.monthVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
{pctFormatter(row.monthVariationPct)}
</td>
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
{cadFormatter(row.ytdActual)}
</td>
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{cadFormatter(row.ytdVariation)}
</td>
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
{pctFormatter(row.ytdVariationPct)}
</td>
</tr>
);
})}
</Fragment>
))}
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)}
</td>
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)}
</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
{pctFormatter(totalMonthPct)}
</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)}
</td>
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View file

@ -5,9 +5,11 @@ import type {
MonthlyTrendItem, MonthlyTrendItem,
CategoryBreakdownItem, CategoryBreakdownItem,
CategoryOverTimeData, CategoryOverTimeData,
BudgetVsActualRow,
} from "../shared/types"; } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService"; import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
interface ReportsState { interface ReportsState {
tab: ReportTab; tab: ReportTab;
@ -15,6 +17,9 @@ interface ReportsState {
monthlyTrends: MonthlyTrendItem[]; monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[]; categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
budgetYear: number;
budgetMonth: number;
budgetVsActual: BudgetVsActualRow[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -26,7 +31,11 @@ type ReportsAction =
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] } | { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] } | { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }; | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] };
const now = new Date();
const initialState: ReportsState = { const initialState: ReportsState = {
tab: "trends", tab: "trends",
@ -34,6 +43,9 @@ const initialState: ReportsState = {
monthlyTrends: [], monthlyTrends: [],
categorySpending: [], categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getFullYear(),
budgetMonth: now.getMonth() + 1,
budgetVsActual: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -54,6 +66,10 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
return { ...state, categorySpending: action.payload, isLoading: false }; return { ...state, categorySpending: action.payload, isLoading: false };
case "SET_CATEGORY_OVER_TIME": case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false }; return { ...state, categoryOverTime: action.payload, isLoading: false };
case "SET_BUDGET_MONTH":
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_BUDGET_VS_ACTUAL":
return { ...state, budgetVsActual: action.payload, isLoading: false };
default: default:
return state; return state;
} }
@ -94,33 +110,45 @@ export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetchData = useCallback(async (tab: ReportTab, period: DashboardPeriod) => { const fetchData = useCallback(async (
tab: ReportTab,
period: DashboardPeriod,
budgetYear: number,
budgetMonth: number,
) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
const { dateFrom, dateTo } = computeDateRange(period);
switch (tab) { switch (tab) {
case "trends": { case "trends": {
const { dateFrom, dateTo } = computeDateRange(period);
const data = await getMonthlyTrends(dateFrom, dateTo); const data = await getMonthlyTrends(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break; break;
} }
case "byCategory": { case "byCategory": {
const { dateFrom, dateTo } = computeDateRange(period);
const data = await getExpensesByCategory(dateFrom, dateTo); const data = await getExpensesByCategory(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break; break;
} }
case "overTime": { case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period);
const data = await getCategoryOverTime(dateFrom, dateTo); const data = await getCategoryOverTime(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break; break;
} }
case "budgetVsActual": {
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
break;
}
} }
} catch (e) { } catch (e) {
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
@ -132,8 +160,8 @@ export function useReports() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(state.tab, state.period); fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth);
}, [state.tab, state.period, fetchData]); }, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]);
const setTab = useCallback((tab: ReportTab) => { const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab }); dispatch({ type: "SET_TAB", payload: tab });
@ -143,5 +171,18 @@ export function useReports() {
dispatch({ type: "SET_PERIOD", payload: period }); dispatch({ type: "SET_PERIOD", payload: period });
}, []); }, []);
return { state, setTab, setPeriod }; const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
let newMonth = state.budgetMonth + delta;
let newYear = state.budgetYear;
if (newMonth < 1) {
newMonth = 12;
newYear -= 1;
} else if (newMonth > 12) {
newMonth = 1;
newYear += 1;
}
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.budgetYear, state.budgetMonth]);
return { state, setTab, setPeriod, navigateBudgetMonth };
} }

View file

@ -331,6 +331,14 @@
"byCategory": "Expenses by Category", "byCategory": "Expenses by Category",
"overTime": "Category Over Time", "overTime": "Category Over Time",
"trends": "Monthly Trends", "trends": "Monthly Trends",
"budgetVsActual": "Budget vs Actual",
"bva": {
"monthly": "Monthly",
"ytd": "Year-to-Date",
"dollarVar": "$ Var",
"pctVar": "% Var",
"noData": "No budget or transaction data for this period."
},
"export": "Export", "export": "Export",
"help": { "help": {
"title": "How to use Reports", "title": "How to use Reports",

View file

@ -331,6 +331,14 @@
"byCategory": "Dépenses par catégorie", "byCategory": "Dépenses par catégorie",
"overTime": "Catégories dans le temps", "overTime": "Catégories dans le temps",
"trends": "Tendances mensuelles", "trends": "Tendances mensuelles",
"budgetVsActual": "Budget vs R\u00e9el",
"bva": {
"monthly": "Mensuel",
"ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
},
"export": "Exporter", "export": "Exporter",
"help": { "help": {
"title": "Comment utiliser les Rapports", "title": "Comment utiliser les Rapports",

View file

@ -4,12 +4,14 @@ import { useReports } from "../hooks/useReports";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types"; import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthNavigator from "../components/budget/MonthNavigator";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import CategoryBarChart from "../components/reports/CategoryBarChart"; import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import TransactionDetailModal from "../components/shared/TransactionDetailModal"; import TransactionDetailModal from "../components/shared/TransactionDetailModal";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"]; const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } { function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {}; if (period === "all") return {};
@ -31,7 +33,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
export default function ReportsPage() { export default function ReportsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setTab, setPeriod } = useReports(); const { state, setTab, setPeriod, navigateBudgetMonth } = useReports();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -60,10 +62,18 @@ export default function ReportsPage() {
<h1 className="text-2xl font-bold">{t("reports.title")}</h1> <h1 className="text-2xl font-bold">{t("reports.title")}</h1>
<PageHelp helpKey="reports" /> <PageHelp helpKey="reports" />
</div> </div>
<PeriodSelector value={state.period} onChange={setPeriod} /> {state.tab === "budgetVsActual" ? (
<MonthNavigator
year={state.budgetYear}
month={state.budgetMonth}
onNavigate={navigateBudgetMonth}
/>
) : (
<PeriodSelector value={state.period} onChange={setPeriod} />
)}
</div> </div>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6 flex-wrap">
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab} key={tab}
@ -104,6 +114,9 @@ export default function ReportsPage() {
onViewDetails={viewDetails} onViewDetails={viewDetails}
/> />
)} )}
{state.tab === "budgetVsActual" && (
<BudgetVsActualTable data={state.budgetVsActual} />
)}
{detailModal && ( {detailModal && (
<TransactionDetailModal <TransactionDetailModal

View file

@ -4,6 +4,7 @@ import type {
BudgetEntry, BudgetEntry,
BudgetTemplate, BudgetTemplate,
BudgetTemplateEntry, BudgetTemplateEntry,
BudgetVsActualRow,
} from "../shared/types"; } from "../shared/types";
function computeMonthDateRange(year: number, month: number) { function computeMonthDateRange(year: number, month: number) {
@ -176,3 +177,208 @@ export async function deleteTemplate(templateId: number): Promise<void> {
); );
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]); await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
} }
// --- Budget vs Actual ---
async function getActualsByCategoryRange(
dateFrom: string,
dateTo: string
): Promise<Array<{ category_id: number | null; actual: number }>> {
const db = await getDb();
return db.select<Array<{ category_id: number | null; actual: number }>>(
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
FROM transactions
WHERE date BETWEEN $1 AND $2
GROUP BY category_id`,
[dateFrom, dateTo]
);
}
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
export async function getBudgetVsActualData(
year: number,
month: number
): Promise<BudgetVsActualRow[]> {
// Date ranges
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
const ytdFrom = `${year}-01-01`;
const ytdTo = monthTo;
// Fetch all data in parallel
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
getAllActiveCategories(),
getBudgetEntriesForYear(year),
getActualsByCategoryRange(monthFrom, monthTo),
getActualsByCategoryRange(ytdFrom, ytdTo),
]);
// Build maps
const entryMap = new Map<number, Map<number, number>>();
for (const e of yearEntries) {
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
entryMap.get(e.category_id)!.set(e.month, e.amount);
}
const monthActualMap = new Map<number, number>();
for (const a of monthActuals) {
if (a.category_id != null) monthActualMap.set(a.category_id, a.actual);
}
const ytdActualMap = new Map<number, number>();
for (const a of ytdActuals) {
if (a.category_id != null) ytdActualMap.set(a.category_id, a.actual);
}
// Index categories
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, Category[]>();
for (const cat of allCategories) {
if (cat.parent_id) {
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
childrenByParent.get(cat.parent_id)!.push(cat);
}
}
// Sign multiplier: budget stored positive, expenses displayed negative
const signFor = (type: string) => (type === "expense" ? -1 : 1);
// Compute leaf row values
function buildLeaf(cat: Category, parentId: number | null): BudgetVsActualRow {
const sign = signFor(cat.type);
const monthMap = entryMap.get(cat.id);
const rawMonthBudget = monthMap?.get(month) ?? 0;
const monthBudget = rawMonthBudget * sign;
let rawYtdBudget = 0;
for (let m = 1; m <= month; m++) {
rawYtdBudget += monthMap?.get(m) ?? 0;
}
const ytdBudget = rawYtdBudget * sign;
const monthActual = monthActualMap.get(cat.id) ?? 0;
const ytdActual = ytdActualMap.get(cat.id) ?? 0;
const monthVariation = monthActual - monthBudget;
const ytdVariation = ytdActual - ytdBudget;
return {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: parentId,
is_parent: false,
monthActual,
monthBudget,
monthVariation,
monthVariationPct: monthBudget !== 0 ? monthVariation / Math.abs(monthBudget) : null,
ytdActual,
ytdBudget,
ytdVariation,
ytdVariationPct: ytdBudget !== 0 ? ytdVariation / Math.abs(ytdBudget) : null,
};
}
function isRowAllZero(r: BudgetVsActualRow): boolean {
return (
r.monthActual === 0 &&
r.monthBudget === 0 &&
r.ytdActual === 0 &&
r.ytdBudget === 0
);
}
const rows: BudgetVsActualRow[] = [];
const topLevel = allCategories.filter((c) => !c.parent_id);
for (const cat of topLevel) {
const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (children.length === 0 && cat.is_inputable) {
// Standalone leaf
const leaf = buildLeaf(cat, null);
if (!isRowAllZero(leaf)) rows.push(leaf);
} else if (children.length > 0) {
const childRows: BudgetVsActualRow[] = [];
// If parent is also inputable, create a "(direct)" child row
if (cat.is_inputable) {
const direct = buildLeaf(cat, cat.id);
direct.category_name = `${cat.name} (direct)`;
if (!isRowAllZero(direct)) childRows.push(direct);
}
for (const child of children) {
const leaf = buildLeaf(child, cat.id);
if (!isRowAllZero(leaf)) childRows.push(leaf);
}
// Skip parent entirely if all children were filtered out
if (childRows.length === 0) continue;
// Build parent subtotal from kept children
const parent: BudgetVsActualRow = {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: true,
monthActual: 0,
monthBudget: 0,
monthVariation: 0,
monthVariationPct: null,
ytdActual: 0,
ytdBudget: 0,
ytdVariation: 0,
ytdVariationPct: null,
};
for (const cr of childRows) {
parent.monthActual += cr.monthActual;
parent.monthBudget += cr.monthBudget;
parent.monthVariation += cr.monthVariation;
parent.ytdActual += cr.ytdActual;
parent.ytdBudget += cr.ytdBudget;
parent.ytdVariation += cr.ytdVariation;
}
parent.monthVariationPct =
parent.monthBudget !== 0 ? parent.monthVariation / Math.abs(parent.monthBudget) : null;
parent.ytdVariationPct =
parent.ytdBudget !== 0 ? parent.ytdVariation / Math.abs(parent.ytdBudget) : null;
rows.push(parent);
// Sort children: "(direct)" first, then alphabetical
childRows.sort((a, b) => {
if (a.category_id === cat.id) return -1;
if (b.category_id === cat.id) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...childRows);
}
}
// Sort by type, then within same type keep parent+children groups together
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id);
const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id);
if (groupA !== groupB) {
const catA = catById.get(groupA);
const catB = catById.get(groupB);
const orderA = catA?.sort_order ?? 999;
const orderB = catB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);
});
return rows;
}

View file

@ -274,7 +274,7 @@ export interface RecentTransaction {
// --- Report Types --- // --- Report Types ---
export type ReportTab = "trends" | "byCategory" | "overTime"; export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
export interface MonthlyTrendItem { export interface MonthlyTrendItem {
month: string; // "2025-01" month: string; // "2025-01"
@ -294,6 +294,23 @@ export interface CategoryOverTimeData {
categoryIds: Record<string, number | null>; categoryIds: Record<string, number | null>;
} }
export interface BudgetVsActualRow {
category_id: number;
category_name: string;
category_color: string;
category_type: "expense" | "income" | "transfer";
parent_id: number | null;
is_parent: boolean;
monthActual: number;
monthBudget: number;
monthVariation: number;
monthVariationPct: number | null;
ytdActual: number;
ytdBudget: number;
ytdVariation: number;
ytdVariationPct: number | null;
}
export type ImportWizardStep = export type ImportWizardStep =
| "source-list" | "source-list"
| "source-config" | "source-config"