diff --git a/src/Budget.Client/src/components/DonutChart.tsx b/src/Budget.Client/src/components/DonutChart.tsx new file mode 100644 index 0000000..0ce2a6c --- /dev/null +++ b/src/Budget.Client/src/components/DonutChart.tsx @@ -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 ( +
+ {/* Donut SVG */} +
+ + {/* Track ring */} + + {arcs.map((arc, i) => ( + + ))} + + {/* Center text */} +
+
+ {centerLabel} +
+
+ {centerValue} +
+
+
+ + {/* Legend */} +
+ {arcs.map((arc, i) => ( +
+
+
+
+ {arc.label} +
+
+ {(arc.pct * 100).toFixed(1)}% +
+
+
+ ))} +
+
+ ); +} diff --git a/src/Budget.Client/src/pages/SummaryPage.tsx b/src/Budget.Client/src/pages/SummaryPage.tsx index 7c8a6d2..519cca3 100644 --- a/src/Budget.Client/src/pages/SummaryPage.tsx +++ b/src/Budget.Client/src/pages/SummaryPage.tsx @@ -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 = { + Need: '#f59e0b', + Want: '#8b5cf6', + Save: '#16a34a', + Unspent: '#9ca3af', +}; + const TYPE_BADGE: Record = { Need: 'badge badge-need', Want: 'badge badge-want', @@ -18,30 +22,23 @@ const TYPE_BADGE: Record = { 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({ - 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 <>
Loading…
; + 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 (

Summary

+ {/* Income stats */}
Monthly Income
@@ -53,75 +50,61 @@ export function SummaryPage() {
-
- - - - - - - - - - - - - {summary.breakdown.map(row => ( - - - - - - - - - ))} - -
TypeMonthly TotalAnnual Total% of IncomeMax (target%)Remaining
- - {row.type} - {row.targetPercent != null && ` (${row.targetPercent}%)`} - - {row.percent.toFixed(1)}%{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'} - {row.remaining != null ? fmt.format(row.remaining) : '—'} -
-
+ {/* Chart + table */} +
-
-
Pre-Tax Income
-
-
- - - -
- {errors.effectiveTaxRate && ( - {errors.effectiveTaxRate.message} - )} -
-
-
- Minimum Annual Gross: - -
-
- Minimum Monthly Gross: - + {/* Donut chart */} +
+
Budget Allocation
+ +
+ + {/* Breakdown table */} +
+
+ + + + + + + + + + + + + {summary.breakdown.map(row => ( + + + + + + + + + ))} + +
TypeMonthlyAnnually% of IncomeTarget MaxRemaining
+ + {row.type} + {row.targetPercent != null && ` (${row.targetPercent}%)`} + + {row.percent.toFixed(1)}% + {row.maxAmount != null ? fmt.format(row.maxAmount) : '—'} + + {row.remaining != null ? fmt.format(row.remaining) : '—'} +
+
);