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>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user