Milestones 0 and 1: foundation and money in (20261007.10)

Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

23
web/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Spendlight</title>
<meta name="description" content="Your household's money, in the spotlight." />
<meta name="theme-color" content="#FAFAF7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0B1020" media="(prefers-color-scheme: dark)" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Spendlight" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<script src="/theme-init.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
web/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@spendlight/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"typecheck": "tsc -p tsconfig.json",
"icons": "node scripts/make-icons.mjs"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@simplewebauthn/browser": "^14.0.0",
"@spendlight/shared": "*",
"@tanstack/react-query": "^5.104.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.52.0",
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.1.2",
"sharp": "^0.35.5",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.3"
}
}

18
web/public/favicon.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="beam" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FBBF24" stop-opacity="0.95"/>
<stop offset="1" stop-color="#F97362" stop-opacity="0.18"/>
</linearGradient>
<radialGradient id="pool" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#FCD34D"/>
<stop offset="0.55" stop-color="#F97362" stop-opacity="0.6"/>
<stop offset="1" stop-color="#F97362" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="116" fill="#0B1020"/>
<path d="M222 140h68l110 240a144 40 0 0 1-288 0z" fill="url(#beam)"/>
<ellipse cx="256" cy="380" rx="168" ry="50" fill="url(#pool)"/>
<rect x="204" y="96" width="104" height="52" rx="20" fill="#818CF8"/>
<rect x="230" y="78" width="52" height="24" rx="10" fill="#6366F1"/>
</svg>

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "Spendlight",
"short_name": "Spendlight",
"description": "Your household's money, in the spotlight.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0B1020",
"theme_color": "#0B1020",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

57
web/public/sw.js Normal file
View File

@@ -0,0 +1,57 @@
// Spendlight service worker. Makes the home-screen app open instantly and
// show its shell offline. It never touches /api: household data is always
// fetched fresh and never stored here.
const SHELL = 'spendlight-shell';
const ASSETS = 'spendlight-assets';
const MAX_ASSETS = 80;
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL && k !== ASSETS).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
// Pages: always try the network first so a new version shows up at once.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(SHELL).then((cache) => cache.put('/', copy));
}
return response;
})
.catch(() => caches.match('/').then((cached) => cached || Response.error())),
);
return;
}
// Versioned files never change, so a cached copy is always right.
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.open(ASSETS).then(async (cache) => {
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
const keys = await cache.keys();
if (keys.length > MAX_ASSETS) await Promise.all(keys.slice(0, keys.length - MAX_ASSETS).map((k) => cache.delete(k)));
}
return response;
}),
);
}
});

9
web/public/theme-init.js Normal file
View File

