From 12c72ddcadb14084c813db063b1ca7ce1834d21f Mon Sep 17 00:00:00 2001 From: Vijayakanth Manoharan Date: Mon, 23 Mar 2026 12:53:09 -0400 Subject: [PATCH] Add monthly dashboard summaries for v1 --- .../monthly-expense-tracker-v1/tasks.md | 4 +- src/app/dashboard/route.ts | 21 ++ src/app/page.tsx | 95 +------- src/components/home-dashboard.tsx | 208 +++++++++++++----- src/lib/dashboard.test.ts | 59 +++++ src/lib/dashboard.ts | 124 +++++++++++ src/lib/date.ts | 13 ++ src/lib/money.ts | 7 + 8 files changed, 382 insertions(+), 149 deletions(-) create mode 100644 src/app/dashboard/route.ts create mode 100644 src/lib/dashboard.test.ts create mode 100644 src/lib/dashboard.ts 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); +}