diff --git a/openspec/changes/monthly-expense-tracker-v1/tasks.md b/openspec/changes/monthly-expense-tracker-v1/tasks.md
index 6f193e0..7a1bd61 100644
--- a/openspec/changes/monthly-expense-tracker-v1/tasks.md
+++ b/openspec/changes/monthly-expense-tracker-v1/tasks.md
@@ -20,8 +20,8 @@
## 4. Dashboard and insights
-- [ ] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
-- [ ] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons.
+- [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
+- [x] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons.
- [ ] 4.3 Implement the `OpenAI` insight service with structured monthly snapshot input and sparse-month fallback logic.
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records.
diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts
new file mode 100644
index 0000000..cb0aeb0
--- /dev/null
+++ b/src/app/dashboard/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+
+import { getDashboardSnapshot } from "@/lib/dashboard";
+import { getCurrentMonthKey } from "@/lib/date";
+import { monthQuerySchema } from "@/lib/validation";
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const rawMonth = url.searchParams.get("month") ?? getCurrentMonthKey();
+ const parsed = monthQuerySchema.safeParse({ month: rawMonth });
+
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Invalid dashboard month." },
+ { status: 400 },
+ );
+ }
+
+ const dashboard = await getDashboardSnapshot(parsed.data.month);
+ return NextResponse.json(dashboard);
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 907abff..8fbbd7b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,94 +1,5 @@
-import Link from "next/link";
-import { unstable_noStore as noStore } from "next/cache";
+import { HomeDashboard } from "@/components/home-dashboard";
-import { getCategoryLabel } from "@/lib/categories";
-import { getMonthLabel } from "@/lib/date";
-import { getExpenseDashboardPreview } from "@/lib/expenses";
-import { formatCurrencyFromCents } from "@/lib/money";
-
-export const dynamic = "force-dynamic";
-
-export default async function Home() {
- noStore();
- const preview = await getExpenseDashboardPreview();
-
- return (
-
-
-
-
Monthly Expense Tracker
-
- A calm local-first home for everyday spending.
-
-
- The dashboard is starting with the expense-tracking slice first: fast entry, visible history, and a live pulse on the current month.
-
-
-
- Add an expense
-
-
- View paycheck plan
-
-
-
-
-
-
This month
-
{getMonthLabel(preview.month)}
-
-
- Total spent
- {formatCurrencyFromCents(preview.totalSpentCents)}
-
-
- Entries logged
- {preview.expenseCount}
-
-
-
-
-
-
-
-
-
Recent expense pulse
-
Latest entries
-
-
- Manage expenses
-
-
-
-
- {preview.recentExpenses.length === 0 ? (
-
- No expenses recorded yet. Start with one quick entry.
-
- ) : (
- preview.recentExpenses.map((expense) => (
-
-
-
{expense.title}
-
- {expense.date} · {getCategoryLabel(expense.category)}
-
-
- {formatCurrencyFromCents(expense.amountCents)}
-
- ))
- )}
-
-
-
- );
+export default function Home() {
+ return ;
}
diff --git a/src/components/home-dashboard.tsx b/src/components/home-dashboard.tsx
index 517f769..24d82ca 100644
--- a/src/components/home-dashboard.tsx
+++ b/src/components/home-dashboard.tsx
@@ -3,41 +3,62 @@
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
-import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
-import { getCurrentMonthKey, getMonthLabel, isDateInMonth } from "@/lib/date";
-import { formatCurrencyFromCents } from "@/lib/money";
+import { getCategoryLabel } from "@/lib/categories";
+import { getCurrentMonthKey, getMonthLabel } from "@/lib/date";
+import { formatCurrencyFromCents, formatPercent } from "@/lib/money";
-type ExpenseRecord = {
- id: string;
- title: string;
- amountCents: number;
- date: string;
- category: CategoryValue;
+type DashboardSnapshot = {
+ month: string;
+ totals: {
+ expensesCents: number;
+ paychecksCents: number;
+ netCashFlowCents: number;
+ averageDailySpendCents: number;
+ paycheckCoverageRatio: number | null;
+ };
+ comparisons: {
+ highestCategory: { category: string; amountCents: number } | null;
+ largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
+ };
+ categoryBreakdown: Array<{ category: string; amountCents: number }>;
+ recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
+ chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
};
export function HomeDashboard() {
- const [expenses, setExpenses] = useState([]);
+ const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
+ const [snapshot, setSnapshot] = useState(null);
+ const [error, setError] = useState(null);
useEffect(() => {
- async function loadExpenses() {
- const response = await fetch("/expenses", { cache: "no-store" });
- const payload = (await response.json()) as { expenses?: ExpenseRecord[] };
- setExpenses(payload.expenses ?? []);
+ async function loadDashboard() {
+ const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" });
+ const payload = (await response.json()) as DashboardSnapshot & { error?: string };
+
+ if (!response.ok) {
+ setError(payload.error ?? "Could not load the dashboard.");
+ return;
+ }
+
+ setError(null);
+ setSnapshot(payload);
}
- void loadExpenses();
- }, []);
+ void loadDashboard();
+ }, [selectedMonth]);
- const month = getCurrentMonthKey();
- const monthExpenses = useMemo(
- () => expenses.filter((expense) => isDateInMonth(expense.date, month)),
- [expenses, month],
- );
- const recentExpenses = useMemo(() => monthExpenses.slice(0, 6), [monthExpenses]);
- const totalSpentCents = useMemo(
- () => monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0),
- [monthExpenses],
- );
+ const topCategoryLabel = useMemo(() => {
+ if (!snapshot?.comparisons.highestCategory) {
+ return "No category leader yet";
+ }
+
+ return `${getCategoryLabel(snapshot.comparisons.highestCategory.category as never)} · ${formatCurrencyFromCents(snapshot.comparisons.highestCategory.amountCents)}`;
+ }, [snapshot]);
+
+ const coverageLabel =
+ snapshot?.totals.paycheckCoverageRatio == null
+ ? "No spend yet"
+ : formatPercent(snapshot.totals.paycheckCoverageRatio);
return (
@@ -48,36 +69,116 @@ export function HomeDashboard() {
A calm local-first home for everyday spending.
- The dashboard is starting with the expense-tracking slice first: fast entry, visible history, and a live pulse on the current month.
+ Track expenses and paycheck timing together so the month-to-date picture stays honest.
-
+
Add an expense
-
- View paycheck plan
+
+ Track paychecks
-
This month
-
{getMonthLabel(month)}
+
+
+
Month to date
+
+ {snapshot ? getMonthLabel(snapshot.month) : getMonthLabel(selectedMonth)}
+
+
+
setSelectedMonth(event.target.value)}
+ className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900"
+ />
+
+
Total spent
- {formatCurrencyFromCents(totalSpentCents)}
+ {formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}
+
+
+ Paychecks tracked
+ {formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}
- Entries logged
- {monthExpenses.length}
+ Net cash flow
+ {formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}
+
+ Average daily spend
+ {formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}
+
+
+ {error ?
{error}
: null}
+
+
+
+
+
+
+
+
Comparisons
+
What stands out this month
+
+
+ Manage expenses
+
+
+
+
+
+ Highest category
+ {topCategoryLabel}
+
+
+ Paycheck coverage
+ {coverageLabel}
+
+
+ Largest expense
+ {snapshot?.comparisons.largestExpense ? (
+
+
+
{snapshot.comparisons.largestExpense.title}
+
+ {snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
+
+
+
+ {formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
+
+
+ ) : (
+ No expense data for this month yet.
+ )}
+
+
+
+
+
+
Category breakdown
+
Where the month is going
+
+ {snapshot?.categoryBreakdown.length ? (
+ snapshot.categoryBreakdown.map((item) => (
+
+
+
{getCategoryLabel(item.category as never)}
+
{formatCurrencyFromCents(item.amountCents)}
+
+
+ ))
+ ) : (
+
+ No category totals yet for this month.
+
+ )}
@@ -88,31 +189,28 @@ export function HomeDashboard() {
Recent expense pulse
Latest entries
-
- Manage expenses
+
+ Manage paychecks
-
- {recentExpenses.length === 0 ? (
-
- No expenses recorded yet. Start with one quick entry.
-
- ) : (
- recentExpenses.map((expense) => (
-
+
+ {snapshot?.recentExpenses.length ? (
+ snapshot.recentExpenses.map((expense) => (
+
{expense.title}
- {expense.date} · {getCategoryLabel(expense.category)}
+ {expense.date} · {getCategoryLabel(expense.category as never)}
{formatCurrencyFromCents(expense.amountCents)}
))
+ ) : (
+
+ No expenses recorded yet. Start with one quick entry.
+
)}
diff --git a/src/lib/dashboard.test.ts b/src/lib/dashboard.test.ts
new file mode 100644
index 0000000..7fdd5c8
--- /dev/null
+++ b/src/lib/dashboard.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+
+import { buildDashboardSnapshot } from "@/lib/dashboard";
+
+describe("buildDashboardSnapshot", () => {
+ it("builds dashboard totals and comparisons", () => {
+ const snapshot = buildDashboardSnapshot({
+ month: "2026-03",
+ expenses: [
+ {
+ id: "expense-1",
+ date: "2026-03-05",
+ title: "Rent",
+ amountCents: 120000,
+ category: "RENT",
+ createdAt: new Date("2026-03-05T10:00:00Z"),
+ },
+ {
+ id: "expense-2",
+ date: "2026-03-10",
+ title: "Groceries",
+ amountCents: 3200,
+ category: "FOOD",
+ createdAt: new Date("2026-03-10T10:00:00Z"),
+ },
+ ],
+ paychecks: [
+ {
+ id: "paycheck-1",
+ payDate: "2026-03-01",
+ amountCents: 180000,
+ createdAt: new Date("2026-03-01T10:00:00Z"),
+ },
+ ],
+ });
+
+ expect(snapshot.totals.expensesCents).toBe(123200);
+ expect(snapshot.totals.paychecksCents).toBe(180000);
+ expect(snapshot.totals.netCashFlowCents).toBe(56800);
+ expect(snapshot.comparisons.highestCategory).toEqual({ category: "RENT", amountCents: 120000 });
+ expect(snapshot.comparisons.largestExpense?.title).toBe("Rent");
+ expect(snapshot.categoryBreakdown).toHaveLength(2);
+ expect(snapshot.chart).toHaveLength(3);
+ });
+
+ it("returns safe values for sparse months", () => {
+ const snapshot = buildDashboardSnapshot({
+ month: "2026-03",
+ expenses: [],
+ paychecks: [],
+ });
+
+ expect(snapshot.totals.expensesCents).toBe(0);
+ expect(snapshot.totals.paychecksCents).toBe(0);
+ expect(snapshot.totals.paycheckCoverageRatio).toBeNull();
+ expect(snapshot.comparisons.highestCategory).toBeNull();
+ expect(snapshot.comparisons.largestExpense).toBeNull();
+ });
+});
diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts
new file mode 100644
index 0000000..fef5266
--- /dev/null
+++ b/src/lib/dashboard.ts
@@ -0,0 +1,124 @@
+import type { Expense, Paycheck } from "@prisma/client";
+
+import { db } from "@/lib/db";
+import {
+ getCurrentMonthKey,
+ getDayOfMonthFromLocalDate,
+ getDaysInMonth,
+ getLocalToday,
+ isCurrentMonthKey,
+ isDateInMonth,
+} from "@/lib/date";
+
+export type DashboardSnapshot = {
+ month: string;
+ totals: {
+ expensesCents: number;
+ paychecksCents: number;
+ netCashFlowCents: number;
+ averageDailySpendCents: number;
+ paycheckCoverageRatio: number | null;
+ };
+ comparisons: {
+ highestCategory: { category: string; amountCents: number } | null;
+ largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
+ };
+ categoryBreakdown: Array<{ category: string; amountCents: number }>;
+ recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
+ chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
+};
+
+export function buildDashboardSnapshot(input: {
+ month: string;
+ expenses: Expense[];
+ paychecks: Paycheck[];
+}): DashboardSnapshot {
+ const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
+ const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
+
+ const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
+ const paychecksCents = monthPaychecks.reduce((sum, paycheck) => sum + paycheck.amountCents, 0);
+ const netCashFlowCents = paychecksCents - expensesCents;
+
+ const daysConsidered = isCurrentMonthKey(input.month)
+ ? Math.max(1, getDayOfMonthFromLocalDate(getLocalToday()))
+ : getDaysInMonth(input.month);
+
+ const categoryTotals = new Map();
+ for (const expense of monthExpenses) {
+ categoryTotals.set(expense.category, (categoryTotals.get(expense.category) ?? 0) + expense.amountCents);
+ }
+
+ const categoryBreakdown = [...categoryTotals.entries()]
+ .map(([category, amountCents]) => ({ category, amountCents }))
+ .sort((left, right) => right.amountCents - left.amountCents);
+
+ const highestCategory = categoryBreakdown[0] ?? null;
+
+ const largestExpense =
+ monthExpenses
+ .slice()
+ .sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
+
+ const dailyMap = new Map();
+
+ for (const expense of monthExpenses) {
+ const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
+ current.expensesCents += expense.amountCents;
+ dailyMap.set(expense.date, current);
+ }
+
+ for (const paycheck of monthPaychecks) {
+ const current = dailyMap.get(paycheck.payDate) ?? { expensesCents: 0, paychecksCents: 0 };
+ current.paychecksCents += paycheck.amountCents;
+ dailyMap.set(paycheck.payDate, current);
+ }
+
+ const chart = [...dailyMap.entries()]
+ .map(([date, values]) => ({ date, ...values }))
+ .sort((left, right) => left.date.localeCompare(right.date));
+
+ return {
+ month: input.month,
+ totals: {
+ expensesCents,
+ paychecksCents,
+ netCashFlowCents,
+ averageDailySpendCents: Math.round(expensesCents / daysConsidered),
+ paycheckCoverageRatio: expensesCents > 0 ? paychecksCents / expensesCents : null,
+ },
+ comparisons: {
+ highestCategory,
+ largestExpense: largestExpense
+ ? {
+ title: largestExpense.title,
+ amountCents: largestExpense.amountCents,
+ date: largestExpense.date,
+ category: largestExpense.category,
+ }
+ : null,
+ },
+ categoryBreakdown,
+ recentExpenses: monthExpenses
+ .slice()
+ .sort((left, right) => right.date.localeCompare(left.date) || right.createdAt.getTime() - left.createdAt.getTime())
+ .slice(0, 6)
+ .map((expense) => ({
+ id: expense.id,
+ title: expense.title,
+ amountCents: expense.amountCents,
+ date: expense.date,
+ category: expense.category,
+ })),
+ chart,
+ };
+}
+
+export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
+ const [expenses, paychecks] = await Promise.all([
+ db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
+ db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
+ ]);
+
+ return buildDashboardSnapshot({ month, expenses, paychecks });
+}
diff --git a/src/lib/date.ts b/src/lib/date.ts
index cc03655..8ec68cd 100644
--- a/src/lib/date.ts
+++ b/src/lib/date.ts
@@ -13,6 +13,10 @@ export function getCurrentMonthKey() {
return getMonthKeyFromLocalDate(getLocalToday());
}
+export function isCurrentMonthKey(month: string) {
+ return month === getCurrentMonthKey();
+}
+
export function isValidLocalDate(date: string) {
if (!LOCAL_DATE_PATTERN.test(date)) {
return false;
@@ -41,6 +45,15 @@ export function getMonthKeyFromLocalDate(date: string) {
return date.slice(0, 7);
}
+export function getDayOfMonthFromLocalDate(date: string) {
+ return Number.parseInt(date.slice(8, 10), 10);
+}
+
+export function getDaysInMonth(month: string) {
+ const [year, numericMonth] = month.split("-").map(Number);
+ return new Date(year, numericMonth, 0).getDate();
+}
+
export function isDateInMonth(date: string, month: string) {
return date.startsWith(`${month}-`);
}
diff --git a/src/lib/money.ts b/src/lib/money.ts
index 021557d..73343f8 100644
--- a/src/lib/money.ts
+++ b/src/lib/money.ts
@@ -20,3 +20,10 @@ export function formatCurrencyFromCents(value: number) {
maximumFractionDigits: 2,
}).format(value / 100);
}
+
+export function formatPercent(value: number) {
+ return new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 0,
+ }).format(value);
+}