@@ -0,0 +1,9 @@
// Runs before the page paints, so dark mode never flashes white.
(function () {
try {
var choice = localStorage.getItem('sl-theme') || 'system';
var dark = choice === 'dark' || (choice === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) {}
})();

View File

@@ -0,0 +1,19 @@
// Render the app icons from public/favicon.svg. Run with: npm run icons
import sharp from 'sharp';
import { readFileSync } from 'node:fs';
const svg = readFileSync(new URL('../public/favicon.svg', import.meta.url));
const out = (name) => new URL(`../public/icons/${name}`, import.meta.url).pathname;
const night = { r: 11, g: 16, b: 32, alpha: 1 };
await sharp(svg, { density: 384 }).resize(192, 192).png().toFile(out('icon-192.png'));
await sharp(svg, { density: 384 }).resize(512, 512).png().toFile(out('icon-512.png'));
// iOS adds its own rounded corners and dislikes transparency: full-bleed square.
await sharp(svg, { density: 384 }).resize(180, 180).flatten({ background: night }).png().toFile(out('apple-touch-icon.png'));
// Maskable: the mark inside the 80% safe zone on a full-bleed background.
const inner = await sharp(svg, { density: 384 }).resize(360, 360).png().toBuffer();
await sharp({ create: { width: 512, height: 512, channels: 4, background: night } })
.composite([{ input: inner, gravity: 'center' }])
.png()
.toFile(out('icon-maskable-512.png'));
console.log('Icons written to public/icons/');

View File

@@ -0,0 +1,230 @@
import { Ellipsis, Upload } from 'lucide-react';
import { Suspense, useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useReviewCount } from '@/lib/data';
import { queueFiles } from '@/lib/pendingFiles';
import { useConfig, useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
import { APP_VERSION } from '@/lib/version';
import { LogoMark, VersionTag } from './brand';
import { IdeaButton } from './IdeaDialog';
import { NAV, SETTINGS_NAV, type NavItem } from './nav';
import { Button } from './ui/button';
import { Skeleton, Tip } from './ui/misc';
import { UserMenu } from './UserMenu';
function pageTitle(pathname: string): string {
if (pathname === '/') return 'Home';
if (pathname.startsWith('/settings')) return 'Settings';
if (pathname.startsWith('/more')) return 'More';
if (pathname.startsWith('/import')) return 'Import';
return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight';
}
function CountBadge({ count, className }: { count: number | undefined; className?: string }) {
if (!count) return null;
return (
<span className={cn('tabular flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-foreground', className)}>
{count > 999 ? '999+' : count}
</span>
);
}
function SideLink({ item, badge }: { item: NavItem; badge?: number }) {
const Icon = item.icon;
return (
<NavLink
to={item.to}
end={item.to === '/'}
title={item.label}
className={({ isActive }) =>
cn(
'flex h-11 items-center gap-3 rounded-xl px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:justify-center lg:justify-start',
isActive && 'bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground',
)
}
>
<span className="relative">
<Icon className="size-[19px] shrink-0" strokeWidth={1.9} />
<CountBadge count={badge} className="absolute -right-2.5 -top-2 h-4 min-w-4 px-1 text-[10px] lg:hidden" />
</span>
<span className="flex-1 md:sr-only lg:not-sr-only">{item.label}</span>
<CountBadge count={badge} className="hidden lg:flex" />
</NavLink>
);
}
function Sidebar() {
const { data: toReview } = useReviewCount();
return (
<aside className="fixed inset-y-0 left-0 z-40 hidden flex-col border-r bg-card/70 backdrop-blur-xl md:flex md:w-[84px] lg:w-64">
<Link to="/" className="flex h-16 items-center gap-2.5 px-6 md:justify-center lg:justify-start" aria-label="Spendlight home">
<LogoMark />
<span className="hidden text-[17px] font-semibold tracking-tight lg:inline">Spendlight</span>
</Link>
<nav className="flex flex-1 flex-col gap-1 px-3 py-2" aria-label="Main">
{NAV.map((item) => (
<SideLink key={item.to} item={item} badge={item.to === '/review' ? toReview : undefined} />
))}
</nav>
<div className="px-3 pb-4">
<SideLink item={SETTINGS_NAV} />
<div className="mt-3 px-3 md:text-center lg:text-left">
<VersionTag />
</div>
</div>
</aside>
);
}
function PhoneTabBar() {
const { pathname } = useLocation();
const { data: toReview } = useReviewCount();
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to));
const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors';
return (
<nav className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/85 pb-safe backdrop-blur-xl md:hidden" aria-label="Main">
<div className="mx-auto grid max-w-md grid-cols-5">
{NAV.filter((n) => n.phone).map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => cn(tab, isActive && 'text-primary')}>
<span className="relative">
<Icon className="size-[22px]" strokeWidth={1.9} />
{to === '/review' ? <CountBadge count={toReview} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" /> : null}
</span>
{label}
</NavLink>
))}
<NavLink to="/more" className={cn(tab, inMore && 'text-primary')}>
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
More
</NavLink>
</div>
</nav>
);
}
/** When the server is running a newer version than these screens, offer a reload. */
function UpdateNotice() {
const { data } = useConfig();
useEffect(() => {
if (!data || data.version === 'dev' || APP_VERSION === 'dev' || data.version === APP_VERSION) return;
toast('A new version of Spendlight is ready', {
id: 'update',
description: `Version ${data.version}`,
duration: Infinity,
action: { label: 'Reload', onClick: () => window.location.reload() },
});
}, [data]);
return null;
}
export function PageSkeleton() {
return (
<div className="grid gap-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-40 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
</div>
);
}
/** Drop statement files anywhere on a big screen to start importing them. */
function useFileDrop(enabled: boolean) {
const navigate = useNavigate();
const [over, setOver] = useState(false);
useEffect(() => {
if (!enabled) return;
let depth = 0;
const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes('Files');
const enter = (e: DragEvent) => {
if (!hasFiles(e)) return;
depth++;
setOver(true);
};
const leave = (e: DragEvent) => {
if (!hasFiles(e)) return;
depth = Math.max(0, depth - 1);
if (!depth) setOver(false);
};
const overHandler = (e: DragEvent) => {
if (hasFiles(e)) e.preventDefault();
};
const drop = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depth = 0;
setOver(false);
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length && !window.location.pathname.startsWith('/import')) {
queueFiles(files);
navigate('/import');
}
};
window.addEventListener('dragenter', enter);
window.addEventListener('dragleave', leave);
window.addEventListener('dragover', overHandler);
window.addEventListener('drop', drop);
return () => {
window.removeEventListener('dragenter', enter);
window.removeEventListener('dragleave', leave);
window.removeEventListener('dragover', overHandler);
window.removeEventListener('drop', drop);
};
}, [enabled, navigate]);
return over;
}
export function AppShell() {
const { data: config } = useConfig();
const me = useCurrentUser();
const { pathname } = useLocation();
const dropping = useFileDrop(me.access === 'full' && !pathname.startsWith('/import'));
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return (
<div className="min-h-dvh">
<Sidebar />
<div className="md:pl-[84px] lg:pl-64">
<header className="sticky top-0 z-30 border-b bg-background/80 pt-safe backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6 lg:px-8">
<Link to="/" className="mr-1 md:hidden" aria-label="Spendlight home">
<LogoMark className="size-7" />
</Link>
<h1 className="flex-1 truncate text-[17px] font-semibold tracking-tight">{pageTitle(pathname)}</h1>
{me.access === 'full' ? (
<Tip label="Import statements">
<Button variant="ghost" size="icon" asChild aria-label="Import statements">
<Link to="/import">
<Upload />
</Link>
</Button>
</Tip>
) : null}
{config?.ideasEnabled ? <IdeaButton /> : null}
<UserMenu />
</div>
</header>
<main className="mx-auto max-w-6xl px-4 pb-32 pt-5 sm:px-6 md:pb-14 md:pt-8 lg:px-8">
<Suspense fallback={<PageSkeleton />}>
<Outlet />
</Suspense>
</main>
</div>
<PhoneTabBar />
<UpdateNotice />
{dropping ? (
<div className="pointer-events-none fixed inset-0 z-[60] flex items-center justify-center bg-night/50 p-8 backdrop-blur-sm">
<div className="flex flex-col items-center rounded-3xl border-2 border-dashed border-white/50 px-16 py-12 text-center text-white">
<Upload className="mb-3 size-10" strokeWidth={1.6} />
<p className="text-xl font-semibold">Drop to import</p>
<p className="mt-1 text-white/75">Statements from any of your banks</p>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { useConfig } from '@/lib/session';
import { LogoMark, VersionTag } from './brand';
/** The calm, centered frame for setup, sign-in and the first password change. */
export function AuthLayout({ title, subtitle, children }: { title: string; subtitle?: ReactNode; children: ReactNode }) {
const { data } = useConfig();
return (
<div className="relative flex min-h-dvh flex-col items-center px-5 pt-safe">
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-[460px]" aria-hidden />
<div className="relative flex w-full max-w-sm flex-1 flex-col justify-center py-12">
<div className="mb-8 flex flex-col items-center text-center">
<LogoMark className="mb-5 size-16 rounded-[22px] shadow-lift" />
<h1 className="text-[26px] font-semibold tracking-tight">{title}</h1>
{subtitle ? <p className="mt-2 text-[15px] leading-relaxed text-muted-foreground">{subtitle}</p> : null}
</div>
<div className="animate-fade-up rounded-3xl border bg-card p-6 shadow-lift sm:p-7">{children}</div>
</div>
<footer className="relative pb-6 pb-safe">
<VersionTag version={data?.version} />
</footer>
</div>
);
}
export function FormError({ message }: { message: string | null }) {
if (!message) return null;
return (
<p role="alert" className="rounded-xl bg-bad/10 px-3.5 py-2.5 text-[13.5px] leading-snug text-bad">
{message}
</p>
);
}

View File

@@ -0,0 +1,202 @@
import type { Category } from '@spendlight/shared';
import { Check, Search } from 'lucide-react';
import { Dialog as DialogPrimitive, Popover } from 'radix-ui';
import { useMemo, useRef, useState, type ReactNode } from 'react';
import { recentCategories, rememberCategory, useCategories } from '@/lib/data';
import { useIsPhone } from '@/lib/useMediaQuery';
import { cn } from '@/lib/utils';
import { CategoryIcon } from './money';
// The one category picker used everywhere: a flat, searchable list. Suggested
// and recent picks sit on top, then every category A to Z with its group as a
// small label. Phones get a full-height sheet; bigger screens a popover.
interface PickerProps {
value: number | null;
suggestions?: number[];
onPick: (category: Category) => void;
children: ReactNode;
/** Hide transfer categories (for splits, which are always spending or income). */
noTransfers?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function PickerList({
value,
suggestions = [],
onPick,
noTransfers,
autoFocus,
}: Omit<PickerProps, 'children' | 'open' | 'onOpenChange'> & { autoFocus: boolean }) {
const { all, byId } = useCategories();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const sections = useMemo(() => {
const usable = all.filter((c) => !c.archived && !(noTransfers && c.kind === 'transfer'));
const q = query.trim().toLowerCase();
if (q) {
const matches = usable
.filter((c) => c.name.toLowerCase().includes(q) || c.groupName.toLowerCase().includes(q))
.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)) || a.name.localeCompare(b.name));
return [{ title: `${matches.length} match${matches.length === 1 ? '' : 'es'}`, items: matches }];
}
const pick = (ids: number[]) => ids.map((id) => byId.get(id)).filter((c): c is Category => !!c && usable.includes(c));
const suggested = pick(suggestions);
const recent = pick(recentCategories()).filter((c) => !suggested.includes(c)).slice(0, 4);
return [
...(suggested.length ? [{ title: 'Suggested', items: suggested }] : []),
...(recent.length ? [{ title: 'Recent', items: recent }] : []),
{ title: 'All categories', items: [...usable].sort((a, b) => a.name.localeCompare(b.name)) },
];
}, [all, byId, query, suggestions, noTransfers]);
const flat = sections.flatMap((s) => s.items);
const choose = (c: Category) => {
rememberCategory(c.id);
onPick(c);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const next = Math.max(0, Math.min(flat.length - 1, active + (e.key === 'ArrowDown' ? 1 : -1)));
setActive(next);
listRef.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter' && flat[active]) {
e.preventDefault();
choose(flat[active]!);
}
};
let index = -1;
return (
<div className="flex min-h-0 flex-1 flex-col" onKeyDown={onKeyDown}>
<div className="relative border-b p-2.5">
<Search className="pointer-events-none absolute left-5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
autoFocus={autoFocus}
placeholder="Search categories"
aria-label="Search categories"
className="h-11 w-full rounded-xl bg-muted pl-9 pr-3 text-[16px] outline-none placeholder:text-muted-foreground/80 focus-visible:ring-2 focus-visible:ring-ring/30 sm:h-10 sm:text-sm"
/>
</div>
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5" role="listbox" aria-label="Categories">
{sections.map((section) => (
<div key={section.title} className="mb-1">
<p className="px-2.5 pb-1 pt-2 text-[11.5px] font-semibold uppercase tracking-wider text-muted-foreground">{section.title}</p>
{section.items.map((c) => {
index++;
const i = index;
return (
<button
key={`${section.title}-${c.id}`}
type="button"
role="option"
aria-selected={c.id === value}
data-index={i}
onMouseEnter={() => setActive(i)}
onClick={() => choose(c)}
className={cn(
'flex h-12 w-full items-center gap-3 rounded-xl px-2.5 text-left sm:h-10',
i === active && 'bg-muted',
)}
>
<CategoryIcon icon={c.icon} color={c.color} size="sm" />
<span className="min-w-0 flex-1 truncate text-[15px] font-medium sm:text-[14px]">{c.name}</span>
<span className="shrink-0 text-[12px] text-muted-foreground">{c.groupName}</span>
{c.id === value ? <Check className="size-4 shrink-0 text-primary" /> : <span className="size-4 shrink-0" />}
</button>
);
})}
{section.items.length === 0 ? <p className="px-2.5 py-3 text-[13.5px] text-muted-foreground">Nothing matches that.</p> : null}
</div>
))}
</div>
</div>
);
}
export function CategoryPicker({ children, open: controlledOpen, onOpenChange, ...props }: PickerProps) {
const phone = useIsPhone();
const [uncontrolled, setUncontrolled] = useState(false);
const open = controlledOpen ?? uncontrolled;
const setOpen = onOpenChange ?? setUncontrolled;
const pick = (c: Category) => {
setOpen(false);
props.onPick(c);
};
if (phone) {
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Trigger asChild>{children}</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-night/40 backdrop-blur-[2px]" />
<DialogPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 flex h-[88dvh] animate-sheet-up flex-col rounded-t-3xl border bg-popover pb-safe text-popover-foreground shadow-lift outline-none">
<div className="mx-auto mt-2.5 h-1.5 w-10 rounded-full bg-muted" aria-hidden />
<DialogPrimitive.Title className="px-4 pt-3 text-[17px] font-semibold">Choose a category</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">Pick the category for this transaction</DialogPrimitive.Description>
<PickerList {...props} onPick={pick} autoFocus={false} />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
collisionPadding={12}
className="z-50 flex h-[min(440px,var(--radix-popover-content-available-height))] w-80 animate-fade-up flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-lift"
>
<PickerList {...props} onPick={pick} autoFocus />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
/** A tappable chip showing a category (or "Choose category"), opening the picker. */
export function CategoryButton({
categoryId,
suggestions,
onPick,
className,
placeholder = 'Choose category',
}: {
categoryId: number | null;
suggestions?: number[];
onPick: (category: Category) => void;
className?: string;
placeholder?: string;
}) {
const { byId } = useCategories();
const c = categoryId ? byId.get(categoryId) : undefined;
return (
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick}>
<button
type="button"
className={cn(
'flex h-10 min-w-0 items-center gap-2 rounded-xl border bg-card px-2.5 text-left text-[14px] font-medium shadow-soft transition-colors hover:bg-muted',
!c && 'border-dashed text-muted-foreground',
className,
)}
>
{c ? <CategoryIcon icon={c.icon} color={c.color} size="sm" className="-ml-0.5 size-6 rounded-md" /> : null}
<span className="truncate">{c?.name ?? placeholder}</span>
</button>
</CategoryPicker>
);
}

View File

@@ -0,0 +1,82 @@
import type { Idea } from '@spendlight/shared';
import { useMutation } from '@tanstack/react-query';
import { Lightbulb } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { useLocation } from 'react-router';
import { toast } from 'sonner';
import { api, errorMessage } from '@/lib/api';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog';
import { Field, Input, Textarea } from './ui/form';
import { Tip } from './ui/misc';
/** The lightbulb at the top right: share an idea in a few seconds. */
export function IdeaButton() {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const location = useLocation();
const save = useMutation({
mutationFn: () =>
api<Idea>('/ideas', { method: 'POST', body: { title, description, path: location.pathname } }),
onSuccess: () => {
toast.success('Thanks! Your idea is saved.', { description: 'It’s on the list to review.' });
setTitle('');
setDescription('');
setOpen(false);
},
onError: (error) => toast.error(errorMessage(error)),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (title.trim()) save.mutate();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tip label="Share an idea">
<DialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Share an idea" className="text-muted-foreground hover:text-warn">
<Lightbulb />
</Button>
</DialogTrigger>
</Tip>
<DialogContent
title="Share an idea"
description="Spotted something Spendlight should do, or do better? Jot it down and it goes straight onto the list."
>
<form onSubmit={submit} className="grid gap-4">
<Field label="Idea">
{(id) => (
<Input
id={id}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
placeholder="For example: show our coffee total for the year"
autoFocus
required
/>
)}
</Field>
<Field label="Details" hint="What would it help with? Any example helps.">
{(id) => (
<Textarea
id={id}
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={4000}
placeholder="Optional"
/>
)}
</Field>
<Button type="submit" loading={save.isPending} disabled={!title.trim()} className="mt-1 w-full sm:w-auto sm:justify-self-end">
Save idea
</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,170 @@
import type { ImportColumnMap, ImportPreview } from '@spendlight/shared';
import { useMemo, useState } from 'react';
import { Button } from './ui/button';
import { Dialog, DialogContent } from './ui/dialog';
import { Select } from './ui/form';
import { cn } from '@/lib/utils';
// For a bank Spendlight hasn't seen: "which column is which?", with the file's
// own first rows underneath, and one plain question to get the sign right.
type Role = 'skip' | 'date' | 'description' | 'amount' | 'debit' | 'credit' | 'balance' | 'category' | 'postedDate';
const ROLE_LABEL: Record<Role, string> = {
skip: 'Skip',
date: 'Date',
description: 'Description',
amount: 'Amount',
debit: 'Money out',
credit: 'Money in',
balance: 'Balance',
category: 'Bank’s category',
postedDate: 'Posted date',
};
function rolesFrom(map: ImportColumnMap | null, columns: number): Role[] {
const roles: Role[] = Array.from({ length: columns }, () => 'skip');
if (!map) return roles;
for (const role of ['date', 'description', 'amount', 'debit', 'credit', 'balance', 'category', 'postedDate'] as const) {
const i = map[role];
if (i !== undefined && i < columns) roles[i] = role;
}
return roles;
}
function guessDateOrder(rows: string[][], column: number): ImportColumnMap['dateOrder'] {
const values = rows.map((r) => r[column] ?? '');
if (values.some((v) => /^\d{4}[-/.]/.test(v))) return 'YMD';
if (values.some((v) => Number(v.split(/[/.-]/)[0]) > 12)) return 'DMY';
return 'MDY';
}
const money = (s: string) => Number(s.replace(/[$,\s()]/g, '').replace(/^\((.*)\)$/, '-$1')) || 0;
export function MappingDialog({
preview,
open,
onOpenChange,
onSave,
saving,
}: {
preview: ImportPreview;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (map: ImportColumnMap) => void;
saving: boolean;
}) {
const header = preview.headerRow ?? preview.sampleRows[0]?.map((_, i) => `Column ${i + 1}`) ?? [];
const rows = preview.sampleRows;
const [roles, setRoles] = useState<Role[]>(() => rolesFrom(preview.suggestedMap, header.length));
const dateCol = roles.indexOf('date');
const [dateOrder, setDateOrder] = useState<ImportColumnMap['dateOrder']>(() =>
preview.suggestedMap ? guessDateOrder(rows, preview.suggestedMap.date) : 'MDY',
);
const [spent, setSpent] = useState<boolean | null>(null);
const amountCol = roles.indexOf('amount');
const example = useMemo(() => {
if (amountCol < 0) return null;
const descCol = roles.indexOf('description');
const row = rows.find((r) => money(r[amountCol] ?? '') !== 0);
return row ? { desc: descCol >= 0 ? row[descCol] : 'the first row', amount: row[amountCol]!, value: money(row[amountCol]!) } : null;
}, [rows, roles, amountCol]);
const setRole = (i: number, role: Role) =>
setRoles((prev) => prev.map((r, j) => (j === i ? role : role !== 'skip' && r === role ? 'skip' : r)));
const complete =
dateCol >= 0 && roles.includes('description') && (roles.includes('debit') || (amountCol >= 0 && (spent !== null || !example)));
const save = () => {
const map: ImportColumnMap = {
date: dateCol,
description: roles.indexOf('description'),
dateOrder,
positiveIs: example && spent !== null ? ((example.value > 0) === spent ? 'out' : 'in') : 'in',
};
roles.forEach((role, i) => {
if (role !== 'skip' && role !== 'date' && role !== 'description') map[role] = i;
});
onSave(map);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
title="Which column is which?"
description="Spendlight hasn’t seen this bank’s layout before. Show it once and it will remember."
className="sm:max-w-3xl"
>
<div className="-mx-5 overflow-x-auto px-5 sm:-mx-6 sm:px-6">
<table className="w-full min-w-max border-separate border-spacing-0 text-[13px]">
<thead>
<tr>
{header.map((name, i) => (
<th key={i} className="min-w-36 pb-2 pr-2 text-left align-bottom font-medium">
<div className="mb-1 truncate text-[12px] text-muted-foreground">{name || `Column ${i + 1}`}</div>
<Select
aria-label={`What is ${name || `column ${i + 1}`}?`}
value={roles[i]}
onChange={(e) => setRole(i, e.target.value as Role)}
className={cn('h-9 text-[13px]', roles[i] !== 'skip' && 'border-primary/50 bg-accent text-accent-foreground')}
>
{(Object.keys(ROLE_LABEL) as Role[]).map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r]}
</option>
))}
</Select>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r}>
{header.map((_, i) => (
<td key={i} className={cn('border-t py-2 pr-3 text-muted-foreground', roles[i] !== 'skip' && 'text-foreground')}>
<span className="block max-w-56 truncate">{row[i]}</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-5 grid gap-4">
{dateCol >= 0 ? (
<label className="flex flex-wrap items-center justify-between gap-2 text-[14px]">
<span className="font-medium">Dates are written</span>
<Select value={dateOrder} onChange={(e) => setDateOrder(e.target.value as ImportColumnMap['dateOrder'])} className="h-9 w-auto">
<option value="MDY">Month / day / year</option>
<option value="DMY">Day / month / year</option>
<option value="YMD">Year - month - day</option>
</Select>
</label>
) : null}
{example ? (
<div className="rounded-2xl bg-muted p-4">
<p className="text-[14px] font-medium">
Was “{example.desc}” for {example.amount} money you spent?
</p>
<div className="mt-3 flex gap-2">
<Button size="sm" variant={spent === true ? 'default' : 'outline'} onClick={() => setSpent(true)}>
Yes, money spent
</Button>
<Button size="sm" variant={spent === false ? 'default' : 'outline'} onClick={() => setSpent(false)}>
No, money received
</Button>
</div>
</div>
) : null}
<Button onClick={save} disabled={!complete} loading={saving}>
{complete ? 'Looks right' : 'Choose the date, description and amount columns'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,101 @@
import type { Me } from '@spendlight/shared';
import { useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router';
import { toast } from 'sonner';
import { api, errorMessage } from '@/lib/api';
import { useConfig, useMe } from '@/lib/session';
import { applyTheme, storedTheme } from '@/lib/theme';
import { AuthLayout, FormError } from './AuthLayout';
import { LogoMark } from './brand';
import { Button } from './ui/button';
import { Field, PasswordInput } from './ui/form';
export function Splash() {
return (
<div className="flex min-h-dvh items-center justify-center">
<LogoMark className="size-14 animate-pulse rounded-[20px]" />
</div>
);
}
function Unreachable({ onRetry }: { onRetry: () => void }) {
return (
<AuthLayout title="Can’t reach Spendlight" subtitle="The server didn’t answer. Check your connection, or try again in a moment.">
<Button className="w-full" onClick={onRetry}>
<RefreshCw /> Try again
</Button>
</AuthLayout>
);
}
/** First sign-in with a temporary password: choose your own before anything else. */
function ChangePasswordGate() {
const client = useQueryClient();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const me = await api<Me>('/me/password', { method: 'POST', body: { currentPassword: current, newPassword: next } });
client.setQueryData(['me'], me);
toast.success('Password saved. You’re all set.');
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
};
return (
<AuthLayout title="Choose your password" subtitle="You signed in with a temporary password. Pick one only you know.">
<form onSubmit={submit} className="grid gap-4">
<FormError message={error} />
<Field label="Temporary password">
{(id) => <PasswordInput id={id} value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required />}
</Field>
<Field label="New password" hint="At least 8 characters. A short phrase is easy to remember.">
{(id) => <PasswordInput id={id} value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={8} required />}
</Field>
<Button type="submit" size="lg" loading={busy} className="mt-1 w-full">
Save and continue
</Button>
</form>
</AuthLayout>
);
}
export function RequireAuth({ children }: { children: ReactNode }) {
const config = useConfig();
const me = useMe();
const location = useLocation();
useEffect(() => {
if (me.data && me.data.theme !== storedTheme()) applyTheme(me.data.theme);
}, [me.data]);
if (config.isError || me.isError) {
return (
<Unreachable
onRetry={() => {
void config.refetch();
void me.refetch();
}}
/>
);
}
if (config.isPending || me.isPending) return <Splash />;
if (config.data.needsSetup) return <Navigate to="/setup" replace />;
if (!me.data) {
const next = location.pathname === '/' ? '' : `?next=${encodeURIComponent(location.pathname)}`;
return <Navigate to={`/sign-in${next}`} replace />;
}
if (me.data.mustChangePassword) return <ChangePasswordGate />;
return children;
}

View File

@@ -0,0 +1,25 @@
import { RefreshCw, TriangleAlert } from 'lucide-react';
import { useRouteError } from 'react-router';
import { Button } from './ui/button';
/** Shown instead of a blank screen if something breaks while drawing a page. */
export function RouteError() {
const error = useRouteError();
if (error) console.error(error);
return (
<div className="flex min-h-[60dvh] items-center justify-center px-4 py-16">
<div className="max-w-sm text-center">
<span className="mx-auto mb-5 flex size-14 items-center justify-center rounded-2xl bg-warn/12 text-warn">
<TriangleAlert className="size-7" strokeWidth={1.8} />
</span>
<h2 className="text-xl font-semibold tracking-tight">Something went wrong</h2>
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">
This screen hit a problem. Reloading usually fixes it, and nothing you saved has been lost.
</p>
<Button className="mt-6" onClick={() => window.location.reload()}>
<RefreshCw /> Reload
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,364 @@
import { formatCents, type Transaction } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftRight, Plus, Split as SplitIcon, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { api, errorMessage } from '@/lib/api';
import { MONEY_KEYS, useAccounts, useCategories, useTags } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
import { CategoryButton } from './CategoryPicker';
import { AccountDot, Amount, formatDay } from './money';
import { Button } from './ui/button';
import { ConfirmDialog, Dialog, DialogContent } from './ui/dialog';
import { Field, Input, Textarea } from './ui/form';
import { Switch } from './ui/misc';
function parseMoney(text: string): number | null {
const clean = text.replace(/[$,\s]/g, '');
if (!/^-?\d*(\.\d{0,2})?$/.test(clean) || clean === '' || clean === '-') return null;
return Math.round(Number(clean) * 100);
}
export function useRefreshMoney() {
const client = useQueryClient();
return () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
}
function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: string[]) => void }) {
const tags = useTags();
const [draft, setDraft] = useState('');
const add = (name: string) => {
const clean = name.trim();
if (clean && !value.some((t) => t.toLowerCase() === clean.toLowerCase())) onChange([...value, clean]);
setDraft('');
};
const suggestions = (tags.data ?? []).map((t) => t.name).filter((n) => !value.some((v) => v.toLowerCase() === n.toLowerCase()));
return (
<div className="grid gap-2">
{value.length ? (
<div className="flex flex-wrap gap-1.5">
{value.map((t) => (
<span key={t} className="flex items-center gap-1 rounded-full bg-accent py-1 pl-3 pr-1.5 text-[13px] font-medium text-accent-foreground">
{t}
<button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(value.filter((v) => v !== t))} className="rounded-full p-0.5 hover:bg-primary/15">
<X className="size-3.5" />
</button>
</span>
))}
</div>
) : null}
<Input
value={draft}
list="tag-suggestions"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
add(draft);
}
}}
onBlur={() => draft && add(draft)}
placeholder="Add a tag, like Vacation 2026"
aria-label="Add a tag"
/>
<datalist id="tag-suggestions">
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
);
}
interface Part {
key: string;
categoryId: number | null;
amount: string;
note: string;
}
function SplitEditor({ t, onDone, onCancel }: { t: Transaction; onDone: () => void; onCancel: () => void }) {
const sign = Math.sign(t.amountCents) || -1;
const total = Math.abs(t.amountCents);
const [parts, setParts] = useState<Part[]>(() =>
t.isSplit
? t.splits.map((s) => ({ key: crypto.randomUUID(), categoryId: s.categoryId, amount: (Math.abs(s.amountCents) / 100).toFixed(2), note: s.note ?? '' }))
: [
{ key: crypto.randomUUID(), categoryId: t.categoryId, amount: (total / 100).toFixed(2), note: '' },
{ key: crypto.randomUUID(), categoryId: null, amount: '', note: '' },
],
);
const assigned = parts.reduce((s, p) => s + (parseMoney(p.amount) ?? 0), 0);
const left = total - assigned;
const valid = left === 0 && parts.length >= 2 && parts.every((p) => p.categoryId && (parseMoney(p.amount) ?? 0) > 0);
const refresh = useRefreshMoney();
const save = useMutation({
mutationFn: () =>
api<Transaction>(`/transactions/${t.id}/split`, {
method: 'PUT',
body: { parts: parts.map((p) => ({ categoryId: p.categoryId, amountCents: sign * (parseMoney(p.amount) ?? 0), note: p.note || null })) },
}),
onSuccess: () => {
toast.success('Split saved');
refresh();
onDone();
},
onError: (error) => toast.error(errorMessage(error)),
});
const update = (key: string, change: Partial<Part>) =>
setParts((list) => {
const next = list.map((p) => (p.key === key ? { ...p, ...change } : p));
// Typing an amount in a later part takes it from the first part, so the total stays right.
if (change.amount !== undefined && key !== next[0]!.key) {
const others = next.slice(1).reduce((sum, p) => sum + (parseMoney(p.amount) ?? 0), 0);
if (others <= total) next[0] = { ...next[0]!, amount: ((total - others) / 100).toFixed(2) };
}
return next;
});
return (
<div className="grid gap-3">
{parts.map((p, i) => (
<div key={p.key} className="grid gap-2 rounded-2xl border p-3">
<div className="flex items-center gap-2">
<CategoryButton categoryId={p.categoryId} onPick={(c) => update(p.key, { categoryId: c.id })} className="min-w-0 flex-1" />
<Input
value={p.amount}
onChange={(e) => update(p.key, { amount: e.target.value })}
inputMode="decimal"
aria-label={`Amount for part ${i + 1}`}
placeholder="0.00"
className="w-28 text-right tabular"
/>
{parts.length > 2 ? (
<Button variant="ghost" size="icon" aria-label="Remove this part" onClick={() => setParts((list) => list.filter((x) => x.key !== p.key))}>
<X className="text-muted-foreground" />
</Button>
) : null}
</div>
<Input value={p.note} onChange={(e) => update(p.key, { note: e.target.value })} placeholder="Note (optional), like Paper towels" aria-label={`Note for part ${i + 1}`} />
</div>
))}
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setParts((list) => [...list, { key: crypto.randomUUID(), categoryId: null, amount: left > 0 ? (left / 100).toFixed(2) : '', note: '' }])}>
<Plus /> Add a part
</Button>
<p className={cn('tabular text-[13.5px] font-medium', left === 0 ? 'text-good' : 'text-warn')}>
{left === 0 ? 'All assigned' : left > 0 ? `${formatCents(left)} left to assign` : `${formatCents(-left)} too much`}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1">
Cancel
</Button>
<Button onClick={() => save.mutate()} disabled={!valid} loading={save.isPending} className="flex-1">
Save split
</Button>
</div>
</div>
);
}
/** Everything about one transaction, editable in place. */
export function TransactionSheet({
transaction,
onClose,
startWithSplit,
}: {
transaction: Transaction | null;
onClose: () => void;
startWithSplit?: boolean;
}) {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const accounts = useAccounts();
const { byId } = useCategories();
const refresh = useRefreshMoney();
const [t, setT] = useState<Transaction | null>(transaction);
const [splitting, setSplitting] = useState(false);
const [merchant, setMerchant] = useState('');
const [everywhere, setEverywhere] = useState(false);
const [note, setNote] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
setT(transaction);
setSplitting(!!startWithSplit);
setMerchant(transaction?.merchant ?? '');
setNote(transaction?.note ?? '');
setEverywhere(false);
}, [transaction, startWithSplit]);
const account = useMemo(() => accounts.data?.find((a) => a.id === t?.accountId), [accounts.data, t]);
const save = useMutation({
mutationFn: (body: Record<string, unknown>) => api<{ transaction: Transaction }>(`/transactions/${t!.id}`, { method: 'PATCH', body }),
onSuccess: (res) => {
setT(res.transaction);
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const unsplit = useMutation({
mutationFn: () => api<Transaction>(`/transactions/${t!.id}/split`, { method: 'DELETE' }),
onSuccess: (res) => {
setT(res);
refresh();
},
});
const remove = useMutation({
mutationFn: () => api(`/transactions/${t!.id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Deleted');
refresh();
onClose();
},
onError: (error) => toast.error(errorMessage(error)),
});
if (!t) return null;
const transfer = t.transferPeer;
return (
<>
<Dialog open={!!transaction && !confirmDelete} onOpenChange={(o) => !o && onClose()}>
<DialogContent title={splitting ? 'Split this purchase' : t.merchant} description={splitting ? `Divide ${formatCents(t.amountCents)} between categories.` : undefined}>
{splitting ? (
<SplitEditor
t={t}
onCancel={() => setSplitting(false)}
onDone={() => {
setSplitting(false);
onClose();
}}
/>
) : (
<div className="grid gap-5">
<div className="flex items-end justify-between gap-3 rounded-2xl bg-muted p-4">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{account ? <AccountDot color={account.color} /> : null}
{account?.name} · {formatDay(t.date)}
</p>
<p className="mt-1 break-words text-[12px] text-muted-foreground">{t.description}</p>
</div>
<Amount cents={t.amountCents} transfer={!!transfer} className="text-[22px] font-semibold" />
</div>
{transfer ? (
<p className="flex items-center gap-2 rounded-xl border px-3.5 py-3 text-[14px]">
<ArrowLeftRight className="size-4 text-muted-foreground" />
<span>
<em>Transfer {t.amountCents < 0 ? 'to' : 'from'} {transfer.accountName}</em>. Not counted as spending or income.
</span>
</p>
) : null}
{t.isSplit ? (
<div className="grid gap-2">
<p className="text-[13px] font-medium">Split between</p>
{t.splits.map((s) => (
<div key={s.id} className="flex items-center gap-2 rounded-xl border px-3 py-2.5 text-[14px]">
<span className="flex-1 truncate">
{byId.get(s.categoryId)?.name}
{s.note ? <span className="text-muted-foreground"> · {s.note}</span> : null}
</span>
<span className="tabular">{formatCents(s.amountCents)}</span>
</div>
))}
{canEdit ? (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setSplitting(true)}>
Edit split
</Button>
<Button variant="ghost" size="sm" onClick={() => unsplit.mutate()}>
Remove split
</Button>
</div>
) : null}
</div>
) : (
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Category</span>
<div className="flex gap-2">
{canEdit ? (
<CategoryButton
categoryId={t.categoryId}
onPick={(c) => save.mutate({ categoryId: c.id })}
className="min-w-0 flex-1"
/>
) : (
<p className="text-[14.5px]">{t.categoryId ? byId.get(t.categoryId)?.name : 'Not filed yet'}</p>
)}
{canEdit && !transfer ? (
<Button variant="outline" onClick={() => setSplitting(true)}>
<SplitIcon /> Split
</Button>
) : null}
</div>
</div>
)}
{canEdit ? (
<>
<Field label="Merchant name">
{(id) => (
<div className="grid gap-2">
<Input id={id} value={merchant} onChange={(e) => setMerchant(e.target.value)} maxLength={80} />
{merchant.trim() && merchant.trim() !== t.merchant ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Switch checked={everywhere} onCheckedChange={setEverywhere} />
Rename everywhere, and in future imports
</label>
<Button size="sm" loading={save.isPending} onClick={() => save.mutate({ merchant: merchant.trim(), renameEverywhere: everywhere })}>
Save name
</Button>
</div>
) : null}
</div>
)}
</Field>
<Field label="Tags">{() => <TagEditor value={t.tags} onChange={(tags) => save.mutate({ tags })} />}</Field>
<Field label="Note">
{(id) => (
<Textarea
id={id}
value={note}
onChange={(e) => setNote(e.target.value)}
onBlur={() => note !== (t.note ?? '') && save.mutate({ note: note || null })}
placeholder="Anything to remember about this one"
className="min-h-20"
/>
)}
</Field>
{t.source === 'manual' ? (
<Button variant="ghost" className="justify-self-start text-bad hover:text-bad" onClick={() => setConfirmDelete(true)}>
<Trash2 /> Delete this entry
</Button>
) : null}
</>
) : (
<>
{t.tags.length ? <p className="text-[14px]">Tags: {t.tags.join(', ')}</p> : null}
{t.note ? <p className="text-[14px]">{t.note}</p> : null}
</>
)}
</div>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete this entry?"
description="It was added by hand, so deleting it removes it completely."
confirmLabel="Delete"
destructive
loading={remove.isPending}
onConfirm={() => remove.mutate()}
/>
</>
);
}

View File

@@ -0,0 +1,71 @@
import type { Theme } from '@spendlight/shared';
import { LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSetTheme } from '@/lib/useSetTheme';
import { useCurrentUser, useSignOut } from '@/lib/session';
import { initials } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/menu';
export function Avatar({ name, className = 'size-9 text-[13px]' }: { name: string; className?: string }) {
return (
<span
className={`relative flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-glow-1 to-glow-2 p-[2px] ${className}`}
aria-hidden
>
<span className="flex size-full items-center justify-center rounded-full bg-card font-semibold text-foreground">
{initials(name)}
</span>
</span>
);
}
const THEMES: { value: Theme; label: string; icon: typeof Sun }[] = [
{ value: 'system', label: 'Match my device', icon: Monitor },
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
];
export function UserMenu() {
const me = useCurrentUser();
const navigate = useNavigate();
const signOut = useSignOut();
const setTheme = useSetTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Your account">
<Avatar name={me.displayName} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="text-[14px] font-semibold">{me.displayName}</div>
<div className="text-[12.5px] font-normal text-muted-foreground">@{me.username}</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{THEMES.map(({ value, label, icon: Icon }) => (
<DropdownMenuItem key={value} onSelect={() => setTheme(value)}>
<Icon />
<span className="flex-1">{label}</span>
{me.theme === value ? <span className="size-1.5 rounded-full bg-primary" aria-label="(current)" /> : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => navigate('/settings')}>
<Settings />
Settings
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => signOut.mutate()}>
<LogOut />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,49 @@
import { useId } from 'react';
import { cn } from '@/lib/utils';
import { APP_VERSION } from '@/lib/version';
/** The Spendlight mark: a spotlight pouring warm light into a pool. */
export function LogoMark({ className }: { className?: string }) {
const id = useId();
return (
<svg viewBox="0 0 512 512" className={cn('size-8 shrink-0', className)} aria-hidden>
<defs>
<linearGradient id={`${id}b`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#FBBF24" stopOpacity="0.95" />
<stop offset="1" stopColor="#F97362" stopOpacity="0.18" />
</linearGradient>
<radialGradient id={`${id}p`} cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stopColor="#FCD34D" />
<stop offset="0.55" stopColor="#F97362" stopOpacity="0.6" />
<stop offset="1" stopColor="#F97362" stopOpacity="0" />
</radialGradient>
</defs>
<rect width="512" height="512" rx="116" fill="#0B1020" />
<path d="M222 140h68l110 240a144 40 0 0 1-288 0z" fill={`url(#${id}b)`} />
<ellipse cx="256" cy="380" rx="168" ry="50" fill={`url(#${id}p)`} />
<rect x="204" y="96" width="104" height="52" rx="20" fill="#818CF8" />
<rect x="230" y="78" width="52" height="24" rx="10" fill="#6366F1" />
</svg>
);
}
export function Wordmark({ className }: { className?: string }) {
return (
<span className={cn('flex items-center gap-2.5', className)}>
<LogoMark />
<span className="text-[17px] font-semibold tracking-tight">Spendlight</span>
</span>
);
}
/** The build version, always visible somewhere so it's clear which code is running. */
export function VersionTag({ className, version = APP_VERSION }: { className?: string; version?: string }) {
return (
<span
className={cn('tabular text-[11px] font-medium tracking-wide text-muted-foreground/80', className)}
title="Spendlight version: build date, then that day's build number"
>
v{version}
</span>
);
}

View File

@@ -0,0 +1,152 @@
import { formatCents, type PaletteColor } from '@spendlight/shared';
import {
ArrowLeftRight,
Award,
Baby,
Banknote,
Car,
CarFront,
Circle,
CircleDollarSign,
CircleParking,
Coffee,
CreditCard,
Droplets,
Dumbbell,
Fuel,
Gift,
HeartHandshake,
House,
Landmark,
MonitorSmartphone,
Palette,
PawPrint,
Percent,
Pill,
Plane,
Receipt,
Scale,
Shield,
ShieldCheck,
Shirt,
ShoppingBag,
ShoppingCart,
Smartphone,
Sofa,
Sparkles,
SprayCan,
Stethoscope,
Ticket,
Tv,
Utensils,
Wifi,
Wrench,
Zap,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
// Colors and icons for categories and accounts, written out in full so the
// styling tool can see every class.
export const PALETTE_CLASS: Record<PaletteColor, { soft: string; text: string; dot: string }> = {
indigo: { soft: 'bg-indigo-500/12 dark:bg-indigo-400/15', text: 'text-indigo-600 dark:text-indigo-300', dot: 'bg-indigo-500' },
sky: { soft: 'bg-sky-500/12 dark:bg-sky-400/15', text: 'text-sky-600 dark:text-sky-300', dot: 'bg-sky-500' },
teal: { soft: 'bg-teal-500/12 dark:bg-teal-400/15', text: 'text-teal-600 dark:text-teal-300', dot: 'bg-teal-500' },
emerald: { soft: 'bg-emerald-500/12 dark:bg-emerald-400/15', text: 'text-emerald-600 dark:text-emerald-300', dot: 'bg-emerald-500' },
lime: { soft: 'bg-lime-500/14 dark:bg-lime-400/15', text: 'text-lime-700 dark:text-lime-300', dot: 'bg-lime-500' },
amber: { soft: 'bg-amber-500/14 dark:bg-amber-400/15', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
orange: { soft: 'bg-orange-500/12 dark:bg-orange-400/15', text: 'text-orange-600 dark:text-orange-300', dot: 'bg-orange-500' },
rose: { soft: 'bg-rose-500/12 dark:bg-rose-400/15', text: 'text-rose-600 dark:text-rose-300', dot: 'bg-rose-500' },
pink: { soft: 'bg-pink-500/12 dark:bg-pink-400/15', text: 'text-pink-600 dark:text-pink-300', dot: 'bg-pink-500' },
violet: { soft: 'bg-violet-500/12 dark:bg-violet-400/15', text: 'text-violet-600 dark:text-violet-300', dot: 'bg-violet-500' },
cyan: { soft: 'bg-cyan-500/12 dark:bg-cyan-400/15', text: 'text-cyan-700 dark:text-cyan-300', dot: 'bg-cyan-500' },
slate: { soft: 'bg-slate-500/12 dark:bg-slate-400/15', text: 'text-slate-600 dark:text-slate-300', dot: 'bg-slate-500' },
};
const ICONS: Record<string, LucideIcon> = {
banknote: Banknote,
award: Award,
percent: Percent,
'circle-dollar-sign': CircleDollarSign,
house: House,
shield: Shield,
landmark: Landmark,
wrench: Wrench,
sofa: Sofa,
zap: Zap,
droplets: Droplets,
wifi: Wifi,
smartphone: Smartphone,
'shopping-cart': ShoppingCart,
utensils: Utensils,
coffee: Coffee,
fuel: Fuel,
car: Car,
'shield-check': ShieldCheck,
'car-front': CarFront,
'circle-parking': CircleParking,
shirt: Shirt,
'spray-can': SprayCan,
'monitor-smartphone': MonitorSmartphone,
'shopping-bag': ShoppingBag,
stethoscope: Stethoscope,
pill: Pill,
sparkles: Sparkles,
dumbbell: Dumbbell,
ticket: Ticket,
tv: Tv,
palette: Palette,
plane: Plane,
baby: Baby,
'paw-print': PawPrint,
gift: Gift,
'heart-handshake': HeartHandshake,
receipt: Receipt,
scale: Scale,
'credit-card': CreditCard,
'arrow-left-right': ArrowLeftRight,
};
export function CategoryIcon({
icon,
color,
className,
size = 'md',
}: {
icon: string | undefined;
color: PaletteColor | undefined;
className?: string;
size?: 'sm' | 'md' | 'lg';
}) {
const Icon = (icon && ICONS[icon]) || Circle;
const tone = PALETTE_CLASS[color ?? 'slate'];
const box = size === 'sm' ? 'size-7 rounded-lg [&_svg]:size-[15px]' : size === 'lg' ? 'size-12 rounded-2xl [&_svg]:size-6' : 'size-9 rounded-xl [&_svg]:size-[18px]';
return (
<span className={cn('flex shrink-0 items-center justify-center', box, tone.soft, tone.text, className)} aria-hidden>
<Icon strokeWidth={1.9} />
</span>
);
}
/** An amount with money in shown in green and a plus sign; money out plain. */
export function Amount({ cents, className, transfer }: { cents: number; className?: string; transfer?: boolean }) {
return (
<span className={cn('tabular whitespace-nowrap font-medium', cents > 0 && !transfer && 'text-good', transfer && 'text-muted-foreground', className)}>
{formatCents(cents, { sign: cents > 0 })}
</span>
);
}
export function AccountDot({ color, className }: { color: PaletteColor; className?: string }) {
return <span className={cn('inline-block size-2 shrink-0 rounded-full', PALETTE_CLASS[color].dot, className)} aria-hidden />;
}
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
const shortFormat = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
/** "2026-09-03" → "Thu, Sep 3" (or "Sep 3"), read as a calendar day, never shifted by time zones. */
export function formatDay(date: string, short = false): string {
const [y, m, d] = date.split('-').map(Number);
return (short ? shortFormat : dayFormat).format(new Date(y!, m! - 1, d!));
}

21
web/src/components/nav.ts Normal file
View File

@@ -0,0 +1,21 @@
import { ChartColumnBig, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react';
export interface NavItem {
to: string;
label: string;
icon: LucideIcon;
/** Shown on a phone's bottom bar; the rest live under More. */
phone?: boolean;
}
export const NAV: NavItem[] = [
{ to: '/', label: 'Home', icon: House, phone: true },
{ to: '/review', label: 'Review', icon: Inbox, phone: true },
{ to: '/activity', label: 'Activity', icon: ListOrdered, phone: true },
{ to: '/budget', label: 'Budget', icon: Target, phone: true },
{ to: '/subscriptions', label: 'Subscriptions', icon: Repeat },
{ to: '/insights', label: 'Insights', icon: ChartColumnBig },
{ to: '/accounts', label: 'Accounts', icon: Landmark },
];
export const SETTINGS_NAV: NavItem = { to: '/settings', label: 'Settings', icon: Settings };

View File

@@ -0,0 +1,63 @@
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Badge } from './ui/misc';
export function PageIntro({
title,
description,
actions,
titleOnPhone = true,
}: {
title: string;
description?: ReactNode;
actions?: ReactNode;
/** Main screens already show their name in the phone's top bar. */
titleOnPhone?: boolean;
}) {
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="grid gap-1">
<h2 className={cn('text-[22px] font-semibold tracking-tight sm:text-2xl', !titleOnPhone && 'hidden sm:block')}>{title}</h2>
{description ? <p className="max-w-2xl text-[14.5px] leading-relaxed text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 gap-2">{actions}</div> : null}
</div>
);
}
/** A considered placeholder: what this screen will do, and when it arrives. */
export function EmptyState({
icon: Icon,
title,
description,
milestone,
children,
className,
}: {
icon: LucideIcon;
title: string;
description: ReactNode;
milestone?: string;
children?: ReactNode;
className?: string;
}) {
return (
<div className={cn('relative overflow-hidden rounded-3xl border bg-card px-6 py-14 text-center shadow-soft sm:py-20', className)}>
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-56 opacity-70" aria-hidden />
<div className="relative mx-auto flex max-w-md flex-col items-center">
<span className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-accent text-accent-foreground shadow-soft">
<Icon className="size-7" strokeWidth={1.7} />
</span>
{milestone ? (
<Badge tone="primary" className="mb-3">
{milestone}
</Badge>
) : null}
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>
{children ? <div className="mt-6">{children}</div> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { Slot } from 'radix-ui';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export const buttonVariants = cva(
'inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-[color,background-color,box-shadow,transform] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-soft hover:bg-primary/90',
secondary: 'bg-muted text-foreground hover:bg-muted/70',
outline: 'border bg-card text-foreground shadow-soft hover:bg-muted',
ghost: 'text-foreground hover:bg-muted',
destructive: 'bg-bad text-white shadow-soft hover:bg-bad/90',
link: 'h-auto px-0 text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-11 px-4 sm:h-10',
sm: 'h-9 px-3 text-[13px]',
lg: 'h-12 px-6 text-base',
icon: 'size-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps extends ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
export function Button({ className, variant, size, asChild, loading, disabled, children, ...props }: ButtonProps) {
const classes = cn(buttonVariants({ variant, size }), className);
// A slotted button styles its single child (usually a link) and adds nothing to it.
if (asChild) {
return (
<Slot.Root className={classes} {...props}>
{children}
</Slot.Root>
);
}
return (
<button className={classes} disabled={disabled || loading} {...props}>
{loading ? <Loader2 className="animate-spin" aria-hidden /> : null}
{children}
</button>
);
}

View File

@@ -0,0 +1,26 @@
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: ComponentProps<'section'>) {
return <section className={cn('rounded-2xl border bg-card text-card-foreground shadow-soft', className)} {...props} />;
}
export function CardHeader({ className, ...props }: ComponentProps<'div'>) {
return <div className={cn('flex flex-col gap-1 p-5 pb-3 sm:p-6 sm:pb-3', className)} {...props} />;
}
export function CardTitle({ className, ...props }: ComponentProps<'h2'>) {
return <h2 className={cn('text-[15px] font-semibold tracking-tight', className)} {...props} />;
}
export function CardDescription({ className, ...props }: ComponentProps<'p'>) {
return <p className={cn('text-[13.5px] leading-relaxed text-muted-foreground', className)} {...props} />;
}
export function CardContent({ className, ...props }: ComponentProps<'div'>) {
return <div className={cn('px-5 pb-5 sm:px-6 sm:pb-6', className)} {...props} />;
}
export function CardFooter({ className, ...props }: ComponentProps<'div'>) {
return <div className={cn('flex items-center gap-2 border-t px-5 py-3.5 sm:px-6', className)} {...props} />;
}

View File

@@ -0,0 +1,104 @@
import { X } from 'lucide-react';
import { AlertDialog as AlertPrimitive, Dialog as DialogPrimitive } from 'radix-ui';
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Button } from './button';
// On phones a dialog rises from the bottom like a native sheet; on larger
// screens it floats in the middle.
const overlayClass = 'fixed inset-0 z-50 bg-night/40 backdrop-blur-[2px] data-[state=open]:animate-[fade-up_.2s_ease]';
const contentClass =
'fixed z-50 w-full border bg-popover text-popover-foreground shadow-lift outline-none ' +
'inset-x-0 bottom-0 max-h-[92dvh] overflow-y-auto rounded-t-3xl px-5 pt-3 pb-safe animate-sheet-up ' +
'sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:max-w-md sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-6 sm:animate-fade-up';
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({
className,
children,
title,
description,
...props
}: ComponentProps<typeof DialogPrimitive.Content> & { title: string; description?: ReactNode }) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className={overlayClass} />
<DialogPrimitive.Content className={cn(contentClass, className)} {...props}>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-muted sm:hidden" aria-hidden />
<div className="mb-4 flex items-start justify-between gap-4">
<div className="grid gap-1">
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">{title}</DialogPrimitive.Title>
{description ? (
<DialogPrimitive.Description className="text-[13.5px] leading-relaxed text-muted-foreground">
{description}
</DialogPrimitive.Description>
) : (
<DialogPrimitive.Description className="sr-only">{title}</DialogPrimitive.Description>
)}
</div>
<DialogPrimitive.Close asChild>
<Button variant="ghost" size="icon" className="-mr-2 -mt-1 size-9 text-muted-foreground" aria-label="Close">
<X />
</Button>
</DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
/** "Are you sure?" with a clear consequence and a single confirming action. */
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
destructive,
loading,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
confirmLabel: string;
destructive?: boolean;
loading?: boolean;
onConfirm: () => void;
}) {
return (
<AlertPrimitive.Root open={open} onOpenChange={onOpenChange}>
<AlertPrimitive.Portal>
<AlertPrimitive.Overlay className={overlayClass} />
<AlertPrimitive.Content className={contentClass}>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-muted sm:hidden" aria-hidden />
<AlertPrimitive.Title className="text-lg font-semibold tracking-tight">{title}</AlertPrimitive.Title>
<AlertPrimitive.Description className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">
{description}
</AlertPrimitive.Description>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<AlertPrimitive.Cancel asChild>
<Button variant="outline">Cancel</Button>
</AlertPrimitive.Cancel>
<Button
variant={destructive ? 'destructive' : 'default'}
loading={loading}
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
>
{confirmLabel}
</Button>
</div>
</AlertPrimitive.Content>
</AlertPrimitive.Portal>
</AlertPrimitive.Root>
);
}

View File

@@ -0,0 +1,64 @@
import { Eye, EyeOff } from 'lucide-react';
import { Label as LabelPrimitive } from 'radix-ui';
import { useId, useState, type ComponentProps, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
const fieldClass =
'w-full rounded-xl border border-input bg-card px-3.5 text-[16px] text-foreground shadow-[inset_0_1px_1px_rgb(0_0_0/0.03)] outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15 disabled:opacity-60 sm:text-sm';
export function Input({ className, ...props }: ComponentProps<'input'>) {
return <input className={cn(fieldClass, 'h-11 sm:h-10', className)} {...props} />;
}
export function Textarea({ className, ...props }: ComponentProps<'textarea'>) {
return <textarea className={cn(fieldClass, 'min-h-28 resize-y py-2.5 leading-relaxed', className)} {...props} />;
}
export function Select({ className, children, ...props }: ComponentProps<'select'>) {
return (
<select className={cn(fieldClass, 'h-11 appearance-none bg-[length:16px] pr-9 sm:h-10', 'select-chevron', className)} {...props}>
{children}
</select>
);
}
export function Label({ className, ...props }: ComponentProps<typeof LabelPrimitive.Root>) {
return <LabelPrimitive.Root className={cn('text-[13px] font-medium text-foreground', className)} {...props} />;
}
/** A label, a control and an optional hint, wired together for screen readers. */
export function Field({
label,
hint,
children,
}: {
label: string;
hint?: ReactNode;
children: (id: string) => ReactNode;
}) {
const id = useId();
return (
<div className="grid gap-1.5">
<Label htmlFor={id}>{label}</Label>
{children(id)}
{hint ? <p className="text-[12.5px] leading-snug text-muted-foreground">{hint}</p> : null}
</div>
);
}
export function PasswordInput(props: Omit<ComponentProps<'input'>, 'type'>) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<Input type={visible ? 'text' : 'password'} className="pr-11" {...props} />
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={visible ? 'Hide password' : 'Show password'}
>
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { DropdownMenu as Menu } from 'radix-ui';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export const DropdownMenu = Menu.Root;
export const DropdownMenuTrigger = Menu.Trigger;
export function DropdownMenuContent({ className, sideOffset = 8, ...props }: ComponentProps<typeof Menu.Content>) {
return (
<Menu.Portal>
<Menu.Content
sideOffset={sideOffset}
className={cn(
'z-50 min-w-52 animate-fade-up overflow-hidden rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-lift',
className,
)}
{...props}
/>
</Menu.Portal>
);
}
export function DropdownMenuItem({ className, destructive, ...props }: ComponentProps<typeof Menu.Item> & { destructive?: boolean }) {
return (
<Menu.Item
className={cn(
'flex h-10 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2.5 text-[14px] outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-50 sm:h-9 [&_svg]:size-4 [&_svg]:text-muted-foreground',
destructive && 'text-bad [&_svg]:text-bad',
className,
)}
{...props}
/>
);
}
export function DropdownMenuLabel({ className, ...props }: ComponentProps<typeof Menu.Label>) {
return <Menu.Label className={cn('px-2.5 py-2', className)} {...props} />;
}
export function DropdownMenuSeparator({ className, ...props }: ComponentProps<typeof Menu.Separator>) {
return <Menu.Separator className={cn('-mx-1.5 my-1.5 h-px bg-border', className)} {...props} />;
}

View File

@@ -0,0 +1,90 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Switch as SwitchPrimitive, Tooltip as TooltipPrimitive } from 'radix-ui';
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium', {
variants: {
tone: {
neutral: 'bg-muted text-muted-foreground',
primary: 'bg-accent text-accent-foreground',
good: 'bg-good/12 text-good',
warn: 'bg-warn/14 text-warn',
bad: 'bg-bad/12 text-bad',
},
},
defaultVariants: { tone: 'neutral' },
});
export function Badge({ className, tone, ...props }: ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
export function Switch({ className, ...props }: ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
'peer inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input transition-colors data-[state=checked]:bg-primary disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb className="pointer-events-none block size-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-[18px]" />
</SwitchPrimitive.Root>
);
}
export function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
return (
<TooltipPrimitive.Root delayDuration={300}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
sideOffset={6}
className="z-50 animate-fade-up rounded-lg bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-lift"
>
{label}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
export function Skeleton({ className, ...props }: ComponentProps<'div'>) {
return <div className={cn('animate-pulse rounded-lg bg-muted', className)} {...props} />;
}
/** A row of choices where exactly one is on, like Light / Dark / System. */
export function Segmented<T extends string>({
value,
onChange,
options,
label,
}: {
value: T;
onChange: (value: T) => void;
options: { value: T; label: string; icon?: ReactNode }[];
label: string;
}) {
return (
<div role="radiogroup" aria-label={label} className="inline-flex rounded-xl bg-muted p-1">
{options.map((o) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex h-9 items-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium text-muted-foreground transition-all [&_svg]:size-4',
value === o.value && 'bg-card text-foreground shadow-soft',
)}
>
{o.icon}
{o.label}
</button>
))}
</div>
);
}

39
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { ApiErrorBody } from '@spendlight/shared';
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
/** Call the Spendlight API. Errors arrive as ApiError with a friendly message. */
export async function api<T>(path: string, options: { method?: Method; body?: unknown } = {}): Promise<T> {
const { method = 'GET', body } = options;
let res: Response;
try {
res = await fetch(`/api/v1${path}`, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError(0, 'offline', 'Can’t reach Spendlight right now. Check your connection and try again.');
}
const isJson = res.headers.get('content-type')?.includes('application/json');
const data = isJson ? await res.json() : await res.text();
if (!res.ok) {
const error = (isJson ? (data as ApiErrorBody).error : undefined) ?? { code: 'error', message: res.statusText };
throw new ApiError(res.status, error.code, error.message);
}
return data as T;
}
export const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : 'Something went wrong. Please try again.';

50
web/src/lib/data.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Account, Category, CategoryGroup } from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from './api';
export function useAccounts() {
return useQuery({ queryKey: ['accounts'], queryFn: () => api<Account[]>('/accounts') });
}
export function useCategories() {
const query = useQuery({ queryKey: ['categories'], queryFn: () => api<CategoryGroup[]>('/categories'), staleTime: 5 * 60_000 });
const lookup = useMemo(() => {
const all: Category[] = query.data?.flatMap((g) => g.categories) ?? [];
return { all, byId: new Map(all.map((c) => [c.id, c])) };
}, [query.data]);
return { ...query, ...lookup };
}
export function useReviewCount() {
return useQuery({
queryKey: ['review-count'],
queryFn: () => api<{ count: number }>('/review/count'),
select: (d) => d.count,
refetchOnWindowFocus: true,
});
}
export function useTags() {
return useQuery({ queryKey: ['tags'], queryFn: () => api<{ name: string; count: number }[]>('/tags') });
}
/** Everything that changes when transactions change. */
export const MONEY_KEYS = [['transactions'], ['review'], ['review-count'], ['accounts'], ['imports'], ['tags']] as const;
/** Remember recent category picks on this device, for the top of the picker. */
const RECENT_KEY = 'sl-recent-categories';
export function recentCategories(): number[] {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as number[];
} catch {
return [];
}
}
export function rememberCategory(id: number): void {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...recentCategories().filter((x) => x !== id)].slice(0, 6)));
} catch {
/* fine without it */
}
}

