From fecc2332e5a8a6e0acade662da6acaf6dadbdeba Mon Sep 17 00:00:00 2001 From: Sparsh Sam <110058692+sparshsam@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:55:16 -0400 Subject: [PATCH 1/3] feat: add Budget and Goal types to data model and persistence --- src/lib/data/persistence.ts | 20 ++++++++++++++++++-- src/lib/data/seed.ts | 2 ++ src/lib/data/types.ts | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/lib/data/persistence.ts b/src/lib/data/persistence.ts index 99d3e20..94e4c52 100644 --- a/src/lib/data/persistence.ts +++ b/src/lib/data/persistence.ts @@ -18,6 +18,8 @@ export function createDemoLedgerState(): PersistedLedgerState { memories: ledgerData.memories, forecastItems: ledgerData.forecastItems, importMetadata: ledgerData.importMetadata ?? [], + budgets: ledgerData.budgets ?? [], + goals: ledgerData.goals ?? [], }; } @@ -73,6 +75,8 @@ export function normalizeLedgerBackup(value: unknown, source: "saved" | "backup" const rawMemories = Array.isArray(value.memories) ? value.memories : demo.memories; const rawForecast = Array.isArray(value.forecastItems) ? value.forecastItems : demo.forecastItems; const rawMetadata = Array.isArray(value.importMetadata) ? value.importMetadata : []; + const rawBudgets = Array.isArray(value.budgets) ? value.budgets : []; + const rawGoals = Array.isArray(value.goals) ? value.goals : []; const validAccounts = rawAccounts .filter(isRecord) @@ -99,6 +103,12 @@ export function normalizeLedgerBackup(value: unknown, source: "saved" | "backup" const validMetadata = rawMetadata .filter(isRecord) .filter((m) => typeof m.id === "string" && typeof m.fileName === "string"); + const validBudgets = rawBudgets + .filter(isRecord) + .filter((b) => typeof b.id === "string" && typeof b.category === "string" && typeof b.month === "string" && typeof b.amount === "number"); + const validGoals = rawGoals + .filter(isRecord) + .filter((g) => typeof g.id === "string" && typeof g.name === "string" && typeof g.targetAmount === "number" && typeof g.currentAmount === "number"); const state: PersistedLedgerState = { schemaVersion: LEDGER_SCHEMA_VERSION, @@ -111,6 +121,8 @@ export function normalizeLedgerBackup(value: unknown, source: "saved" | "backup" memories: validMemories.length > 0 ? (validMemories as PersistedLedgerState["memories"]) : demo.memories, forecastItems: validForecast.length > 0 ? (validForecast as PersistedLedgerState["forecastItems"]) : demo.forecastItems, importMetadata: validMetadata.length > 0 ? (validMetadata as PersistedLedgerState["importMetadata"]) : [], + budgets: validBudgets.length > 0 ? (validBudgets as PersistedLedgerState["budgets"]) : [], + goals: validGoals.length > 0 ? (validGoals as PersistedLedgerState["goals"]) : [], }; const filteredCount = @@ -119,13 +131,17 @@ export function normalizeLedgerBackup(value: unknown, source: "saved" | "backup" rawSnapshots.length + rawMemories.length + rawForecast.length + - rawMetadata.length - + rawMetadata.length + + rawBudgets.length + + rawGoals.length - (validAccounts.length + validTransactions.length + validSnapshots.length + validMemories.length + validForecast.length + - validMetadata.length); + validMetadata.length + + validBudgets.length + + validGoals.length); const warning = filteredCount > 0 diff --git a/src/lib/data/seed.ts b/src/lib/data/seed.ts index d2f6a14..5802626 100644 --- a/src/lib/data/seed.ts +++ b/src/lib/data/seed.ts @@ -96,6 +96,8 @@ export const ledgerData: LedgerData = { { id: "low-1", date: "May 11 - May 13", label: "Low-cash pressure window", amount: 0, kind: "pressure" }, { id: "low-2", date: "May 26 - May 28", label: "Low-cash pressure window", amount: 0, kind: "pressure" }, ], + budgets: [], + goals: [], lifeCostEvents: [ { id: "bonus", month: "Dec 2025", label: "Year-end bonus", date: "Dec 24", kind: "income" }, { id: "insurance-car", month: "Jan 2026", label: "Car insurance", date: "Jan 5", kind: "recurring" }, diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 0118f6c..ca41806 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -82,6 +82,22 @@ export type LifeCostEvent = { kind: "income" | "large-expense" | "recurring"; }; +export type Budget = { + id: string; + category: string; + month: string; + amount: number; +}; + +export type Goal = { + id: string; + name: string; + targetAmount: number; + currentAmount: number; + targetDate?: string; + createdAt: string; +}; + export type LedgerData = { accounts: Account[]; transactions: Transaction[]; @@ -91,6 +107,8 @@ export type LedgerData = { memories: FinancialMemory[]; forecastItems: ForecastItem[]; lifeCostEvents: LifeCostEvent[]; + budgets: Budget[]; + goals: Goal[]; }; export type PersistedLedgerState = { @@ -102,4 +120,6 @@ export type PersistedLedgerState = { memories: FinancialMemory[]; forecastItems: ForecastItem[]; importMetadata: ImportMetadata[]; + budgets: Budget[]; + goals: Goal[]; }; From ac93b8c092ec9dc4de5dd2e0e8a593178175cf54 Mon Sep 17 00:00:00 2001 From: Sparsh Sam <110058692+sparshsam@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:55:59 -0400 Subject: [PATCH 2/3] feat: add budget and goal finance helpers with tests --- src/lib/finance/__tests__/budgets.test.ts | 85 +++++++++++++++++++++++ src/lib/finance/__tests__/goals.test.ts | 25 +++++++ src/lib/finance/budgets.ts | 34 +++++++++ src/lib/finance/goals.ts | 6 ++ src/lib/finance/index.ts | 2 + 5 files changed, 152 insertions(+) create mode 100644 src/lib/finance/__tests__/budgets.test.ts create mode 100644 src/lib/finance/__tests__/goals.test.ts create mode 100644 src/lib/finance/budgets.ts create mode 100644 src/lib/finance/goals.ts diff --git a/src/lib/finance/__tests__/budgets.test.ts b/src/lib/finance/__tests__/budgets.test.ts new file mode 100644 index 0000000..c8607fb --- /dev/null +++ b/src/lib/finance/__tests__/budgets.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { budgetUtilization, remainingBudget, isOverBudget, findOverBudget } from "../budgets"; +import type { Budget, Transaction } from "@/lib/data/types"; + +describe("budgetUtilization", () => { + it("calculates percentage of budget spent", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 500 }; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -200 }, + { id: "2", date: "2026-05-10", description: "Market", category: "Groceries", accountId: "a", amount: -150 }, + ]; + expect(budgetUtilization(budget, txns)).toBe(70); + }); + + it("returns 0 when no transactions match", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 500 }; + expect(budgetUtilization(budget, [])).toBe(0); + }); + + it("caps at 100 when over budget", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 100 }; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -200 }, + ]; + expect(budgetUtilization(budget, txns)).toBe(100); + }); +}); + +describe("remainingBudget", () => { + it("returns positive remaining amount", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 500 }; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -200 }, + ]; + expect(remainingBudget(budget, txns)).toBe(300); + }); + + it("returns negative when over budget", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 100 }; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -200 }, + ]; + expect(remainingBudget(budget, txns)).toBe(-100); + }); +}); + +describe("isOverBudget", () => { + it("returns true when spent exceeds budget", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 100 }; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -150 }, + ]; + expect(isOverBudget(budget, txns)).toBe(true); + }); + + it("returns false when within budget", () => { + const budget: Budget = { id: "b1", category: "Groceries", month: "2026-05", amount: 200 }; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -150 }, + ]; + expect(isOverBudget(budget, txns)).toBe(false); + }); +}); + +describe("findOverBudget", () => { + it("returns only budgets that are over budget", () => { + const budgets: Budget[] = [ + { id: "b1", category: "Groceries", month: "2026-05", amount: 100 }, + { id: "b2", category: "Rent", month: "2026-05", amount: 1600 }, + ]; + const txns: Transaction[] = [ + { id: "1", date: "2026-05-01", description: "Store", category: "Groceries", accountId: "a", amount: -150 }, + ]; + const over = findOverBudget(budgets, txns); + expect(over).toHaveLength(1); + expect(over[0].budget.id).toBe("b1"); + }); + + it("returns empty array when all budgets are on track", () => { + const budgets: Budget[] = [ + { id: "b1", category: "Groceries", month: "2026-05", amount: 200 }, + ]; + expect(findOverBudget(budgets, [])).toEqual([]); + }); +}); diff --git a/src/lib/finance/__tests__/goals.test.ts b/src/lib/finance/__tests__/goals.test.ts new file mode 100644 index 0000000..91ee579 --- /dev/null +++ b/src/lib/finance/__tests__/goals.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { goalProgress } from "../goals"; +import type { Goal } from "@/lib/data/types"; + +describe("goalProgress", () => { + it("returns percentage of target achieved", () => { + const goal: Goal = { id: "g1", name: "Emergency fund", targetAmount: 10000, currentAmount: 2500, createdAt: "2026-01-01" }; + expect(goalProgress(goal)).toBe(25); + }); + + it("returns 0 when no progress made", () => { + const goal: Goal = { id: "g1", name: "Emergency fund", targetAmount: 10000, currentAmount: 0, createdAt: "2026-01-01" }; + expect(goalProgress(goal)).toBe(0); + }); + + it("caps at 100 when target reached", () => { + const goal: Goal = { id: "g1", name: "Emergency fund", targetAmount: 10000, currentAmount: 15000, createdAt: "2026-01-01" }; + expect(goalProgress(goal)).toBe(100); + }); + + it("returns 0 when target is 0", () => { + const goal: Goal = { id: "g1", name: "Free", targetAmount: 0, currentAmount: 0, createdAt: "2026-01-01" }; + expect(goalProgress(goal)).toBe(0); + }); +}); diff --git a/src/lib/finance/budgets.ts b/src/lib/finance/budgets.ts new file mode 100644 index 0000000..29c7a67 --- /dev/null +++ b/src/lib/finance/budgets.ts @@ -0,0 +1,34 @@ +import type { Budget, Transaction } from "@/lib/data/types"; + +function spentInBudget(budget: Budget, transactions: Transaction[]): number { + return transactions + .filter((t) => t.category === budget.category && t.date.startsWith(budget.month) && t.amount < 0) + .reduce((sum, t) => sum + Math.abs(t.amount), 0); +} + +export function budgetUtilization(budget: Budget, transactions: Transaction[]): number { + const spent = spentInBudget(budget, transactions); + if (budget.amount === 0) return spent > 0 ? 100 : 0; + return Math.min(100, Math.round((spent / budget.amount) * 100)); +} + +export function remainingBudget(budget: Budget, transactions: Transaction[]): number { + return budget.amount - spentInBudget(budget, transactions); +} + +export function isOverBudget(budget: Budget, transactions: Transaction[]): boolean { + return remainingBudget(budget, transactions) < 0; +} + +export function findOverBudget( + budgets: Budget[], + transactions: Transaction[], +): Array<{ budget: Budget; spent: number; overBy: number }> { + return budgets + .map((b) => { + const spent = spentInBudget(b, transactions); + const overBy = spent - b.amount; + return { budget: b, spent, overBy }; + }) + .filter((r) => r.overBy > 0); +} diff --git a/src/lib/finance/goals.ts b/src/lib/finance/goals.ts new file mode 100644 index 0000000..8d8dca8 --- /dev/null +++ b/src/lib/finance/goals.ts @@ -0,0 +1,6 @@ +import type { Goal } from "@/lib/data/types"; + +export function goalProgress(goal: Goal): number { + if (goal.targetAmount <= 0) return 0; + return Math.min(100, Math.round((goal.currentAmount / goal.targetAmount) * 100)); +} diff --git a/src/lib/finance/index.ts b/src/lib/finance/index.ts index 92a4e58..8272a55 100644 --- a/src/lib/finance/index.ts +++ b/src/lib/finance/index.ts @@ -2,3 +2,5 @@ export * from "./totals"; export * from "./grouping"; export * from "./insights"; export * from "./trends"; +export * from "./budgets"; +export * from "./goals"; From d61e851d07c01270e0fe60c7284822541b17484b Mon Sep 17 00:00:00 2001 From: Sparsh Sam <110058692+sparshsam@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:59:44 -0400 Subject: [PATCH 3/3] feat: add budgets and goals --- CHANGELOG.md | 10 + README.md | 5 +- docs/architecture.md | 9 +- src/app/globals.css | 298 ++++++++++++++++++++++++++ src/app/page.tsx | 116 +++++++++- src/components/budgets-panel.tsx | 174 +++++++++++++++ src/components/cloud-backup-panel.tsx | 8 +- src/components/goals-panel.tsx | 206 ++++++++++++++++++ 8 files changed, 816 insertions(+), 10 deletions(-) create mode 100644 src/components/budgets-panel.tsx create mode 100644 src/components/goals-panel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 915bb6d..f48081a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to OpenLedger will be documented here. +## 0.5.0 — 2026-06-19 + +- Added monthly category budgets with create, edit, delete, progress bars, and overspending warnings. +- Added savings goals with target amounts, progress tracking, contribution support, and optional target dates. +- Added dashboard widgets for budget summary, over-budget categories, goal progress, and upcoming goal dates. +- Updated cloud backup to include budgets and goals in payload and restore preview. +- Added budget and goal finance helpers (budget utilization, remaining budget, overspending detection, goal progress) with 13 new unit tests. +- Added empty states for budgets and goals sections. +- All computations are local derivations from in-memory state. No changes to persistence schema version, auth, or storage keys. + ## 0.4.0 — 2026-06-19 - Redesigned dashboard with financial summary cards (income, expenses, net cash flow, net worth). diff --git a/README.md b/README.md index 3f4f62d..9b4e210 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,15 @@ It runs in the browser, stores the active ledger locally, supports CSV import an ## Current Status -**Maturity:** Maintained. v0.4.0 — dashboard with financial insights and improved transaction view; guest mode remains default. +**Maturity:** Maintained. v0.5.0 — budgets, savings goals, and enhanced dashboard widgets; guest mode remains default. OpenLedger is a **maintained early public MVP**. It is useful today as a browser-local ledger, but it is not a bank-connected finance platform and should not be treated as secure long-term storage for sensitive records. What exists now: +- Monthly category budgets with create, edit, delete, progress bars, and overspending warnings. +- Savings goals with target amounts, progress tracking, contribution support, and optional target dates. +- Budget and goal dashboard widgets (budget summary, over-budget alerts, goal progress). - Redesigned dashboard with financial summary cards (income, expenses, net cash flow, net worth). - SVG charts: spending by category, income vs expenses, account balance distribution, monthly trend. - Improved transactions view with search, date range filter, account/category/type filters, and sortable columns. diff --git a/docs/architecture.md b/docs/architecture.md index 1a6012c..53d4dc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,14 @@ Computation helpers live in `src/lib/finance/`. All functions are pure derivatio | `insights.ts` | Largest expense, top category, month-over-month change, recurring detection, low balance alerts | | `trends.ts` | Monthly trend series (income, expense, net per month) | -All finance functions are tested under `src/lib/finance/__tests__/` (28 tests across 4 test files). +All finance functions are tested under `src/lib/finance/__tests__/` (41 tests across 6 test files). Additional helpers: + +| Module | Purpose | +|--------|---------| +| `budgets.ts` | Budget utilization percentage, remaining budget, overspending detection | +| `goals.ts` | Goal progress percentage | + +Both `Budget` and `Goal` types join the `LedgerData` and `PersistedLedgerState` types. The backup payload already included `budgets` and `goals` fields — v0.5.0 populates them with real data. ## Supabase Foundation (v0.1.1) diff --git a/src/app/globals.css b/src/app/globals.css index 7c69b5e..49ff69f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1919,3 +1919,301 @@ select { .guidance-banner strong { color: var(--ink); } + +/* ── Budgets panel ── */ +.budgets-panel-section { + grid-column: 1 / -1; +} + +.budgets-panel { + display: grid; + gap: 20px; +} + +.budget-form { + border-bottom: 1px solid var(--line-dark); + padding-bottom: 16px; +} + +.budget-form-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} + +.budget-form-grid label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--muted); +} + +.budget-form-grid input, +.budget-form-grid select { + border: 1px solid var(--line-dark); + border-radius: 6px; + background: var(--graphite-2); + color: var(--ink); + padding: 8px 10px; + font-size: 14px; +} + +.budget-month-group { + display: grid; + gap: 10px; +} + +.budget-month-label { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: 16px; + font-weight: 500; + color: var(--paper-2); + border-bottom: 1px solid var(--line-dark); + padding-bottom: 6px; +} + +.budget-list { + display: grid; + gap: 8px; +} + +.budget-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 16px; + align-items: center; + border: 1px solid var(--line-dark); + border-radius: 6px; + background: var(--graphite-2); + padding: 12px 16px; +} + +.budget-row.over-budget { + border-color: var(--amber); + background: rgba(193, 136, 64, 0.08); +} + +.budget-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.budget-info strong { + font-size: 14px; + font-weight: 560; +} + +.budget-amounts { + font-size: 12px; + color: var(--muted); +} + +.budget-progress-section { + display: grid; + gap: 6px; +} + +.budget-bar-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: var(--line-dark); +} + +.budget-bar-fill { + height: 100%; + border-radius: inherit; + transition: width 300ms ease; +} + +.budget-bar-ok { + background: var(--sage); +} + +.budget-bar-warn { + background: var(--amber); +} + +.budget-bar-over { + background: var(--danger-soft); +} + +.budget-stats, +.goal-stats { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.budget-warn { + color: var(--amber); +} + +.budget-pct { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.budget-dashboard-summary { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} + +.budget-mini { + border: 1px solid var(--line-dark); + border-radius: 6px; + background: var(--graphite-2); + padding: 12px; + display: grid; + gap: 8px; +} + +.budget-mini.over-budget { + border-color: var(--amber); +} + +.budget-mini-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; +} + +.budget-mini-header strong { + font-weight: 560; +} + +/* ── Goals panel ── */ +.goals-panel-section { + grid-column: 1 / -1; +} + +.goals-panel { + display: grid; + gap: 20px; +} + +.goal-form { + border-bottom: 1px solid var(--line-dark); + padding-bottom: 16px; +} + +.goal-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} + +.goal-form-grid label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--muted); +} + +.goal-form-grid input { + border: 1px solid var(--line-dark); + border-radius: 6px; + background: var(--graphite-2); + color: var(--ink); + padding: 8px 10px; + font-size: 14px; +} + +.goal-list { + display: grid; + gap: 12px; +} + +.goal-row { + display: grid; + gap: 10px; + border: 1px solid var(--line-dark); + border-radius: 6px; + background: var(--graphite-2); + padding: 14px 16px; +} + +.goal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.goal-header strong { + font-size: 15px; + font-weight: 560; +} + +.goal-target { + font-size: 12px; + color: var(--muted); +} + +.goal-progress-section { + display: grid; + gap: 6px; +} + +.goal-meta { + display: flex; + gap: 20px; + font-size: 12px; + color: var(--muted); +} + +.goal-meta .negative { + color: var(--amber); +} + +.goal-actions { + display: flex; + gap: 6px; + border-top: 1px solid var(--line-dark); + padding-top: 10px; +} + +.goal-actions button { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--line-dark); + border-radius: 4px; + background: transparent; + color: var(--ink); + font-size: 12px; + padding: 5px 10px; + cursor: pointer; +} + +.goal-actions button:hover { + background: var(--line-dark); +} + +.contribute-form { + display: flex; + gap: 6px; + align-items: center; +} + +.contribute-form input { + width: 100px; + border: 1px solid var(--line-dark); + border-radius: 4px; + background: var(--graphite-2); + color: var(--ink); + padding: 5px 8px; + font-size: 13px; +} + +.goal-dashboard-list { + display: grid; + gap: 10px; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0036687..29bb1de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -59,16 +59,20 @@ import { saveLedgerState, } from "@/lib/data/persistence"; import { ledgerData } from "@/lib/data/seed"; -import type { Account, AccountKind, CategoryPattern, ImportMetadata, LifeCostEvent, MonthlySnapshot, Transaction } from "@/lib/data/types"; +import type { Account, AccountKind, Budget, CategoryPattern, Goal, ImportMetadata, LifeCostEvent, MonthlySnapshot, Transaction } from "@/lib/data/types"; import { PwaRegister } from "@/components/pwa-register"; import { SpendingByCategoryChart, IncomeVsExpensesChart, AccountBalancesChart, MonthlyTrendChart } from "@/components/charts"; import { DashboardSummary } from "@/components/dashboard-summary"; import { InsightsPanel } from "@/components/insights-panel"; import { TransactionsView } from "@/components/transactions-view"; import { GuestModeGuidance, CloudBackupGuidance, NoChartData } from "@/components/empty-states"; +import { BudgetsPanel } from "@/components/budgets-panel"; +import { GoalsPanel } from "@/components/goals-panel"; import { categoryTotals } from "@/lib/finance/grouping"; import { monthlyTrend } from "@/lib/finance/trends"; import { accountEffectiveBalance } from "@/lib/finance/totals"; +import { budgetUtilization, remainingBudget, isOverBudget } from "@/lib/finance/budgets"; +import { goalProgress } from "@/lib/finance/goals"; const currency = new Intl.NumberFormat("en-CA", { style: "currency", @@ -80,6 +84,8 @@ const navItems = [ { label: "Overview", icon: Archive }, { label: "Accounts", icon: WalletCards }, { label: "Transactions", icon: ReceiptText }, + { label: "Budgets", icon: PiggyBank }, + { label: "Goals", icon: Sparkles }, { label: "Memory", icon: Archive }, { label: "Forecast", icon: Cloud }, { label: "Settings", icon: Settings }, @@ -169,6 +175,8 @@ export default function Home() { const [memories, setMemories] = useState(ledgerData.memories); const [forecastItems, setForecastItems] = useState(ledgerData.forecastItems); const [importMetadata, setImportMetadata] = useState([]); + const [budgets, setBudgets] = useState(ledgerData.budgets); + const [goals, setGoals] = useState(ledgerData.goals); const [lastSavedAt, setLastSavedAt] = useState(null); const [storageNotice, setStorageNotice] = useState("Loading local ledger..."); const [hydrated, setHydrated] = useState(false); @@ -215,7 +223,7 @@ export default function Home() { () => accounts.map((a) => ({ ...a, balance: accountEffectiveBalance(a, transactions) })), [accounts, transactions], ); - const currentLedgerData = { ...ledgerData, accounts, transactions, monthlySnapshots, memories, forecastItems, importMetadata }; + const currentLedgerData = { ...ledgerData, accounts, transactions, monthlySnapshots, memories, forecastItems, importMetadata, budgets, goals }; const activeAccounts = accounts.filter((account) => !account.archivedAt); const accountsWithBalances = useMemo( () => @@ -294,11 +302,13 @@ export default function Home() { memories, forecastItems, importMetadata, + budgets, + goals, }); setLastSavedAt(saved.savedAt); setStorageNotice(nextSaveNoticeRef.current ?? "Local ledger saved."); nextSaveNoticeRef.current = null; - }, [accounts, forecastItems, hydrated, importMetadata, memories, monthlySnapshots, transactions]); + }, [accounts, budgets, forecastItems, goals, hydrated, importMetadata, memories, monthlySnapshots, transactions]); function applyLedgerState(state: ReturnType) { setAccounts(state.accounts); @@ -307,6 +317,8 @@ export default function Home() { setMemories(state.memories); setForecastItems(state.forecastItems); setImportMetadata(state.importMetadata); + setBudgets(state.budgets); + setGoals(state.goals); setSelectedAccountId(state.accounts[0]?.id ?? "chequing"); setSelectedMonth(state.monthlySnapshots[0]?.month ?? "2026-05"); setSelectedMemoryId(state.memories[0]?.id ?? "feb-2026"); @@ -391,11 +403,12 @@ export default function Home() { setStorageNotice("Local browser data cleared. Demo fallback is showing."); } - function handleRestoreFromCloud(payload: { accounts: unknown[]; transactions: unknown[] }) { + function handleRestoreFromCloud(payload: { accounts: unknown[]; transactions: unknown[]; budgets?: unknown[]; goals?: unknown[] }) { if (!window.confirm("Replace local ledger with cloud backup? Current local changes will be lost.")) return; - // Apply cloud data to local state if (payload.accounts.length > 0) setAccounts(payload.accounts as typeof ledgerData.accounts); if (payload.transactions.length > 0) setTransactions(payload.transactions as typeof ledgerData.transactions); + if (payload.budgets && payload.budgets.length > 0) setBudgets(payload.budgets as typeof ledgerData.budgets); + if (payload.goals && payload.goals.length > 0) setGoals(payload.goals as typeof ledgerData.goals); skipNextSaveCountRef.current = 1; nextSaveNoticeRef.current = "Local data restored from cloud backup."; } @@ -519,6 +532,43 @@ export default function Home() { if (selectedAccountId === account.id) setSelectedAccountId(activeAccounts.find((item) => item.id !== account.id)?.id ?? "chequing"); } + function saveBudget(budget: Budget) { + nextSaveNoticeRef.current = "Budget saved locally."; + setBudgets((current) => + budget.id && current.some((b) => b.id === budget.id) + ? current.map((b) => (b.id === budget.id ? budget : b)) + : [...current, budget], + ); + } + + function deleteBudget(id: string) { + if (!window.confirm("Delete this budget?")) return; + nextSaveNoticeRef.current = "Budget deleted locally."; + setBudgets((current) => current.filter((b) => b.id !== id)); + } + + function saveGoal(goal: Goal) { + nextSaveNoticeRef.current = "Goal saved locally."; + setGoals((current) => + goal.id && current.some((g) => g.id === goal.id) + ? current.map((g) => (g.id === goal.id ? goal : g)) + : [...current, goal], + ); + } + + function deleteGoal(id: string) { + if (!window.confirm("Delete this goal?")) return; + nextSaveNoticeRef.current = "Goal deleted locally."; + setGoals((current) => current.filter((g) => g.id !== id)); + } + + function contributeToGoal(id: string, amount: number) { + nextSaveNoticeRef.current = "Goal contribution added."; + setGoals((current) => + current.map((g) => (g.id === id ? { ...g, currentAmount: g.currentAmount + amount } : g)), + ); + } + return (
@@ -657,12 +707,66 @@ export default function Home() { + {budgets.length > 0 ? ( +
+

Budget Overview — {new Intl.DateTimeFormat("en-CA", { month: "long", year: "numeric" }).format(new Date())}

+
+ {budgets.filter((b) => b.month === `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`).map((b) => { + const util = budgetUtilization(b, transactions); + const remaining = remainingBudget(b, transactions); + const over = isOverBudget(b, transactions); + return ( +
+
+ {b.category} + {remaining >= 0 ? `$${remaining.toFixed(0)} left` : `$${Math.abs(remaining).toFixed(0)} over`} +
+
+
80 ? "budget-bar-warn" : "budget-bar-ok"}`} style={{ width: `${util}%` }} /> +
+
+ ); + })} +
+
+ ) : null} + + {goals.length > 0 ? ( +
+

Goal Progress

+
+ {goals.slice(0, 5).map((g) => { + const progress = goalProgress(g); + return ( +
+
+ {g.name} + {progress}% +
+
+
= 100 ? "budget-bar-ok" : "budget-bar-warn"}`} style={{ width: `${progress}%` }} /> +
+
+ ); + })} +
+
+ ) : null} +

