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 */}
+
+
+ {/* 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() {
-
-
-
-
- | Type |
- Monthly Total |
- Annual Total |
- % of Income |
- Max (target%) |
- Remaining |
-
-
-
- {summary.breakdown.map(row => (
-
- |
-
- {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
-
-
-
- Minimum Annual Gross:
-
-
-
-
Minimum Monthly Gross:
-
+ {/* Donut chart */}
+
+
+ {/* Breakdown table */}
+
+
+
+
+
+ | Type |
+ Monthly |
+ Annually |
+ % of Income |
+ Target Max |
+ Remaining |
+
+
+
+ {summary.breakdown.map(row => (
+
+ |
+
+ {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) : '—'}
+ |
+
+ ))}
+
+
+
);