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>
80 lines
4.2 KiB
TypeScript
80 lines
4.2 KiB
TypeScript
import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
|
|
import type { ReactNode } from 'react';
|
|
import { budgetStatus, whole, type BudgetStatus } from '@/lib/money';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// Progress bars and rings for "spent against budget". Status always comes
|
|
// with an icon and a word, never color alone.
|
|
|
|
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-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 }) {
|
|
const status = budgetStatus(spent, budget);
|
|
// Without a budget, the bar compares categories with each other (scale is the largest).
|
|
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : scale ? Math.max(0, Math.min(100, (spent / scale) * 100)) : 0;
|
|
return (
|
|
<div className={cn('relative h-2 overflow-hidden rounded-full bg-muted', className)} role="presentation">
|
|
<div className={cn('h-full rounded-full transition-[width] duration-500 ease-out', STATUS_TONE[status].bar)} style={{ width: `${pct}%` }} />
|
|
{pace !== undefined && budget > 0 && pace > 0 && pace < 1 ? (
|
|
// Where spending "should" be by today, if it were spread evenly over the month.
|
|
<div className="absolute inset-y-0 w-0.5 bg-foreground/35" style={{ left: `${pace * 100}%` }} aria-hidden />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** "$42 left", "$18 over" or "no budget", with an icon. */
|
|
export function BudgetNote({ spent, budget, className, income }: { spent: number; budget: number; className?: string; income?: boolean }) {
|
|
if (budget <= 0) return <span className={cn('text-[12.5px] text-muted-foreground', className)}>No budget</span>;
|
|
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-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>
|
|
);
|
|
}
|
|
const status = budgetStatus(spent, budget);
|
|
const Icon = status === 'over' ? CircleAlert : status === 'close' ? TriangleAlert : CircleCheck;
|
|
return (
|
|
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', STATUS_TONE[status].text, className)}>
|
|
<Icon className="size-3.5" />
|
|
{status === 'over' ? `${whole(spent - budget)} over` : `${whole(budget - spent)} left`}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** A ring that fills as the month's budget is spent. */
|
|
export function Ring({ spent, budget, size = 168, stroke = 14, children, className }: { spent: number; budget: number; size?: number; stroke?: number; children?: ReactNode; className?: string }) {
|
|
const status = budgetStatus(spent, budget);
|
|
const r = (size - stroke) / 2;
|
|
const circumference = 2 * Math.PI * r;
|
|
const share = budget > 0 ? Math.min(1, spent / budget) : 0;
|
|
const color = { none: 'stroke-primary', under: 'stroke-good', close: 'stroke-warn', over: 'stroke-bad' }[status];
|
|
return (
|
|
<div className={cn('relative shrink-0', className)} style={{ width: size, height: size }}>
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90" aria-hidden>
|
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-current opacity-15" />
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={r}
|
|
fill="none"
|
|
strokeWidth={stroke}
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={circumference * (1 - share)}
|
|
className={cn('transition-[stroke-dashoffset] duration-700 ease-out', color)}
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">{children}</div>
|
|
</div>
|
|
);
|
|
}
|