Transactions

{authMode === "signed-in" ? : null} + ) : activeNav === "Budgets" ? ( + + + + ) : activeNav === "Goals" ? ( + + + ) : ( <> @@ -911,7 +1015,7 @@ export default function Home() { > diff --git a/src/components/budgets-panel.tsx b/src/components/budgets-panel.tsx new file mode 100644 index 0000000..6e9c17c --- /dev/null +++ b/src/components/budgets-panel.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Plus, Pencil, Trash2 } from "lucide-react"; +import type { Budget, Transaction } from "@/lib/data/types"; +import { budgetUtilization, remainingBudget, isOverBudget } from "@/lib/finance/budgets"; + +const currency = new Intl.NumberFormat("en-CA", { + style: "currency", + currency: "CAD", +}); + +const today = new Date(); +const defaultMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; + +const categoryOptions = [ + "Groceries", "Rent", "Food delivery", "Transport", "Subscriptions", + "Utilities", "Shopping", "Health", "Misc", "Debt", +]; + +type BudgetFormValues = { + id?: string; + category: string; + month: string; + amount: string; +}; + +export function BudgetsPanel({ + budgets, + transactions, + onSave, + onDelete, +}: { + budgets: Budget[]; + transactions: Transaction[]; + onSave: (budget: Budget) => void; + onDelete: (id: string) => void; +}) { + const [form, setForm] = useState({ + category: categoryOptions[0], + month: defaultMonth, + amount: "", + }); + const [editingId, setEditingId] = useState(null); + const [error, setError] = useState(""); + + const budgetsByMonth = useMemo(() => { + const grouped: Record = {}; + for (const b of budgets) { + if (!grouped[b.month]) grouped[b.month] = []; + grouped[b.month].push(b); + } + return grouped; + }, [budgets]); + + function handleSave() { + const amount = Number(form.amount); + if (!form.category || !form.month || !Number.isFinite(amount) || amount <= 0) { + setError("Enter a category, month, and positive budget amount."); + return; + } + onSave({ + id: form.id ?? `budget-${crypto.randomUUID()}`, + category: form.category, + month: form.month, + amount, + }); + setForm({ category: categoryOptions[0], month: defaultMonth, amount: "" }); + setEditingId(null); + setError(""); + } + + function handleEdit(b: Budget) { + setForm({ id: b.id, category: b.category, month: b.month, amount: String(b.amount) }); + setEditingId(b.id); + setError(""); + } + + function handleCancel() { + setForm({ category: categoryOptions[0], month: defaultMonth, amount: "" }); + setEditingId(null); + setError(""); + } + + return ( +
+
+

{editingId ? "Edit budget" : "New budget"}

+
+ + + +
+ {error ?

{error}

: null} +
+ + {editingId ? : null} +
+
+ + {budgets.length === 0 ? ( +
+ No budgets yet +

Create a budget to track your monthly spending by category.

+
+ ) : ( + Object.entries(budgetsByMonth) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([month, monthBudgets]) => ( +
+

+ {new Intl.DateTimeFormat("en-CA", { month: "long", year: "numeric" }).format(new Date(`${month}-01T12:00:00`))} +

+
+ {monthBudgets.map((b) => { + const util = budgetUtilization(b, transactions); + const remaining = remainingBudget(b, transactions); + const over = isOverBudget(b, transactions); + return ( +
+
+ {b.category} + + {currency.format(b.amount)} budgeted + +
+
+
+
80 ? "budget-bar-warn" : "budget-bar-ok"}`} + style={{ width: `${util}%` }} + /> +
+
+ + {remaining >= 0 ? `${currency.format(remaining)} left` : `${currency.format(Math.abs(remaining))} over`} + + {util}% +
+
+
+ + +
+
+ ); + })} +
+
+ )) + )} +
+ ); +} diff --git a/src/components/cloud-backup-panel.tsx b/src/components/cloud-backup-panel.tsx index de383cb..dff6551 100644 --- a/src/components/cloud-backup-panel.tsx +++ b/src/components/cloud-backup-panel.tsx @@ -15,6 +15,8 @@ type Props = { ledgerData: { accounts: unknown[]; transactions: unknown[]; + budgets?: unknown[]; + goals?: unknown[]; }; onRestore: (payload: BackupPayload) => void; }; @@ -51,8 +53,8 @@ export function CloudBackupPanel({ user, ledgerData, onRestore }: Props) { accounts: ledgerData.accounts, transactions: ledgerData.transactions, categories: [], - budgets: [], - goals: [], + budgets: ledgerData.budgets ?? [], + goals: ledgerData.goals ?? [], }; const result = await uploadBackup(payload); @@ -141,6 +143,8 @@ export function CloudBackupPanel({ user, ledgerData, onRestore }: Props) {
  • {preview.accounts.length} accounts
  • {preview.transactions.length} transactions
  • +
  • {preview.budgets?.length ?? 0} budgets
  • +
  • {preview.goals?.length ?? 0} goals
+ {editingId ? : null} +
+
+ + {goals.length === 0 ? ( +
+ No goals yet +

Set a savings goal to track your progress over time.

+
+ ) : ( +
+ {goals.map((g) => { + const progress = goalProgress(g); + const remaining = g.targetAmount - g.currentAmount; + const daysLeft = g.targetDate ? Math.ceil((new Date(g.targetDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) : null; + + return ( +
+
+ {g.name} + {currency.format(g.targetAmount)} goal +
+ +
+
+
= 100 ? "budget-bar-ok" : "budget-bar-warn"}`} + style={{ width: `${progress}%` }} + /> +
+
+ {currency.format(g.currentAmount)} saved + {progress}% +
+
+ +
+ {currency.format(remaining)} remaining + {daysLeft !== null ? ( + + {daysLeft > 0 ? `${daysLeft} days left` : "Past due"} + + ) : null} +
+ +
+ {contributeId === g.id ? ( +
+ setContributeAmount(e.target.value)} + placeholder="Amount" + autoFocus + /> + + +
+ ) : ( + <> + + + + + )} +
+
+ ); + })} +
+ )} +
+ ); +}