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
+1
View File
@@ -13,6 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup> </ItemGroup>
+21 -1
View File
@@ -1,7 +1,9 @@
using Budget.Api.Data; using Budget.Api.Data;
using Budget.Api.Services; using Budget.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -29,11 +31,20 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddScoped<Budget.Api.Services.BudgetAuthorizationService>(); builder.Services.AddScoped<BudgetAuthorizationService>();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
var app = builder.Build(); var app = builder.Build();
// Apply EF migrations automatically on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
@@ -43,6 +54,15 @@ app.UseAuthorization();
app.UseMiddleware<KnownUserMiddleware>(); app.UseMiddleware<KnownUserMiddleware>();
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/healthz", new HealthCheckOptions
{
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status200OK,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
}
});
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
+6
View File
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth/AuthContext'; import { AuthProvider, useAuth } from './auth/AuthContext';
import { AuthGuard } from './auth/AuthGuard'; import { AuthGuard } from './auth/AuthGuard';
import { ErrorBoundary } from './components/ErrorBoundary';
import { ToastProvider } from './components/Toast';
import { setTokenProvider } from './api/client'; import { setTokenProvider } from './api/client';
import { CallbackPage } from './pages/CallbackPage'; import { CallbackPage } from './pages/CallbackPage';
import { BudgetsPage } from './pages/BudgetsPage'; import { BudgetsPage } from './pages/BudgetsPage';
@@ -18,6 +20,8 @@ function TokenWirer() {
function App() { function App() {
return ( return (
<ErrorBoundary>
<ToastProvider>
<AuthProvider> <AuthProvider>
<TokenWirer /> <TokenWirer />
<BrowserRouter> <BrowserRouter>
@@ -32,6 +36,8 @@ function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </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;
}
+17 -1
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import { LoadingSkeleton } from '../components/LoadingSkeleton';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -87,24 +89,36 @@ function SortableRow({
export function IncomePage() { export function IncomePage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const [incomes, setIncomes] = useState<IncomeDto[]>([]); const [incomes, setIncomes] = useState<IncomeDto[]>([]);
const [loading, setLoading] = useState(true);
const { showError } = useToast();
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => { 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]); }, [budgetId]);
const handleSave = async (id: string, edit: EditState) => { const handleSave = async (id: string, edit: EditState) => {
try {
const updated = await api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, { const updated = await api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, {
name: edit.name, name: edit.name,
frequency: edit.frequency, frequency: edit.frequency,
amount: parseFloat(edit.amount), amount: parseFloat(edit.amount),
}); });
setIncomes(prev => prev.map(i => i.id === id ? updated : i)); setIncomes(prev => prev.map(i => i.id === id ? updated : i));
} catch (e) { showError(String(e)); }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Delete this income row?')) return;
try {
await api.delete(`/api/budgets/${budgetId}/incomes/${id}`); await api.delete(`/api/budgets/${budgetId}/incomes/${id}`);
setIncomes(prev => prev.filter(i => i.id !== id)); setIncomes(prev => prev.filter(i => i.id !== id));
} catch (e) { showError(String(e)); }
}; };
const handleAdd = async () => { const handleAdd = async () => {
@@ -128,6 +142,8 @@ export function IncomePage() {
}); });
}; };
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
return ( return (
<div> <div>
<BudgetNav /> <BudgetNav />
+21 -3
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import { LoadingSkeleton } from '../components/LoadingSkeleton';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -130,13 +132,20 @@ export function OutgoPage() {
const [outgos, setOutgos] = useState<OutgoDto[]>([]); const [outgos, setOutgos] = useState<OutgoDto[]>([]);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [paymentSources, setPaymentSources] = useState<string[]>([]); const [paymentSources, setPaymentSources] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const { showError } = useToast();
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => { useEffect(() => {
if (!budgetId) return; if (!budgetId) return;
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`).then(setOutgos); setLoading(true);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories); Promise.all([
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources); 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]); }, [budgetId]);
const refreshSuggestions = () => { const refreshSuggestions = () => {
@@ -146,6 +155,7 @@ export function OutgoPage() {
}; };
const handleSave = async (id: string, edit: EditState) => { const handleSave = async (id: string, edit: EditState) => {
try {
const updated = await api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, { const updated = await api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, {
name: edit.name, name: edit.name,
category: edit.category || null, category: edit.category || null,
@@ -157,14 +167,19 @@ export function OutgoPage() {
}); });
setOutgos(prev => prev.map(o => o.id === id ? updated : o)); setOutgos(prev => prev.map(o => o.id === id ? updated : o));
refreshSuggestions(); refreshSuggestions();
} catch (e) { showError(String(e)); }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Delete this outgo row?')) return;
try {
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`); await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
setOutgos(prev => prev.filter(o => o.id !== id)); setOutgos(prev => prev.filter(o => o.id !== id));
} catch (e) { showError(String(e)); }
}; };
const handleAdd = async () => { const handleAdd = async () => {
try {
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, { const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
name: 'New Outgo', name: 'New Outgo',
category: null, category: null,
@@ -175,6 +190,7 @@ export function OutgoPage() {
notes: null, notes: null,
}); });
setOutgos(prev => [...prev, created]); setOutgos(prev => [...prev, created]);
} catch (e) { showError(String(e)); }
}; };
const handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = async (event: DragEndEvent) => {
@@ -189,6 +205,8 @@ export function OutgoPage() {
}); });
}; };
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
return ( return (
<div> <div>
<BudgetNav /> <BudgetNav />