Milestone 2: the full picture (20261007.12)
Budgets: one amount per category for every month with any month set on its own, filled from recent spending in one step and copied into the next year. Home shows what's left to spend, year pacing, insights, balances and upcoming charges. Insights covers year pacing, money in and out, budget against actual, categories by month, merchants, balances over time and tags. Subscriptions and bills are found across every account (rhythm and price matching plus a built-in catalog), confirmed with one tap, and watched for price changes, missed charges, charges after cancelling, double billing, renewals and trials. Alerts are worked out again whenever the data changes. Also: category, rule, tag and alert settings; Excel and CSV export from a small built-in writer; the AI module designed in and switched off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
79
web/src/components/meter.tsx
Normal file
79
web/src/components/meter.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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', 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' },
|
||||
};
|
||||
|
||||
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' : '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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user