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 { useParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||||
import { BudgetNav } from '../components/BudgetNav';
|
import { BudgetNav } from '../components/BudgetNav';
|
||||||
import { useSummary, useUpdateTaxRate } from '../api/summary';
|
import { DonutChart } from '../components/DonutChart';
|
||||||
import { updateTaxRateSchema, type UpdateTaxRateInput } from '../schemas/index';
|
import { useSummary } from '../api/summary';
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
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> = {
|
const TYPE_BADGE: Record<string, string> = {
|
||||||
Need: 'badge badge-need',
|
Need: 'badge badge-need',
|
||||||
Want: 'badge badge-want',
|
Want: 'badge badge-want',
|
||||||
@@ -18,30 +22,23 @@ const TYPE_BADGE: Record<string, string> = {
|
|||||||
export function SummaryPage() {
|
export function SummaryPage() {
|
||||||
const { id: budgetId } = useParams<{ id: string }>();
|
const { id: budgetId } = useParams<{ id: string }>();
|
||||||
const { data: summary } = useSummary(budgetId!);
|
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></>;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BudgetNav />
|
<BudgetNav />
|
||||||
<h1>Summary</h1>
|
<h1>Summary</h1>
|
||||||
|
|
||||||
|
{/* Income stats */}
|
||||||
<div className="summary-stats">
|
<div className="summary-stats">
|
||||||
<div className="summary-stat">
|
<div className="summary-stat">
|
||||||
<div className="summary-stat-label">Monthly Income</div>
|
<div className="summary-stat-label">Monthly Income</div>
|
||||||
@@ -53,75 +50,61 @@ export function SummaryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-wrapper">
|
{/* Chart + table */}
|
||||||
<table>
|
<div style={{ display: 'flex', gap: '2rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="card" style={{ maxWidth: '480px' }}>
|
{/* Donut chart */}
|
||||||
<div className="section-title">Pre-Tax Income</div>
|
<div className="card" style={{ flexShrink: 0 }}>
|
||||||
<form onSubmit={handleSubmit(onSaveTaxRate)} style={{ marginBottom: '0.875rem' }}>
|
<div className="section-title">Budget Allocation</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<DonutChart
|
||||||
<label htmlFor="tax-rate">Effective Tax Rate (%)</label>
|
segments={chartSegments}
|
||||||
<input
|
centerLabel="Monthly"
|
||||||
id="tax-rate"
|
centerValue={monthlyFmt}
|
||||||
type="number"
|
/>
|
||||||
min="0"
|
</div>
|
||||||
max="99"
|
|
||||||
style={{ width: '72px' }}
|
{/* Breakdown table */}
|
||||||
{...register('effectiveTaxRate', { valueAsNumber: true })}
|
<div style={{ flex: '1 1 400px' }}>
|
||||||
/>
|
<div className="table-wrapper">
|
||||||
<button
|
<table>
|
||||||
type="submit"
|
<thead>
|
||||||
className="btn btn-primary btn-sm"
|
<tr>
|
||||||
disabled={isSubmitting || updateTaxRate.isPending}
|
<th>Type</th>
|
||||||
>
|
<th className="col-money">Monthly</th>
|
||||||
{updateTaxRate.isPending ? 'Saving…' : 'Save'}
|
<th className="col-money">Annually</th>
|
||||||
</button>
|
<th className="col-pct">% of Income</th>
|
||||||
</div>
|
<th className="col-money">Target Max</th>
|
||||||
{errors.effectiveTaxRate && (
|
<th className="col-money">Remaining</th>
|
||||||
<span className="field-error">{errors.effectiveTaxRate.message}</span>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
</form>
|
<tbody>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem', fontSize: '0.9rem' }}>
|
{summary.breakdown.map(row => (
|
||||||
<div>
|
<tr key={row.type}>
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>Minimum Annual Gross: </span>
|
<td>
|
||||||
<strong className="font-mono"><MoneyDisplay value={summary.preTaxIncome.minimumAnnualGross} /></strong>
|
<span className={TYPE_BADGE[row.type] ?? 'badge'}>
|
||||||
</div>
|
{row.type}
|
||||||
<div>
|
{row.targetPercent != null && ` (${row.targetPercent}%)`}
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>Minimum Monthly Gross: </span>
|
</span>
|
||||||
<strong className="font-mono"><MoneyDisplay value={summary.preTaxIncome.minimumMonthlyGross} /></strong>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user