diff --git a/CHANGELOG.md b/CHANGELOG.md index 19365dc..5d3ceda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [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] ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 3092686..17f307e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ **Stockage :** SQLite local (tauri-plugin-sql) **Langues supportées :** Français (FR) et Anglais (EN) **Plateformes :** Windows, Linux -**Version actuelle :** 0.3.11 +**Version actuelle :** 0.4.4 --- @@ -35,7 +35,7 @@ ``` src/ -├── components/ # 49 composants React organisés par domaine +├── components/ # 53 composants React organisés par domaine │ ├── adjustments/ # Ajustements │ ├── budget/ # Budget │ ├── categories/ # Catégories hiérarchiques @@ -67,7 +67,7 @@ src-tauri/ │ │ ├── schema.sql # Schéma initial (v1) │ │ ├── seed_categories.sql # Seed catégories (v2) │ │ └── 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 └── Cargo.toml ``` @@ -114,8 +114,8 @@ src-tauri/ ## Base de données -- **13 tables** SQLite, **9 index** (voir `docs/architecture.md` pour le détail) -- **6 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`) +- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail) +- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`) - **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 @@ -153,7 +153,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC ## CI/CD - 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 --- diff --git a/docs/architecture.md b/docs/architecture.md index e1fd2f6..3ae0b6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ simpl-resultat/ │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── reports/ # 8 composants (graphiques + rapport dynamique) │ │ ├── settings/ # 2 composants -│ │ ├── shared/ # 4 composants réutilisables +│ │ ├── shared/ # 6 composants réutilisables │ │ └── transactions/ # 5 composants │ ├── contexts/ # ProfileContext (état global profil) │ ├── 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). +### Gestion d'erreurs + +- **`ErrorBoundary`** (class component) : wrape `` 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 | |-------|------|-------------| | `/` | `DashboardPage` | Tableau de bord avec graphiques | diff --git a/src/App.tsx b/src/App.tsx index 4dc7fd7..a0e2889 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ 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 AppShell from "./components/layout/AppShell"; import DashboardPage from "./pages/DashboardPage"; @@ -12,19 +13,42 @@ import ReportsPage from "./pages/ReportsPage"; import SettingsPage from "./pages/SettingsPage"; import DocsPage from "./pages/DocsPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage"; +import ErrorPage from "./components/shared/ErrorPage"; + +const STARTUP_TIMEOUT_MS = 10_000; export default function App() { + const { t } = useTranslation(); const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile(); const [dbReady, setDbReady] = useState(false); + const [startupError, setStartupError] = useState(null); + const timeoutRef = useRef | null>(null); useEffect(() => { if (activeProfile && !isLoading) { setDbReady(false); + setStartupError(null); + + timeoutRef.current = setTimeout(() => { + setStartupError(t("error.startupTimeout")); + }, STARTUP_TIMEOUT_MS); + connectActiveProfile() - .then(() => setDbReady(true)) - .catch((err) => console.error("Failed to connect profile:", err)); + .then(() => { + 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) { return ( @@ -34,6 +58,10 @@ export default function App() { ); } + if (startupError) { + return ; + } + if (!activeProfile) { return ; } diff --git a/src/components/shared/ErrorBoundary.tsx b/src/components/shared/ErrorBoundary.tsx new file mode 100644 index 0000000..1e74b6a --- /dev/null +++ b/src/components/shared/ErrorBoundary.tsx @@ -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 { + 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 ; + } + + return this.props.children; + } +} diff --git a/src/components/shared/ErrorPage.tsx b/src/components/shared/ErrorPage.tsx new file mode 100644 index 0000000..630c95b --- /dev/null +++ b/src/components/shared/ErrorPage.tsx @@ -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(null); + const [updateError, setUpdateError] = useState(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 ( +
+
+ + +

+ {t("error.title")} +

+ + {error && ( +
+ + {showDetails && ( +
+                {error}
+              
+ )} +
+ )} + +
+ + + + + {updateStatus === "available" && updateVersion && ( +

+ {t("error.updateAvailable", { version: updateVersion })} +

+ )} + {updateStatus === "upToDate" && ( +

+ {t("error.upToDate")} +

+ )} + {updateStatus === "error" && updateError && ( +

{updateError}

+ )} +
+ + +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 429523e..35fef04 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -778,6 +778,20 @@ "manageProfiles": "Manage Profiles", "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": { "save": "Save", "cancel": "Cancel", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8794227..105f315 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -778,6 +778,20 @@ "manageProfiles": "Gérer les profils", "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": { "save": "Enregistrer", "cancel": "Annuler", diff --git a/src/main.tsx b/src/main.tsx index 29aca4e..cd4ea6c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,13 +2,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { ProfileProvider } from "./contexts/ProfileContext"; +import ErrorBoundary from "./components/shared/ErrorBoundary"; import "./i18n/config"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , );