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

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>
);
}