View File

@@ -0,0 +1,20 @@
// Files dropped anywhere in the app wait here until the Import screen picks them up.
let pending: File[] = [];
const listeners = new Set<() => void>();
export function queueFiles(files: File[]): void {
pending = [...pending, ...files];
listeners.forEach((l) => l());
}
export function takeFiles(): File[] {
const files = pending;
pending = [];
return files;
}
export function onFilesQueued(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

57
web/src/lib/session.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Me, PublicConfig } from '@spendlight/shared';
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { api, ApiError } from './api';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (count, error) => !(error instanceof ApiError && error.status >= 400 && error.status < 500) && count < 2,
},
},
});
export function useConfig() {
return useQuery({
queryKey: ['config'],
queryFn: () => api<PublicConfig>('/config'),
refetchOnWindowFocus: true,
staleTime: 60_000,
});
}
/** The person signed in on this device, or null when signed out. */
export function useMe() {
return useQuery({
queryKey: ['me'],
queryFn: async () => {
try {
return await api<Me>('/me');
} catch (error) {
if (error instanceof ApiError && error.status === 401) return null;
throw error;
}
},
});
}
/** Me, for components that only render once someone is signed in. */
export function useCurrentUser(): Me {
const { data } = useMe();
if (!data) throw new Error('useCurrentUser used outside a signed-in screen');
return data;
}
export function useSignOut() {
const client = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: () => api('/auth/logout', { method: 'POST' }),
onSettled: () => {
client.setQueryData(['me'], null);
client.removeQueries({ predicate: (q) => q.queryKey[0] !== 'me' && q.queryKey[0] !== 'config' });
navigate('/sign-in', { replace: true });
},
});
}

