Milestone 3: launch (20261008.05)

Backup and restore: restore from Settings (any backup in the folder, or an
uploaded file) or from the command line with scripts/restore.sh. Each restore
checks the file first, saves a copy of the current data so it can be undone,
and keeps the person restoring signed in. Backups are now single
self-contained files. Drilled end to end: last night's backup brings
everything back exactly, including onto an empty new machine.

Speed: a phone's first load is 147 KB compressed (target 150). The ideas box,
account menu and sign-in screens load on demand, a small built-in toast
replaces sonner, the Inter font is trimmed to the characters used, and files
and API answers are served compressed. Every screen's data comes back in
under 100 ms with 50,000 transactions (server/bench).

Accessibility: no WCAG 2.2 AA issues on any screen in either theme. Darker
status text colors, 44 px touch targets on phones, reduced motion honoured,
and focus returns after dialogs close.

Also: an in-app guide ("How Spendlight works"), OFX/QFX/QBO import, and tags
on individual parts of a split purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:23:45 -04:00
parent 4ecd64132b
commit a68c343647
82 changed files with 3822 additions and 265 deletions

View File

@@ -4,12 +4,11 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"build": "tsc -p tsconfig.json && vite build && node scripts/compress.mjs",
"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",
@@ -22,7 +21,6 @@
"react-is": "^19.3.0",
"react-router": "^8.4.0",
"recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0"
},
"devDependencies": {

25
web/scripts/compress.mjs Normal file
View File

@@ -0,0 +1,25 @@
// After the build: write brotli and gzip copies of every text file, so the
// server can send them compressed without compressing on each request.
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { extname, join } from 'node:path';
import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
const root = new URL('../dist/', import.meta.url).pathname;
const TEXT = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.webmanifest', '.txt']);
let count = 0;
function walk(dir) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (TEXT.has(extname(name)) && statSync(path).size > 1024) {
const data = readFileSync(path);
writeFileSync(`${path}.br`, brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }));
writeFileSync(`${path}.gz`, gzipSync(data, { level: 9 }));
count++;
}
}
}
walk(root);
console.log(`Compressed ${count} files`);

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@@ -0,0 +1,29 @@
import { lazy, Suspense, useState } from 'react';
import { useCurrentUser } from '@/lib/session';
import { Avatar } from './Avatar';
const UserMenu = lazy(() => import('./UserMenu').then((m) => ({ default: m.UserMenu })));
/** The avatar at the top right. Its menu loads on first use, keeping the first visit light. */
export function AccountButton() {
const me = useCurrentUser();
const [used, setUsed] = useState(false);
const button = (
<button
type="button"
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Your account"
aria-haspopup="menu"
onPointerEnter={() => void import('./UserMenu')}
onClick={() => setUsed(true)}
>
<Avatar name={me.displayName} />
</button>
);
if (!used) return button;
return (
<Suspense fallback={button}>
<UserMenu defaultOpen />
</Suspense>
);
}

View File

@@ -6,9 +6,9 @@ import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
const LOOK = {
bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad' },
warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn' },
good: { icon: PartyPopper, tone: 'bg-good/12 text-good' },
bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad-fg' },
warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn-fg' },
good: { icon: PartyPopper, tone: 'bg-good/12 text-good-fg' },
info: { icon: Lightbulb, tone: 'bg-accent text-accent-foreground' },
} as const;

View File

@@ -1,24 +1,25 @@
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 { toast } from '@/components/toast';
import { useAlertCounts, 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 { IdeaButton } from './IdeaButton';
import { HELP_NAV, NAV, SETTINGS_NAV, type NavItem } from './nav';
import { Button } from './ui/button';
import { Skeleton, Tip } from './ui/misc';
import { UserMenu } from './UserMenu';
import { Skeleton } from './ui/misc';
import { AccountButton } from './AccountButton';
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';
if (pathname.startsWith('/help')) return 'Help';
return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight';
}
@@ -75,6 +76,7 @@ function Sidebar() {
))}
</nav>
<div className="px-3 pb-4">
<SideLink item={{ ...HELP_NAV, label: 'Help' }} />
<SideLink item={SETTINGS_NAV} />
<div className="mt-3 px-3 md:text-center lg:text-left">
<VersionTag />
@@ -87,7 +89,7 @@ function Sidebar() {
function PhoneTabBar() {
const { pathname } = useLocation();
const badges = useBadges();
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to));
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || pathname.startsWith('/help') || 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">
@@ -205,16 +207,14 @@ export function AppShell() {
</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>
<Button variant="ghost" size="icon" asChild aria-label="Import statements" title="Import statements">
<Link to="/import">
<Upload />
</Link>
</Button>
) : null}
{config?.ideasEnabled ? <IdeaButton /> : null}
<UserMenu />
<AccountButton />
</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">

View File

@@ -26,7 +26,7 @@ export function AuthLayout({ title, subtitle, children }: { title: string; subti
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">
<p role="alert" className="rounded-xl bg-bad/10 px-3.5 py-2.5 text-[13.5px] leading-snug text-bad-fg">
{message}
</p>
);

View File

@@ -0,0 +1,14 @@
import { initials } from '@/lib/utils';
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>
);
}

View File

@@ -1,7 +1,7 @@
import { monthName, type BudgetScope } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { api, errorMessage } from '@/lib/api';
import { centsToInput, parseDollars } from '@/lib/money';
import { CategoryIcon } from './money';

View File

@@ -0,0 +1,34 @@
import { Lightbulb } from 'lucide-react';
import { lazy, Suspense, useState } from 'react';
import { Button } from './ui/button';
const IdeaDialog = lazy(() => import('./IdeaDialog'));
/** The lightbulb at the top right. Its pop-up loads on first use, keeping the first visit light. */
export function IdeaButton() {
const [open, setOpen] = useState(false);
const [used, setUsed] = useState(false);
return (
<>
<Button
variant="ghost"
size="icon"
aria-label="Share an idea"
title="Share an idea"
className="text-muted-foreground hover:text-warn-fg"
onPointerEnter={() => void import('./IdeaDialog')}
onClick={() => {
setUsed(true);
setOpen(true);
}}
>
<Lightbulb />
</Button>
{used ? (
<Suspense fallback={null}>
<IdeaDialog open={open} setOpen={setOpen} />
</Suspense>
) : null}
</>
);
}

View File

