From 45f921bb7128e17574205fba625175303b03c87f Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:03:05 -0500 Subject: [PATCH] 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 --- src/Budget.Api/Budget.Api.csproj | 1 + src/Budget.Api/Program.cs | 22 +++++- src/Budget.Client/src/App.tsx | 34 +++++---- .../src/components/ErrorBoundary.tsx | 30 ++++++++ .../src/components/LoadingSkeleton.tsx | 26 +++++++ src/Budget.Client/src/components/Toast.tsx | 53 ++++++++++++++ src/Budget.Client/src/pages/IncomePage.tsx | 34 ++++++--- src/Budget.Client/src/pages/OutgoPage.tsx | 70 ++++++++++++------- 8 files changed, 220 insertions(+), 50 deletions(-) create mode 100644 src/Budget.Client/src/components/ErrorBoundary.tsx create mode 100644 src/Budget.Client/src/components/LoadingSkeleton.tsx create mode 100644 src/Budget.Client/src/components/Toast.tsx diff --git a/src/Budget.Api/Budget.Api.csproj b/src/Budget.Api/Budget.Api.csproj index 3cff11f..4c47c15 100644 --- a/src/Budget.Api/Budget.Api.csproj +++ b/src/Budget.Api/Budget.Api.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index bcd22f6..4b2e42e 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -1,7 +1,9 @@ using Budget.Api.Data; using Budget.Api.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -29,11 +31,20 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); builder.Services.AddAuthorization(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddControllers(); +builder.Services.AddHealthChecks() + .AddDbContextCheck(); var app = builder.Build(); +// Apply EF migrations automatically on startup +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + app.UseDefaultFiles(); app.UseStaticFiles(); @@ -43,6 +54,15 @@ app.UseAuthorization(); app.UseMiddleware(); app.MapControllers(); +app.MapHealthChecks("/healthz", new HealthCheckOptions +{ + ResultStatusCodes = + { + [HealthStatus.Healthy] = StatusCodes.Status200OK, + [HealthStatus.Degraded] = StatusCodes.Status200OK, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable, + } +}); app.MapFallbackToFile("index.html"); diff --git a/src/Budget.Client/src/App.tsx b/src/Budget.Client/src/App.tsx index 5a9b74f..22d77ed 100644 --- a/src/Budget.Client/src/App.tsx +++ b/src/Budget.Client/src/App.tsx @@ -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 ( - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/src/Budget.Client/src/components/ErrorBoundary.tsx b/src/Budget.Client/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..776c660 --- /dev/null +++ b/src/Budget.Client/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+

Something went wrong

+
{this.state.error.message}
+ +
+ ); + } + return this.props.children; + } +} diff --git a/src/Budget.Client/src/components/LoadingSkeleton.tsx b/src/Budget.Client/src/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..ba4c359 --- /dev/null +++ b/src/Budget.Client/src/components/LoadingSkeleton.tsx @@ -0,0 +1,26 @@ +interface Props { rows?: number; cols?: number; } + +export function LoadingSkeleton({ rows = 5, cols = 4 }: Props) { + return ( + + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: cols }).map((_, c) => ( + + ))} + + ))} + +
+
+
+ ); +} diff --git a/src/Budget.Client/src/components/Toast.tsx b/src/Budget.Client/src/components/Toast.tsx new file mode 100644 index 0000000..edabe35 --- /dev/null +++ b/src/Budget.Client/src/components/Toast.tsx @@ -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(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + 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 ( + + {children} +
+ {toasts.map(t => ( +
+ {t.message} +
+ ))} +
+
+ ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx index ce397f4..4db679b 100644 --- a/src/Budget.Client/src/pages/IncomePage.tsx +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const { showError } = useToast(); const sensors = useSensors(useSensor(PointerSensor)); useEffect(() => { - if (budgetId) api.get(`/api/budgets/${budgetId}/incomes`).then(setIncomes); + if (!budgetId) return; + setLoading(true); + api.get(`/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(`/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(`/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 <>; + return (
diff --git a/src/Budget.Client/src/pages/OutgoPage.tsx b/src/Budget.Client/src/pages/OutgoPage.tsx index 03586ca..7dc266d 100644 --- a/src/Budget.Client/src/pages/OutgoPage.tsx +++ b/src/Budget.Client/src/pages/OutgoPage.tsx @@ -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([]); const [categories, setCategories] = useState([]); const [paymentSources, setPaymentSources] = useState([]); + const [loading, setLoading] = useState(true); + const { showError } = useToast(); const sensors = useSensors(useSensor(PointerSensor)); useEffect(() => { if (!budgetId) return; - api.get(`/api/budgets/${budgetId}/outgos`).then(setOutgos); - api.get(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories); - api.get(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources); + setLoading(true); + Promise.all([ + api.get(`/api/budgets/${budgetId}/outgos`), + api.get(`/api/budgets/${budgetId}/outgos/categories`), + api.get(`/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(`/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(`/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(`/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(`/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 <>; + return (