38
web/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { Theme } from '@spendlight/shared';
const KEY = 'sl-theme';
const media = () => window.matchMedia('(prefers-color-scheme: dark)');
export function storedTheme(): Theme {
try {
const value = localStorage.getItem(KEY);
return value === 'light' || value === 'dark' ? value : 'system';
} catch {
return 'system';
}
}
/** Apply a theme now and remember it on this device for an instant first paint. */
export function applyTheme(theme: Theme): void {
try {
localStorage.setItem(KEY, theme);
} catch {
/* private mode: the server copy still applies */
}
const dark = theme === 'dark' || (theme === 'system' && media().matches);
const root = document.documentElement;
root.classList.toggle('dark', dark);
root.style.colorScheme = dark ? 'dark' : 'light';
document
.querySelectorAll('meta[name="theme-color"]')
.forEach((m) => m.setAttribute('content', dark ? '#0B1020' : '#FAFAF7'));
}
/** Follow the device's light/dark switch while the choice is "system". */
export function watchSystemTheme(): () => void {
const listener = () => {
if (storedTheme() === 'system') applyTheme('system');
};
media().addEventListener('change', listener);
return () => media().removeEventListener('change', listener);
}

View File

@@ -0,0 +1,15 @@
import { useSyncExternalStore } from 'react';
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
const list = window.matchMedia(query);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
},
() => window.matchMedia(query).matches,
);
}
/** Phones get bottom sheets and big targets; larger screens get popovers. */
export const useIsPhone = () => !useMediaQuery('(min-width: 640px)');

View File

@@ -0,0 +1,15 @@
import type { Me, Theme } from '@spendlight/shared';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, errorMessage } from './api';
import { applyTheme } from './theme';
/** Switch theme instantly, then save it to the person's profile. */
export function useSetTheme() {
const client = useQueryClient();
return (theme: Theme) => {
applyTheme(theme);
client.setQueryData<Me | null>(['me'], (me) => (me ? { ...me, theme } : me));
api<Me>('/me', { method: 'PATCH', body: { theme } }).catch((error) => toast.error(errorMessage(error)));
};
}

64
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,64 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
return ((parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '')).toUpperCase() || '?';
}
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
/** "just now", "5 minutes ago", "yesterday", "3 weeks ago". */
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return 'never';
const seconds = (Date.parse(iso) - Date.now()) / 1000;
const abs = Math.abs(seconds);
if (abs < 60) return 'just now';
const units: [Intl.RelativeTimeFormatUnit, number][] = [
['minute', 60],
['hour', 3600],
['day', 86400],
['week', 604800],
['month', 2629800],
['year', 31557600],
];
let unit: Intl.RelativeTimeFormatUnit = 'minute';
let size = 60;
for (const [u, s] of units) if (abs >= s) [unit, size] = [u, s];
return relative.format(Math.round(seconds / size), unit);
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function isStandalone(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
export function isIos(): boolean {
return /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}

2
web/src/lib/version.ts Normal file
View File

@@ -0,0 +1,2 @@
/** The version this copy of the screens was built as, e.g. 20261007.01. */
export const APP_VERSION: string = import.meta.env.VITE_APP_VERSION || 'dev';

35
web/src/main.tsx Normal file
View File

@@ -0,0 +1,35 @@
import '@fontsource-variable/inter';
import './styles.css';
import { QueryClientProvider } from '@tanstack/react-query';
import { Tooltip } from 'radix-ui';
import { StrictMode, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { Toaster } from 'sonner';
import { Splash } from './components/RequireAuth';
import { queryClient } from './lib/session';
import { watchSystemTheme } from './lib/theme';
import { router } from './router';
watchSystemTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Tooltip.Provider>
<Suspense fallback={<Splash />}>
<RouterProvider router={router} />
</Suspense>
<Toaster position="top-center" richColors closeButton theme="system" toastOptions={{ className: 'font-sans' }} />
</Tooltip.Provider>
</QueryClientProvider>
</StrictMode>,
);
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* the app works fine without it */
});
});
}

View File