@@ -1,18 +1,15 @@
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 { toast } from '@/components/toast';
import { api, errorMessage } from '@/lib/api';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog';
import { Dialog, DialogContent } 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);
/** The idea pop-up, loaded the first time someone taps the lightbulb. */
export default function IdeaDialog({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const location = useLocation();
@@ -36,13 +33,6 @@ export function IdeaButton() {
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."

View File

@@ -3,7 +3,7 @@ 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 { toast } from '@/components/toast';
import { api, errorMessage } from '@/lib/api';
import { useConfig, useMe } from '@/lib/session';
import { applyTheme, storedTheme } from '@/lib/theme';

View File

@@ -9,7 +9,7 @@ export function RouteError() {
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">
<span className="mx-auto mb-5 flex size-14 items-center justify-center rounded-2xl bg-warn/12 text-warn-fg">
<TriangleAlert className="size-7" strokeWidth={1.8} />
</span>
<h2 className="text-xl font-semibold tracking-tight">Something went wrong</h2>

View File

@@ -1,9 +1,9 @@
import { formatCents, type Transaction } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftRight, ArrowRight, Plus, Repeat, Split as SplitIcon, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { api, errorMessage } from '@/lib/api';
import { MONEY_KEYS, useAccounts, useCategories, useSubscriptions, useTags } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
@@ -26,9 +26,10 @@ export function useRefreshMoney() {
return () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
}
function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: string[]) => void }) {
function TagEditor({ value, onChange, placeholder = 'Add a tag, like Vacation 2026', label = 'Add a tag' }: { value: string[]; onChange: (tags: string[]) => void; placeholder?: string; label?: string }) {
const tags = useTags();
const [draft, setDraft] = useState('');
const listId = useId();
const add = (name: string) => {
const clean = name.trim();
if (clean && !value.some((t) => t.toLowerCase() === clean.toLowerCase())) onChange([...value, clean]);
@@ -51,7 +52,7 @@ function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: stri
) : null}
<Input
value={draft}
list="tag-suggestions"
list={listId}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
@@ -60,10 +61,10 @@ function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: stri
}
}}
onBlur={() => draft && add(draft)}
placeholder="Add a tag, like Vacation 2026"
aria-label="Add a tag"
placeholder={placeholder}
aria-label={label}
/>
<datalist id="tag-suggestions">
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
@@ -77,6 +78,7 @@ interface Part {
categoryId: number | null;
amount: string;
note: string;
tags: string[];
}
function SplitEditor({ t, onDone, onCancel }: { t: Transaction; onDone: () => void; onCancel: () => void }) {
@@ -84,10 +86,10 @@ function SplitEditor({ t, onDone, onCancel }: { t: Transaction; onDone: () => vo
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 ?? '' }))
? t.splits.map((s) => ({ key: crypto.randomUUID(), categoryId: s.categoryId, amount: (Math.abs(s.amountCents) / 100).toFixed(2), note: s.note ?? '', tags: s.tags }))
: [
{ key: crypto.randomUUID(), categoryId: t.categoryId, amount: (total / 100).toFixed(2), note: '' },
{ key: crypto.randomUUID(), categoryId: null, amount: '', note: '' },
{ key: crypto.randomUUID(), categoryId: t.categoryId, amount: (total / 100).toFixed(2), note: '', tags: [] },
{ key: crypto.randomUUID(), categoryId: null, amount: '', note: '', tags: [] },
],
);
const assigned = parts.reduce((s, p) => s + (parseMoney(p.amount) ?? 0), 0);
@@ -98,7 +100,7 @@ function SplitEditor({ t, onDone, onCancel }: { t: Transaction; onDone: () => vo
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 })) },
body: { parts: parts.map((p) => ({ categoryId: p.categoryId, amountCents: sign * (parseMoney(p.amount) ?? 0), note: p.note || null, tags: p.tags })) },
}),
onSuccess: () => {
toast.success('Split saved');
@@ -139,13 +141,14 @@ function SplitEditor({ t, onDone, onCancel }: { t: Transaction; onDone: () => vo
) : 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}`} />
<TagEditor value={p.tags} onChange={(tags) => update(p.key, { tags })} placeholder="Tags for this part (optional)" label={`Tags 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: '' }])}>
<Button variant="ghost" size="sm" onClick={() => setParts((list) => [...list, { key: crypto.randomUUID(), categoryId: null, amount: left > 0 ? (left / 100).toFixed(2) : '', note: '', tags: [] }])}>
<Plus /> Add a part
</Button>
<p className={cn('tabular text-[13.5px] font-medium', left === 0 ? 'text-good' : 'text-warn')}>
<p className={cn('tabular text-[13.5px] font-medium', left === 0 ? 'text-good-fg' : 'text-warn-fg')}>
{left === 0 ? 'All assigned' : left > 0 ? `${formatCents(left)} left to assign` : `${formatCents(-left)} too much`}
</p>
</div>
@@ -276,6 +279,7 @@ export function TransactionSheet({
<span className="flex-1 truncate">
{byId.get(s.categoryId)?.name}
{s.note ? <span className="text-muted-foreground"> · {s.note}</span> : null}
{s.tags.length ? <span className="text-muted-foreground"> · {s.tags.join(', ')}</span> : null}
</span>
<span className="tabular">{formatCents(s.amountCents)}</span>
</div>
@@ -347,7 +351,7 @@ export function TransactionSheet({
)}
</Field>
{t.source === 'manual' ? (
<Button variant="ghost" className="justify-self-start text-bad hover:text-bad" onClick={() => setConfirmDelete(true)}>
<Button variant="ghost" className="justify-self-start text-bad-fg hover:text-bad-fg" onClick={() => setConfirmDelete(true)}>
<Trash2 /> Delete this entry
</Button>
) : null}

View File

@@ -1,9 +1,9 @@
import type { Theme } from '@spendlight/shared';
import { LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react';
import { CircleHelp, 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 { Avatar } from './Avatar';
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,33 +13,20 @@ import {
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() {
export function UserMenu({ defaultOpen }: { defaultOpen?: boolean }) {
const me = useCurrentUser();
const navigate = useNavigate();
const signOut = useSignOut();
const setTheme = useSetTheme();
return (
<DropdownMenu>
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Your account">
<Avatar name={me.displayName} />
</DropdownMenuTrigger>
@@ -61,6 +48,10 @@ export function UserMenu() {
<Settings />
Settings
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/help')}>
<CircleHelp />
How Spendlight works
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => signOut.mutate()}>
<LogOut />
Sign out

View File

@@ -40,7 +40,7 @@ export function Wordmark({ className }: { className?: string }) {
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)}
className={cn('tabular text-[11px] font-medium tracking-wide text-muted-foreground', className)}
title="Spendlight version: build date, then that day's build number"
>
v{version}

View File

@@ -8,9 +8,9 @@ import { cn } from '@/lib/utils';
export const STATUS_TONE: Record<BudgetStatus, { bar: string; text: string; soft: string }> = {
none: { bar: 'bg-primary/70', text: 'text-muted-foreground', soft: 'bg-muted' },
under: { bar: 'bg-good', text: 'text-good', soft: 'bg-good/12' },
close: { bar: 'bg-warn', text: 'text-warn', soft: 'bg-warn/14' },
over: { bar: 'bg-bad', text: 'text-bad', soft: 'bg-bad/12' },
under: { bar: 'bg-good', text: 'text-good-fg', soft: 'bg-good/12' },
close: { bar: 'bg-warn', text: 'text-warn-fg', soft: 'bg-warn/14' },
over: { bar: 'bg-bad', text: 'text-bad-fg', soft: 'bg-bad/12' },
};
export function ProgressBar({ spent, budget, className, pace, scale }: { spent: number; budget: number; className?: string; pace?: number; scale?: number }) {
@@ -34,7 +34,7 @@ export function BudgetNote({ spent, budget, className, income }: { spent: number
if (income) {
const short = budget - spent;
return (
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', short <= 0 ? 'text-good' : 'text-muted-foreground', className)}>
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', short <= 0 ? 'text-good-fg' : 'text-muted-foreground', className)}>
{short <= 0 ? <CircleCheck className="size-3.5" /> : null}
{short <= 0 ? `${whole(-short)} above plan` : `${whole(short)} still to come`}
</span>

View File

@@ -199,7 +199,7 @@ export function CategoryIcon({
/** 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)}>
<span className={cn('tabular whitespace-nowrap font-medium', cents > 0 && !transfer && 'text-good-fg', transfer && 'text-muted-foreground', className)}>
{formatCents(cents, { sign: cents > 0 })}
</span>
);

View File

@@ -1,4 +1,4 @@
import { ChartColumnBig, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react';
import { ChartColumnBig, CircleHelp, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react';
export interface NavItem {
to: string;
@@ -19,3 +19,4 @@ export const NAV: NavItem[] = [
];
export const SETTINGS_NAV: NavItem = { to: '/settings', label: 'Settings', icon: Settings };
export const HELP_NAV: NavItem = { to: '/help', label: 'How Spendlight works', icon: CircleHelp };

View File

@@ -0,0 +1,115 @@
import { CircleAlert, CircleCheck, Info, X } from 'lucide-react';
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
// Small notices at the top of the screen: "Saved", "Filed 6 more", "Undo".
// A few lines of our own instead of a library, to keep a phone's first load light.
type Kind = 'default' | 'success' | 'error' | 'info';
interface Options {
id?: string | number;
description?: ReactNode;
/** Milliseconds on screen; Infinity keeps it until dismissed. */
duration?: number;
action?: { label: string; onClick: () => void };
}
interface Item extends Options {
id: string | number;
kind: Kind;
message: ReactNode;
duration: number;
}
let items: Item[] = [];
let seq = 0;
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
function show(kind: Kind, message: ReactNode, options: Options = {}) {
const id = options.id ?? ++seq;
const duration = options.duration ?? (kind === 'error' ? 7000 : options.action ? 8000 : 4000);
items = [{ ...options, id, kind, message, duration }, ...items.filter((t) => t.id !== id)].slice(0, 4);
emit();
return id;
}
function dismiss(id: string | number) {
items = items.filter((t) => t.id !== id);
emit();
}
export const toast = Object.assign((message: ReactNode, options?: Options) => show('default', message, options), {
success: (message: ReactNode, options?: Options) => show('success', message, options),
error: (message: ReactNode, options?: Options) => show('error', message, options),
info: (message: ReactNode, options?: Options) => show('info', message, options),
dismiss,
});
const ICON = {
success: <CircleCheck className="text-good-fg" />,
error: <CircleAlert className="text-bad-fg" />,
info: <Info className="text-primary" />,
default: null,
};
function Toast({ t }: { t: Item }) {
const [paused, setPaused] = useState(false);
const left = useRef(t.duration);
useEffect(() => {
if (paused || !Number.isFinite(left.current)) return;
const started = Date.now();
const timer = setTimeout(() => dismiss(t.id), left.current);
return () => {
clearTimeout(timer);
left.current -= Date.now() - started;
};
}, [paused, t.id, t]);
return (
<div
role={t.kind === 'error' ? 'alert' : 'status'}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
className="pointer-events-auto flex w-full max-w-md animate-fade-up items-start gap-3 rounded-2xl border bg-popover px-4 py-3 text-popover-foreground shadow-lift"
>
{ICON[t.kind] ? <span className="mt-0.5 shrink-0 [&_svg]:size-[18px]">{ICON[t.kind]}</span> : null}
<div className="min-w-0 flex-1">
<p className="text-[14px] font-medium leading-snug">{t.message}</p>
{t.description ? <p className="mt-0.5 text-[13px] leading-snug text-muted-foreground">{t.description}</p> : null}
</div>
{t.action ? (
<button
onClick={() => {
t.action!.onClick();
dismiss(t.id);
}}
className="h-8 shrink-0 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground"
>
{t.action.label}
</button>
) : null}
<button onClick={() => dismiss(t.id)} className={cn('-mr-1.5 flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted', t.action && '-ml-1')} aria-label="Dismiss">
<X className="size-4" />
</button>
</div>
);
}
export function Toaster() {
const list = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => items,
);
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-3 pt-[max(env(safe-area-inset-top),0.75rem)]" aria-live="polite">
{list.map((t) => (
<Toast key={t.id} t={t} />
))}
</div>
);
}

View File

@@ -18,9 +18,10 @@ export const buttonVariants = cva(
},
size: {
default: 'h-11 px-4 sm:h-10',
sm: 'h-9 px-3 text-[13px]',
// 44 px on touch screens (sm:), a little tighter where a mouse does the pointing.
sm: 'h-11 px-3.5 text-[13px] sm:h-9 sm:px-3',
lg: 'h-12 px-6 text-base',
icon: 'size-10',
icon: 'size-11 sm:size-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },

View File

@@ -1,6 +1,6 @@
import { X } from 'lucide-react';
import { AlertDialog as AlertPrimitive, Dialog as DialogPrimitive } from 'radix-ui';
import type { ComponentProps, ReactNode } from 'react';
import { useState, type ComponentProps, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Button } from './button';
@@ -22,12 +22,26 @@ export function DialogContent({
children,
title,
description,
onCloseAutoFocus,
...props
}: ComponentProps<typeof DialogPrimitive.Content> & { title: string; description?: ReactNode }) {
// Dialogs opened from a plain button (not a Dialog trigger) hand focus back
// to that button when they close, so keyboard users keep their place.
const [returnTo] = useState(() => (typeof document === 'undefined' ? null : document.activeElement));
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className={overlayClass} />
<DialogPrimitive.Content className={cn(contentClass, className)} {...props}>
<DialogPrimitive.Content
className={cn(contentClass, className)}
onCloseAutoFocus={(e) => {
onCloseAutoFocus?.(e);
if (!e.defaultPrevented && returnTo instanceof HTMLElement && returnTo.isConnected && returnTo !== document.body) {
e.preventDefault();
returnTo.focus();
}
}}
{...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">

View File

@@ -25,7 +25,7 @@ export function DropdownMenuItem({ className, destructive, ...props }: Component
<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',
destructive && 'text-bad-fg [&_svg]:text-bad-fg',
className,
)}
{...props}

View File

@@ -1,5 +1,5 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Switch as SwitchPrimitive, Tooltip as TooltipPrimitive } from 'radix-ui';
import { Switch as SwitchPrimitive } from 'radix-ui';
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/utils';
@@ -8,9 +8,9 @@ const badgeVariants = cva('inline-flex items-center gap-1 rounded-full px-2 py-0
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',
good: 'bg-good/12 text-good-fg',
warn: 'bg-warn/14 text-warn-fg',
bad: 'bg-bad/12 text-bad-fg',
},
},
defaultVariants: { tone: 'neutral' },
@@ -34,23 +34,6 @@ export function Switch({ className, ...props }: ComponentProps<typeof SwitchPrim
);
}
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} />;
}

View File

@@ -1,6 +1,6 @@
import type { Me, Theme } from '@spendlight/shared';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { api, errorMessage } from './api';
import { applyTheme } from './theme';

View File

@@ -1,12 +1,10 @@
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 { Toaster } from './components/toast';
import { queryClient } from './lib/session';
import { watchSystemTheme } from './lib/theme';
import { router } from './router';
@@ -16,12 +14,10 @@ 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>
<Suspense fallback={<Splash />}>
<RouterProvider router={router} />
</Suspense>
<Toaster />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -3,7 +3,7 @@ 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 { toast } from '@/components/toast';
import { AccountDot, PALETTE_CLASS } from '@/components/money';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
@@ -427,7 +427,7 @@ export default function AccountsPage() {
</Button>
)}
{selected.transactionCount === 0 ? (
<Button variant="ghost" className="text-bad hover:text-bad" onClick={() => setConfirm('delete')}>
<Button variant="ghost" className="text-bad-fg hover:text-bad-fg" onClick={() => setConfirm('delete')}>
<Trash2 /> Delete
</Button>
) : null}

View File

@@ -3,7 +3,7 @@ import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
import { ArrowLeftRight, Download, 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 { toast } from '@/components/toast';
import { CategoryButton, CategoryPicker } from '@/components/CategoryPicker';
import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money';
import { EmptyState, PageIntro } from '@/components/page';
@@ -234,7 +234,7 @@ export default function ActivityPage() {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Button variant="outline" aria-label="Export">
<Download /> <span className="hidden sm:inline">Export</span>
</Button>
</DropdownMenuTrigger>
@@ -281,7 +281,7 @@ export default function ActivityPage() {
type="search"
/>
</form>
<Button variant={filterCount ? 'default' : 'outline'} onClick={() => setShowFilters((v) => !v)} aria-expanded={showFilters}>
<Button variant={filterCount ? 'default' : 'outline'} onClick={() => setShowFilters((v) => !v)} aria-expanded={showFilters} aria-label="Filters">
<SlidersHorizontal /> <span className="hidden sm:inline">Filters</span>
{filterCount ? <span className="tabular">{filterCount}</span> : null}
</Button>
@@ -359,7 +359,7 @@ export default function ActivityPage() {
{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{' '}
In <span className="font-medium text-good-fg">{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>

View File

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CalendarRange, ChevronLeft, ChevronRight, Copy, Inbox, LayoutList, Sparkles, Table2, Wand2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { BudgetEditor, type EditTarget } from '@/components/BudgetEditor';
import { BudgetNote, ProgressBar, STATUS_TONE } from '@/components/meter';
import { CategoryIcon } from '@/components/money';
@@ -227,14 +227,14 @@ function YearGrid({ year, sections, canEdit, onEdit, onMonth, through }: {
{r.budget[i] ? whole(r.budget[i]!) : '–'}
{own ? <span className="ml-0.5 align-super text-[9px] text-primary">●</span> : null}
</span>
{past && r.actual[i] ? <span className={cn('tabular text-[11px]', status === 'over' ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(r.actual[i]!)}</span> : null}
{past && r.actual[i] ? <span className={cn('tabular text-[11px]', status === 'over' ? 'font-medium text-bad-fg' : 'text-muted-foreground')}>{whole(r.actual[i]!)}</span> : null}
</button>
</td>
);
})}
<td className={cn(cell, 'border-l')}>
<div className="font-semibold">{yearBudget ? whole(yearBudget) : '–'}</div>
{yearActual ? <div className={cn('text-[11px]', !income && yearBudget && yearActual > yearBudget ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(yearActual)}</div> : null}
{yearActual ? <div className={cn('text-[11px]', !income && yearBudget && yearActual > yearBudget ? 'font-medium text-bad-fg' : 'text-muted-foreground')}>{whole(yearActual)}</div> : null}
</td>
</tr>
);

174
web/src/pages/HelpPage.tsx Normal file
View File

@@ -0,0 +1,174 @@
import { ArrowRight, Download, Inbox, Upload } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { PageIntro } from '@/components/page';
import { Card } from '@/components/ui/card';
// A short guide inside the app: the monthly routine first, then a few
// sections to open when they're needed. Plain words, no accounting terms.
function Step({ n, icon, title, children }: { n: number; icon: ReactNode; title: string; children: ReactNode }) {
return (
<li className="flex gap-4">
<span className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-accent text-accent-foreground [&_svg]:size-5">{icon}</span>
<div>
<p className="text-[15.5px] font-semibold">
<span className="mr-1.5 text-muted-foreground">{n}.</span>
{title}
</p>
<div className="mt-1 text-[14px] leading-relaxed text-muted-foreground">{children}</div>
</div>
</li>
);
}
function Section({ title, children, open }: { title: string; children: ReactNode; open?: boolean }) {
return (
<details open={open} className="group rounded-2xl border bg-card shadow-soft [&_summary::-webkit-details-marker]:hidden">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-2xl px-5 py-4 text-[15.5px] font-semibold">
{title}
<ArrowRight className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" aria-hidden />
</summary>
<div className="grid gap-3 px-5 pb-5 text-[14.5px] leading-relaxed [&_li]:ml-5 [&_li]:list-disc [&_strong]:font-semibold">{children}</div>
</details>
);
}
const Key = ({ children }: { children: ReactNode }) => <kbd className="rounded border bg-muted px-1.5 font-mono text-[12.5px] text-foreground">{children}</kbd>;
export default function HelpPage() {
return (
<div className="mx-auto max-w-3xl">
<PageIntro title="How Spendlight works" description="A few minutes a month: bring in your statements, confirm what Spendlight filled in, and see where you stand." />
<Card className="mb-6 p-5 sm:p-6">
<h2 className="mb-4 text-[16px] font-semibold">The monthly routine</h2>
<ol className="grid gap-5">
<Step n={1} icon={<Download />} title="Download your statements">
On each bank’s website, open the account’s activity and download it as a <strong className="font-medium text-foreground">CSV</strong> file (sometimes
called “spreadsheet” or “Excel”). Include everything since your last import; overlapping dates are fine.
</Step>
<Step n={2} icon={<Upload />} title="Drop them into Spendlight">
Drag all the files onto any screen, or tap{' '}
<Link to="/import" className="font-medium text-primary">
Import
</Link>{' '}
on a phone. Spendlight works out which account each file is for, skips anything already there, and matches card payments with your checking account.
</Step>
<Step n={3} icon={<Inbox />} title="Confirm what came in">
In{' '}
<Link to="/review" className="font-medium text-primary">
Review
</Link>
, most transactions already have a category. One tap confirms it; “Accept all” clears the lot. Then glance at Home to see where the month stands.
</Step>
</ol>
</Card>
<div className="grid gap-3">
<Section title="Getting statements from your bank">
<p>Banks move their buttons around from time to time, but the idea is always the same: find the account’s activity, then Download or Export, then CSV.</p>
<ul className="grid gap-2">
<li>
<strong>Bank of America:</strong> open the checking or savings account, choose <em>Download</em> above the list of transactions, and pick the Microsoft
Excel format. The file is a CSV.
</li>
<li>
<strong>Chase, including the Amazon Prime Visa:</strong> open the card’s activity, choose the download icon, then CSV and the dates you want.
</li>
<li>
<strong>Discover:</strong> open Activity, choose Download, and pick the CSV or spreadsheet option.
</li>
<li>
<strong>Apple Card:</strong> in the Wallet app, open the card’s statements, choose a month, then Export Transactions as CSV.
</li>
<li>
<strong>Any other bank:</strong> Spendlight reads most CSV files. The first time it sees a new layout, it shows a few rows and asks which column is
which, then remembers. Quicken and Money files (OFX or QFX) work too.
</li>
</ul>
</Section>
<Section title="Filing transactions quickly">
<ul className="grid gap-2">
<li>The big button is Spendlight’s best guess. The smaller ones are the next most likely; “Something else” shows every category.</li>
<li>
After you file a purchase, Spendlight may offer to <strong>file the others from that shop</strong> the same way. Say yes and it remembers for future
imports too. Learned rules are listed in Settings → Rules.
</li>
<li>
A <strong>split</strong> divides one purchase between categories, such as groceries and household supplies from the same store. Tap a transaction in
Activity, then Split.
</li>
<li>
<strong>Tags</strong> (“Vacation 2026”, “Reimbursable”) and notes go on any transaction. Insights totals each tag.
</li>
<li>
Cash spending: in Activity, tap <em>Cash entry</em>.
</li>
<li>
On a computer, Review works from the keyboard: <Key>↑</Key> <Key>↓</Key> to move, <Key>Enter</Key> to accept the guess, <Key>1</Key>–<Key>3</Key> for a
suggestion, <Key>/</Key> for every category and <Key>S</Key> to split.
</li>
</ul>
</Section>
<Section title="Card payments and transfers">
<p>
Paying a card from checking, or moving money to savings, isn’t spending or income: the money just moves between your own accounts. Spendlight pairs the two
halves and shows them in <em>italics</em>, naming the other account, and leaves them out of every total.
</p>
<p>When only one half is in Spendlight, such as a payment to a card you don’t import, Review asks once whether it’s a transfer or spending.</p>
</Section>
<Section title="Budgets">
<ul className="grid gap-2">
<li>Each category gets one amount for every month. Any single month can be different, such as Gifts in December.</li>
<li>
<strong>Fill in</strong> on the Budget screen starts every category from your recent spending, rounded to friendly amounts. Adjust from there.
</li>
<li>
There’s no rollover: each month starts fresh. The year view keeps the running score, so a quiet month makes up for a busy one.
</li>
<li>
Green means on track, amber means close, and red means over. Each always comes with a word or an icon too.
</li>
<li>In December, the Budget screen offers to carry this year’s budgets into the next.</li>
</ul>
</Section>
<Section title="Subscriptions and bills">
<ul className="grid gap-2">
<li>
Spendlight spots charges that repeat on a steady rhythm, on any card or account, and asks once: “Is this a subscription?” Your answer is remembered.
</li>
<li>Bills that vary, like electric or insurance, have their own tab.</li>
<li>
It speaks up when a price changes, a charge doesn’t arrive, something is billed on two cards, a free trial is about to end, or a service you marked
cancelled charges again.
</li>
<li>Spendlight never cancels anything itself. Each subscription has a link to the place where you can manage or cancel it.</li>
</ul>
</Section>
<Section title="On your phone">
<ul className="grid gap-2">
<li>
Add Spendlight to your home screen and it opens like an app. On iPhone: in Safari, tap Share, then “Add to Home Screen”. Sign in first, then add it.
</li>
<li>Turn on Face ID or fingerprint sign-in in Settings → Sign-in &amp; devices, so a lapsed sign-in costs one glance, not a password.</li>
<li>Each device stays signed in for about a year, renewed every time you open the app.</li>
</ul>
</Section>
<Section title="Your data">
<ul className="grid gap-2">
<li>Everything stays on your own server. Spendlight makes no outside calls and never stores bank sign-ins.</li>
<li>A backup is made every night, and before every upgrade. An admin can restore any of them from Settings → Backups.</li>
<li>Export everything to Excel or CSV any time from Settings → Export.</li>
</ul>
</Section>
</div>
</div>
);
}

View File

@@ -24,7 +24,7 @@ function Step({ done, icon, title, detail, action }: { done?: boolean; icon: Rea
<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' : 'bg-accent text-accent-foreground',
done ? 'bg-good/12 text-good-fg' : 'bg-accent text-accent-foreground',
)}
>
{done ? <Check strokeWidth={2.5} /> : icon}
@@ -56,7 +56,12 @@ function GettingStarted({ hasAccounts, hasMoney, toReview, hasBudgets, onHide }:
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="grid gap-1">
<CardTitle>Getting started</CardTitle>
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
<CardDescription>
A few steps now, and Spendlight does the bookkeeping from then on.{' '}
<Link to="/help" className="font-medium text-primary">
How it works
</Link>
</CardDescription>
</div>
{onHide ? (
<Button variant="ghost" size="sm" onClick={onHide}>
@@ -286,7 +291,7 @@ export default function HomePage() {
<section>
<div className="mb-2.5 flex items-center justify-between px-1">
<h2 className="flex items-center gap-2 text-[15px] font-semibold">
<Sparkles className="size-4 text-warn" /> Insights
<Sparkles className="size-4 text-warn-fg" /> Insights
</h2>
<Link to="/insights" className="text-[13px] font-medium text-primary">
See all

View File

@@ -3,7 +3,7 @@ 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 { toast } from '@/components/toast';
import { MappingDialog } from '@/components/MappingDialog';
import { formatDay } from '@/components/money';
import { PageIntro } from '@/components/page';
@@ -80,7 +80,7 @@ function FileCard({
<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' ? 'bg-good/12 text-good-fg' : item.status === 'error' ? 'bg-bad/12 text-bad-fg' : 'bg-accent text-accent-foreground',
)}
>
{item.status === 'done' ? <Check /> : item.status === 'error' ? <CircleAlert /> : item.status === 'reading' || item.status === 'importing' ? <Loader2 className="animate-spin" /> : <FileText />}
@@ -172,7 +172,7 @@ function FileCard({
<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>
<span className={cn('tabular', r.amountCents > 0 && 'text-good-fg')}>{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}
@@ -302,7 +302,7 @@ export default function ImportPage() {
<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."
description="Add your bank and card downloads (CSV, or Quicken and Money 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>
@@ -333,7 +333,7 @@ export default function ImportPage() {
<input
ref={inputRef}
type="file"
accept=".csv,.txt,text/csv"
accept=".csv,.txt,.ofx,.qfx,.qbo,text/csv"
multiple
hidden
onChange={(e) => {

View File

@@ -52,7 +52,7 @@ function Headline({ children, tone }: { children: ReactNode; tone: 'good' | 'war
const Icon = tone === 'good' ? TrendingDown : tone === 'warn' ? TrendingUp : Sparkles;
return (
<div className="mb-5 flex items-center gap-3 rounded-2xl border bg-card px-5 py-4 shadow-soft">
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', tone === 'good' ? 'bg-good/12 text-good' : tone === 'warn' ? 'bg-warn/14 text-warn' : 'bg-accent text-accent-foreground')}>
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', tone === 'good' ? 'bg-good/12 text-good-fg' : tone === 'warn' ? 'bg-warn/14 text-warn-fg' : 'bg-accent text-accent-foreground')}>
<Icon className="size-5" />
</span>
<p className="text-[16px] font-semibold tracking-tight sm:text-[17px]">{children}</p>
@@ -174,7 +174,7 @@ function Overview({ report, year }: { report: MonthsReport; year: number }) {
<section>
<h2 className="mb-2.5 flex items-center gap-2 px-1 text-[15px] font-semibold">
<Sparkles className="size-4 text-warn" /> What stands out
<Sparkles className="size-4 text-warn-fg" /> What stands out
</h2>
{alerts.data?.length ? (
<div className="grid gap-2.5 lg:grid-cols-2">
@@ -260,7 +260,7 @@ function BudgetVsActual({ report, year }: { report: MonthsReport; year: number }
<ProgressBar spent={spent} budget={budget} className="mt-1.5" />
<div className="mt-1 flex justify-between">
<BudgetNote spent={spent} budget={budget} />
{pct !== null && budgetStatus(spent, budget) === 'over' ? <span className="tabular text-[12px] font-medium text-bad">+{pct}%</span> : null}
{pct !== null && budgetStatus(spent, budget) === 'over' ? <span className="tabular text-[12px] font-medium text-bad-fg">+{pct}%</span> : null}
</div>
</div>
</Link>
@@ -370,7 +370,7 @@ function Categories({ report, year }: { report: MonthsReport; year: number }) {
{r.actual.map((v, i) => {
const over = r.budget[i]! > 0 && v > r.budget[i]!;
return (
<td key={i} className={cn('tabular px-2 py-1.5 text-right', over && 'bg-bad/8 font-medium text-bad', !v && 'text-muted-foreground/40')}>
<td key={i} className={cn('tabular px-2 py-1.5 text-right', over && 'bg-bad/8 font-medium text-bad-fg', !v && 'text-muted-foreground/40')}>
{v ? whole(v) : '–'}
</td>
);

View File

@@ -1,7 +1,7 @@
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 { HELP_NAV, NAV, SETTINGS_NAV } from '@/components/nav';
import { useAlertCounts } from '@/lib/data';
import { useConfig, useSignOut } from '@/lib/session';
@@ -10,7 +10,7 @@ export default function MorePage() {
const signOut = useSignOut();
const { data: config } = useConfig();
const { data: alerts } = useAlertCounts();
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV];
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV, HELP_NAV];
return (
<div className="grid gap-6">
@@ -30,7 +30,7 @@ export default function MorePage() {
</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"
className="flex h-14 items-center gap-3.5 rounded-2xl border bg-card px-4 text-[15px] font-medium text-bad-fg shadow-soft active:bg-muted"
>
<LogOut className="size-[18px]" /> Sign out
</button>

View File

@@ -3,7 +3,7 @@ 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 { toast } from '@/components/toast';
import { CategoryPicker } from '@/components/CategoryPicker';
import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money';
import { EmptyState } from '@/components/page';

View File

@@ -2,7 +2,7 @@ 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 { toast } from '@/components/toast';
import { AuthLayout, FormError } from '@/components/AuthLayout';
import { Splash } from '@/components/RequireAuth';
import { Button } from '@/components/ui/button';

View File

@@ -14,7 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bell, BellOff, CalendarClock, Check, ExternalLink, Merge, Plus, Repeat, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { AlertCard } from '@/components/AlertCard';
import { CategoryButton } from '@/components/CategoryPicker';
import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
@@ -108,12 +108,15 @@ function Row({ s, onOpen }: { s: Subscription; onOpen: () => void }) {
const category = s.categoryId ? byId.get(s.categoryId) : undefined;
const live = LIVE.includes(s.status);
return (
<button onClick={onOpen} className={cn('flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5', !live && 'opacity-70')}>
{category ? (
<CategoryIcon icon={category.icon} color={category.color} />
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-[14px] font-semibold text-muted-foreground">{s.name[0]}</span>
)}
<button onClick={onOpen} className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5">
{/* Paused and cancelled ones fade their icon only; their text stays readable. */}
<span className={cn('shrink-0', !live && 'opacity-50 grayscale')}>
{category ? (
<CategoryIcon icon={category.icon} color={category.color} />
) : (
<span className="flex size-9 items-center justify-center rounded-xl bg-muted text-[14px] font-semibold text-muted-foreground">{s.name[0]}</span>
)}
</span>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-[15px] font-medium">
<span className="truncate">{s.name}</span>
@@ -389,7 +392,7 @@ function DetailSheet({ id, onClose, all }: { id: number | null; onClose: () => v
) : null}
</div>
{s.charges.length ? (
<ul className="max-h-48 divide-y overflow-y-auto rounded-xl border">
<ul className="max-h-48 divide-y overflow-y-auto rounded-xl border" tabIndex={0} aria-label="Charges">
{s.charges.slice(0, 24).map((c) => {
const a = accounts.data?.find((x) => x.id === c.accountId);
return (
@@ -454,7 +457,7 @@ function DetailSheet({ id, onClose, all }: { id: number | null; onClose: () => v
<Merge /> Merge
</Button>
{s.source === 'manual' ? (
<Button variant="ghost" size="sm" className="text-bad" onClick={() => setConfirmDelete(true)}>
<Button variant="ghost" size="sm" className="text-bad-fg" onClick={() => setConfirmDelete(true)}>
<Trash2 /> Remove
</Button>
) : (

View File

@@ -29,14 +29,14 @@ export default function AboutPage() {
) : (
<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}
{version !== APP_VERSION ? <dd className="text-[12.5px] text-warn-fg">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" />
<ShieldCheck className="mt-0.5 size-[18px] shrink-0 text-good-fg" />
<span>Your data stays on your own server. Spendlight makes no calls to outside services.</span>
</p>
<p className="flex gap-3">

View File

@@ -1,7 +1,7 @@
import type { AiStatus } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { PageIntro } from '@/components/page';
import { Card } from '@/components/ui/card';
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
@@ -24,7 +24,7 @@ export default function AiPage() {
) : (
<>
<div className="mb-5 flex items-start gap-3 rounded-2xl border bg-card px-5 py-4 shadow-soft">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-good" />
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-good-fg" />
<p className="text-[14px] leading-relaxed">
{s.providerConfigured ? (
<>

View File

@@ -1,7 +1,7 @@
import type { AlertSettings } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter } from '@/components/ui/card';

View File

@@ -1,12 +1,14 @@
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 { Archive, Download, FolderSync, History, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { toast } from '@/components/toast';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ConfirmDialog } from '@/components/ui/dialog';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { api, ApiError, errorMessage } from '@/lib/api';
import { formatBytes, formatDateTime } from '@/lib/utils';
const KIND: Record<BackupKind, { label: string; tone: 'neutral' | 'primary' | 'warn' }> = {
@@ -16,14 +18,36 @@ const KIND: Record<BackupKind, { label: string; tone: 'neutral' | 'primary' | 'w
'pre-restore': { label: 'Before restore', tone: 'warn' },
};
interface BackupCheck {
counts: { transactions: number; accounts: number; people: number; budgets: number; subscriptions: number };
newestTransaction: string | null;
}
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' });
}
const plural = (n: number, word: string) => `${n.toLocaleString()} ${word}${n === 1 ? '' : 's'}`;
/** After a restore everything on screen is stale, and the session may belong to the old data. */
function afterRestore(safetyCopy: string | null) {
toast.success('Restored', { description: safetyCopy ? 'How things were before is saved as a “Before restore” backup.' : undefined });
setTimeout(() => window.location.assign('/'), 1200);
}
export default function BackupsPage() {
const client = useQueryClient();
const overview = useQuery({ queryKey: ['backups'], queryFn: () => api<BackupOverview>('/backups') });
const [restoring, setRestoring] = useState<BackupInfo | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const check = useQuery({
queryKey: ['backups', 'check', restoring?.name],
queryFn: () => api<BackupCheck>(`/backups/${encodeURIComponent(restoring!.name)}/check`),
enabled: !!restoring,
retry: false,
});
const backUp = useMutation({
mutationFn: () => api<BackupInfo>('/backups', { method: 'POST' }),
onSuccess: () => {
@@ -32,6 +56,34 @@ export default function BackupsPage() {
},
onError: (error) => toast.error(errorMessage(error)),
});
const restore = useMutation({
mutationFn: (name: string) => api<{ safetyCopy: string | null }>(`/backups/${encodeURIComponent(name)}/restore`, { method: 'POST' }),
onSuccess: (r) => {
setRestoring(null);
afterRestore(r.safetyCopy);
},
onError: (error) => toast.error(errorMessage(error)),
});
const upload = useMutation({
mutationFn: async (file: File) => {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/v1/backups/restore-upload', { method: 'POST', body: form, credentials: 'same-origin' });
const body = await res.json().catch(() => null);
if (!res.ok) throw new ApiError(res.status, body?.error?.code ?? 'error', body?.error?.message ?? 'That backup couldn’t be restored.');
return body as { safetyCopy: string | null };
},
onSuccess: (r) => {
setPendingFile(null);
afterRestore(r.safetyCopy);
},
onError: (error) => {
setPendingFile(null);
toast.error(errorMessage(error));
},
});
const counts = check.data?.counts;
return (
<div className="grid gap-5">
@@ -39,11 +91,27 @@ export default function BackupsPage() {
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>
<>
<Button variant="outline" onClick={() => fileInput.current?.click()} loading={upload.isPending}>
{upload.isPending ? null : <Upload />} Restore from a file
</Button>
<Button onClick={() => backUp.mutate()} loading={backUp.isPending}>
{backUp.isPending ? null : <Archive />} Back up now
</Button>
</>
}
/>
<input
ref={fileInput}
type="file"
accept=".db,.sqlite,application/vnd.sqlite3,application/octet-stream"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) setPendingFile(file);
}}
/>
{overview.data ? (
<Card>
@@ -52,8 +120,8 @@ export default function BackupsPage() {
<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.
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 and every restore, so a restore can be undone.
</CardDescription>
</CardHeader>
</Card>
@@ -67,12 +135,16 @@ export default function BackupsPage() {
) : 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">
<li key={b.name} className="flex items-center gap-2 px-4 py-3 sm:gap-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>
<p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
{formatBytes(b.size)} <Badge tone={KIND[b.kind].tone}>{KIND[b.kind].label}</Badge>
</p>
</div>
<Badge tone={KIND[b.kind].tone}>{KIND[b.kind].label}</Badge>
<Button variant="ghost" size="sm" onClick={() => setRestoring(b)} aria-label={`Restore the backup from ${formatDateTime(b.createdAt)}`}>
<History /> <span className="hidden sm:inline">Restore</span>
</Button>
<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 />
@@ -83,6 +155,39 @@ export default function BackupsPage() {
</ul>
</CardContent>
</Card>
<ConfirmDialog
open={!!restoring}
onOpenChange={(open) => !open && setRestoring(null)}
title={`Restore the backup from ${restoring ? formatDateTime(restoring.createdAt) : ''}?`}
description={
check.isError ? (
errorMessage(check.error)
) : counts ? (
<>
Everything in Spendlight goes back to how it was then: {plural(counts.transactions, 'transaction')}, {plural(counts.accounts, 'account')},{' '}
{plural(counts.budgets, 'budget')} and {plural(counts.subscriptions, 'subscription')}. A copy of how things are right now is saved first, so you can
undo this. You stay signed in; anyone else may need to sign in again, with the password they had then.
</>
) : (
'Checking the backup…'
)
}
confirmLabel="Restore"
destructive
loading={restore.isPending}
onConfirm={() => restoring && counts && restore.mutate(restoring.name)}
/>
<ConfirmDialog
open={!!pendingFile}
onOpenChange={(open) => !open && !upload.isPending && setPendingFile(null)}
title="Restore from this file?"
description={`Everything in Spendlight is replaced with “${pendingFile?.name}”, after Spendlight checks it’s a valid backup. A copy of how things are right now is saved first, so you can undo this.`}
confirmLabel="Restore"
destructive
loading={upload.isPending}
onConfirm={() => pendingFile && upload.mutate(pendingFile)}
/>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { PALETTE, type Category, type CategoryGroup, type CategoryKind, type Pal
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Archive, ArrowDown, ArrowUp, FolderPlus, Merge, Pencil, Plus, Trash2 } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { CategoryIcon, ICON_NAMES, PALETTE_CLASS } from '@/components/money';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
@@ -149,7 +149,7 @@ function CategoryForm({ category, group, groups, onDone }: { category?: Category
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
{category ? (
<Button type="button" variant="ghost" className="text-bad" onClick={() => setMerging((m) => !m)}>
<Button type="button" variant="ghost" className="text-bad-fg" onClick={() => setMerging((m) => !m)}>
<Trash2 /> Delete…
</Button>
) : (
@@ -251,7 +251,7 @@ export default function CategoriesPage() {
<Pencil />
</Button>
{!g.categories.length ? (
<Button variant="ghost" size="icon" className="size-8 text-bad" onClick={() => setDeleteGroup(g)} aria-label={`Remove ${g.name}`}>
<Button variant="ghost" size="icon" className="size-8 text-bad-fg" onClick={() => setDeleteGroup(g)} aria-label={`Remove ${g.name}`}>
<Trash2 />
</Button>
) : null}
@@ -262,9 +262,9 @@ export default function CategoriesPage() {
{visible.map((c) => {
const ci = g.categories.indexOf(c);
return (
<div key={c.id} className={cn('flex items-center gap-3 px-4 py-2.5 sm:px-5', c.archived && 'opacity-60')}>
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5 sm:px-5">
<button disabled={!canEdit} onClick={() => setEditing({ category: c, group: g })} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<CategoryIcon icon={c.icon} color={c.color} size="sm" />
<CategoryIcon icon={c.icon} color={c.color} size="sm" className={cn(c.archived && 'opacity-50 grayscale')} />
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
{kinds.size > 1 ? <Badge>{KIND_LABEL[c.kind]}</Badge> : null}
{c.archived ? <Badge>Archived</Badge> : null}

View File

@@ -2,7 +2,7 @@ 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 { toast } from '@/components/toast';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';

View File

@@ -2,8 +2,8 @@ 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 { toast } from '@/components/toast';
import { Avatar } from '@/components/Avatar';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';

View File

@@ -2,8 +2,8 @@ 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 { toast } from '@/components/toast';
import { Avatar } from '@/components/Avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, Input } from '@/components/ui/form';

View File

@@ -2,7 +2,7 @@ import type { Rule } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Trash2, Wand2 } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { CategoryButton } from '@/components/CategoryPicker';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';

View File

@@ -3,7 +3,7 @@ import { browserSupportsWebAuthn, startRegistration, type PublicKeyCredentialCre
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 { toast } from '@/components/toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ConfirmDialog } from '@/components/ui/dialog';

View File

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowRight, Tag, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { toast } from '@/components/toast';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';

View File

@@ -4,10 +4,10 @@ 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 SetupPage = lazy(() => import('./pages/SetupPage'));
const SignInPage = lazy(() => import('./pages/SignInPage'));
const HomePage = lazy(() => import('./pages/HomePage'));
const ReviewPage = lazy(() => import('./pages/ReviewPage'));
const ActivityPage = lazy(() => import('./pages/ActivityPage'));
@@ -17,6 +17,7 @@ const BudgetPage = lazy(() => import('./pages/BudgetPage'));
const SubscriptionsPage = lazy(() => import('./pages/SubscriptionsPage'));
const InsightsPage = lazy(() => import('./pages/InsightsPage'));
const MorePage = lazy(() => import('./pages/MorePage'));
const HelpPage = lazy(() => import('./pages/HelpPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout'));
const ProfilePage = lazy(() => import('./pages/settings/ProfilePage'));
@@ -61,6 +62,7 @@ export const router = createBrowserRouter([
{ path: 'insights', element: <InsightsPage /> },
{ path: 'accounts', element: <AccountsPage /> },
{ path: 'more', element: <MorePage /> },
{ path: 'help', element: <HelpPage /> },
{
path: 'settings',
element: <SettingsLayout />,

View File

@@ -1,5 +1,15 @@
@import 'tailwindcss';
/* Inter (SIL Open Font License, see assets/fonts/LICENSE-Inter.txt), trimmed to
the Latin characters Spendlight shows. Anything else falls back to the system font. */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url('./assets/fonts/inter-latin-wght.woff2') format('woff2');
}
@custom-variant dark (&:where(.dark, .dark *));
/* Palette A, "Spotlight Indigo". Indigo drives every action; the amber-to-coral
@@ -12,7 +22,7 @@
--popover: #ffffff;
--popover-foreground: #111827;
--muted: #f2f2ee;
--muted-foreground: #6b7280;
--muted-foreground: #646a75;
--border: #e7e5df;
--input: #dedcd5;
--ring: #6366f1;
@@ -23,6 +33,10 @@
--good: #16a34a;
--warn: #d97706;
--bad: #dc2626;
/* Status colors for text, dark enough to read at small sizes (WCAG AA). */
--good-fg: #147a3a;
--warn-fg: #a14a07;
--bad-fg: #c22020;
--glow-1: #f59e0b;
--glow-2: #f97362;
--night: #0b1020;
@@ -48,6 +62,9 @@
--good: #22c55e;
--warn: #f59e0b;
--bad: #f87171;
--good-fg: #22c55e;
--warn-fg: #f59e0b;
--bad-fg: #f87171;
--shadow-color: 225 50% 2%;
}
@@ -70,6 +87,9 @@
--color-good: var(--good);
--color-warn: var(--warn);
--color-bad: var(--bad);
--color-good-fg: var(--good-fg);
--color-warn-fg: var(--warn-fg);
--color-bad-fg: var(--bad-fg);
--color-glow-1: var(--glow-1);
--color-glow-2: var(--glow-2);
--color-night: var(--night);
@@ -124,6 +144,17 @@
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* People who ask for less motion get none: no slides, fades or sweeps. */
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 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 > * {