Add monthly dashboard summaries for v1
This commit is contained in:
@@ -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<ExpenseRecord[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-10">
|
||||
@@ -48,36 +69,116 @@ export function HomeDashboard() {
|
||||
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.
|
||||
Track expenses and paycheck timing together so the month-to-date picture stays honest.
|
||||
</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"
|
||||
>
|
||||
<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 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">
|
||||
Track paychecks
|
||||
</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(month)}</h2>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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">
|
||||
<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(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 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">{monthExpenses.length}</p>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Net cash flow</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
|
||||
</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>
|
||||
</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>
|
||||
<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 href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950">
|
||||
Manage paychecks
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{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>
|
||||
) : (
|
||||
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 className="mt-6 grid gap-3 md:grid-cols-2">
|
||||
{snapshot?.recentExpenses.length ? (
|
||||
snapshot.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)}
|
||||
{expense.date} · {getCategoryLabel(expense.category as never)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user