Add error boundary, error page, and startup timeout

Prevent infinite spinner when DB connection fails at startup by adding
a 10s timeout on connectActiveProfile(). Add ErrorBoundary to catch
React crashes and ErrorPage with refresh, update check, and contact links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-28 19:01:39 -05:00
parent f126d08da3
commit 849945f339
9 changed files with 239 additions and 12 deletions

View file

@ -2,6 +2,11 @@
## [Unreleased] ## [Unreleased]
### Added
- Error boundary catches React crashes and displays an error page instead of a white screen
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
- Error page with "Refresh", "Check for updates", and contact/issue links
## [0.4.4] ## [0.4.4]
### Fixed ### Fixed

View file

@ -9,7 +9,7 @@
**Stockage :** SQLite local (tauri-plugin-sql) **Stockage :** SQLite local (tauri-plugin-sql)
**Langues supportées :** Français (FR) et Anglais (EN) **Langues supportées :** Français (FR) et Anglais (EN)
**Plateformes :** Windows, Linux **Plateformes :** Windows, Linux
**Version actuelle :** 0.3.11 **Version actuelle :** 0.4.4
--- ---
@ -35,7 +35,7 @@
``` ```
src/ src/
├── components/ # 49 composants React organisés par domaine ├── components/ # 53 composants React organisés par domaine
│ ├── adjustments/ # Ajustements │ ├── adjustments/ # Ajustements
│ ├── budget/ # Budget │ ├── budget/ # Budget
│ ├── categories/ # Catégories hiérarchiques │ ├── categories/ # Catégories hiérarchiques
@ -67,7 +67,7 @@ src-tauri/
│ │ ├── schema.sql # Schéma initial (v1) │ │ ├── schema.sql # Schéma initial (v1)
│ │ ├── seed_categories.sql # Seed catégories (v2) │ │ ├── seed_categories.sql # Seed catégories (v2)
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils) │ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
│ ├── lib.rs # Point d'entrée, 6 migrations inline, plugins │ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins
│ └── main.rs │ └── main.rs
└── Cargo.toml └── Cargo.toml
``` ```
@ -114,8 +114,8 @@ src-tauri/
## Base de données ## Base de données
- **13 tables** SQLite, **9 index** (voir `docs/architecture.md` pour le détail) - **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
- **6 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`) - **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils - **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle - Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
@ -153,7 +153,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
## CI/CD ## CI/CD
- GitHub Actions (`release.yml`) déclenché par tags `v*` - GitHub Actions (`release.yml`) déclenché par tags `v*`
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.AppImage`) - Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
- Signature des binaires + JSON d'updater pour mises à jour automatiques - Signature des binaires + JSON d'updater pour mises à jour automatiques
--- ---

View file

@ -36,7 +36,7 @@ simpl-resultat/
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique) │ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
│ │ ├── settings/ # 2 composants │ │ ├── settings/ # 2 composants
│ │ ├── shared/ # 4 composants réutilisables │ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 5 composants
│ ├── contexts/ # ProfileContext (état global profil) │ ├── contexts/ # ProfileContext (état global profil)
│ ├── hooks/ # 12 hooks custom (useReducer) │ ├── hooks/ # 12 hooks custom (useReducer)
@ -176,6 +176,12 @@ Chaque hook encapsule la logique d'état via `useReducer` :
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate). Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
### Gestion d'erreurs
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
| Route | Page | Description | | Route | Page | Description |
|-------|------|-------------| |-------|------|-------------|
| `/` | `DashboardPage` | Tableau de bord avec graphiques | | `/` | `DashboardPage` | Tableau de bord avec graphiques |

View file

@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useProfile } from "./contexts/ProfileContext"; import { useProfile } from "./contexts/ProfileContext";
import AppShell from "./components/layout/AppShell"; import AppShell from "./components/layout/AppShell";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
@ -12,19 +13,42 @@ import ReportsPage from "./pages/ReportsPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import DocsPage from "./pages/DocsPage"; import DocsPage from "./pages/DocsPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage";
const STARTUP_TIMEOUT_MS = 10_000;
export default function App() { export default function App() {
const { t } = useTranslation();
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile(); const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
const [dbReady, setDbReady] = useState(false); const [dbReady, setDbReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (activeProfile && !isLoading) { if (activeProfile && !isLoading) {
setDbReady(false); setDbReady(false);
setStartupError(null);
timeoutRef.current = setTimeout(() => {
setStartupError(t("error.startupTimeout"));
}, STARTUP_TIMEOUT_MS);
connectActiveProfile() connectActiveProfile()
.then(() => setDbReady(true)) .then(() => {
.catch((err) => console.error("Failed to connect profile:", err)); if (timeoutRef.current) clearTimeout(timeoutRef.current);
setDbReady(true);
})
.catch((err) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
console.error("Failed to connect profile:", err);
setStartupError(err instanceof Error ? err.message : String(err));
});
} }
}, [activeProfile, isLoading, connectActiveProfile]);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [activeProfile, isLoading, connectActiveProfile, t]);
if (isLoading) { if (isLoading) {
return ( return (
@ -34,6 +58,10 @@ export default function App() {
); );
} }
if (startupError) {
return <ErrorPage error={startupError} />;
}
if (!activeProfile) { if (!activeProfile) {
return <ProfileSelectionPage />; return <ProfileSelectionPage />;
} }

View file

@ -0,0 +1,34 @@
import { Component, type ReactNode } from "react";
import ErrorPage from "./ErrorPage";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: string | null;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error: error.message };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return <ErrorPage error={this.state.error ?? undefined} />;
}
return this.props.children;
}
}

View file

@ -0,0 +1,123 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
import { check } from "@tauri-apps/plugin-updater";
interface ErrorPageProps {
error?: string;
}
export default function ErrorPage({ error }: ErrorPageProps) {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleCheckUpdate = async () => {
setUpdateStatus("checking");
setUpdateError(null);
try {
const update = await check();
if (update) {
setUpdateStatus("available");
setUpdateVersion(update.version);
} else {
setUpdateStatus("upToDate");
}
} catch (e) {
setUpdateStatus("error");
setUpdateError(e instanceof Error ? e.message : String(e));
}
};
const handleRefresh = () => {
window.location.reload();
};
return (
<div className="flex items-center justify-center min-h-screen bg-[var(--background)] p-4">
<div className="max-w-md w-full space-y-6 text-center">
<AlertTriangle className="mx-auto h-16 w-16 text-[var(--destructive)]" />
<h1 className="text-2xl font-bold text-[var(--foreground)]">
{t("error.title")}
</h1>
{error && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="inline-flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
{showDetails ? t("error.hideDetails") : t("error.showDetails")}
{showDetails ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{showDetails && (
<pre className="mt-2 p-3 bg-[var(--muted)] rounded-md text-xs text-left text-[var(--muted-foreground)] overflow-auto max-h-40">
{error}
</pre>
)}
</div>
)}
<div className="flex flex-col gap-3">
<button
onClick={handleRefresh}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
>
<RefreshCw className="h-4 w-4" />
{t("error.refresh")}
</button>
<button
onClick={handleCheckUpdate}
disabled={updateStatus === "checking"}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
{updateStatus === "checking" ? t("common.loading") : t("error.checkUpdate")}
</button>
{updateStatus === "available" && updateVersion && (
<p className="text-sm text-[var(--primary)]">
{t("error.updateAvailable", { version: updateVersion })}
</p>
)}
{updateStatus === "upToDate" && (
<p className="text-sm text-[var(--muted-foreground)]">
{t("error.upToDate")}
</p>
)}
{updateStatus === "error" && updateError && (
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
)}
</div>
<div className="pt-4 border-t border-[var(--border)]">
<p className="text-sm font-medium text-[var(--foreground)] mb-3">
{t("error.contactUs")}
</p>
<div className="flex flex-col gap-2 text-sm">
<a
href="mailto:lacompagniemaximus@protonmail.com"
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
>
<Mail className="h-4 w-4" />
{t("error.contactEmail")} lacompagniemaximus@protonmail.com
</a>
<a
href="https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
>
<Bug className="h-4 w-4" />
{t("error.reportIssue")}
</a>
</div>
</div>
</div>
</div>
);
}

View file

@ -778,6 +778,20 @@
"manageProfiles": "Manage Profiles", "manageProfiles": "Manage Profiles",
"default": "Default" "default": "Default"
}, },
"error": {
"title": "An error occurred",
"startupTimeout": "Database connection timed out",
"unexpectedError": "An unexpected error occurred",
"showDetails": "Show details",
"hideDetails": "Hide details",
"refresh": "Refresh",
"checkUpdate": "Check for updates",
"updateAvailable": "Update available: v{{version}}",
"upToDate": "The application is up to date",
"contactUs": "Contact us",
"contactEmail": "Send an email to",
"reportIssue": "Report an issue"
},
"common": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",

View file

@ -778,6 +778,20 @@
"manageProfiles": "Gérer les profils", "manageProfiles": "Gérer les profils",
"default": "Par défaut" "default": "Par défaut"
}, },
"error": {
"title": "Une erreur est survenue",
"startupTimeout": "La connexion à la base de données a expiré",
"unexpectedError": "Une erreur inattendue s'est produite",
"showDetails": "Afficher les détails",
"hideDetails": "Masquer les détails",
"refresh": "Actualiser",
"checkUpdate": "Vérifier les mises à jour",
"updateAvailable": "Mise à jour disponible : v{{version}}",
"upToDate": "L'application est à jour",
"contactUs": "Nous contacter",
"contactEmail": "Envoyez un email à",
"reportIssue": "Signaler un problème"
},
"common": { "common": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",

View file

@ -2,13 +2,16 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { ProfileProvider } from "./contexts/ProfileContext"; import { ProfileProvider } from "./contexts/ProfileContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import "./i18n/config"; import "./i18n/config";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<ProfileProvider> <ProfileProvider>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</ProfileProvider> </ProfileProvider>
</React.StrictMode>, </React.StrictMode>,
); );