Removed tax nonsense from summary and added a donut graph

This commit is contained in:
Spencer Twaddle
2026-05-03 07:02:33 -05:00
parent 665062f0b5
commit 2f165487d3
2 changed files with 173 additions and 87 deletions
@@ -0,0 +1,103 @@
interface Segment {
label: string;
value: number;
color: string;
}
interface Props {
segments: Segment[];
centerLabel: string;
centerValue: string;
size?: number;
}
const STROKE_WIDTH = 30;
const GAP = 2.5; // degrees of gap between segments
export function DonutChart({ segments, centerLabel, centerValue, size = 220 }: Props) {
const cx = size / 2;
const cy = size / 2;
const r = (size - STROKE_WIDTH) / 2 - 4;
const circumference = 2 * Math.PI * r;
const total = segments.reduce((sum, s) => sum + s.value, 0);
// Build arc descriptors
let cursor = -90; // start at 12 o'clock
const arcs = segments.map(seg => {
const pct = total > 0 ? seg.value / total : 0;
const degrees = pct * 360;
const gapDeg = degrees > GAP * 2 ? GAP : 0;
const dashLen = Math.max(0, ((degrees - gapDeg) / 360) * circumference);
const startAngle = cursor + gapDeg / 2;
cursor += degrees;
return { ...seg, pct, dashLen, startAngle };
});
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap' }}>
{/* Donut SVG */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<svg width={size} height={size} style={{ display: 'block' }}>
{/* Track ring */}
<circle
cx={cx} cy={cy} r={r}
fill="none"
stroke="var(--color-border)"
strokeWidth={STROKE_WIDTH}
/>
{arcs.map((arc, i) => (
<circle
key={i}
cx={cx} cy={cy} r={r}
fill="none"
stroke={arc.color}
strokeWidth={STROKE_WIDTH}
strokeDasharray={`${arc.dashLen} ${circumference}`}
strokeLinecap="butt"
transform={`rotate(${arc.startAngle}, ${cx}, ${cy})`}
style={{ transition: 'stroke-dasharray 0.4s ease' }}
/>
))}
</svg>
{/* Center text */}
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}>
<div style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{centerLabel}
</div>
<div style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--color-heading)', fontFamily: 'var(--font-mono)' }}>
{centerValue}
</div>
</div>
</div>
{/* Legend */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem', minWidth: '160px' }}>
{arcs.map((arc, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
width: 12, height: 12, borderRadius: '3px',
background: arc.color, flexShrink: 0,
}} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-heading)' }}>
{arc.label}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
{(arc.pct * 100).toFixed(1)}%
</div>
</div>
</div>
))}
</div>
</div>
);
}
+70 -87
View File
@@ -1,14 +1,18 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { MoneyDisplay } from '../components/MoneyDisplay';
import { BudgetNav } from '../components/BudgetNav';
import { useSummary, useUpdateTaxRate } from '../api/summary';
import { updateTaxRateSchema, type UpdateTaxRateInput } from '../schemas/index';
import { DonutChart } from '../components/DonutChart';
import { useSummary } from '../api/summary';
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const SEGMENT_COLORS: Record<string, string> = {
Need: '#f59e0b',
Want: '#8b5cf6',
Save: '#16a34a',
Unspent: '#9ca3af',
};
const TYPE_BADGE: Record<string, string> = {
Need: 'badge badge-need',
Want: 'badge badge-want',
@@ -18,30 +22,23 @@ const TYPE_BADGE: Record<string, string> = {
export function SummaryPage() {
const { id: budgetId } = useParams<{ id: string }>();
const { data: summary } = useSummary(budgetId!);
const updateTaxRate = useUpdateTaxRate(budgetId!);
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<UpdateTaxRateInput>({
resolver: zodResolver(updateTaxRateSchema),
defaultValues: { effectiveTaxRate: 0 },
});
useEffect(() => {
if (summary) {
reset({ effectiveTaxRate: Math.round(summary.preTaxIncome.effectiveTaxRate * 100) });
}
}, [summary]); // eslint-disable-line react-hooks/exhaustive-deps
const onSaveTaxRate = async (data: UpdateTaxRateInput) => {
await updateTaxRate.mutateAsync(data.effectiveTaxRate / 100);
};
if (!summary) return <><BudgetNav /><div className="loading-text">Loading</div></>;
const monthlyFmt = fmt.format(summary.monthlyIncome);
const chartSegments = summary.breakdown.map(row => ({
label: row.type,
value: Math.max(0, row.total),
color: SEGMENT_COLORS[row.type] ?? '#d1d5db',
}));
return (
<div>
<BudgetNav />
<h1>Summary</h1>
{/* Income stats */}
<div className="summary-stats">
<div className="summary-stat">
<div className="summary-stat-label">Monthly Income</div>
@@ -53,75 +50,61 @@ export function SummaryPage() {
</div>
</div>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Type</th>
<th className="col-money">Monthly Total</th>
<th className="col-money">Annual Total</th>
<th className="col-pct">% of Income</th>
<th className="col-money">Max (target%)</th>
<th className="col-money">Remaining</th>
</tr>
</thead>
<tbody>
{summary.breakdown.map(row => (
<tr key={row.type}>
<td>
<span className={TYPE_BADGE[row.type] ?? 'badge'}>
{row.type}
{row.targetPercent != null && ` (${row.targetPercent}%)`}
</span>
</td>
<td className="col-money"><MoneyDisplay value={row.total} /></td>
<td className="col-money"><MoneyDisplay value={row.annually} /></td>
<td className="col-pct">{row.percent.toFixed(1)}%</td>
<td className="col-money">{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}</td>
<td className="col-money" style={{ color: row.remaining != null && row.remaining < 0 ? 'var(--color-negative)' : undefined }}>
{row.remaining != null ? fmt.format(row.remaining) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Chart + table */}
<div style={{ display: 'flex', gap: '2rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div className="card" style={{ maxWidth: '480px' }}>
<div className="section-title">Pre-Tax Income</div>
<form onSubmit={handleSubmit(onSaveTaxRate)} style={{ marginBottom: '0.875rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<label htmlFor="tax-rate">Effective Tax Rate (%)</label>
<input
id="tax-rate"
type="number"
min="0"
max="99"
style={{ width: '72px' }}
{...register('effectiveTaxRate', { valueAsNumber: true })}
/>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={isSubmitting || updateTaxRate.isPending}
>
{updateTaxRate.isPending ? 'Saving…' : 'Save'}
</button>
</div>
{errors.effectiveTaxRate && (
<span className="field-error">{errors.effectiveTaxRate.message}</span>
)}
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem', fontSize: '0.9rem' }}>
<div>
<span style={{ color: 'var(--color-text-muted)' }}>Minimum Annual Gross: </span>
<strong className="font-mono"><MoneyDisplay value={summary.preTaxIncome.minimumAnnualGross} /></strong>
</div>
<div>
<span style={{ color: 'var(--color-text-muted)' }}>Minimum Monthly Gross: </span>
<strong className="font-mono"><MoneyDisplay value={summary.preTaxIncome.minimumMonthlyGross} /></strong>
{/* Donut chart */}
<div className="card" style={{ flexShrink: 0 }}>
<div className="section-title">Budget Allocation</div>
<DonutChart
segments={chartSegments}
centerLabel="Monthly"
centerValue={monthlyFmt}
/>
</div>
{/* Breakdown table */}
<div style={{ flex: '1 1 400px' }}>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Type</th>
<th className="col-money">Monthly</th>
<th className="col-money">Annually</th>
<th className="col-pct">% of Income</th>
<th className="col-money">Target Max</th>
<th className="col-money">Remaining</th>
</tr>
</thead>
<tbody>
{summary.breakdown.map(row => (
<tr key={row.type}>
<td>
<span className={TYPE_BADGE[row.type] ?? 'badge'}>
{row.type}
{row.targetPercent != null && ` (${row.targetPercent}%)`}
</span>
</td>
<td className="col-money"><MoneyDisplay value={row.total} /></td>
<td className="col-money"><MoneyDisplay value={row.annually} /></td>
<td className="col-pct">{row.percent.toFixed(1)}%</td>
<td className="col-money">
{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}
</td>
<td
className="col-money"
style={{ color: row.remaining != null && row.remaining < 0 ? 'var(--color-negative)' : undefined }}
>
{row.remaining != null ? fmt.format(row.remaining) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);