Removed tax nonsense from summary and added a donut graph
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user