@@ -0,0 +1,453 @@
import { ACCOUNT_KIND_LABEL, LIABILITY_KINDS, PALETTE, formatCents, type Account, type AccountKind, type PaletteColor } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Archive, ArchiveRestore, ChevronRight, CreditCard, Landmark, ListOrdered, Pencil, Plus, Scale, Trash2, Upload, Wallet } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { AccountDot, PALETTE_CLASS } from '@/components/money';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
import { Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { cn, timeAgo } from '@/lib/utils';
import { formatDay } from '@/components/money';
const isLiability = (kind: AccountKind) => LIABILITY_KINDS.includes(kind);
const today = () => new Date().toLocaleDateString('en-CA');
/** "$1,234.56" typed by a person → cents, or null. */
function parseMoney(text: string): number | null {
const clean = text.replace(/[$,\s]/g, '');
if (!/^-?\d+(\.\d{0,2})?$/.test(clean)) return null;
return Math.round(Number(clean) * 100);
}
function ColorChoice({ value, onChange }: { value: PaletteColor; onChange: (c: PaletteColor) => void }) {
return (
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Color">
{PALETTE.map((c) => (
<button
key={c}
type="button"
role="radio"
aria-checked={value === c}
aria-label={c}
onClick={() => onChange(c)}
className={cn('size-8 rounded-full ring-offset-2 ring-offset-popover transition', PALETTE_CLASS[c].dot, value === c && 'ring-2 ring-foreground')}
/>
))}
</div>
);
}
function AccountForm({ account, onDone }: { account?: Account; onDone: (a: Account) => void }) {
const [name, setName] = useState(account?.name ?? '');
const [kind, setKind] = useState<AccountKind>(account?.kind ?? 'checking');
const [institution, setInstitution] = useState(account?.institution ?? '');
const [last4, setLast4] = useState(account?.last4 ?? '');
const [color, setColor] = useState<PaletteColor>(account?.color ?? 'indigo');
const [balance, setBalance] = useState('');
const save = useMutation({
mutationFn: () => {
const body: Record<string, unknown> = { name, kind, institution: institution || null, last4: last4 || null, color };
if (!account && balance.trim()) {
const cents = parseMoney(balance);
if (cents === null) throw new Error('Please enter the balance as a number, like 1250.00');
body.balanceCents = isLiability(kind) ? -Math.abs(cents) : cents;
}
return account ? api<Account>(`/accounts/${account.id}`, { method: 'PATCH', body }) : api<Account>('/accounts', { method: 'POST', body });
},
onSuccess: onDone,
onError: (error) => toast.error(errorMessage(error)),
});
return (
<form
onSubmit={(e: FormEvent) => {
e.preventDefault();
save.mutate();
}}
className="grid gap-4"
>
<Field label="Name" hint="Whatever you call it, like “Joint checking” or “Prime Visa”.">
{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} required maxLength={60} autoFocus={!account} />}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Kind">
{(id) => (
<Select id={id} value={kind} onChange={(e) => setKind(e.target.value as AccountKind)}>
{(['checking', 'savings', 'credit_card', 'cash'] as AccountKind[]).map((k) => (
<option key={k} value={k}>
{ACCOUNT_KIND_LABEL[k]}
</option>
))}
</Select>
)}
</Field>
<Field label="Last 4 digits">
{(id) => (
<Input id={id} value={last4} onChange={(e) => setLast4(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" placeholder="1234" />
)}
</Field>
</div>
<Field label="Bank">
{(id) => <Input id={id} value={institution} onChange={(e) => setInstitution(e.target.value)} placeholder="Bank of America" maxLength={60} />}
</Field>
{!account ? (
<Field
label={isLiability(kind) ? 'Amount owed today' : 'Balance today'}
hint={isLiability(kind) ? 'From your card’s app or statement. Optional; you can add it later.' : 'Optional. Bank of America files fill this in by themselves.'}
>
{(id) => <Input id={id} value={balance} onChange={(e) => setBalance(e.target.value)} inputMode="decimal" placeholder="0.00" />}
</Field>
) : null}
<div className="grid gap-2">
<span className="text-[13px] font-medium">Color</span>
<ColorChoice value={color} onChange={setColor} />
</div>
<Button type="submit" loading={save.isPending} className="mt-1">
{account ? 'Save' : 'Add account'}
</Button>
</form>
);
}
function BalanceForm({ account, onDone }: { account: Account; onDone: () => void }) {
const liability = isLiability(account.kind);
const [amount, setAmount] = useState(((liability ? -account.balanceCents : account.balanceCents) / 100).toFixed(2));
const [asOf, setAsOf] = useState(today());
const save = useMutation({
mutationFn: () => {
const cents = parseMoney(amount);
if (cents === null) throw new Error('Please enter the balance as a number, like 1250.00');
return api<Account>(`/accounts/${account.id}/balance`, { method: 'POST', body: { balanceCents: liability ? -Math.abs(cents) : cents, asOf } });
},
onSuccess: () => {
toast.success('Balance updated');
onDone();
},
onError: (error) => toast.error(errorMessage(error)),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
className="grid gap-4"
>
<Field label={liability ? 'Amount owed' : 'Balance'} hint="Use the ending balance on your latest statement, or today’s from the bank’s app.">
{(id) => <Input id={id} value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" required />}
</Field>
<Field label="As of">{(id) => <Input id={id} type="date" value={asOf} onChange={(e) => setAsOf(e.target.value)} required />}</Field>
<Button type="submit" loading={save.isPending}>
Save balance
</Button>
</form>
);
}
/** A card's balance means nothing until someone has entered it once. */
const needsBalance = (a: Account) => isLiability(a.kind) && !a.balanceConfirmedOn;
function AccountRow({ account, onOpen }: { account: Account; onOpen: () => void }) {
const liability = isLiability(account.kind);
const missing = needsBalance(account);
return (
<button onClick={onOpen} className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5">
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]', PALETTE_CLASS[account.color].soft, PALETTE_CLASS[account.color].text)}>
{account.kind === 'credit_card' ? <CreditCard /> : account.kind === 'cash' ? <Wallet /> : <Landmark />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-medium">{account.name}</p>
<p className="truncate text-[12.5px] text-muted-foreground">
{[account.institution, account.last4 ? `••${account.last4}` : null].filter(Boolean).join(' ') || ACCOUNT_KIND_LABEL[account.kind]}
{account.lastImportAt ? ` · imported ${timeAgo(account.lastImportAt)}` : account.transactionCount === 0 ? ' · no statements yet' : ''}
</p>
</div>
<div className="shrink-0 text-right">
{missing ? (
<p className="text-[13px] font-medium text-primary">Add balance</p>
) : (
<>
<p className="tabular text-[15px] font-semibold">{formatCents(liability ? -account.balanceCents : account.balanceCents)}</p>
<p className="text-[11.5px] text-muted-foreground">{liability ? 'owed' : 'balance'}</p>
</>
)}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
);
}
export default function AccountsPage() {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const client = useQueryClient();
const accounts = useAccounts();
const [adding, setAdding] = useState(false);
const [openId, setOpenId] = useState<number | null>(null);
const [mode, setMode] = useState<'view' | 'edit' | 'balance'>('view');
const [confirm, setConfirm] = useState<'retire' | 'delete' | null>(null);
const [showRetired, setShowRetired] = useState(false);
const refresh = () => void client.invalidateQueries({ queryKey: ['accounts'] });
const all = accounts.data ?? [];
const open = all.filter((a) => a.status === 'open');
const retired = all.filter((a) => a.status === 'retired');
const selected = all.find((a) => a.id === openId) ?? null;
const cash = open.filter((a) => !isLiability(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
const owed = open.filter((a) => isLiability(a.kind) && !needsBalance(a)).reduce((s, a) => s - a.balanceCents, 0);
const cardsWithoutBalance = open.filter(needsBalance).length;
const setStatus = useMutation({
mutationFn: (status: 'open' | 'retired') => api<Account>(`/accounts/${openId}`, { method: 'PATCH', body: { status } }),
onSuccess: (a) => {
toast.success(a.status === 'retired' ? `${a.name} retired. Its history stays in your reports.` : `${a.name} reopened`);
setConfirm(null);
setOpenId(null);
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const remove = useMutation({
mutationFn: () => api(`/accounts/${openId}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Account deleted');
setConfirm(null);
setOpenId(null);
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const groups: { title: string; items: Account[] }[] = [
{ title: 'Checking, savings and cash', items: open.filter((a) => !isLiability(a.kind)) },
{ title: 'Cards and loans', items: open.filter((a) => isLiability(a.kind)) },
].filter((g) => g.items.length);
return (
<div>
<PageIntro
title="Accounts"
titleOnPhone={false}
description="Every account you track, with balances kept current from your statements."
actions={
canEdit ? (
<>
<Button variant="outline" asChild>
<Link to="/import">
<Upload /> Import
</Link>
</Button>
<Button onClick={() => setAdding(true)}>
<Plus /> Add account
</Button>
</>
) : undefined
}
/>
{accounts.isPending ? <Skeleton className="h-40 rounded-2xl" /> : null}
{accounts.data && open.length === 0 ? (
<EmptyState
icon={Landmark}
title="Add your first account"
description="Checking, savings and each card you use. Or just import a statement and Spendlight will offer to create the account for you."
>
{canEdit ? (
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={() => setAdding(true)}>
<Plus /> Add account
</Button>
<Button variant="outline" asChild>
<Link to="/import">
<Upload /> Import a statement
</Link>
</Button>
</div>
) : null}
</EmptyState>
) : null}
{open.length ? (
<div className="mb-5 grid grid-cols-2 gap-3">
<Card className="p-4 sm:p-5">
<p className="text-[12.5px] font-medium text-muted-foreground">Cash and savings</p>
<p className="tabular mt-1 text-[22px] font-semibold tracking-tight sm:text-2xl">{formatCents(cash)}</p>
</Card>
<Card className="p-4 sm:p-5">
<p className="text-[12.5px] font-medium text-muted-foreground">Owed on cards</p>
<p className="tabular mt-1 text-[22px] font-semibold tracking-tight sm:text-2xl">{formatCents(owed)}</p>
{cardsWithoutBalance ? (
<p className="mt-0.5 text-[12px] text-muted-foreground">
{cardsWithoutBalance} card{cardsWithoutBalance === 1 ? '' : 's'} still need a balance
</p>
) : null}
</Card>
</div>
) : null}
<div className="grid gap-5">
{groups.map((g) => (
<section key={g.title}>
<h3 className="mb-2 px-1 text-[12.5px] font-semibold uppercase tracking-wider text-muted-foreground">{g.title}</h3>
<Card className="divide-y overflow-hidden">
{g.items.map((a) => (
<AccountRow
key={a.id}
account={a}
onOpen={() => {
setMode('view');
setOpenId(a.id);
}}
/>
))}
</Card>
</section>
))}
{retired.length ? (
<section>
<button onClick={() => setShowRetired((v) => !v)} className="mb-2 px-1 text-[13px] font-medium text-muted-foreground hover:text-foreground">
{showRetired ? 'Hide' : 'Show'} {retired.length} retired account{retired.length === 1 ? '' : 's'}
</button>
{showRetired ? (
<Card className="divide-y overflow-hidden opacity-75">
{retired.map((a) => (
<AccountRow
key={a.id}
account={a}
onOpen={() => {
setMode('view');
setOpenId(a.id);
}}
/>
))}
</Card>
) : null}
</section>
) : null}
</div>
<Dialog open={adding} onOpenChange={setAdding}>
<DialogContent title="Add an account" description="You can import its statements straight afterwards.">
<AccountForm
onDone={(a) => {
toast.success(`${a.name} added`);
setAdding(false);
refresh();
}}
/>
</DialogContent>
</Dialog>
<Dialog open={!!selected && !confirm} onOpenChange={(o) => !o && setOpenId(null)}>
{selected ? (
<DialogContent
title={mode === 'edit' ? 'Edit account' : mode === 'balance' ? 'Correct the balance' : selected.name}
description={
mode === 'view'
? [ACCOUNT_KIND_LABEL[selected.kind], selected.institution, selected.last4 ? `••${selected.last4}` : null].filter(Boolean).join(' · ')
: undefined
}
>
{mode === 'edit' ? (
<AccountForm
account={selected}
onDone={() => {
toast.success('Saved');
setMode('view');
refresh();
}}
/>
) : mode === 'balance' ? (
<BalanceForm
account={selected}
onDone={() => {
setMode('view');
refresh();
}}
/>
) : (
<div className="grid gap-4">
<div className="rounded-2xl bg-muted p-4">
<div className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
<AccountDot color={selected.color} /> {isLiability(selected.kind) ? 'Owed' : 'Balance'}
</div>
<p className="tabular mt-1 text-[26px] font-semibold tracking-tight">
{formatCents(isLiability(selected.kind) ? -selected.balanceCents : selected.balanceCents)}
</p>
<p className="text-[12.5px] text-muted-foreground">
{selected.balanceConfirmedOn ? `Confirmed on ${formatDay(selected.balanceConfirmedOn)}, plus anything since` : 'No balance confirmed yet'}
{` · ${selected.transactionCount} transaction${selected.transactionCount === 1 ? '' : 's'}`}
</p>
</div>
<div className="grid gap-2">
{needsBalance(selected) && canEdit ? (
<Button onClick={() => setMode('balance')}>
<Scale /> Add what you owe today
</Button>
) : null}
<Button variant="outline" asChild>
<Link to={`/activity?accountId=${selected.id}`}>
<ListOrdered /> See transactions
</Link>
</Button>
{canEdit ? (
<>
<Button variant="outline" onClick={() => setMode('balance')}>
<Scale /> Correct the balance
</Button>
<Button variant="outline" onClick={() => setMode('edit')}>
<Pencil /> Edit name, bank or color
</Button>
{selected.status === 'open' ? (
<Button variant="ghost" onClick={() => setConfirm('retire')}>
<Archive /> Retire (closed account)
</Button>
) : (
<Button variant="ghost" loading={setStatus.isPending} onClick={() => setStatus.mutate('open')}>
<ArchiveRestore /> Reopen
</Button>
)}
{selected.transactionCount === 0 ? (
<Button variant="ghost" className="text-bad hover:text-bad" onClick={() => setConfirm('delete')}>
<Trash2 /> Delete
</Button>
) : null}
</>
) : null}
</div>
</div>
)}
</DialogContent>
) : null}
</Dialog>
<ConfirmDialog
open={confirm === 'retire'}
onOpenChange={(o) => !o && setConfirm(null)}
title={`Retire ${selected?.name ?? ''}?`}
description="It stops appearing in pickers and imports. Every past transaction stays in your history and reports, and you can reopen it any time."
confirmLabel="Retire"
loading={setStatus.isPending}
onConfirm={() => setStatus.mutate('retired')}
/>
<ConfirmDialog
open={confirm === 'delete'}
onOpenChange={(o) => !o && setConfirm(null)}
title={`Delete ${selected?.name ?? ''}?`}
description="It has no transactions, so nothing else is affected."
confirmLabel="Delete"
destructive
loading={remove.isPending}
onConfirm={() => remove.mutate()}
/>
</div>
);
}

View File

@@ -0,0 +1,362 @@
import { formatCents, type Transaction, type TransactionPage } from '@spendlight/shared';
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
import { ArrowLeftRight, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
import { useMemo, useState, type FormEvent } from 'react';
import { useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { CategoryButton, CategoryPicker } from '@/components/CategoryPicker';
import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money';
import { EmptyState, PageIntro } from '@/components/page';
import { TransactionSheet, useRefreshMoney } from '@/components/TransactionSheet';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
import { Badge, Segmented, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts, useCategories } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
const PAGE = 100;
const iso = (d: Date) => d.toLocaleDateString('en-CA');
function periodRange(period: string): { from?: string; to?: string } {
const now = new Date();
if (period === 'month') return { from: iso(new Date(now.getFullYear(), now.getMonth(), 1)) };
if (period === 'last') {
return { from: iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)), to: iso(new Date(now.getFullYear(), now.getMonth(), 0)) };
}
if (period === 'year') return { from: iso(new Date(now.getFullYear(), 0, 1)) };
return {};
}
function CashEntry({ onDone }: { onDone: () => void }) {
const accounts = useAccounts();
const open = accounts.data?.filter((a) => a.status === 'open') ?? [];
const [accountId, setAccountId] = useState<number | ''>(() => open.find((a) => a.kind === 'cash')?.id ?? open[0]?.id ?? '');
const [date, setDate] = useState(iso(new Date()));
const [merchant, setMerchant] = useState('');
const [amount, setAmount] = useState('');
const [direction, setDirection] = useState<'out' | 'in'>('out');
const [categoryId, setCategoryId] = useState<number | null>(null);
const refresh = useRefreshMoney();
const save = useMutation({
mutationFn: () => {
const cents = Math.round(Number(amount.replace(/[$,\s]/g, '')) * 100);
if (!cents) throw new Error('Please enter the amount, like 24.00');
return api<Transaction>('/transactions', {
method: 'POST',
body: { accountId, date, merchant, amountCents: direction === 'out' ? -Math.abs(cents) : Math.abs(cents), categoryId },
});
},
onSuccess: () => {
toast.success('Added');
refresh();
onDone();
},
onError: (error) => toast.error(errorMessage(error)),
});
if (!open.length) return <p className="text-[14px] text-muted-foreground">Add an account first, such as a “Cash” account, in Accounts.</p>;
return (
<form
onSubmit={(e: FormEvent) => {
e.preventDefault();
save.mutate();
}}
className="grid gap-4"
>
<Segmented
label="Money in or out"
value={direction}
onChange={setDirection}
options={[
{ value: 'out', label: 'Money out' },
{ value: 'in', label: 'Money in' },
]}
/>
<div className="grid grid-cols-2 gap-3">
<Field label="Amount">{(id) => <Input id={id} value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="0.00" required autoFocus />}</Field>
<Field label="Date">{(id) => <Input id={id} type="date" value={date} onChange={(e) => setDate(e.target.value)} required />}</Field>
</div>
<Field label="Paid to (or from)">{(id) => <Input id={id} value={merchant} onChange={(e) => setMerchant(e.target.value)} placeholder="Farmers market" required maxLength={80} />}</Field>
<Field label="Account">
{(id) => (
<Select id={id} value={accountId} onChange={(e) => setAccountId(Number(e.target.value))}>
{open.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
)}
</Field>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Category</span>
<CategoryButton categoryId={categoryId} onPick={(c) => setCategoryId(c.id)} />
</div>
<Button type="submit" loading={save.isPending}>
Add
</Button>
</form>
);
}
function Row({ t, onOpen }: { t: Transaction; onOpen: () => void }) {
const { byId } = useCategories();
const accounts = useAccounts();
const account = accounts.data?.find((a) => a.id === t.accountId);
const category = t.categoryId ? byId.get(t.categoryId) : undefined;
const transfer = !!t.transferPeer;
return (
<button onClick={onOpen} className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60 sm:px-5">
{t.isSplit ? (
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<SplitIcon className="size-[18px]" />
</span>
) : transfer ? (
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<ArrowLeftRight className="size-[18px]" />
</span>
) : (
<CategoryIcon icon={category?.icon} color={category?.color} />
)}
<div className="min-w-0 flex-1">
<p className={cn('truncate text-[15px] font-medium', transfer && 'italic text-muted-foreground')}>
{transfer ? `Transfer ${t.amountCents < 0 ? 'to' : 'from'} ${t.transferPeer!.accountName}` : t.merchant}
</p>
<p className="flex items-center gap-1.5 truncate text-[12.5px] text-muted-foreground">
{account ? <AccountDot color={account.color} /> : null}
<span className="truncate">
{account?.name}
{' · '}
{transfer ? t.merchant : t.isSplit ? `Split into ${t.splits.length}` : (category?.name ?? 'Not filed yet')}
</span>
{t.tags.length ? (
<span className="flex shrink-0 items-center gap-0.5">
· <Tag className="size-3" /> {t.tags[0]}
{t.tags.length > 1 ? ` +${t.tags.length - 1}` : ''}
</span>
) : null}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Amount cents={t.amountCents} transfer={transfer} />
{t.needsReview ? <Badge tone="warn">To review</Badge> : null}
</div>
</button>
);
}
export default function ActivityPage() {
const me = useCurrentUser();
const [params, setParams] = useSearchParams();
const accounts = useAccounts();
const { byId } = useCategories();
const [search, setSearch] = useState(params.get('q') ?? '');
const [showFilters, setShowFilters] = useState(!!(params.get('accountId') || params.get('categoryId') || params.get('tag')));
const [limit, setLimit] = useState(PAGE);
const [open, setOpen] = useState<Transaction | null>(null);
const [adding, setAdding] = useState(false);
const accountId = params.get('accountId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const tag = params.get('tag') ?? '';
const period = params.get('period') ?? 'all';
const q = params.get('q') ?? '';
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (value && value !== 'all') next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
setLimit(PAGE);
};
const query = useMemo(() => {
const s = new URLSearchParams();
if (q) s.set('q', q);
if (accountId) s.set('accountId', accountId);
if (categoryId) s.set('categoryId', categoryId);
if (tag) s.set('tag', tag);
const r = periodRange(period);
if (r.from) s.set('from', r.from);
if (r.to) s.set('to', r.to);
s.set('limit', String(limit));
return s.toString();
}, [q, accountId, categoryId, tag, period, limit]);
const list = useQuery({
queryKey: ['transactions', query],
queryFn: () => api<TransactionPage>(`/transactions?${query}`),
placeholderData: keepPreviousData,
});
const groups = useMemo(() => {
const out: { date: string; items: Transaction[] }[] = [];
for (const t of list.data?.items ?? []) {
const last = out.at(-1);
if (last?.date === t.date) last.items.push(t);
else out.push({ date: t.date, items: [t] });
}
return out;
}, [list.data]);
const filterCount = [accountId, categoryId, tag].filter(Boolean).length;
const category = categoryId && categoryId !== 'none' ? byId.get(Number(categoryId)) : undefined;
return (
<div>
<PageIntro
title="Activity"
titleOnPhone={false}
description="Every transaction from every account. Tap one to change its category, split it, tag it or add a note."
actions={
me.access === 'full' ? (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus /> Cash entry
</Button>
) : undefined
}
/>
<div className="mb-4 grid gap-3">
<div className="flex gap-2">
<form
className="relative flex-1"
onSubmit={(e) => {
e.preventDefault();
setParam('q', search.trim());
}}
>
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => search.trim() !== q && setParam('q', search.trim())}
placeholder="Search merchants, descriptions and notes"
aria-label="Search"
className="pl-10"
type="search"
/>
</form>
<Button variant={filterCount ? 'default' : 'outline'} onClick={() => setShowFilters((v) => !v)} aria-expanded={showFilters}>
<SlidersHorizontal /> <span className="hidden sm:inline">Filters</span>
{filterCount ? <span className="tabular">{filterCount}</span> : null}
</Button>
</div>
<div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
{[
['all', 'All time'],
['month', 'This month'],
['last', 'Last month'],
['year', 'This year'],
].map(([value, label]) => (
<button
key={value}
onClick={() => setParam('period', value!)}
className={cn(
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
period === value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
)}
>
{label}
</button>
))}
</div>
{showFilters ? (
<Card className="grid gap-3 p-4 sm:grid-cols-3">
<Field label="Account">
{(id) => (
<Select id={id} value={accountId} onChange={(e) => setParam('accountId', e.target.value)}>
<option value="">All accounts</option>
{accounts.data?.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
)}
</Field>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Category</span>
<div className="flex gap-1.5">
<CategoryPicker value={category?.id ?? null} onPick={(c) => setParam('categoryId', String(c.id))}>
<button className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-xl border bg-card px-3 text-left text-[14px] sm:h-10">
{category ? <CategoryIcon icon={category.icon} color={category.color} size="sm" className="size-6 rounded-md" /> : null}
<span className="truncate">{categoryId === 'none' ? 'Not filed yet' : (category?.name ?? 'All categories')}</span>
</button>
</CategoryPicker>
{categoryId ? (
<Button variant="ghost" size="icon" aria-label="Clear category" onClick={() => setParam('categoryId', '')}>
<X />
</Button>
) : (
<Button variant="ghost" size="sm" className="h-11 sm:h-10" onClick={() => setParam('categoryId', 'none')}>
Unfiled
</Button>
)}
</div>
</div>
<Field label="Tag">
{(id) => <Input id={id} defaultValue={tag} onBlur={(e) => setParam('tag', e.target.value.trim())} placeholder="Any tag" />}
</Field>
</Card>
) : null}
</div>
{list.data ? (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 px-1 text-[13px] text-muted-foreground">
<span>
{list.data.total} transaction{list.data.total === 1 ? '' : 's'}
</span>
<span className="tabular">
In <span className="font-medium text-good">{formatCents(list.data.sums.inCents)}</span> · Out{' '}
<span className="font-medium text-foreground">{formatCents(-list.data.sums.outCents)}</span>
<span className="hidden sm:inline"> · transfers not counted</span>
</span>
</div>
) : null}
{list.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
{list.data && list.data.total === 0 ? (
<EmptyState
icon={Search}
title={q || filterCount || period !== 'all' ? 'Nothing matches' : 'No transactions yet'}
description={q || filterCount || period !== 'all' ? 'Try a different search or clear the filters.' : 'Import a statement and everything shows up here.'}
/>
) : null}
<div className={cn('grid gap-4 transition-opacity', list.isPlaceholderData && 'opacity-60')}>
{groups.map((g) => (
<section key={g.date}>
<h3 className="sticky top-14 z-10 -mx-1 mb-1.5 bg-background/90 px-2 py-1 text-[12.5px] font-semibold text-muted-foreground backdrop-blur">
{formatDay(g.date)}
</h3>
<Card className="divide-y overflow-hidden">
{g.items.map((t) => (
<Row key={t.id} t={t} onOpen={() => setOpen(t)} />
))}
</Card>
</section>
))}
</div>
{list.data && list.data.items.length < list.data.total ? (
<div className="mt-5 flex justify-center">
<Button variant="outline" onClick={() => setLimit((l) => l + PAGE)} loading={list.isFetching}>
Show more
</Button>
</div>
) : null}
<TransactionSheet transaction={open} onClose={() => setOpen(null)} />
<Dialog open={adding} onOpenChange={setAdding}>
<DialogContent title="Add a cash entry" description="For spending that won’t appear on a statement, like cash or a check.">
<CashEntry onDone={() => setAdding(false)} />
</DialogContent>
</Dialog>
</div>
);
}

196
web/src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,196 @@
import { formatCents, type TransactionPage } from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { ArrowRight, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Target, Upload } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api } from '@/lib/api';
import { useAccounts, useReviewCount } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { cn, isIos, isStandalone } from '@/lib/utils';
function greeting(): string {
const hour = new Date().getHours();
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
}
function Step({
done,
icon,
title,
detail,
action,
later,
}: {
done?: boolean;
icon: ReactNode;
title: string;
detail: ReactNode;
action?: ReactNode;
later?: string;
}) {
return (
<li className="flex gap-4 py-4 first:pt-1 last:pb-1">
<span
className={cn(
'mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]',
done ? 'bg-good/12 text-good' : later ? 'bg-muted text-muted-foreground' : 'bg-accent text-accent-foreground',
)}
>
{done ? <Check strokeWidth={2.5} /> : icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className={cn('text-[15px] font-medium', done && 'text-muted-foreground line-through decoration-muted-foreground/40')}>{title}</p>
{later ? <Badge>{later}</Badge> : null}
</div>
{!done ? <p className="mt-0.5 text-[13.5px] leading-relaxed text-muted-foreground">{detail}</p> : null}
</div>
{!done && action ? <div className="shrink-0 self-center">{action}</div> : null}
</li>
);
}
export default function HomePage() {
const me = useCurrentUser();
const firstName = me.displayName.split(' ')[0];
const installed = isStandalone();
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toLocaleDateString('en-CA');
const monthName = now.toLocaleDateString(undefined, { month: 'long' });
const accounts = useAccounts();
const { data: toReview } = useReviewCount();
const everything = useQuery({ queryKey: ['transactions', 'home-any'], queryFn: () => api<TransactionPage>('/transactions?limit=1') });
// Statements usually arrive after a month ends, so until this month has
// transactions, Home shows the most recent month that does.
const latest = everything.data?.items[0]?.date;
const showStart = latest && latest < monthStart ? `${latest.slice(0, 7)}-01` : monthStart;
const showEnd = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)), 0).toLocaleDateString('en-CA');
const shownName = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)) - 1, 1).toLocaleDateString(undefined, { month: 'long' });
const month = useQuery({
queryKey: ['transactions', 'home-month', showStart],
queryFn: () => api<TransactionPage>(`/transactions?from=${showStart}&to=${showEnd}&limit=1`),
enabled: everything.isSuccess,
});
const hasAccounts = (accounts.data?.length ?? 0) > 0;
const hasMoney = (everything.data?.total ?? 0) > 0;
return (
<div className="grid gap-5 sm:gap-6">
<section className="relative overflow-hidden rounded-3xl bg-night px-6 dark:bg-card dark:ring-1 dark:ring-border py-8 text-white shadow-lift sm:px-9 sm:py-10">
<div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(251_191_36/0.45),rgb(249_115_98/0.18),transparent)] blur-2xl" aria-hidden />
<div className="relative">
<p className="text-[13px] font-medium text-white/60">{today}</p>
<h2 className="mt-1 text-[26px] font-semibold tracking-tight sm:text-3xl">
{greeting()}, {firstName}.
</h2>
<p className="mt-2 max-w-lg text-[15px] leading-relaxed text-white/75">
{hasMoney
? showStart === monthStart
? `Here’s ${monthName} so far. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
: `Here’s ${shownName}, your latest month in Spendlight. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
: 'Spendlight is ready for your accounts. Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.'}
</p>
<div className="mt-7 grid max-w-xl grid-cols-3 gap-3">
{[
{ label: 'Spent', value: month.data ? formatCents(-month.data.sums.outCents, { whole: true }) : null },
{ label: 'Money in', value: month.data ? formatCents(month.data.sums.inCents, { whole: true }) : null },
{ label: 'To review', value: toReview !== undefined ? String(toReview) : null, to: '/review' },
].map(({ label, value, to }) => {
const body = (
<>
<p className="text-[11.5px] font-medium text-white/55">{label}</p>
{value !== null && hasMoney ? (
<p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[22px]">{value}</p>
) : (
<Skeleton className="mt-2 h-6 w-16 bg-white/10" />
)}
</>
);
return to && hasMoney ? (
<Link key={label} to={to} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur transition-colors hover:bg-white/10">
{body}
</Link>
) : (
<div key={label} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur">
{body}
</div>
);
})}
</div>
</div>
</section>
<Card>
<CardHeader>
<CardTitle>Getting started</CardTitle>
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
</CardHeader>
<CardContent>
<ol className="divide-y">
<Step done icon={<Check />} title="Create your sign-in" detail="" />
<Step
done={me.hasPasskey}
icon={<ScanFace />}
title="Turn on Face ID or fingerprint sign-in"
detail="One glance instead of a password, if this device is ever signed out."
action={
<Link to="/settings/security" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Set up <ArrowRight className="size-4" />
</Link>
}
/>
<Step
done={installed}
icon={isIos() ? <Share /> : <Smartphone />}
title="Add Spendlight to your home screen"
detail={
isIos()
? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.'
: 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'
}
/>
<Step
done={hasAccounts}
icon={<Landmark />}
title="Add your accounts"
detail="Checking, savings and every card, each with its own color. Or skip ahead: importing a statement offers to create its account."
action={
<Link to="/accounts" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Add <ArrowRight className="size-4" />
</Link>
}
/>
<Step
done={hasMoney}
icon={<Upload />}
title="Drop in your statements"
detail="Download a CSV from each bank’s website and drop them in together. Spendlight recognizes the bank, skips duplicates and fills in categories."
action={
<Link to="/import" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Import <ArrowRight className="size-4" />
</Link>
}
/>
<Step
done={hasMoney && toReview === 0}
icon={<Inbox />}
title="Review what came in"
detail={toReview ? `${toReview} transaction${toReview === 1 ? '' : 's'} waiting, most already filled in. Usually a few taps.` : 'Confirm Spendlight’s guesses. It learns from every choice.'}
action={
toReview ? (
<Link to="/review" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Review <ArrowRight className="size-4" />
</Link>
) : undefined
}
/>
<Step icon={<Target />} later="Coming in milestone 2" title="Set your monthly budgets" detail="Start from last month’s spending and adjust from there." />
</ol>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,432 @@
import { formatCents, type Account, type ImportColumnMap, type ImportPreview, type ImportSummary } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowRight, Check, CircleAlert, FileText, Inbox, Loader2, Plus, Undo2, Upload, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { MappingDialog } from '@/components/MappingDialog';
import { formatDay } from '@/components/money';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog } from '@/components/ui/dialog';
import { Select } from '@/components/ui/form';
import { Badge } from '@/components/ui/misc';
import { api, ApiError, errorMessage } from '@/lib/api';
import { MONEY_KEYS, useAccounts, useReviewCount } from '@/lib/data';
import { onFilesQueued, takeFiles } from '@/lib/pendingFiles';
import { cn, timeAgo } from '@/lib/utils';
type Status = 'reading' | 'needs-mapping' | 'needs-account' | 'ready' | 'importing' | 'done' | 'error';
interface Item {
key: string;
file: File;
status: Status;
preview?: ImportPreview;
accountId?: number;
mapping?: ImportColumnMap;
include: string[];
summary?: ImportSummary;
error?: string;
}
async function uploadFile(file: File): Promise<ImportPreview> {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/v1/imports/files', { method: 'POST', body: form, credentials: 'same-origin' });
const data = await res.json().catch(() => null);
if (!res.ok) throw new ApiError(res.status, data?.error?.code ?? 'error', data?.error?.message ?? 'Couldn’t read that file.');
return data as ImportPreview;
}
const statusFor = (p: ImportPreview, accountId?: number): Status =>
!p.layout ? 'needs-mapping' : !(accountId ?? p.account?.id) ? 'needs-account' : 'ready';
function range(p: ImportPreview) {
if (!p.dateFrom || !p.dateTo) return null;
return p.dateFrom === p.dateTo ? formatDay(p.dateFrom, true) : `${formatDay(p.dateFrom, true)} – ${formatDay(p.dateTo, true)}`;
}
function FileCard({
item,
accounts,
onAccount,
onNewAccount,
onMap,
onToggle,
onImport,
onRemove,
onUndo,
}: {
item: Item;
accounts: Account[];
onAccount: (id: number) => void;
onNewAccount: () => void;
onMap: () => void;
onToggle: (key: string) => void;
onImport: () => void;
onRemove: () => void;
onUndo: () => void;
}) {
const p = item.preview;
const accountId = item.accountId ?? p?.account?.id;
const open = accounts.filter((a) => a.status === 'open');
const toImport = p ? p.counts.new + item.include.length : 0;
return (
<Card className="animate-fade-up overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3.5 sm:px-5">
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]',
item.status === 'done' ? 'bg-good/12 text-good' : item.status === 'error' ? 'bg-bad/12 text-bad' : 'bg-accent text-accent-foreground',
)}
>
{item.status === 'done' ? <Check /> : item.status === 'error' ? <CircleAlert /> : item.status === 'reading' || item.status === 'importing' ? <Loader2 className="animate-spin" /> : <FileText />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-medium">{item.file.name}</p>
<p className="truncate text-[12.5px] text-muted-foreground">
{item.status === 'reading'
? 'Reading…'
: item.status === 'error'
? item.error
: [p?.layout?.name ?? (p ? 'A layout Spendlight hasn’t seen yet' : null), p ? range(p) : null].filter(Boolean).join(' · ')}
</p>
</div>
{item.status !== 'importing' && item.status !== 'done' ? (
<Button variant="ghost" size="icon" aria-label={`Remove ${item.file.name}`} onClick={onRemove}>
<X className="text-muted-foreground" />
</Button>
) : null}
</div>
{p && item.status === 'needs-mapping' ? (
<div className="border-t px-4 py-4 sm:px-5">
<p className="text-[14px] text-muted-foreground">Show Spendlight which column is which, once. It will recognize this bank’s files after that.</p>
<Button className="mt-3" onClick={onMap}>
Set up columns <ArrowRight />
</Button>
</div>
) : null}
{p && p.layout && item.status !== 'done' && item.status !== 'reading' ? (
<div className="grid gap-4 border-t px-4 py-4 sm:px-5">
<div className="grid gap-1.5">
<label className="text-[13px] font-medium" htmlFor={`acct-${item.key}`}>
{accountId ? 'Account' : 'Which account is this?'}
</label>
<div className="flex flex-wrap gap-2">
<Select
id={`acct-${item.key}`}
value={accountId ?? ''}
onChange={(e) => (e.target.value === 'new' ? onNewAccount() : onAccount(Number(e.target.value)))}
className={cn('min-w-0 flex-1 sm:max-w-xs', !accountId && 'border-warn/60')}
>
<option value="" disabled>
Choose an account
</option>
{open.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
{a.last4 ? ` ••${a.last4}` : ''}
</option>
))}
{p.accountHint ? <option value="new">+ New account: {p.accountHint.name}</option> : <option value="new">+ New account</option>}
</Select>
</div>
{p.account && accountId === p.account.id ? <p className="text-[12.5px] text-muted-foreground">{p.account.reason}</p> : null}
</div>
{accountId ? (
<>
<div className="flex flex-wrap gap-1.5">
<Badge tone="primary">{p.counts.new} new</Badge>
{p.counts.duplicates ? <Badge>{p.counts.duplicates} already imported</Badge> : null}
{p.counts.possibleDuplicates ? <Badge tone="warn">{p.counts.possibleDuplicates} possible duplicates</Badge> : null}
{p.counts.skipped ? <Badge>{p.counts.skipped} lines skipped</Badge> : null}
</div>
{p.possibleDuplicates.length ? (
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
<p className="text-[13px] font-medium">These look like transactions you already have, a few days apart. Skipped unless you tick them:</p>
<ul className="mt-2 grid gap-1.5">
{p.possibleDuplicates.map((d) => (
<li key={d.key}>
<label className="flex items-center gap-2.5 text-[13.5px]">
<input type="checkbox" className="size-4 accent-[var(--primary)]" checked={item.include.includes(d.key)} onChange={() => onToggle(d.key)} />
<span className="flex-1 truncate">
{formatDay(d.date, true)} · {d.merchant}
<span className="text-muted-foreground"> (you have one on {formatDay(d.existingDate, true)})</span>
</span>
<span className="tabular">{formatCents(d.amountCents)}</span>
</label>
</li>
))}
</ul>
</div>
) : null}
{p.newRows.length ? (
<ul className="divide-y rounded-xl border text-[13.5px]">
{p.newRows.slice(0, 5).map((r, i) => (
<li key={i} className="flex items-center gap-3 px-3 py-2">
<span className="w-14 shrink-0 text-muted-foreground">{formatDay(r.date, true)}</span>
<span className="min-w-0 flex-1 truncate">{r.merchant}</span>
<span className={cn('tabular', r.amountCents > 0 && 'text-good')}>{formatCents(r.amountCents, { sign: r.amountCents > 0 })}</span>
</li>
))}
{p.counts.new > 5 ? <li className="px-3 py-2 text-muted-foreground">and {p.counts.new - 5} more</li> : null}
</ul>
) : null}
<Button onClick={onImport} loading={item.status === 'importing'} disabled={toImport === 0} className="justify-self-start">
{toImport === 0 ? 'Nothing new to import' : `Import ${toImport} transaction${toImport === 1 ? '' : 's'}`}
</Button>
</>
) : null}
</div>
) : null}
{item.status === 'done' && item.summary ? (
<div className="flex flex-wrap items-center gap-3 border-t px-4 py-3.5 sm:px-5">
<p className="flex-1 text-[14px]">
Added <strong>{item.summary.addedCount}</strong> to {item.summary.accountName}
{item.summary.transferCount ? `, matched ${item.summary.transferCount} transfer${item.summary.transferCount === 1 ? '' : 's'}` : ''}.
</p>
<Button variant="ghost" size="sm" onClick={onUndo}>
<Undo2 /> Undo
</Button>
</div>
) : null}
</Card>
);
}
export default function ImportPage() {
const client = useQueryClient();
const accounts = useAccounts();
const recent = useQuery({ queryKey: ['imports'], queryFn: () => api<ImportSummary[]>('/imports') });
const [items, setItems] = useState<Item[]>([]);
const [mapping, setMapping] = useState<Item | null>(null);
const [savingMap, setSavingMap] = useState(false);
const [undoing, setUndoing] = useState<{ id: number; name: string } | null>(null);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const patch = (key: string, change: Partial<Item>) => setItems((list) => list.map((i) => (i.key === key ? { ...i, ...change } : i)));
const refreshMoney = () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
const addFiles = useCallback((files: File[]) => {
const fresh = files.map((file) => ({ key: crypto.randomUUID(), file, status: 'reading' as Status, include: [] }));
setItems((list) => [...fresh, ...list]);
for (const item of fresh) {
uploadFile(item.file)
.then((preview) => patch(item.key, { preview, status: statusFor(preview), accountId: preview.account?.id }))
.catch((error) => patch(item.key, { status: 'error', error: errorMessage(error) }));
}
}, []);
useEffect(() => {
const pending = takeFiles();
if (pending.length) addFiles(pending);
return onFilesQueued(() => addFiles(takeFiles()));
}, [addFiles]);
const repreview = async (item: Item, change: { accountId?: number; mapping?: ImportColumnMap }) => {
const accountId = change.accountId ?? item.accountId;
const map = change.mapping ?? item.mapping;
const preview = await api<ImportPreview>('/imports/preview', {
method: 'POST',
body: { stagingId: item.preview!.stagingId, ...(accountId ? { accountId } : {}), ...(map ? { mapping: map } : {}) },
});
patch(item.key, { preview, accountId, mapping: map, include: [], status: statusFor(preview, accountId) });
};
const newAccount = async (item: Item) => {
const hint = item.preview?.accountHint;
try {
const account = await api<Account>('/accounts', {
method: 'POST',
body: hint ? { name: hint.name, kind: hint.kind, institution: hint.institution, last4: hint.last4 } : { name: 'New account', kind: 'checking' },
});
toast.success(`Created ${account.name}`, { description: 'You can rename it any time in Accounts.' });
void client.invalidateQueries({ queryKey: ['accounts'] });
await repreview(item, { accountId: account.id });
} catch (error) {
toast.error(errorMessage(error));
}
};
const doImport = async (item: Item) => {
patch(item.key, { status: 'importing' });
try {
const summary = await api<ImportSummary>('/imports', {
method: 'POST',
body: {
stagingId: item.preview!.stagingId,
accountId: item.accountId ?? item.preview!.account!.id,
...(item.mapping ? { mapping: item.mapping } : {}),
includePossibleDuplicates: item.include,
},
});
patch(item.key, { status: 'done', summary });
refreshMoney();
} catch (error) {
patch(item.key, { status: 'ready' });
toast.error(errorMessage(error));
}
};
const undo = useMutation({
mutationFn: (id: number) => api<ImportSummary>(`/imports/${id}`, { method: 'DELETE' }),
onSuccess: (s) => {
toast.success(`Undone: removed ${s.addedCount} transaction${s.addedCount === 1 ? '' : 's'} from ${s.accountName}`);
setItems((list) => list.filter((i) => i.summary?.id !== s.id));
setUndoing(null);
refreshMoney();
},
onError: (error) => toast.error(errorMessage(error)),
});
const ready = items.filter((i) => i.status === 'ready' && (i.preview?.counts.new ?? 0) + i.include.length > 0);
const { data: reviewCount } = useReviewCount();
const imported = items.some((i) => i.status === 'done');
const toReview = imported ? (reviewCount ?? 0) : 0;
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
};
return (
<div>
<PageIntro
title="Import statements"
description="Add your bank and card downloads (CSV files). Spendlight recognizes the bank, skips anything already imported, and fills in categories it knows."
actions={
ready.length > 1 ? (
<Button onClick={() => ready.forEach((i) => void doImport(i))}>Import all {ready.length} files</Button>
) : undefined
}
/>
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={cn(
'relative mb-5 flex flex-col items-center justify-center rounded-3xl border-2 border-dashed px-6 py-10 text-center transition-colors',
dragging ? 'border-primary bg-accent' : 'border-border bg-card',
)}
>
<span className="mb-3 flex size-12 items-center justify-center rounded-2xl bg-accent text-accent-foreground">
<Upload className="size-6" strokeWidth={1.8} />
</span>
<p className="text-[16px] font-semibold">Drop statement files here</p>
<p className="mt-1 text-[13.5px] text-muted-foreground">As many as you like, from any of your banks</p>
<Button className="mt-4" onClick={() => inputRef.current?.click()}>
<Plus /> Choose files
</Button>
<input
ref={inputRef}
type="file"
accept=".csv,.txt,text/csv"
multiple
hidden
onChange={(e) => {
if (e.target.files?.length) addFiles([...e.target.files]);
e.target.value = '';
}}
/>
</div>
{toReview > 0 ? (
<Card className="mb-5 flex flex-wrap items-center gap-3 border-primary/30 bg-accent/60 p-4 sm:px-5">
<Inbox className="size-5 text-primary" />
<p className="flex-1 text-[14.5px] font-medium">
{toReview} transaction{toReview === 1 ? '' : 's'} ready to review, most already filled in.
</p>
<Button asChild>
<Link to="/review">
Review now <ArrowRight />
</Link>
</Button>
</Card>
) : null}
<div className="grid gap-3">
{items.map((item) => (
<FileCard
key={item.key}
item={item}
accounts={accounts.data ?? []}
onAccount={(id) => void repreview(item, { accountId: id }).catch((e) => toast.error(errorMessage(e)))}
onNewAccount={() => void newAccount(item)}
onMap={() => setMapping(item)}
onToggle={(key) => patch(item.key, { include: item.include.includes(key) ? item.include.filter((k) => k !== key) : [...item.include, key] })}
onImport={() => void doImport(item)}
onRemove={() => setItems((list) => list.filter((i) => i.key !== item.key))}
onUndo={() => item.summary && setUndoing({ id: item.summary.id, name: item.file.name })}
/>
))}
</div>
{recent.data?.length ? (
<section className="mt-8">
<h3 className="mb-2 px-1 text-[12.5px] font-semibold uppercase tracking-wider text-muted-foreground">Recent imports</h3>
<Card className="divide-y overflow-hidden">
{recent.data.slice(0, 12).map((s) => (
<div key={s.id} className={cn('flex items-center gap-3 px-4 py-3 sm:px-5', s.status === 'undone' && 'opacity-55')}>
<div className="min-w-0 flex-1">
<p className="truncate text-[14.5px] font-medium">{s.fileName}</p>
<p className="truncate text-[12.5px] text-muted-foreground">
{s.accountName} · {s.status === 'undone' ? 'undone' : `${s.addedCount} added`} · {timeAgo(s.createdAt)}
</p>
</div>
{s.status === 'committed' ? (
<Button variant="ghost" size="sm" onClick={() => setUndoing({ id: s.id, name: s.fileName })}>
<Undo2 /> Undo
</Button>
) : null}
</div>
))}
</Card>
</section>
) : null}
{mapping?.preview ? (
<MappingDialog
preview={mapping.preview}
open
saving={savingMap}
onOpenChange={(o) => !o && setMapping(null)}
onSave={async (map) => {
setSavingMap(true);
try {
await repreview(mapping, { mapping: map });
setMapping(null);
} catch (error) {
toast.error(errorMessage(error));
} finally {
setSavingMap(false);
}
}}
/>
) : null}
<ConfirmDialog
open={!!undoing}
onOpenChange={(o) => !o && setUndoing(null)}
title="Undo this import?"
description={`Everything ${undoing?.name ?? 'this file'} added is removed, including any categories you’ve chosen for those transactions. You can import the file again afterwards.`}
confirmLabel="Undo import"
destructive
loading={undo.isPending}
onConfirm={() => undoing && undo.mutate(undoing.id)}
/>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { ChevronRight, LogOut } from 'lucide-react';
import { Link } from 'react-router';
import { Wordmark, VersionTag } from '@/components/brand';
import { NAV, SETTINGS_NAV } from '@/components/nav';
import { useConfig, useSignOut } from '@/lib/session';
/** The phone's "More" tab: everything that doesn't fit on the bottom bar. */
export default function MorePage() {
const signOut = useSignOut();
const { data: config } = useConfig();
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV];
return (
<div className="grid gap-6">
<nav className="overflow-hidden rounded-2xl border bg-card shadow-soft" aria-label="More">
{items.map(({ to, label, icon: Icon }) => (
<Link key={to} to={to} className="flex h-14 items-center gap-3.5 border-b px-4 last:border-b-0 active:bg-muted">
<span className="flex size-8 items-center justify-center rounded-lg bg-accent text-accent-foreground">
<Icon className="size-[18px]" strokeWidth={1.9} />
</span>
<span className="flex-1 text-[15px] font-medium">{label}</span>
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
))}
</nav>
<button
onClick={() => signOut.mutate()}
className="flex h-14 items-center gap-3.5 rounded-2xl border bg-card px-4 text-[15px] font-medium text-bad shadow-soft active:bg-muted"
>
<LogOut className="size-[18px]" /> Sign out
</button>
<div className="flex flex-col items-center gap-1.5 pt-4 text-center">
<Wordmark className="opacity-80" />
<VersionTag version={config?.version} />
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Compass } from 'lucide-react';
import { Link } from 'react-router';
import { EmptyState } from '@/components/page';
import { Button } from '@/components/ui/button';
export default function NotFoundPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-16">
<EmptyState icon={Compass} title="Nothing here" description="That page doesn’t exist. It may have moved, or the link may be mistyped.">
<Button asChild>
<Link to="/">Go to Home</Link>
</Button>
</EmptyState>
</div>
);
}

View File

@@ -0,0 +1,383 @@
import { formatCents, type Category, type ReviewItem, type Transaction } from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeftRight, Check, CheckCheck, ChevronRight, Inbox, PartyPopper, Search, SkipForward, Split as SplitIcon, Upload } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { CategoryPicker } from '@/components/CategoryPicker';
import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money';
import { EmptyState } from '@/components/page';
import { TransactionSheet, useRefreshMoney } from '@/components/TransactionSheet';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts, useCategories } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { useIsPhone } from '@/lib/useMediaQuery';
import { cn } from '@/lib/utils';
interface Filed {
transaction: Transaction;
similar: { merchant: string; count: number; categoryId: number } | null;
}
/** One suggestion button: the best guess is big and bold, alternates are quieter. */
function Choice({ category, primary, onClick, hint }: { category: Category; primary?: boolean; onClick: () => void; hint?: string }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex min-w-0 items-center gap-2.5 rounded-2xl border text-left font-medium transition-all active:scale-[0.98]',
primary
? 'h-14 w-full border-primary/40 bg-accent px-3.5 text-[16px] text-accent-foreground shadow-soft hover:border-primary sm:h-11 sm:w-auto sm:px-3 sm:text-[14px]'
: 'h-12 flex-1 bg-card px-3 text-[14.5px] hover:bg-muted sm:h-11 sm:flex-none sm:px-3 sm:text-[14px]',
)}
>
<CategoryIcon icon={category.icon} color={category.color} size="sm" />
<span className="truncate">{category.name}</span>
{primary ? <Check className="ml-auto size-5 shrink-0 text-primary sm:ml-1 sm:size-4" /> : null}
{hint ? <kbd className="ml-auto hidden rounded-md border bg-muted px-1.5 font-mono text-[11px] text-muted-foreground lg:inline">{hint}</kbd> : null}
</button>
);
}
export default function ReviewPage() {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const phone = useIsPhone();
const refresh = useRefreshMoney();
const { byId } = useCategories();
const accounts = useAccounts();
const review = useQuery({ queryKey: ['review'], queryFn: () => api<ReviewItem[]>('/review') });
const [gone, setGone] = useState<Set<number>>(new Set());
const [cursor, setCursor] = useState(0);
const [pickerFor, setPickerFor] = useState<number | null>(null);
const [sheet, setSheet] = useState<{ t: Transaction; split: boolean } | null>(null);
const [accepting, setAccepting] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const items = useMemo(() => (review.data ?? []).filter((i) => !gone.has(i.id)), [review.data, gone]);
const current = items[Math.min(cursor, items.length - 1)];
const prefilled = items.filter((i) => i.categoryId !== null);
const accountOf = (id: number) => accounts.data?.find((a) => a.id === id);
useEffect(() => {
if (cursor > items.length - 1) setCursor(Math.max(0, items.length - 1));
}, [items.length, cursor]);
const hide = (ids: number[]) => setGone((s) => new Set([...s, ...ids]));
const unhide = (ids: number[]) => setGone((s) => new Set([...s].filter((x) => !ids.includes(x))));
const offerSimilar = (similar: Filed['similar']) => {
if (!similar) return;
const c = byId.get(similar.categoryId);
toast(`Also file ${similar.count} other ${similar.merchant} ${similar.count === 1 ? 'purchase' : 'purchases'} as ${c?.name}?`, {
description: 'Spendlight will remember this for future imports too.',
duration: 12_000,
action: {
label: 'Yes',
onClick: async () => {
try {
const res = await api<{ updated: number }>('/transactions/bulk', {
method: 'POST',
body: { merchant: similar.merchant, categoryId: similar.categoryId, remember: true },
});
hide(items.filter((i) => i.merchant === similar.merchant).map((i) => i.id));
toast.success(`Filed ${res.updated} more, and remembered ${similar.merchant}`);
refresh();
} catch (error) {
toast.error(errorMessage(error));
}
},
},
});
};
const file = useCallback(
async (item: ReviewItem, categoryId: number) => {
hide([item.id]);
try {
const res = await api<Filed>(`/transactions/${item.id}`, { method: 'PATCH', body: { categoryId } });
const name = byId.get(categoryId)?.name ?? 'that';
toast.success(`${item.merchant} filed as ${name}`, {
duration: 4000,
action: {
label: 'Undo',
onClick: async () => {
await api(`/transactions/${item.id}`, { method: 'PATCH', body: { categoryId: item.categoryId, needsReview: true } }).catch(() => {});
unhide([item.id]);
refresh();
},
},
});
offerSimilar(res.similar);
refresh();
} catch (error) {
unhide([item.id]);
toast.error(errorMessage(error));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[byId, items],
);
const acceptAll = async () => {
setAccepting(true);
try {
const ids = prefilled.map((i) => i.id);
const res = await api<{ accepted: number }>('/transactions/accept', { method: 'POST', body: { ids } });
hide(ids);
toast.success(`Filed ${res.accepted} transaction${res.accepted === 1 ? '' : 's'}`);
refresh();
} catch (error) {
toast.error(errorMessage(error));
} finally {
setAccepting(false);
}
};
// Keyboard on larger screens: arrows or j/k move, Enter files the best guess,
// 1-3 pick a suggestion, / opens the full list, s splits.
useEffect(() => {
if (phone || !canEdit) return;
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (sheet || pickerFor !== null || /input|textarea|select/i.test(target.tagName) || target.isContentEditable) return;
if (!current) return;
if (e.key === 'ArrowDown' || e.key === 'j') setCursor((c) => Math.min(items.length - 1, c + 1));
else if (e.key === 'ArrowUp' || e.key === 'k') setCursor((c) => Math.max(0, c - 1));
else if (e.key === 'Enter' && current.categoryId) void file(current, current.categoryId);
else if (['1', '2', '3'].includes(e.key) && current.suggestions[Number(e.key) - 1]) void file(current, current.suggestions[Number(e.key) - 1]!);
else if (e.key === '/') setPickerFor(current.id);
else if (e.key === 's') setSheet({ t: current, split: true });
else return;
e.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [phone, canEdit, current, items.length, sheet, pickerFor, file]);
useEffect(() => {
listRef.current?.querySelector(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'nearest' });
}, [cursor]);
if (review.isPending) return <Skeleton className="h-72 rounded-3xl" />;
if (!items.length) {
return (
<EmptyState
icon={review.data?.length ? PartyPopper : Inbox}
title={review.data?.length ? 'All caught up' : 'Nothing to review'}
description={
review.data?.length
? 'Every transaction is filed. New ones appear here after your next import.'
: 'When you import statements, new transactions land here with Spendlight’s best guess already filled in.'
}
>
<div className="flex flex-wrap justify-center gap-2">
<Button asChild>
<Link to="/import">
<Upload /> Import statements
</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/activity">See all activity</Link>
</Button>
</div>
</EmptyState>
);
}
const suggestionCats = (item: ReviewItem) => item.suggestions.map((id) => byId.get(id)).filter((c): c is Category => !!c);
const header = (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-[15px] text-muted-foreground">
<strong className="text-foreground">{items.length}</strong> to review
{prefilled.length ? (
<>
{' '}
· <strong className="text-foreground">{prefilled.length}</strong> already filled in
</>
) : null}
</p>
{canEdit && prefilled.length ? (
<Button onClick={acceptAll} loading={accepting} className="w-full sm:w-auto">
{accepting ? null : <CheckCheck />} Accept all {prefilled.length} suggestions
</Button>
) : null}
</div>
);
// ---- Phone: one card at a time ---------------------------------------------
if (phone && current) {
const cats = suggestionCats(current);
const best = current.categoryId ? byId.get(current.categoryId) : undefined;
const alternates = cats.filter((c) => c.id !== best?.id).slice(0, 2);
const account = accountOf(current.accountId);
return (
<div>
{header}
<Card className="animate-fade-up overflow-hidden p-5" key={current.id}>
<div className="flex items-center justify-between gap-2 text-[12.5px] font-medium text-muted-foreground">
<span className="flex min-w-0 items-center gap-1.5 truncate">
{account ? <AccountDot color={account.color} /> : null}
{account?.name}
</span>
<span>{formatDay(current.date)}</span>
</div>
<div className="mt-3 flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-[22px] font-semibold leading-tight tracking-tight">{current.merchant}</h2>
<Amount cents={current.amountCents} className="text-[22px] font-semibold" />
</div>
<p className="mt-1 break-words text-[12.5px] text-muted-foreground">{current.description}</p>
{current.unpairedTransfer ? (
<p className="mt-4 flex gap-2 rounded-xl bg-muted px-3.5 py-3 text-[13.5px]">
<ArrowLeftRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
This looks like a payment to an account Spendlight doesn’t track. Is it a transfer, or spending?
</p>
) : null}
{canEdit ? (
<div className="mt-5 grid gap-2.5">
{best ? (
<>
<p className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground">
{current.confident ? 'You always file this as' : 'Best guess'}
</p>
<Choice category={best} primary onClick={() => void file(current, best.id)} />
</>
) : (
<p className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground">Choose a category</p>
)}
{alternates.length ? (
<div className="flex gap-2">
{alternates.map((c) => (
<Choice key={c.id} category={c} onClick={() => void file(current, c.id)} />
))}
</div>
) : null}
<CategoryPicker
value={current.categoryId}
suggestions={current.suggestions}
open={pickerFor === current.id}
onOpenChange={(o) => setPickerFor(o ? current.id : null)}
onPick={(c) => void file(current, c.id)}
>
<Button variant="outline" size="lg" className="w-full">
<Search /> Something else…
</Button>
</CategoryPicker>
<div className="mt-1 grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={() => setSheet({ t: current, split: true })}>
<SplitIcon /> Split
</Button>
<Button variant="ghost" onClick={() => setCursor((c) => (c + 1) % items.length)}>
<SkipForward /> Skip for now
</Button>
</div>
</div>
) : null}
</Card>
{items.length > 1 ? (
<section className="mt-6">
<h3 className="mb-2 px-1 text-[12.5px] font-semibold uppercase tracking-wider text-muted-foreground">Up next</h3>
<Card className="divide-y overflow-hidden">
{items
.filter((i) => i.id !== current.id)
.slice(0, 6)
.map((i) => {
const c = i.categoryId ? byId.get(i.categoryId) : undefined;
return (
<button key={i.id} onClick={() => setCursor(items.indexOf(i))} className="flex w-full items-center gap-3 px-4 py-3 text-left active:bg-muted">
<div className="min-w-0 flex-1">
<p className={cn('truncate text-[15px] font-medium', i.transferPeer && 'italic')}>{i.merchant}</p>
<p className="truncate text-[12.5px] text-muted-foreground">{c ? c.name : 'Needs a category'}</p>
</div>
<Amount cents={i.amountCents} />
<ChevronRight className="size-4 text-muted-foreground" />
</button>
);
})}
</Card>
</section>
) : null}
<TransactionSheet transaction={sheet?.t ?? null} startWithSplit={sheet?.split} onClose={() => setSheet(null)} />
</div>
);
}
// ---- Larger screens: a fast list --------------------------------------------
return (
<div>
{header}
{canEdit ? (
<p className="mb-3 hidden text-[12.5px] text-muted-foreground lg:block">
Keyboard: <kbd className="rounded border bg-muted px-1 font-mono">↑</kbd> <kbd className="rounded border bg-muted px-1 font-mono">↓</kbd> move ·{' '}
<kbd className="rounded border bg-muted px-1 font-mono">Enter</kbd> accept the guess · <kbd className="rounded border bg-muted px-1 font-mono">1</kbd>–
<kbd className="rounded border bg-muted px-1 font-mono">3</kbd> pick a suggestion · <kbd className="rounded border bg-muted px-1 font-mono">/</kbd> all categories ·{' '}
<kbd className="rounded border bg-muted px-1 font-mono">S</kbd> split
</p>
) : null}
<Card ref={listRef as never} className="divide-y overflow-hidden">
{items.map((item, index) => {
const account = accountOf(item.accountId);
const cats = suggestionCats(item);
const selected = index === cursor;
return (
<div
key={item.id}
data-row={index}
onClick={() => setCursor(index)}
className={cn('grid grid-cols-[1fr_auto] gap-x-4 gap-y-2.5 px-4 py-3.5 transition-colors sm:px-5 lg:grid-cols-[1fr_auto_auto] lg:items-center', selected && 'bg-accent/50')}
>
<button className="min-w-0 text-left" onClick={() => setSheet({ t: item, split: false })}>
<p className={cn('truncate text-[15px] font-medium', item.transferPeer && 'italic')}>{item.merchant}</p>
<p className="flex items-center gap-1.5 truncate text-[12.5px] text-muted-foreground">
{account ? <AccountDot color={account.color} /> : null}
{account?.name} · {formatDay(item.date, true)}
{item.unpairedTransfer ? ' · payment to an account you don’t track?' : ''}
</p>
</button>
<Amount cents={item.amountCents} className="text-right text-[15px] lg:order-3 lg:w-28" />
{canEdit ? (
<div className="col-span-2 flex flex-wrap gap-2 lg:order-2 lg:col-span-1 lg:justify-end">
{cats.map((c, n) => (
<Choice
key={c.id}
category={c}
primary={c.id === item.categoryId}
hint={selected ? String(n + 1) : undefined}
onClick={() => void file(item, c.id)}
/>
))}
<CategoryPicker
value={item.categoryId}
suggestions={item.suggestions}
open={pickerFor === item.id}
onOpenChange={(o) => setPickerFor(o ? item.id : null)}
onPick={(c) => void file(item, c.id)}
>
<Button variant="ghost" size="sm" className="h-11" aria-label={`More categories for ${item.merchant}`}>
<Search /> More
</Button>
</CategoryPicker>
</div>
) : null}
</div>
);
})}
</Card>
{items.length && prefilled.length === 0 ? null : (
<p className="mt-3 text-center text-[12.5px] text-muted-foreground">
Showing {items.length} · total {formatCents(items.reduce((s, i) => s + i.amountCents, 0))}
</p>
)}
<TransactionSheet transaction={sheet?.t ?? null} startWithSplit={sheet?.split} onClose={() => setSheet(null)} />
</div>
);
}

View File

@@ -0,0 +1,74 @@
import type { Me } from '@spendlight/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { Navigate, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { AuthLayout, FormError } from '@/components/AuthLayout';
import { Splash } from '@/components/RequireAuth';
import { Button } from '@/components/ui/button';
import { Field, Input, PasswordInput } from '@/components/ui/form';
import { api, errorMessage } from '@/lib/api';
import { useConfig } from '@/lib/session';
export default function SetupPage() {
const config = useConfig();
const client = useQueryClient();
const navigate = useNavigate();
const [displayName, setDisplayName] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
if (config.isPending) return <Splash />;
if (config.data && !config.data.needsSetup) return <Navigate to="/" replace />;
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const me = await api<Me>('/setup', { method: 'POST', body: { displayName, username, password } });
client.setQueryData(['me'], me);
await client.invalidateQueries({ queryKey: ['config'] });
toast.success(`Welcome to Spendlight, ${me.displayName}!`);
navigate('/', { replace: true });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
};
return (
<AuthLayout title="Welcome to Spendlight" subtitle="Let’s create your sign-in. You’ll be the admin, so you can invite the rest of the household later.">
<form onSubmit={submit} className="grid gap-4">
<FormError message={error} />
<Field label="Your name">
{(id) => <Input id={id} value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" placeholder="Chris" required />}
</Field>
<Field label="Username" hint="What you’ll type to sign in. Letters and numbers work best.">
{(id) => (
<Input
id={id}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="chris"
required
/>
)}
</Field>
<Field label="Password" hint="At least 8 characters. A short phrase is easy to remember.">
{(id) => <PasswordInput id={id} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" minLength={8} required />}
</Field>
<Button type="submit" size="lg" loading={busy} className="mt-1 w-full">
Create and continue
</Button>
</form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,104 @@
import type { Me } from '@spendlight/shared';
import { browserSupportsWebAuthn, startAuthentication, type PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';
import { useQueryClient } from '@tanstack/react-query';
import { ScanFace } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { Navigate, useNavigate, useSearchParams } from 'react-router';
import { AuthLayout, FormError } from '@/components/AuthLayout';
import { Splash } from '@/components/RequireAuth';
import { Button } from '@/components/ui/button';
import { Field, Input, PasswordInput } from '@/components/ui/form';
import { api, errorMessage } from '@/lib/api';
import { useConfig, useMe } from '@/lib/session';
export default function SignInPage() {
const config = useConfig();
const me = useMe();
const client = useQueryClient();
const navigate = useNavigate();
const [params] = useSearchParams();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<'password' | 'passkey' | null>(null);
if (config.isPending || me.isPending) return <Splash />;
if (config.data?.needsSetup) return <Navigate to="/setup" replace />;
if (me.data) return <Navigate to="/" replace />;
const next = params.get('next')?.startsWith('/') ? params.get('next')! : '/';
const finish = (signedIn: Me) => {
client.setQueryData(['me'], signedIn);
navigate(next, { replace: true });
};
const withPassword = async (e: FormEvent) => {
e.preventDefault();
setBusy('password');
setError(null);
try {
finish(await api<Me>('/auth/login', { method: 'POST', body: { username, password } }));
} catch (err) {
setError(errorMessage(err));
setBusy(null);
}
};
const withPasskey = async () => {
setBusy('passkey');
setError(null);
try {
const optionsJSON = await api<PublicKeyCredentialRequestOptionsJSON>('/auth/passkey/options', { method: 'POST' });
const response = await startAuthentication({ optionsJSON });
finish(await api<Me>('/auth/passkey/verify', { method: 'POST', body: { response } }));
} catch (err) {
const cancelled = err instanceof Error && (err.name === 'NotAllowedError' || err.name === 'AbortError');
setError(cancelled ? null : errorMessage(err));
setBusy(null);
}
};
return (
<AuthLayout title="Welcome back" subtitle="Your household’s money, in the spotlight.">
<div className="grid gap-4">
<FormError message={error} />
{browserSupportsWebAuthn() ? (
<>
<Button size="lg" variant="outline" className="h-auto min-h-12 w-full whitespace-normal py-3" onClick={withPasskey} loading={busy === 'passkey'} disabled={busy !== null}>
{busy === 'passkey' ? null : <ScanFace />}
Use Face ID or fingerprint
</Button>
<div className="flex items-center gap-3 text-[12px] font-medium uppercase tracking-wider text-muted-foreground">
<span className="h-px flex-1 bg-border" />
or
<span className="h-px flex-1 bg-border" />
</div>
</>
) : null}
<form onSubmit={withPassword} className="grid gap-4">
<Field label="Username">
{(id) => (
<Input
id={id}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required
/>
)}
</Field>
<Field label="Password">
{(id) => <PasswordInput id={id} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />}
</Field>
<Button type="submit" size="lg" loading={busy === 'password'} disabled={busy !== null} className="mt-1 w-full">
Sign in
</Button>
</form>
<p className="text-center text-[12.5px] leading-relaxed text-muted-foreground">You’ll stay signed in on this device.</p>
</div>
</AuthLayout>
);
}

View File

@@ -0,0 +1,40 @@
import { ChartColumnBig, Repeat, Target, type LucideIcon } from 'lucide-react';
import { EmptyState } from '@/components/page';
// Screens that arrive in later milestones, each explaining what it will do.
interface Upcoming {
icon: LucideIcon;
title: string;
description: string;
milestone: string;
}
const SCREENS: Record<string, Upcoming> = {
budget: {
icon: Target,
title: 'Monthly budgets',
description:
'Set a target for each category, copy last month in one tap, and see over or under for the month and the year at a glance.',
milestone: 'Milestone 2 · The full picture',
},
subscriptions: {
icon: Repeat,
title: 'Every subscription in one place',
description:
'Spendlight spots recurring charges across all your cards, totals them by month and year, and speaks up when a price changes or a cancelled service charges again.',
milestone: 'Milestone 2 · The full picture',
},
insights: {
icon: ChartColumnBig,
title: 'Where the money goes',
description:
'Month-by-month spending by category, budget against actual, year pacing, and money in against money out, each in plain words first.',
milestone: 'Milestone 2 · The full picture',
},
};
export default function UpcomingPage({ screen }: { screen: keyof typeof SCREENS }) {
const s = SCREENS[screen]!;
return <EmptyState icon={s.icon} title={s.title} description={s.description} milestone={s.milestone} />;
}

View File

@@ -0,0 +1,60 @@
import { versionDate } from '@spendlight/shared';
import { BookOpen, HardDrive, ShieldCheck } from 'lucide-react';
import { LogoMark } from '@/components/brand';
import { Card, CardContent } from '@/components/ui/card';
import { useConfig } from '@/lib/session';
import { APP_VERSION } from '@/lib/version';
export default function AboutPage() {
const { data: config } = useConfig();
const version = config?.version ?? APP_VERSION;
const built = versionDate(version);
return (
<div className="grid gap-5">
<Card className="relative overflow-hidden">
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-40" aria-hidden />
<CardContent className="relative flex flex-col items-center py-10 text-center sm:py-12">
<LogoMark className="size-16 rounded-[22px] shadow-lift" />
<h2 className="mt-4 text-2xl font-semibold tracking-tight">Spendlight</h2>
<p className="mt-1 text-[14px] text-muted-foreground">Your household’s money, in the spotlight.</p>
<dl className="mt-6 grid gap-1">
<dt className="text-[12px] font-medium uppercase tracking-wider text-muted-foreground">Version</dt>
<dd className="tabular text-[22px] font-semibold tracking-tight">{version}</dd>
{built ? (
<dd className="text-[13px] text-muted-foreground">
Built {built.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}, build{' '}
{Number(version.split('.')[1])} of the day
</dd>
) : (
<dd className="text-[13px] text-muted-foreground">Development build</dd>
)}
{version !== APP_VERSION ? <dd className="text-[12.5px] text-warn">These screens are {APP_VERSION}; reload to update.</dd> : null}
</dl>
</CardContent>
</Card>
<Card>
<CardContent className="grid gap-4 pt-5 text-[14px] sm:pt-6">
<p className="flex gap-3">
<ShieldCheck className="mt-0.5 size-[18px] shrink-0 text-good" />
<span>Your data stays on your own server. Spendlight makes no calls to outside services.</span>
</p>
<p className="flex gap-3">
<HardDrive className="mt-0.5 size-[18px] shrink-0 text-primary" />
<span>Everything lives in one database file, copied to the backup folder every night.</span>
</p>
<p className="flex gap-3">
<BookOpen className="mt-0.5 size-[18px] shrink-0 text-primary" />
<span>
Building on Spendlight? The{' '}
<a href="/api/docs" className="font-medium text-primary underline-offset-4 hover:underline">
API documentation
</a>{' '}
describes everything the screens can do.
</span>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import type { BackupInfo, BackupKind, BackupOverview } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Archive, Download, FolderSync } from 'lucide-react';
import { toast } from 'sonner';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { formatBytes, formatDateTime } from '@/lib/utils';
const KIND: Record<BackupKind, { label: string; tone: 'neutral' | 'primary' | 'warn' }> = {
nightly: { label: 'Nightly', tone: 'neutral' },
manual: { label: 'Manual', tone: 'primary' },
'pre-upgrade': { label: 'Before upgrade', tone: 'warn' },
'pre-restore': { label: 'Before restore', tone: 'warn' },
};
function time12(hhmm: string): string {
const [h, m] = hhmm.split(':').map(Number);
return new Date(2000, 0, 1, h, m).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
export default function BackupsPage() {
const client = useQueryClient();
const overview = useQuery({ queryKey: ['backups'], queryFn: () => api<BackupOverview>('/backups') });
const backUp = useMutation({
mutationFn: () => api<BackupInfo>('/backups', { method: 'POST' }),
onSuccess: () => {
toast.success('Backed up');
void client.invalidateQueries({ queryKey: ['backups'] });
},
onError: (error) => toast.error(errorMessage(error)),
});
return (
<div className="grid gap-5">
<PageIntro
title="Backups"
description="Spendlight copies everything to the backup folder every night. Point your backup tool at that folder to keep a copy off this server."
actions={
<Button onClick={() => backUp.mutate()} loading={backUp.isPending}>
{backUp.isPending ? null : <Archive />} Back up now
</Button>
}
/>
{overview.data ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderSync className="size-[18px] text-primary" /> Every night at {time12(overview.data.nightlyAt)}
</CardTitle>
<CardDescription>
Keeps the last {overview.data.keepDaily} nights and one copy from each of the last {overview.data.keepMonthly} months. Copies are
also taken before every upgrade.
</CardDescription>
</CardHeader>
</Card>
) : null}
<Card className="overflow-hidden">
<CardContent className="p-0 sm:p-0">
{overview.isPending ? <Skeleton className="m-4 h-12" /> : null}
{overview.data?.backups.length === 0 ? (
<p className="px-5 py-8 text-center text-[14px] text-muted-foreground">No backups yet. The first one runs tonight, or tap Back up now.</p>
) : null}
<ul className="divide-y">
{overview.data?.backups.map((b) => (
<li key={b.name} className="flex items-center gap-3 px-4 py-3 sm:px-5">
<div className="min-w-0 flex-1">
<p className="text-[14.5px] font-medium">{formatDateTime(b.createdAt)}</p>
<p className="text-[12.5px] text-muted-foreground">{formatBytes(b.size)}</p>
</div>
<Badge tone={KIND[b.kind].tone}>{KIND[b.kind].label}</Badge>
<Button variant="ghost" size="icon" asChild>
<a href={`/api/v1/backups/${encodeURIComponent(b.name)}/download`} download aria-label={`Download backup from ${formatDateTime(b.createdAt)}`}>
<Download />
</a>
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { IDEA_STATUSES, type Idea, type IdeaStatus } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ClipboardCopy, Download, Lightbulb } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Select, Textarea } from '@/components/ui/form';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { cn, formatDate } from '@/lib/utils';
/** "/settings/people" reads better as "Settings › people". */
function screenName(path: string | null): string {
if (!path) return 'an unknown screen';
if (path === '/') return 'Home';
const [first, ...rest] = path.split('/').filter(Boolean);
const title = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
return [title(first ?? ''), ...rest].join(' › ');
}
const STATUS_LABEL: Record<IdeaStatus, string> = { new: 'New', planned: 'Planned', done: 'Done', declined: 'Declined' };
const STATUS_TONE: Record<IdeaStatus, 'primary' | 'warn' | 'good' | 'neutral'> = {
new: 'primary',
planned: 'warn',
done: 'good',
declined: 'neutral',
};
function IdeaCard({ idea }: { idea: Idea }) {
const client = useQueryClient();
const [note, setNote] = useState(idea.adminNote ?? '');
const save = useMutation({
mutationFn: (body: { status?: IdeaStatus; adminNote?: string | null }) =>
api<Idea>(`/ideas/${idea.id}`, { method: 'PATCH', body }),
onSuccess: () => void client.invalidateQueries({ queryKey: ['ideas'] }),
onError: (error) => toast.error(errorMessage(error)),
});
return (
<Card className="p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[12.5px] font-medium text-muted-foreground">#{idea.id}</span>
<Badge tone={STATUS_TONE[idea.status]}>{STATUS_LABEL[idea.status]}</Badge>
</div>
<h3 className="mt-1.5 text-[16px] font-semibold leading-snug">{idea.title}</h3>
{idea.description ? <p className="mt-1.5 whitespace-pre-wrap text-[14px] leading-relaxed text-muted-foreground">{idea.description}</p> : null}
<p className="mt-3 text-[12.5px] text-muted-foreground">
{idea.author ?? 'Someone'} · {formatDate(idea.createdAt)} · on {screenName(idea.path)} · v{idea.appVersion ?? '?'}
</p>
</div>
<Select
aria-label="Status"
value={idea.status}
onChange={(e) => save.mutate({ status: e.target.value as IdeaStatus })}
className="w-full sm:w-36"
>
{IDEA_STATUSES.map((s) => (
<option key={s} value={s}>
{STATUS_LABEL[s]}
</option>
))}
</Select>
</div>
<Textarea
aria-label="Note"
value={note}
onChange={(e) => setNote(e.target.value)}
onBlur={() => note !== (idea.adminNote ?? '') && save.mutate({ adminNote: note || null })}
placeholder="Add a note (saved when you click away)"
className="mt-4 min-h-16"
/>
</Card>
);
}
export default function IdeasPage() {
const [filter, setFilter] = useState<IdeaStatus | 'all'>('new');
const ideas = useQuery({ queryKey: ['ideas'], queryFn: () => api<Idea[]>('/ideas') });
const shown = ideas.data?.filter((i) => filter === 'all' || i.status === filter) ?? [];
const exportUrl = `/api/v1/ideas/export.md${filter === 'all' ? '' : `?status=${filter}`}`;
const copyMarkdown = async () => {
try {
const res = await fetch(exportUrl, { credentials: 'same-origin' });
if (!res.ok) throw new Error('Couldn’t export ideas.');
await navigator.clipboard.writeText(await res.text());
toast.success('Copied as Markdown, ready to paste into Claude.');
} catch (error) {
toast.error(errorMessage(error));
}
};
const count = (s: IdeaStatus | 'all') => (s === 'all' ? ideas.data?.length : ideas.data?.filter((i) => i.status === s).length) ?? 0;
return (
<div>
<PageIntro
title="Ideas"
description="Everything shared through the lightbulb. Copy them as Markdown to review and build with Claude."
actions={
<>
<Button variant="outline" onClick={copyMarkdown}>
<ClipboardCopy /> Copy
</Button>
<Button variant="outline" asChild>
<a href={exportUrl} download>
<Download /> .md
</a>
</Button>
</>
}
/>
<div className="-mx-4 mb-4 flex gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
{(['new', 'planned', 'done', 'declined', 'all'] as const).map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={cn(
'flex h-9 shrink-0 items-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
filter === s ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
)}
>
{s === 'all' ? 'All' : STATUS_LABEL[s]}
<span className="tabular opacity-70">{count(s)}</span>
</button>
))}
</div>
{ideas.isPending ? <Skeleton className="h-32 rounded-2xl" /> : null}
{ideas.data && shown.length === 0 ? (
<EmptyState icon={Lightbulb} title="Nothing here yet" description="Ideas shared with the lightbulb at the top right of every screen show up here." />
) : (
<div className="grid gap-3">
{shown.map((idea) => (
<IdeaCard key={`${idea.id}-${idea.updatedAt}`} idea={idea} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { ACCESS_LABEL, type Access, type Person } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Copy, Ellipsis, KeyRound, ShieldCheck, ShieldOff, Trash2, UserPlus, Eye, Pencil } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Avatar } from '@/components/UserMenu';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/menu';
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useCurrentUser } from '@/lib/session';
import { timeAgo } from '@/lib/utils';
interface WithPassword {
person: Person;
temporaryPassword: string;
}
function TemporaryPassword({ result, onClose }: { result: WithPassword | null; onClose: () => void }) {
return (
<Dialog open={!!result} onOpenChange={(open) => !open && onClose()}>
<DialogContent
title={`Share this with ${result?.person.displayName ?? ''}`}
description="It works once. They’ll choose their own password the first time they sign in."
>
{result ? (
<div className="grid gap-4">
<div className="grid gap-1 rounded-2xl bg-muted p-4 text-center">
<p className="text-[12.5px] text-muted-foreground">Username</p>
<p className="text-[17px] font-semibold">{result.person.username}</p>
<p className="mt-2 text-[12.5px] text-muted-foreground">Temporary password</p>
<p className="tabular font-mono text-[22px] font-semibold tracking-wider">{result.temporaryPassword}</p>
</div>
<Button
variant="outline"
onClick={() => {
void navigator.clipboard?.writeText(result.temporaryPassword);
toast.success('Copied');
}}
>
<Copy /> Copy password
</Button>
<Button onClick={onClose}>Done</Button>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
function AddPerson({ open, onOpenChange, onAdded }: { open: boolean; onOpenChange: (o: boolean) => void; onAdded: (r: WithPassword) => void }) {
const [displayName, setDisplayName] = useState('');
const [username, setUsername] = useState('');
const [access, setAccess] = useState<Access>('full');
const [isAdmin, setIsAdmin] = useState(false);
const add = useMutation({
mutationFn: () => api<WithPassword>('/people', { method: 'POST', body: { displayName, username, access, isAdmin } }),
onSuccess: (result) => {
setDisplayName('');
setUsername('');
setAccess('full');
setIsAdmin(false);
onOpenChange(false);
onAdded(result);
},
onError: (error) => toast.error(errorMessage(error)),
});
const submit = (e: FormEvent) => {
e.preventDefault();
add.mutate();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent title="Add a person" description="They’ll get a temporary password to share with them.">
<form onSubmit={submit} className="grid gap-4">
<Field label="Name">{(id) => <Input id={id} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required autoFocus />}</Field>
<Field label="Username">
{(id) => (
<Input id={id} value={username} onChange={(e) => setUsername(e.target.value)} autoCapitalize="none" autoCorrect="off" spellCheck={false} required />
)}
</Field>
<Field label="Access" hint="Read-only people can see everything but change nothing.">
{(id) => (
<Select id={id} value={access} onChange={(e) => setAccess(e.target.value as Access)}>
<option value="full">Full access</option>
<option value="read">Read-only</option>
</Select>
)}
</Field>
<label className="flex items-center justify-between gap-4 rounded-xl border px-3.5 py-3">
<span>
<span className="block text-[14px] font-medium">Admin</span>
<span className="block text-[12.5px] text-muted-foreground">Can manage people, backups and settings</span>
</span>
<Switch checked={isAdmin} onCheckedChange={setIsAdmin} />
</label>
<Button type="submit" loading={add.isPending} className="mt-1">
Add person
</Button>
</form>
</DialogContent>
</Dialog>
);
}
function RenamePerson({ person, onClose }: { person: Person | null; onClose: () => void }) {
const client = useQueryClient();
const [name, setName] = useState('');
const save = useMutation({
mutationFn: () => api<Person>(`/people/${person!.id}`, { method: 'PATCH', body: { displayName: name } }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['people'] });
onClose();
},
onError: (error) => toast.error(errorMessage(error)),
});
return (
<Dialog open={!!person} onOpenChange={(open) => (open ? setName(person?.displayName ?? '') : onClose())}>
<DialogContent title="Rename" onOpenAutoFocus={() => setName(person?.displayName ?? '')}>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
className="grid gap-4"
>
<Field label="Name">{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} required />}</Field>
<Button type="submit" loading={save.isPending}>
Save
</Button>
</form>
</DialogContent>
</Dialog>
);
}
export default function PeoplePage() {
const me = useCurrentUser();
const client = useQueryClient();
const people = useQuery({ queryKey: ['people'], queryFn: () => api<Person[]>('/people') });
const [adding, setAdding] = useState(false);
const [shared, setShared] = useState<WithPassword | null>(null);
const [removing, setRemoving] = useState<Person | null>(null);
const [renaming, setRenaming] = useState<Person | null>(null);
const refresh = () => void client.invalidateQueries({ queryKey: ['people'] });
const update = useMutation({
mutationFn: ({ id, body }: { id: number; body: Partial<Pick<Person, 'access' | 'isAdmin'>> }) =>
api<Person>(`/people/${id}`, { method: 'PATCH', body }),
onSuccess: (p) => {
toast.success(`Updated ${p.displayName}`);
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const reset = useMutation({
mutationFn: (id: number) => api<WithPassword>(`/people/${id}/reset-password`, { method: 'POST' }),
onSuccess: (r) => {
setShared(r);
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const remove = useMutation({
mutationFn: (id: number) => api(`/people/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Removed');
setRemoving(null);
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
return (
<div>
<PageIntro
title="People"
description="Everyone in the household signs in with their own login and shares the same books."
actions={
<Button onClick={() => setAdding(true)}>
<UserPlus /> Add person
</Button>
}
/>
<Card className="divide-y">
{people.isPending ? <Skeleton className="m-4 h-12" /> : null}
{people.data?.map((p) => (
<div key={p.id} className="flex items-center gap-3.5 px-4 py-3.5 sm:px-5">
<Avatar name={p.displayName} className="size-10 text-[13px]" />
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-[15px] font-medium">
{p.displayName}
{p.id === me.id ? <span className="text-[12.5px] font-normal text-muted-foreground">(you)</span> : null}
</p>
<p className="text-[12.5px] text-muted-foreground">
@{p.username} · {p.mustChangePassword ? 'hasn’t signed in yet' : `active ${timeAgo(p.lastActiveAt)}`}
</p>
</div>
<div className="hidden gap-1.5 sm:flex">
<Badge tone={p.access === 'full' ? 'primary' : 'neutral'}>{ACCESS_LABEL[p.access]}</Badge>
{p.isAdmin ? <Badge tone="warn">Admin</Badge> : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Options for ${p.displayName}`}>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(p)}>
<Pencil /> Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => update.mutate({ id: p.id, body: { access: p.access === 'full' ? 'read' : 'full' } })}>
<Eye /> {p.access === 'full' ? 'Make read-only' : 'Give full access'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => update.mutate({ id: p.id, body: { isAdmin: !p.isAdmin } })}>
{p.isAdmin ? <ShieldOff /> : <ShieldCheck />} {p.isAdmin ? 'Remove admin' : 'Make admin'}
</DropdownMenuItem>
{p.id !== me.id ? (
<>
<DropdownMenuItem onSelect={() => reset.mutate(p.id)}>
<KeyRound /> Reset password
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={() => setRemoving(p)}>
<Trash2 /> Remove
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</Card>
<AddPerson open={adding} onOpenChange={setAdding} onAdded={(r) => { setShared(r); refresh(); }} />
<TemporaryPassword result={shared} onClose={() => setShared(null)} />
<RenamePerson person={renaming} onClose={() => setRenaming(null)} />
<ConfirmDialog
open={!!removing}
onOpenChange={(open) => !open && setRemoving(null)}
title={`Remove ${removing?.displayName ?? ''}?`}
description="They’ll be signed out everywhere and can’t sign in again. Anything they changed keeps their name in the history."
confirmLabel="Remove"
destructive
loading={remove.isPending}
onConfirm={() => removing && remove.mutate(removing.id)}
/>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { ACCESS_LABEL, type Me } from '@spendlight/shared';
import { useQueryClient } from '@tanstack/react-query';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Avatar } from '@/components/UserMenu';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, Input } from '@/components/ui/form';
import { Badge, Segmented } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useCurrentUser } from '@/lib/session';
import { useSetTheme } from '@/lib/useSetTheme';
export default function ProfilePage() {
const me = useCurrentUser();
const client = useQueryClient();
const setTheme = useSetTheme();
const [name, setName] = useState(me.displayName);
const [saving, setSaving] = useState(false);
const saveName = async (e: FormEvent) => {
e.preventDefault();
setSaving(true);
try {
client.setQueryData(['me'], await api<Me>('/me', { method: 'PATCH', body: { displayName: name } }));
toast.success('Name saved');
} catch (error) {
toast.error(errorMessage(error));
} finally {
setSaving(false);
}
};
return (
<div className="grid gap-5">
<Card>
<CardContent className="flex items-center gap-4 pt-5 sm:pt-6">
<Avatar name={me.displayName} className="size-14 text-lg" />
<div className="min-w-0">
<p className="truncate text-[17px] font-semibold">{me.displayName}</p>
<p className="text-[13.5px] text-muted-foreground">@{me.username}</p>
<div className="mt-1.5 flex gap-1.5">
<Badge tone={me.access === 'full' ? 'primary' : 'neutral'}>{ACCESS_LABEL[me.access]}</Badge>
{me.isAdmin ? <Badge tone="warn">Admin</Badge> : null}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Your name</CardTitle>
<CardDescription>How Spendlight greets you, and how you appear to the rest of the household.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={saveName} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<Field label="Name">{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} maxLength={60} required />}</Field>
</div>
<Button type="submit" loading={saving} disabled={name.trim() === me.displayName || !name.trim()}>
Save
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>“Match my device” follows your phone or computer’s light and dark setting.</CardDescription>
</CardHeader>
<CardContent>
<Segmented
label="Theme"
value={me.theme}
onChange={setTheme}
options={[
{ value: 'system', label: 'Match device', icon: <Monitor /> },
{ value: 'light', label: 'Light', icon: <Sun /> },
{ value: 'dark', label: 'Dark', icon: <Moon /> },
]}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import type { Device, Me, PasskeyInfo } from '@spendlight/shared';
import { browserSupportsWebAuthn, startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { KeyRound, Laptop, ScanFace, Smartphone, Trash2 } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ConfirmDialog } from '@/components/ui/dialog';
import { Field, PasswordInput } from '@/components/ui/form';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { timeAgo } from '@/lib/utils';
function FaceIdCard() {
const client = useQueryClient();
const keys = useQuery({ queryKey: ['passkeys'], queryFn: () => api<PasskeyInfo[]>('/passkeys') });
const [removing, setRemoving] = useState<PasskeyInfo | null>(null);
const add = useMutation({
mutationFn: async () => {
const optionsJSON = await api<PublicKeyCredentialCreationOptionsJSON>('/passkeys/register/options', { method: 'POST' });
const response = await startRegistration({ optionsJSON });
return api<PasskeyInfo>('/passkeys/register/verify', { method: 'POST', body: { response } });
},
onSuccess: (key) => {
toast.success(`Face ID and fingerprint sign-in is on for ${key.name}`);
void client.invalidateQueries({ queryKey: ['passkeys'] });
client.setQueryData<Me | null>(['me'], (me) => (me ? { ...me, hasPasskey: true } : me));
},
onError: (error) => {
if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) return;
if (error instanceof Error && error.name === 'InvalidStateError') {
toast.info('This device is already set up for Face ID or fingerprint sign-in.');
return;
}
toast.error(errorMessage(error));
},
});
const remove = useMutation({
mutationFn: (id: string) => api(`/passkeys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Removed');
setRemoving(null);
void client.invalidateQueries({ queryKey: ['passkeys'] });
void client.invalidateQueries({ queryKey: ['me'] });
},
onError: (error) => toast.error(errorMessage(error)),
});
const supported = browserSupportsWebAuthn();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ScanFace className="size-[18px] text-primary" /> Face ID and fingerprint
</CardTitle>
<CardDescription>
Sign in with a glance or a touch instead of your password. Set it up once on each phone or computer you use.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{keys.isPending ? <Skeleton className="h-14" /> : null}
{keys.data?.map((key) => (
<div key={key.id} className="flex items-center gap-3 rounded-xl border px-3.5 py-3">
<KeyRound className="size-[18px] text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-[14.5px] font-medium">{key.name}</p>
<p className="text-[12.5px] text-muted-foreground">
Added {timeAgo(key.createdAt)} · last used {timeAgo(key.lastUsedAt)}
</p>
</div>
<Button variant="ghost" size="icon" aria-label={`Remove ${key.name}`} onClick={() => setRemoving(key)}>
<Trash2 className="text-muted-foreground" />
</Button>
</div>
))}
{supported ? (
<Button onClick={() => add.mutate()} loading={add.isPending} className="justify-self-start">
{add.isPending ? null : <ScanFace />} Set up on this device
</Button>
) : (
<p className="text-[13.5px] text-muted-foreground">This browser doesn’t support Face ID or fingerprint sign-in.</p>
)}
</CardContent>
<ConfirmDialog
open={!!removing}
onOpenChange={(open) => !open && setRemoving(null)}
title={`Remove ${removing?.name ?? 'this passkey'}?`}
description="That device will need your password next time it signs in. You can set it up again any time."
confirmLabel="Remove"
destructive
loading={remove.isPending}
onConfirm={() => removing && remove.mutate(removing.id)}
/>
</Card>
);
}
function PasswordCard() {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
try {
await api<Me>('/me/password', { method: 'POST', body: { currentPassword: current, newPassword: next } });
toast.success('Password changed. Your other devices were signed out.');
setCurrent('');
setNext('');
} catch (error) {
toast.error(errorMessage(error));
} finally {
setBusy(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>Changing it signs out every other device, as a precaution.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="grid gap-4 sm:max-w-sm">
<Field label="Current password">
{(id) => <PasswordInput id={id} value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required />}
</Field>
<Field label="New password" hint="At least 8 characters.">
{(id) => <PasswordInput id={id} value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={8} required />}
</Field>
<Button type="submit" loading={busy} className="justify-self-start">
Change password
</Button>
</form>
</CardContent>
</Card>
);
}
function DevicesCard() {
const client = useQueryClient();
const devices = useQuery({ queryKey: ['devices'], queryFn: () => api<Device[]>('/devices') });
const refresh = () => void client.invalidateQueries({ queryKey: ['devices'] });
const signOutOne = useMutation({
mutationFn: (id: string) => api(`/devices/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Signed out');
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const signOutOthers = useMutation({
mutationFn: () => api('/devices/sign-out-others', { method: 'POST' }),
onSuccess: () => {
toast.success('Signed out everywhere else');
refresh();
},
onError: (error) => toast.error(errorMessage(error)),
});
const others = devices.data?.filter((d) => !d.current).length ?? 0;
return (
<Card>
<CardHeader>
<CardTitle>Signed-in devices</CardTitle>
<CardDescription>Each device stays signed in for about a year and renews whenever it’s used. Lost a phone? Sign it out here.</CardDescription>
</CardHeader>
<CardContent className="grid gap-2.5">
{devices.isPending ? <Skeleton className="h-14" /> : null}
{devices.data?.map((d) => {
const Icon = /iPhone|Android/.test(d.deviceLabel) ? Smartphone : Laptop;
return (
<div key={d.id} className="flex items-center gap-3 rounded-xl border px-3.5 py-3">
<Icon className="size-[18px] text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-[14.5px] font-medium">
{d.deviceLabel}
{d.current ? <Badge tone="good">This device</Badge> : null}
</p>
<p className="text-[12.5px] text-muted-foreground">
{d.current ? 'Active now' : `Last active ${timeAgo(d.lastSeenAt)}`} · signed in {timeAgo(d.createdAt)}
</p>
</div>
{!d.current ? (
<Button variant="ghost" size="sm" onClick={() => signOutOne.mutate(d.id)}>
Sign out
</Button>
) : null}
</div>
);
})}
{others > 0 ? (
<Button variant="outline" className="justify-self-start" loading={signOutOthers.isPending} onClick={() => signOutOthers.mutate()}>
Sign out all other devices
</Button>
) : null}
</CardContent>
</Card>
);
}
export default function SecurityPage() {
return (
<div className="grid gap-5">
<FaceIdCard />
<DevicesCard />
<PasswordCard />
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Archive, Info, Lightbulb, ShieldCheck, UserRound, Users } from 'lucide-react';
import { NavLink, Outlet } from 'react-router';
import { useConfig, useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
export default function SettingsLayout() {
const me = useCurrentUser();
const { data: config } = useConfig();
const sections = [
{ to: 'profile', label: 'Profile', icon: UserRound, show: true },
{ to: 'security', label: 'Sign-in & devices', icon: ShieldCheck, show: true },
{ to: 'people', label: 'People', icon: Users, show: me.isAdmin },
{ to: 'backups', label: 'Backups', icon: Archive, show: me.isAdmin },
{ to: 'ideas', label: 'Ideas', icon: Lightbulb, show: me.isAdmin && !!config?.ideasEnabled },
{ to: 'about', label: 'About', icon: Info, show: true },
].filter((s) => s.show);
return (
<div className="grid gap-6 lg:grid-cols-[220px_1fr] lg:gap-10">
<nav
aria-label="Settings"
className="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0 lg:sticky lg:top-24 lg:flex-col lg:self-start lg:overflow-visible"
>
{sections.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
'flex h-10 shrink-0 items-center gap-2.5 rounded-xl px-3.5 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
isActive && 'bg-card text-foreground shadow-soft ring-1 ring-border hover:bg-card',
)
}
>
<Icon className="size-[17px]" strokeWidth={1.9} />
{label}
</NavLink>
))}
</nav>
<div className="min-w-0">
<Outlet />
</div>
</div>
);
}

74
web/src/router.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { lazy, type ReactNode } from 'react';
import { createBrowserRouter, Navigate } from 'react-router';
import { AppShell } from './components/AppShell';
import { RequireAuth } from './components/RequireAuth';
import { RouteError } from './components/RouteError';
import { useCurrentUser } from './lib/session';
import SetupPage from './pages/SetupPage';
import SignInPage from './pages/SignInPage';
// Each screen loads its own code, so a phone's first visit stays small.
const HomePage = lazy(() => import('./pages/HomePage'));
const UpcomingPage = lazy(() => import('./pages/Upcoming'));
const ReviewPage = lazy(() => import('./pages/ReviewPage'));
const ActivityPage = lazy(() => import('./pages/ActivityPage'));
const AccountsPage = lazy(() => import('./pages/AccountsPage'));
const ImportPage = lazy(() => import('./pages/ImportPage'));
const MorePage = lazy(() => import('./pages/MorePage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout'));
const ProfilePage = lazy(() => import('./pages/settings/ProfilePage'));
const SecurityPage = lazy(() => import('./pages/settings/SecurityPage'));
const PeoplePage = lazy(() => import('./pages/settings/PeoplePage'));
const BackupsPage = lazy(() => import('./pages/settings/BackupsPage'));
const IdeasPage = lazy(() => import('./pages/settings/IdeasPage'));
const AboutPage = lazy(() => import('./pages/settings/AboutPage'));
function AdminOnly({ children }: { children: ReactNode }) {
const me = useCurrentUser();
return me.isAdmin ? children : <Navigate to="/settings" replace />;
}
export const router = createBrowserRouter([
{ path: '/setup', element: <SetupPage />, errorElement: <RouteError /> },
{ path: '/sign-in', element: <SignInPage />, errorElement: <RouteError /> },
{
element: (
<RequireAuth>
<AppShell />
</RequireAuth>
),
errorElement: <RouteError />,
children: [
{
// Errors inside a screen show within the app frame, so navigation still works.
errorElement: <RouteError />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'review', element: <ReviewPage /> },
{ path: 'activity', element: <ActivityPage /> },
{ path: 'import', element: <ImportPage /> },
{ path: 'budget', element: <UpcomingPage screen="budget" /> },
{ path: 'subscriptions', element: <UpcomingPage screen="subscriptions" /> },
{ path: 'insights', element: <UpcomingPage screen="insights" /> },
{ path: 'accounts', element: <AccountsPage /> },
{ path: 'more', element: <MorePage /> },
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <Navigate to="profile" replace /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'security', element: <SecurityPage /> },
{ path: 'people', element: <AdminOnly><PeoplePage /></AdminOnly> },
{ path: 'backups', element: <AdminOnly><BackupsPage /></AdminOnly> },
{ path: 'ideas', element: <AdminOnly><IdeasPage /></AdminOnly> },
{ path: 'about', element: <AboutPage /> },
],
},
],
},
],
},
{ path: '*', element: <NotFoundPage /> },
]);

158
web/src/styles.css Normal file
View File

@@ -0,0 +1,158 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Palette A, "Spotlight Indigo". Indigo drives every action; the amber-to-coral
glow is the brand and appears only in the logo and hero moments. */
:root {
--background: #fafaf7;
--foreground: #111827;
--card: #ffffff;
--card-foreground: #111827;
--popover: #ffffff;
--popover-foreground: #111827;
--muted: #f2f2ee;
--muted-foreground: #6b7280;
--border: #e7e5df;
--input: #dedcd5;
--ring: #6366f1;
--primary: #4f46e5;
--primary-foreground: #ffffff;
--accent: #eef0ff;
--accent-foreground: #3730a3;
--good: #16a34a;
--warn: #d97706;
--bad: #dc2626;
--glow-1: #f59e0b;
--glow-2: #f97362;
--night: #0b1020;
--shadow-color: 220 20% 20%;
}
.dark {
--background: #0b1020;
--foreground: #e5e7eb;
--card: #141b2e;
--card-foreground: #e5e7eb;
--popover: #182036;
--popover-foreground: #e5e7eb;
--muted: #1b2338;
--muted-foreground: #9ca3af;
--border: #26304a;
--input: #2d3854;
--ring: #818cf8;
--primary: #818cf8;
--primary-foreground: #0b1020;
--accent: #1e2547;
--accent-foreground: #c7d2fe;
--good: #22c55e;
--warn: #f59e0b;
--bad: #f87171;
--shadow-color: 225 50% 2%;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-good: var(--good);
--color-warn: var(--warn);
--color-bad: var(--bad);
--color-glow-1: var(--glow-1);
--color-glow-2: var(--glow-2);
--color-night: var(--night);
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--shadow-soft: 0 1px 2px hsl(var(--shadow-color) / 0.06), 0 8px 24px -12px hsl(var(--shadow-color) / 0.18);
--shadow-lift: 0 2px 4px hsl(var(--shadow-color) / 0.08), 0 18px 40px -16px hsl(var(--shadow-color) / 0.32);
--animate-fade-up: fade-up 0.24s cubic-bezier(0.2, 0.8, 0.2, 1);
--animate-sheet-up: sheet-up 0.26s cubic-bezier(0.2, 0.8, 0.2, 1);
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheet-up {
from {
opacity: 0.6;
transform: translateY(24px);
}
to {
opacity: 1;
transform: none;
}
}
}
@layer base {
* {
border-color: var(--border);
}
html {
-webkit-text-size-adjust: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-feature-settings: 'cv11', 'ss01';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
::selection {
background: color-mix(in oklab, var(--primary) 25%, transparent);
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Grid items default to their content's width, so one long, truncated line
could push a whole column past the edge of a phone screen. */
.grid > * {
min-width: 0;
}
}
@utility tabular {
font-variant-numeric: tabular-nums;
}
/* The brand glow: a soft pool of light behind hero moments. */
@utility spotlight {
background:
radial-gradient(60% 80% at 50% 0%, color-mix(in oklab, var(--glow-1) 26%, transparent), transparent 70%),
radial-gradient(50% 60% at 85% 10%, color-mix(in oklab, var(--glow-2) 18%, transparent), transparent 70%);
}
@utility pb-safe {
padding-bottom: max(env(safe-area-inset-bottom), 0.5rem);
}
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}
@utility select-chevron {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px;
}

9
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

12
web/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

20
web/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
port: 5173,
host: true,
proxy: { '/api': 'http://127.0.0.1:3000' },
},
build: {
target: 'es2022',
chunkSizeWarningLimit: 300,
},
});