Implement expense tracking foundation for v1

This commit is contained in:
2026-03-23 12:32:36 -04:00
parent 5d7e25c015
commit 905af75cd8
39 changed files with 9923 additions and 9 deletions

View File

@@ -0,0 +1,22 @@
import { ExpenseWorkspace } from "@/components/expense-workspace";
import { CATEGORY_OPTIONS } from "@/lib/categories";
export const metadata = {
title: "Add Expense | Monthy Tracker",
};
export default function AddExpensePage() {
return (
<div className="space-y-8">
<header className="max-w-2xl space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
<p className="text-lg leading-8 text-stone-600">
This first slice focuses on fast local entry. Each saved expense appears immediately in your running history.
</p>
</header>
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { removeExpense } from "@/lib/expenses";
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function DELETE(_: Request, context: RouteContext) {
const { id } = await context.params;
try {
await removeExpense(id);
return new NextResponse(null, { status: 204 });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2025"
) {
return NextResponse.json({ error: "Expense not found." }, { status: 404 });
}
throw error;
}
}

30
src/app/expenses/route.ts Normal file
View File

@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { createExpense, listExpenses } from "@/lib/expenses";
import { expenseInputSchema } from "@/lib/validation";
export async function GET() {
const expenses = await listExpenses();
return NextResponse.json({ expenses });
}
export async function POST(request: Request) {
const payload = await request.json();
const parsed = expenseInputSchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid expense payload." },
{ status: 400 },
);
}
const expense = await createExpense({
title: parsed.data.title,
amountCents: parsed.data.amount,
date: parsed.data.date,
category: parsed.data.category,
});
return NextResponse.json({ expense }, { status: 201 });
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

41
src/app/globals.css Normal file
View File

@@ -0,0 +1,41 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: #fbfaf7;
--color-foreground: #1c1917;
--font-sans: var(--font-body);
--font-heading: var(--font-heading);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-body), sans-serif;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-heading), serif;
}
a,
button,
input,
select {
transition-duration: 180ms;
}

15
src/app/income/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
export const metadata = {
title: "Income & Paychecks | Monthy Tracker",
};
export default function IncomePage() {
return (
<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.28em] text-stone-500">Coming next</p>
<h1 className="mt-3 text-4xl font-semibold text-stone-950">Paycheck tracking lands in the next implementation slice.</h1>
<p className="mt-4 max-w-2xl text-lg leading-8 text-stone-600">
The data model is already prepared for paychecks. This view will add create, list, and delete flows after expense tracking is validated.
</p>
</div>
);
}

47
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Fraunces, Manrope } from "next/font/google";
import { SiteNav } from "@/components/site-nav";
import "./globals.css";
const headingFont = Fraunces({
variable: "--font-heading",
subsets: ["latin"],
});
const bodyFont = Manrope({
variable: "--font-body",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Monthy Tracker",
description: "Local-first monthly expense tracking with AI insights.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`}
>
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
<header className="mb-10 flex flex-col gap-4 rounded-[2rem] border border-white/70 bg-white/80 px-6 py-5 shadow-[0_20px_50px_rgba(120,90,50,0.08)] backdrop-blur sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700">Monthy Tracker</p>
<p className="mt-2 text-lg text-stone-600">Track the month as it unfolds, not after it slips away.</p>
</div>
<SiteNav />
</header>
<main className="flex-1 pb-10">{children}</main>
</div>
</body>
</html>
);
}

94
src/app/page.tsx Normal file
View File

@@ -0,0 +1,94 @@
import Link from "next/link";
import { unstable_noStore as noStore } from "next/cache";
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 (
<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>
);
}