Add monthly dashboard summaries for v1
This commit is contained in:
@@ -20,8 +20,8 @@
|
|||||||
|
|
||||||
## 4. Dashboard and insights
|
## 4. Dashboard and insights
|
||||||
|
|
||||||
- [ ] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
|
- [x] 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.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.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.
|
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records.
|
||||||
|
|
||||||
|
|||||||
21
src/app/dashboard/route.ts
Normal file
21
src/app/dashboard/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,94 +1,5 @@
|
|||||||
import Link from "next/link";
|
import { HomeDashboard } from "@/components/home-dashboard";
|
||||||
import { unstable_noStore as noStore } from "next/cache";
|
|
||||||
|
|
||||||
import { getCategoryLabel } from "@/lib/categories";
|
export default function Home() {
|
||||||
import { getMonthLabel } from "@/lib/date";
|
return <HomeDashboard />;
|
||||||
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 (
|
|
||||||
<div className="space-y-10">
|
|
||||||
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Monthly Expense Tracker</p>
|
|
||||||
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950">
|
|
||||||
A calm local-first home for everyday spending.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-lg leading-8 text-stone-600">
|
|
||||||
The dashboard is starting with the expense-tracking slice first: fast entry, visible history, and a live pulse on the current month.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/add-expense"
|
|
||||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800"
|
|
||||||
>
|
|
||||||
Add an expense
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/income"
|
|
||||||
className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900"
|
|
||||||
>
|
|
||||||
View paycheck plan
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">This month</p>
|
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">{getMonthLabel(preview.month)}</h2>
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
|
||||||
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white">
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(preview.totalSpentCents)}</p>
|
|
||||||
</article>
|
|
||||||
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Entries logged</p>
|
|
||||||
<p className="mt-3 text-3xl font-semibold">{preview.expenseCount}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
|
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
|
|
||||||
</div>
|
|
||||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
|
||||||
Manage expenses
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3">
|
|
||||||
{preview.recentExpenses.length === 0 ? (
|
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600">
|
|
||||||
No expenses recorded yet. Start with one quick entry.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
preview.recentExpenses.map((expense) => (
|
|
||||||
<article
|
|
||||||
key={expense.id}
|
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
|
||||||
{expense.date} · {getCategoryLabel(expense.category)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,62 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
import { getCategoryLabel } from "@/lib/categories";
|
||||||
import { getCurrentMonthKey, getMonthLabel, isDateInMonth } from "@/lib/date";
|
import { getCurrentMonthKey, getMonthLabel } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents, formatPercent } from "@/lib/money";
|
||||||
|
|
||||||
type ExpenseRecord = {
|
type DashboardSnapshot = {
|
||||||
id: string;
|
month: string;
|
||||||
title: string;
|
totals: {
|
||||||
amountCents: number;
|
expensesCents: number;
|
||||||
date: string;
|
paychecksCents: number;
|
||||||
category: CategoryValue;
|
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() {
|
export function HomeDashboard() {
|
||||||
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
||||||
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
async function loadDashboard() {
|
||||||
const response = await fetch("/expenses", { cache: "no-store" });
|
const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" });
|
||||||
const payload = (await response.json()) as { expenses?: ExpenseRecord[] };
|
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
|
||||||
setExpenses(payload.expenses ?? []);
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(payload.error ?? "Could not load the dashboard.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadExpenses();
|
setError(null);
|
||||||
}, []);
|
setSnapshot(payload);
|
||||||
|
}
|
||||||
|
|
||||||
const month = getCurrentMonthKey();
|
void loadDashboard();
|
||||||
const monthExpenses = useMemo(
|
}, [selectedMonth]);
|
||||||
() => expenses.filter((expense) => isDateInMonth(expense.date, month)),
|
|
||||||
[expenses, month],
|
const topCategoryLabel = useMemo(() => {
|
||||||
);
|
if (!snapshot?.comparisons.highestCategory) {
|
||||||
const recentExpenses = useMemo(() => monthExpenses.slice(0, 6), [monthExpenses]);
|
return "No category leader yet";
|
||||||
const totalSpentCents = useMemo(
|
}
|
||||||
() => monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
|
||||||
[monthExpenses],
|
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 (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -48,36 +69,116 @@ export function HomeDashboard() {
|
|||||||
A calm local-first home for everyday spending.
|
A calm local-first home for everyday spending.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl text-lg leading-8 text-stone-600">
|
<p className="max-w-2xl text-lg leading-8 text-stone-600">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link
|
<Link href="/add-expense" className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800">
|
||||||
href="/add-expense"
|
|
||||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800"
|
|
||||||
>
|
|
||||||
Add an expense
|
Add an expense
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/income" className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900">
|
||||||
href="/income"
|
Track paychecks
|
||||||
className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900"
|
|
||||||
>
|
|
||||||
View paycheck plan
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6">
|
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">This month</p>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">{getMonthLabel(month)}</h2>
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Month to date</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
|
||||||
|
{snapshot ? getMonthLabel(snapshot.month) : getMonthLabel(selectedMonth)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white">
|
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(totalSpentCents)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Paychecks tracked</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Entries logged</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Net cash flow</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{monthExpenses.length}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
|
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-600">Average daily spend</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="mt-4 text-sm text-rose-700">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Comparisons</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">What stands out this month</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
||||||
|
Manage expenses
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Highest category</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-stone-950">{topCategoryLabel}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Paycheck coverage</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-stone-950">{coverageLabel}</p>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Largest expense</p>
|
||||||
|
{snapshot?.comparisons.largestExpense ? (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-stone-950">{snapshot.comparisons.largestExpense.title}</p>
|
||||||
|
<p className="text-sm text-stone-600">
|
||||||
|
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold text-stone-950">
|
||||||
|
{formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-stone-600">No expense data for this month yet.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Category breakdown</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Where the month is going</h2>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{snapshot?.categoryBreakdown.length ? (
|
||||||
|
snapshot.categoryBreakdown.map((item) => (
|
||||||
|
<article key={item.category} className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="font-semibold text-stone-950">{getCategoryLabel(item.category as never)}</p>
|
||||||
|
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(item.amountCents)}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm text-stone-600">
|
||||||
|
No category totals yet for this month.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -88,31 +189,28 @@ export function HomeDashboard() {
|
|||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950">
|
||||||
Manage expenses
|
Manage paychecks
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3">
|
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
||||||
{recentExpenses.length === 0 ? (
|
{snapshot?.recentExpenses.length ? (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600">
|
snapshot.recentExpenses.map((expense) => (
|
||||||
No expenses recorded yet. Start with one quick entry.
|
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4">
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
recentExpenses.map((expense) => (
|
|
||||||
<article
|
|
||||||
key={expense.id}
|
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
<p className="font-semibold text-stone-950">{expense.title}</p>
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
<p className="mt-1 text-sm text-stone-600">
|
||||||
{expense.date} · {getCategoryLabel(expense.category)}
|
{expense.date} · {getCategoryLabel(expense.category as never)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600 md:col-span-2">
|
||||||
|
No expenses recorded yet. Start with one quick entry.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
59
src/lib/dashboard.test.ts
Normal file
59
src/lib/dashboard.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/lib/dashboard.ts
Normal file
124
src/lib/dashboard.ts
Normal file
@@ -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<string, number>();
|
||||||
|
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<string, { expensesCents: number; paychecksCents: number }>();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ export function getCurrentMonthKey() {
|
|||||||
return getMonthKeyFromLocalDate(getLocalToday());
|
return getMonthKeyFromLocalDate(getLocalToday());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCurrentMonthKey(month: string) {
|
||||||
|
return month === getCurrentMonthKey();
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidLocalDate(date: string) {
|
export function isValidLocalDate(date: string) {
|
||||||
if (!LOCAL_DATE_PATTERN.test(date)) {
|
if (!LOCAL_DATE_PATTERN.test(date)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -41,6 +45,15 @@ export function getMonthKeyFromLocalDate(date: string) {
|
|||||||
return date.slice(0, 7);
|
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) {
|
export function isDateInMonth(date: string, month: string) {
|
||||||
return date.startsWith(`${month}-`);
|
return date.startsWith(`${month}-`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,10 @@ export function formatCurrencyFromCents(value: number) {
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(value / 100);
|
}).format(value / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user