Phase 8: Polish and production readiness

- Add ErrorBoundary component wrapping the whole app
- Add ToastProvider with showError/showInfo; Income and Outgo pages use it for API errors
- Add LoadingSkeleton component with shimmer animation; Income and Outgo show it while loading
- Add confirm-on-delete dialogs for income and outgo rows
- Apply EF migrations automatically on startup via MigrateAsync()
- Add /healthz health check endpoint using DbContext check
- Add Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore package

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 08:03:05 -05:00
parent 64203606a6
commit 45f921bb71
8 changed files with 220 additions and 50 deletions
+20 -14
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth/AuthContext';
import { AuthGuard } from './auth/AuthGuard';
import { ErrorBoundary } from './components/ErrorBoundary';
import { ToastProvider } from './components/Toast';
import { setTokenProvider } from './api/client';
import { CallbackPage } from './pages/CallbackPage';
import { BudgetsPage } from './pages/BudgetsPage';
@@ -18,20 +20,24 @@ function TokenWirer() {
function App() {
return (
<AuthProvider>
<TokenWirer />
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/budgets" replace />} />
<Route path="/callback" element={<CallbackPage />} />
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
</Routes>
</BrowserRouter>
</AuthProvider>
<ErrorBoundary>
<ToastProvider>
<AuthProvider>
<TokenWirer />
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/budgets" replace />} />
<Route path="/callback" element={<CallbackPage />} />
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ToastProvider>
</ErrorBoundary>
);
}
@@ -0,0 +1,30 @@
import { Component } from 'react';
import type { ReactNode, ErrorInfo } from 'react';
interface Props { children: ReactNode; }
interface State { error: Error | null; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.error) {
return (
<div style={{ padding: '2rem', color: 'red' }}>
<h2>Something went wrong</h2>
<pre>{this.state.error.message}</pre>
<button onClick={() => this.setState({ error: null })}>Retry</button>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,26 @@
interface Props { rows?: number; cols?: number; }
export function LoadingSkeleton({ rows = 5, cols = 4 }: Props) {
return (
<table>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: cols }).map((_, c) => (
<td key={c}>
<div style={{
height: '1em',
background: 'linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
borderRadius: '4px',
width: `${60 + Math.random() * 30}%`,
}} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,53 @@
import { createContext, useContext, useState, useCallback } from 'react';
import type { ReactNode } from 'react';
interface Toast { id: number; message: string; type: 'error' | 'info'; }
interface ToastContextValue {
showError: (message: string) => void;
showInfo: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
let nextId = 0;
const addToast = useCallback((message: string, type: Toast['type']) => {
const id = ++nextId;
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000);
}, []);
const showError = useCallback((message: string) => addToast(message, 'error'), [addToast]);
const showInfo = useCallback((message: string) => addToast(message, 'info'), [addToast]);
return (
<ToastContext.Provider value={{ showError, showInfo }}>
{children}
<div style={{
position: 'fixed', bottom: '1rem', right: '1rem',
display: 'flex', flexDirection: 'column', gap: '8px', zIndex: 100,
}}>
{toasts.map(t => (
<div key={t.id} style={{
padding: '10px 16px',
background: t.type === 'error' ? '#dc2626' : '#1d4ed8',
color: 'white',
borderRadius: '6px',
maxWidth: '320px',
}}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
+25 -9
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import { LoadingSkeleton } from '../components/LoadingSkeleton';
import {
DndContext,
closestCenter,
@@ -87,24 +89,36 @@ function SortableRow({
export function IncomePage() {
const { id: budgetId } = useParams<{ id: string }>();
const [incomes, setIncomes] = useState<IncomeDto[]>([]);
const [loading, setLoading] = useState(true);
const { showError } = useToast();
const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => {
if (budgetId) api.get<IncomeDto[]>(`/api/budgets/${budgetId}/incomes`).then(setIncomes);
if (!budgetId) return;
setLoading(true);
api.get<IncomeDto[]>(`/api/budgets/${budgetId}/incomes`)
.then(setIncomes)
.catch(e => showError(String(e)))
.finally(() => setLoading(false));
}, [budgetId]);
const handleSave = async (id: string, edit: EditState) => {
const updated = await api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, {
name: edit.name,
frequency: edit.frequency,
amount: parseFloat(edit.amount),
});
setIncomes(prev => prev.map(i => i.id === id ? updated : i));
try {
const updated = await api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, {
name: edit.name,
frequency: edit.frequency,
amount: parseFloat(edit.amount),
});
setIncomes(prev => prev.map(i => i.id === id ? updated : i));
} catch (e) { showError(String(e)); }
};
const handleDelete = async (id: string) => {
await api.delete(`/api/budgets/${budgetId}/incomes/${id}`);
setIncomes(prev => prev.filter(i => i.id !== id));
if (!confirm('Delete this income row?')) return;
try {
await api.delete(`/api/budgets/${budgetId}/incomes/${id}`);
setIncomes(prev => prev.filter(i => i.id !== id));
} catch (e) { showError(String(e)); }
};
const handleAdd = async () => {
@@ -128,6 +142,8 @@ export function IncomePage() {
});
};
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
return (
<div>
<BudgetNav />
+44 -26
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import { LoadingSkeleton } from '../components/LoadingSkeleton';
import {
DndContext,
closestCenter,
@@ -130,13 +132,20 @@ export function OutgoPage() {
const [outgos, setOutgos] = useState<OutgoDto[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [paymentSources, setPaymentSources] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const { showError } = useToast();
const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => {
if (!budgetId) return;
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`).then(setOutgos);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources);
setLoading(true);
Promise.all([
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`),
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`),
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`),
]).then(([o, c, p]) => { setOutgos(o); setCategories(c); setPaymentSources(p); })
.catch(e => showError(String(e)))
.finally(() => setLoading(false));
}, [budgetId]);
const refreshSuggestions = () => {
@@ -146,35 +155,42 @@ export function OutgoPage() {
};
const handleSave = async (id: string, edit: EditState) => {
const updated = await api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, {
name: edit.name,
category: edit.category || null,
type: edit.type,
frequency: edit.frequency,
amount: parseFloat(edit.amount),
paymentSource: edit.paymentSource || null,
notes: edit.notes || null,
});
setOutgos(prev => prev.map(o => o.id === id ? updated : o));
refreshSuggestions();
try {
const updated = await api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, {
name: edit.name,
category: edit.category || null,
type: edit.type,
frequency: edit.frequency,
amount: parseFloat(edit.amount),
paymentSource: edit.paymentSource || null,
notes: edit.notes || null,
});
setOutgos(prev => prev.map(o => o.id === id ? updated : o));
refreshSuggestions();
} catch (e) { showError(String(e)); }
};
const handleDelete = async (id: string) => {
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
setOutgos(prev => prev.filter(o => o.id !== id));
if (!confirm('Delete this outgo row?')) return;
try {
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
setOutgos(prev => prev.filter(o => o.id !== id));
} catch (e) { showError(String(e)); }
};
const handleAdd = async () => {
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
name: 'New Outgo',
category: null,
type: 'Need',
frequency: 'Monthly',
amount: 0,
paymentSource: null,
notes: null,
});
setOutgos(prev => [...prev, created]);
try {
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
name: 'New Outgo',
category: null,
type: 'Need',
frequency: 'Monthly',
amount: 0,
paymentSource: null,
notes: null,
});
setOutgos(prev => [...prev, created]);
} catch (e) { showError(String(e)); }
};
const handleDragEnd = async (event: DragEndEvent) => {
@@ -189,6 +205,8 @@ export function OutgoPage() {
});
};
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
return (
<div>
<BudgetNav />