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:
@@ -13,6 +13,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<Budget.Api.Services.BudgetAuthorizationService>();
|
||||
builder.Services.AddScoped<BudgetAuthorizationService>();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
|
||||
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.UseStaticFiles();
|
||||
|
||||
@@ -43,6 +54,15 @@ app.UseAuthorization();
|
||||
app.UseMiddleware<KnownUserMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
{
|
||||
ResultStatusCodes =
|
||||
{
|
||||
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
|
||||
}
|
||||
});
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user