Add dark mode with theme toggle and OpenSpec change
- Add @custom-variant dark in globals.css for class-based dark mode - Add ThemeToggle component with localStorage persistence and system preference fallback - Inject blocking inline script in layout to prevent flash on load - Apply dark: variants across all components (layout, site-nav, home-dashboard, expense-workspace, paycheck-workspace, recurring-expense-manager) and page headers - Create openspec/changes/theming-dark-mode with proposal, design, and tasks artifacts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,9 @@ 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">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Add Expense</p>
|
||||
<h1 className="text-4xl font-semibold text-stone-950 dark:text-white">Capture spending while it still feels fresh.</h1>
|
||||
<p className="text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #1a1714;
|
||||
--foreground: #f5f0eb;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: #fbfaf7;
|
||||
--color-foreground: #1c1917;
|
||||
|
||||
@@ -8,9 +8,9 @@ export default function IncomePage() {
|
||||
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-emerald-700">Income & Paychecks</p>
|
||||
<h1 className="text-4xl font-semibold text-stone-950">Capture income on real pay dates, not rough monthly averages.</h1>
|
||||
<p className="text-lg leading-8 text-stone-600">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700 dark:text-emerald-400">Income & Paychecks</p>
|
||||
<h1 className="text-4xl font-semibold text-stone-950 dark:text-white">Capture income on real pay dates, not rough monthly averages.</h1>
|
||||
<p className="text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||
This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Fraunces, Manrope } from "next/font/google";
|
||||
|
||||
import { SiteNav } from "@/components/site-nav";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
@@ -20,6 +21,18 @@ export const metadata: Metadata = {
|
||||
description: "Local-first monthly expense tracking with AI insights.",
|
||||
};
|
||||
|
||||
const themeScript = `
|
||||
(function() {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (saved === 'dark' || (!saved && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -30,14 +43,20 @@ export default function RootLayout({
|
||||
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">
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950 dark:bg-[linear-gradient(180deg,#1a1714_0%,#1c1a17_28%,#1e1c19_100%)] dark:text-stone-100">
|
||||
<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">
|
||||
<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 dark:border-stone-700/60 dark:bg-stone-900/80">
|
||||
<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>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Monthy Tracker</p>
|
||||
<p className="mt-2 text-lg text-stone-600 dark:text-stone-400">Track the month as it unfolds, not after it slips away.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SiteNav />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<SiteNav />
|
||||
</header>
|
||||
<main className="flex-1 pb-10">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -207,24 +207,24 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-500">
|
||||
{editingId ? "Edit expense" : "Daily entry"}
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">
|
||||
{editingId ? "Update this entry" : "Log today\u2019s spend in seconds"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalSpent)}</p>
|
||||
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right dark:bg-amber-900/20">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">Current list total</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(totalSpent)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 md:col-span-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||
Title
|
||||
<input
|
||||
required
|
||||
@@ -237,35 +237,35 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
setNeedsSuggestionConfirmation(false);
|
||||
setSuggestionMessage(null);
|
||||
}}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
placeholder="Groceries, rent, train pass..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Amount
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={formState.amount}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
placeholder="42.50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Date
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
value={formState.date}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, date: event.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 md:col-span-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||
Category
|
||||
<select
|
||||
value={formState.category}
|
||||
@@ -273,7 +273,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
|
||||
setNeedsSuggestionConfirmation(false);
|
||||
}}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -283,13 +283,13 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-400">
|
||||
<p>{suggestionMessage ?? "Merchant rules auto-fill known shops. Unknown shops use local AI when available."}</p>
|
||||
{needsSuggestionConfirmation ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeedsSuggestionConfirmation(false)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Confirm suggestion
|
||||
</button>
|
||||
@@ -303,7 +303,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -311,7 +311,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||
>
|
||||
{busy ? (editingId ? "Updating..." : "Saving...") : editingId ? "Update expense" : "Save expense"}
|
||||
</button>
|
||||
@@ -320,51 +320,51 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900/60">
|
||||
<div className="mb-5">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent entries</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Expense history</h2>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent entries</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Expense history</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{expenses.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||
No expenses yet. Add your first entry to start the month.
|
||||
</div>
|
||||
) : (
|
||||
expenses.map((expense) => (
|
||||
<article
|
||||
key={expense.id}
|
||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4 dark:border-stone-700 dark:bg-stone-800"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{expense.title}</p>
|
||||
{expense.isRecurring && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700">
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
||||
Recurring
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||
{expense.date} · {getCategoryLabel(expense.category)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||
<p className="mr-2 font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||
{!expense.isRecurring && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(expense)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900"
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900 dark:border-stone-600 dark:text-stone-400 dark:hover:border-stone-300 dark:hover:text-stone-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(expense.id)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600"
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -131,30 +131,30 @@ export function HomeDashboard() {
|
||||
|
||||
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]">
|
||||
<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] dark:border-stone-700 dark:bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.08),_transparent_32%),linear-gradient(135deg,#242019,#1e1c18)]">
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Monthly Expense Tracker</p>
|
||||
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950 dark:text-white">
|
||||
A calm local-first home for everyday spending.
|
||||
</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 dark:text-stone-400">
|
||||
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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white">
|
||||
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">
|
||||
<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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:border-stone-300">
|
||||
Track paychecks
|
||||
</Link>
|
||||
</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 dark:border-stone-700/60 dark:bg-stone-900/80">
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Month to date</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">
|
||||
{snapshot ? getMonthLabel(snapshot.month) : getMonthLabel(selectedMonth)}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -162,25 +162,25 @@ export function HomeDashboard() {
|
||||
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"
|
||||
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:focus:border-stone-400"
|
||||
/>
|
||||
</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">
|
||||
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white dark:bg-stone-800">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</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>
|
||||
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950 dark:bg-emerald-900/20 dark:text-emerald-100">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400">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">Net cash flow</p>
|
||||
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950 dark:bg-amber-900/20 dark:text-amber-100">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">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>
|
||||
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950 dark:bg-stone-700 dark:text-stone-100">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Average daily spend</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
|
||||
</article>
|
||||
</div>
|
||||
@@ -188,27 +188,27 @@ export function HomeDashboard() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Private monthly insight</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Offline guidance for this month</h2>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Private monthly insight</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Offline guidance for this month</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleGenerateInsights()}
|
||||
disabled={insightBusy}
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-600 dark:disabled:text-stone-400"
|
||||
>
|
||||
{insightBusy ? "Generating..." : "Generate insights"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-3xl border border-stone-200 bg-stone-50 px-5 py-4">
|
||||
<div className="mt-6 rounded-3xl border border-stone-200 bg-stone-50 px-5 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Ollama runtime</p>
|
||||
<p className="mt-2 text-sm font-medium text-stone-700">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Ollama runtime</p>
|
||||
<p className="mt-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
{ollamaStatus?.message ?? "Checking local runtime status..."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,19 +226,19 @@ export function HomeDashboard() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 text-sm text-stone-600 sm:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 text-sm text-stone-600 dark:text-stone-400 sm:grid-cols-2">
|
||||
<p>
|
||||
Model: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredModel ?? "-"}</span>
|
||||
Model: <span className="font-semibold text-stone-900 dark:text-stone-200">{ollamaStatus?.configuredModel ?? "-"}</span>
|
||||
</p>
|
||||
<p>
|
||||
URL: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredUrl ?? "-"}</span>
|
||||
URL: <span className="font-semibold text-stone-900 dark:text-stone-200">{ollamaStatus?.configuredUrl ?? "-"}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadOllamaStatus()}
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Refresh status
|
||||
</button>
|
||||
@@ -247,14 +247,14 @@ export function HomeDashboard() {
|
||||
type="button"
|
||||
onClick={() => void handlePullModel()}
|
||||
disabled={ollamaBusy}
|
||||
className="rounded-full bg-stone-950 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-stone-950 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white"
|
||||
>
|
||||
{ollamaBusy ? "Pulling model..." : "Pull configured model"}
|
||||
</button>
|
||||
) : null}
|
||||
<a
|
||||
href="/backup/database"
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Download backup
|
||||
</a>
|
||||
@@ -264,31 +264,31 @@ export function HomeDashboard() {
|
||||
{snapshot?.insight ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* AI summary */}
|
||||
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-[#fffdf8] to-[#fff8ec] px-6 py-6">
|
||||
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-[#fffdf8] to-[#fff8ec] px-6 py-6 dark:border-amber-900/40 dark:from-[#2a2418] dark:to-[#251f13]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">AI Summary</span>
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600">✦ Offline</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">AI Summary</span>
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:bg-amber-900/40 dark:text-amber-400">✦ Offline</span>
|
||||
</div>
|
||||
<p className="mt-3 text-lg leading-8 text-stone-700">{snapshot.insight.summary}</p>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400">
|
||||
<p className="mt-3 text-lg leading-8 text-stone-700 dark:text-stone-300">{snapshot.insight.summary}</p>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400 dark:text-stone-500">
|
||||
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Spend vs income + category bars */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Spend vs Income</p>
|
||||
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Spend vs Income</p>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-2xl font-semibold text-stone-950">
|
||||
<span className="text-2xl font-semibold text-stone-950 dark:text-white">
|
||||
{formatCurrencyFromCents(snapshot.totals.expensesCents)}
|
||||
</span>
|
||||
<span className="text-sm text-stone-500">
|
||||
<span className="text-sm text-stone-500 dark:text-stone-400">
|
||||
of {formatCurrencyFromCents(snapshot.totals.paychecksCents)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-stone-200">
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-700">
|
||||
<div
|
||||
className={`h-2.5 rounded-full transition-all ${snapshot.totals.expensesCents > snapshot.totals.paychecksCents ? "bg-rose-500" : "bg-amber-500"}`}
|
||||
style={{
|
||||
@@ -296,7 +296,7 @@ export function HomeDashboard() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400">
|
||||
{snapshot.totals.paychecksCents > 0
|
||||
? `${Math.round((snapshot.totals.expensesCents / snapshot.totals.paychecksCents) * 100)}% of income spent`
|
||||
: "No income tracked this month"}
|
||||
@@ -304,8 +304,8 @@ export function HomeDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Category flow</p>
|
||||
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Category flow</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{snapshot.categoryBreakdown.slice(0, 4).map((item) => {
|
||||
const pct =
|
||||
@@ -315,12 +315,12 @@ export function HomeDashboard() {
|
||||
return (
|
||||
<div key={item.category}>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-stone-600">{getCategoryLabel(item.category as never)}</span>
|
||||
<span className="font-semibold text-stone-900">{formatCurrencyFromCents(item.amountCents)}</span>
|
||||
<span className="text-stone-600 dark:text-stone-400">{getCategoryLabel(item.category as never)}</span>
|
||||
<span className="font-semibold text-stone-900 dark:text-stone-200">{formatCurrencyFromCents(item.amountCents)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-stone-200">
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-700">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-stone-700 transition-all"
|
||||
className="h-1.5 rounded-full bg-stone-700 dark:bg-stone-400 transition-all"
|
||||
style={{ width: `${pct.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -328,15 +328,15 @@ export function HomeDashboard() {
|
||||
);
|
||||
})}
|
||||
{snapshot.categoryBreakdown.length === 0 && (
|
||||
<p className="text-xs text-stone-400">No categories recorded yet.</p>
|
||||
<p className="text-xs text-stone-400 dark:text-stone-500">No categories recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
|
||||
<div className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5 dark:border-stone-700 dark:bg-stone-800/60">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Next month guidance</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(() => {
|
||||
let items: string[];
|
||||
@@ -347,11 +347,11 @@ export function HomeDashboard() {
|
||||
items = [snapshot.insight.recommendations];
|
||||
}
|
||||
return items.map((item, i) => (
|
||||
<div key={i} className="flex gap-3 rounded-2xl border border-stone-200 bg-white px-4 py-4">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-stone-950 text-[10px] font-bold text-white">
|
||||
<div key={i} className="flex gap-3 rounded-2xl border border-stone-200 bg-white px-4 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-stone-950 text-[10px] font-bold text-white dark:bg-stone-300 dark:text-stone-900">
|
||||
{i + 1}
|
||||
</span>
|
||||
<p className="text-sm leading-6 text-stone-700">{item}</p>
|
||||
<p className="text-sm leading-6 text-stone-700 dark:text-stone-300">{item}</p>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
@@ -359,69 +359,69 @@ export function HomeDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
|
||||
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||
No saved insight for this month yet. Generate one to get a private offline summary.
|
||||
</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="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||
<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>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Comparisons</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">What stands out this month</h2>
|
||||
</div>
|
||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950 dark:text-amber-400 dark:hover:text-white">
|
||||
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 className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Highest category</p>
|
||||
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{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 className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Paycheck coverage</p>
|
||||
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{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>
|
||||
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2 dark:border-stone-700 dark:bg-stone-800/50">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">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">
|
||||
<p className="text-lg font-semibold text-stone-950 dark:text-white">{snapshot.comparisons.largestExpense.title}</p>
|
||||
<p className="text-sm text-stone-600 dark:text-stone-400">
|
||||
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-stone-950">
|
||||
<p className="text-xl font-semibold text-stone-950 dark:text-white">
|
||||
{formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-stone-600">No expense data for this month yet.</p>
|
||||
<p className="mt-3 text-sm text-stone-600 dark:text-stone-400">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="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Category breakdown</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">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">
|
||||
<article key={item.category} className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||
<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>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{getCategoryLabel(item.category as never)}</p>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{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">
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||
No category totals yet for this month.
|
||||
</div>
|
||||
)}
|
||||
@@ -429,13 +429,13 @@ export function HomeDashboard() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||
<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>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent expense pulse</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Latest entries</h2>
|
||||
</div>
|
||||
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950">
|
||||
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950 dark:text-emerald-400 dark:hover:text-white">
|
||||
Manage paychecks
|
||||
</Link>
|
||||
</div>
|
||||
@@ -443,25 +443,25 @@ export function HomeDashboard() {
|
||||
<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">
|
||||
<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 dark:border-stone-700 dark:bg-stone-800">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{expense.title}</p>
|
||||
{expense.isRecurring && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700">
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
||||
Recurring
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||
{expense.date} · {getCategoryLabel(expense.category as never)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{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">
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600 md:col-span-2 dark:border-stone-600 dark:text-stone-400">
|
||||
No expenses recorded yet. Start with one quick entry.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -162,11 +162,11 @@ export function PaycheckWorkspace() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Biweekly schedule panel */}
|
||||
<section className="rounded-[2rem] border border-emerald-100 bg-gradient-to-br from-[#f6fbf6] to-[#edf7ed] p-6 shadow-[0_24px_60px_rgba(60,120,60,0.06)]">
|
||||
<section className="rounded-[2rem] border border-emerald-100 bg-gradient-to-br from-[#f6fbf6] to-[#edf7ed] p-6 shadow-[0_24px_60px_rgba(60,120,60,0.06)] dark:border-emerald-900/30 dark:from-[#1a2420] dark:to-[#182219]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Biweekly schedule</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold text-stone-950">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700 dark:text-emerald-400">Biweekly schedule</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">
|
||||
{schedule ? "Active pay schedule" : "Set up your pay schedule"}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -175,7 +175,7 @@ export function PaycheckWorkspace() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowScheduleForm(true); setScheduleForm({ amount: (schedule.amountCents / 100).toFixed(2), anchorDate: schedule.anchorDate }); }}
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -183,7 +183,7 @@ export function PaycheckWorkspace() {
|
||||
type="button"
|
||||
onClick={() => void handleClearSchedule()}
|
||||
disabled={busy}
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
|
||||
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -203,39 +203,39 @@ export function PaycheckWorkspace() {
|
||||
|
||||
{showScheduleForm && (
|
||||
<form className="mt-5 grid gap-4 sm:grid-cols-[1fr_1fr_auto]" onSubmit={handleSaveSchedule}>
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Amount per paycheck
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={scheduleForm.amount}
|
||||
onChange={(e) => setScheduleForm((s) => ({ ...s, amount: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600"
|
||||
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-emerald-500"
|
||||
placeholder="2400.00"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
A known pay date (anchor)
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
value={scheduleForm.anchorDate}
|
||||
onChange={(e) => setScheduleForm((s) => ({ ...s, anchorDate: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600"
|
||||
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||
>
|
||||
{busy ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScheduleForm(false)}
|
||||
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -245,21 +245,21 @@ export function PaycheckWorkspace() {
|
||||
|
||||
{schedule && !showScheduleForm && (
|
||||
<div className="mt-5">
|
||||
<div className="flex flex-wrap gap-6 text-sm text-stone-600">
|
||||
<div className="flex flex-wrap gap-6 text-sm text-stone-600 dark:text-stone-400">
|
||||
<span>
|
||||
Amount: <span className="font-semibold text-stone-950">{formatCurrencyFromCents(schedule.amountCents)}</span>
|
||||
Amount: <span className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(schedule.amountCents)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Cadence: <span className="font-semibold text-stone-950">Every 2 weeks</span>
|
||||
Cadence: <span className="font-semibold text-stone-950 dark:text-white">Every 2 weeks</span>
|
||||
</span>
|
||||
<span>
|
||||
Anchor: <span className="font-semibold text-stone-950">{schedule.anchorDate}</span>
|
||||
Anchor: <span className="font-semibold text-stone-950 dark:text-white">{schedule.anchorDate}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{projectedDates.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">
|
||||
Projected pay dates this month
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
@@ -270,19 +270,19 @@ export function PaycheckWorkspace() {
|
||||
key={date}
|
||||
className={`flex items-center gap-2 rounded-2xl border px-4 py-2 text-sm ${
|
||||
confirmed
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-stone-200 bg-white text-stone-700"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-900/20 dark:text-emerald-300"
|
||||
: "border-stone-200 bg-white text-stone-700 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-300"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{date}</span>
|
||||
{confirmed ? (
|
||||
<span className="text-xs font-semibold text-emerald-600">✓ Confirmed</span>
|
||||
<span className="text-xs font-semibold text-emerald-600 dark:text-emerald-400">✓ Confirmed</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmProjected(date)}
|
||||
disabled={busy}
|
||||
className="rounded-full bg-emerald-700 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-emerald-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-emerald-700 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-emerald-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-emerald-600 dark:hover:bg-emerald-500"
|
||||
>
|
||||
Mark received
|
||||
</button>
|
||||
@@ -292,7 +292,7 @@ export function PaycheckWorkspace() {
|
||||
})}
|
||||
</div>
|
||||
{pendingProjectedDates.length > 0 && (
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400">
|
||||
{pendingProjectedDates.length} pending — included in dashboard totals as projected income.
|
||||
</p>
|
||||
)}
|
||||
@@ -304,48 +304,48 @@ export function PaycheckWorkspace() {
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
{/* Manual paycheck entry */}
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">One-off entry</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log a bonus or extra deposit</h2>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700 dark:text-emerald-400">One-off entry</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Log a bonus or extra deposit</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
|
||||
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right dark:bg-emerald-900/20">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400">Tracked paychecks</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(totalIncome)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Amount
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={formState.amount}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
placeholder="1800.00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Pay date
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
value={formState.payDate}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-rose-700">{error}</p>
|
||||
<p className="text-sm text-rose-700 dark:text-rose-400">{error}</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||
>
|
||||
{busy ? "Saving..." : "Save paycheck"}
|
||||
</button>
|
||||
@@ -354,33 +354,33 @@ export function PaycheckWorkspace() {
|
||||
</section>
|
||||
|
||||
{/* Paycheck history */}
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900/60">
|
||||
<div className="mb-5">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Income history</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Recent paychecks</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{paychecks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||
No paychecks yet. Use "Mark received" on a projected date or add one manually.
|
||||
</div>
|
||||
) : (
|
||||
paychecks.map((paycheck) => (
|
||||
<article
|
||||
key={paycheck.id}
|
||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4 dark:border-stone-700 dark:bg-stone-800"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-950">Paycheck</p>
|
||||
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">Paycheck</p>
|
||||
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">{paycheck.payDate}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(paycheck.id)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600"
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -120,18 +120,18 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[2rem] border border-amber-200 bg-amber-50 p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<section className="rounded-[2rem] border border-amber-200 bg-amber-50 p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-amber-900/40 dark:bg-amber-900/10">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">Fixed monthly costs</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recurring expenses</h2>
|
||||
<p className="mt-1 text-sm text-stone-600">These appear automatically in every month without manual entry.</p>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-400">Fixed monthly costs</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Recurring expenses</h2>
|
||||
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">These appear automatically in every month without manual entry.</p>
|
||||
</div>
|
||||
{!showAddForm && editingId === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAdd}
|
||||
className="shrink-0 rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700"
|
||||
className="shrink-0 rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white"
|
||||
>
|
||||
+ Add recurring
|
||||
</button>
|
||||
@@ -141,36 +141,36 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
{(showAddForm || editingId !== null) && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mb-5 grid gap-3 rounded-2xl border border-amber-200 bg-white p-5 md:grid-cols-2"
|
||||
className="mb-5 grid gap-3 rounded-2xl border border-amber-200 bg-white p-5 md:grid-cols-2 dark:border-amber-900/40 dark:bg-stone-900"
|
||||
>
|
||||
<p className="text-sm font-semibold text-stone-700 md:col-span-2">
|
||||
<p className="text-sm font-semibold text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||
{editingId ? "Edit recurring expense" : "New recurring expense"}
|
||||
</p>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 md:col-span-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||
Title
|
||||
<input
|
||||
required
|
||||
value={formState.title}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, title: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
placeholder="Rent, car insurance, EMI..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Amount
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={formState.amount}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, amount: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
placeholder="1200.00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700">
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
Day of month
|
||||
<input
|
||||
required
|
||||
@@ -179,17 +179,17 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
max={28}
|
||||
value={formState.dayOfMonth}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, dayOfMonth: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
placeholder="1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 md:col-span-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||
Category
|
||||
<select
|
||||
value={formState.category}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, category: e.target.value as CategoryValue }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||
>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -205,14 +205,14 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelForm}
|
||||
className="rounded-full border border-stone-300 px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||
className="rounded-full border border-stone-300 px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
className="rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||
>
|
||||
{busy ? "Saving..." : editingId ? "Update" : "Save"}
|
||||
</button>
|
||||
@@ -226,7 +226,7 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
)}
|
||||
|
||||
{definitions.length === 0 && !showAddForm ? (
|
||||
<div className="rounded-3xl border border-dashed border-amber-300 px-4 py-6 text-sm text-stone-600">
|
||||
<div className="rounded-3xl border border-dashed border-amber-300 px-4 py-6 text-sm text-stone-600 dark:border-amber-800/50 dark:text-stone-400">
|
||||
No recurring expenses yet. Add fixed monthly costs like rent or EMI.
|
||||
</div>
|
||||
) : (
|
||||
@@ -236,24 +236,24 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
key={def.id}
|
||||
className={`flex items-center justify-between gap-4 rounded-3xl border px-4 py-4 ${
|
||||
editingId === def.id
|
||||
? "border-amber-300 bg-amber-100/50"
|
||||
: "border-amber-200 bg-white"
|
||||
? "border-amber-300 bg-amber-100/50 dark:border-amber-700/50 dark:bg-amber-900/20"
|
||||
: "border-amber-200 bg-white dark:border-amber-900/30 dark:bg-stone-900"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-950">{def.title}</p>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
<p className="font-semibold text-stone-950 dark:text-white">{def.title}</p>
|
||||
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||
Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(def.amountCents)}</p>
|
||||
<p className="mr-2 font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(def.amountCents)}</p>
|
||||
{editingId !== def.id && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(def)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900"
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900 dark:border-stone-600 dark:text-stone-400 dark:hover:border-stone-300 dark:hover:text-stone-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -261,7 +261,7 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDelete(def.id)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SiteNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
|
||||
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700 dark:text-stone-300">
|
||||
{links.map((link) => {
|
||||
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||
return (
|
||||
@@ -22,8 +22,8 @@ export function SiteNav() {
|
||||
href={link.href}
|
||||
className={
|
||||
isActive
|
||||
? "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-white transition hover:bg-stone-700 hover:border-stone-700"
|
||||
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
|
||||
? "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-white transition hover:bg-stone-700 hover:border-stone-700 dark:border-stone-100 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:hover:border-white"
|
||||
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950 dark:border-stone-600 dark:bg-stone-800/60 dark:hover:border-stone-300 dark:hover:text-white"
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
44
src/components/theme-toggle.tsx
Normal file
44
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const root = document.documentElement;
|
||||
if (root.classList.contains("dark")) {
|
||||
root.classList.remove("dark");
|
||||
localStorage.setItem("theme", "light");
|
||||
setIsDark(false);
|
||||
} else {
|
||||
root.classList.add("dark");
|
||||
localStorage.setItem("theme", "dark");
|
||||
setIsDark(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="rounded-full border border-stone-300/80 bg-white/80 px-3 py-2 text-stone-700 transition hover:border-stone-900 hover:text-stone-950 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:hover:border-stone-400 dark:hover:text-white"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user