diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 64412313..cbbf1c35 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -32,6 +32,7 @@ "react": "^19.2.0", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", + "react-error-boundary": "^6.1.2", "react-i18next": "^16.5.4", "react-plotly.js": "^2.6.0", "react-resizable-panels": "^3.0.6", @@ -6997,6 +6998,15 @@ "react": "^19.2.4" } }, + "node_modules/react-error-boundary": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz", + "integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-i18next": { "version": "16.5.4", "license": "MIT", diff --git a/webapp/package.json b/webapp/package.json index 2b94f3de..4647beeb 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -49,6 +49,7 @@ "react": "^19.2.0", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", + "react-error-boundary": "^6.1.2", "react-i18next": "^16.5.4", "react-plotly.js": "^2.6.0", "react-resizable-panels": "^3.0.6", diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index b8e056f3..4ac6920c 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -1,114 +1,5 @@ -import { useEffect, useState } from "react"; -import { ClipLoader } from "react-spinners"; - -import { DashboardHeader } from "@widgets/dashboard/dashboard-header"; - -import { - ChartForestPotential, - type ChartForestPotentialData, -} from "@features/charts/biodiversity/chart-forest-potential"; - -import { LAYERS } from "@shared/api/layers"; -import { useApi } from "@shared/hooks/useApi"; - -export type DataField = { value: number | null; error: number | null }; - -export type DashboardData = Record< - number, - { beneficiary: Record; control: Record } ->; - -function twoDecimals(data: Record) { - return Object.fromEntries( - Object.entries(data).map(([key, { value, error }]) => [ - key, - { - error: error == null ? 0 : Number(error.toFixed(2)), - value: value == null ? 0 : Number(value.toFixed(2)), - }, - ]), - ) as Record; -} - -function formatBeneficiaryData( - beneficiary: Record, -): ChartForestPotentialData { - return { - deadWood: beneficiary.epf_deadWood.value ?? 0, - density: beneficiary.epf_tree_density.value ?? 0, - diameterDistribution: beneficiary.epf_diameter_distribution.value ?? 0, - diversity: beneficiary.epf_tree_diversity.value ?? 0, - dominantHeight: beneficiary.epf_dominant_height.value ?? 0, - microHabitat: beneficiary.epf_microhabitats.value ?? 0, - spatialDistribution: beneficiary.epf_spatial_distribution.value ?? 0, - verticalDistribution: beneficiary.epf_vertical_distribution.value ?? 0, - }; -} +import Dashboard from "@widgets/dashboard/dashboard"; export default function DashboardPage() { - const api = useApi(); - const [selectedYear, setSelectedYear] = useState(2024); - const [data, setData] = useState({}); - const [chartData, setChartData] = useState>({}); - const [loading, setLoading] = useState(true); - - // biome-ignore lint/correctness/useExhaustiveDependencies : - useEffect(() => { - loadDashboardData(); - }, []); - - const loadDashboardData = async () => { - try { - const dashboardData = await api.getDashboardData(LAYERS.INVENTARY); - setData(dashboardData); - setChartData(dashboardData[selectedYear]?.beneficiary ?? {}); - } catch (error) { - console.error("Erreur lors du chargement des données:", error); - } finally { - setLoading(false); - } - }; - - const handleYearChange = (year: string) => { - const numericYear = Number(year); - if (!isNaN(numericYear)) { - setSelectedYear(numericYear); - setChartData(data[numericYear]?.beneficiary ?? {}); - } else { - console.warn("Année sélectionnée invalide:", year); - } - }; - - if (loading) { - return ( -
- -
- ); - } - - return ( -
- -
- -
-
- ); + return ; } diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index 08232962..a1a079b7 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -1,5 +1,10 @@ { "dashboard": { + "error": { + "retry": "Retry", + "title": "Error while loading data", + "unknownMessage": "An unknown error occurred. Please try again later." + }, "select": { "year": "Year" } diff --git a/webapp/src/shared/i18n/translations/fr/all4trees.json b/webapp/src/shared/i18n/translations/fr/all4trees.json index 82472bfa..f53adf8e 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -1,5 +1,10 @@ { "dashboard": { + "error": { + "retry": "Réessayer", + "title": "Erreur lors du chargement des données", + "unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard." + }, "select": { "year": "Année" } diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx new file mode 100644 index 00000000..b749a8c6 --- /dev/null +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -0,0 +1,96 @@ +import { Suspense, useCallback, useMemo, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import { getFallbackRender } from "@widgets/dashboard/error-boundary-fallback"; +import LoadedDashboard, { + type DashboardData, +} from "@widgets/dashboard/loaded-dashboard"; +import Loading from "@widgets/dashboard/loading"; + +import { LAYERS } from "@shared/api/layers"; +import { useApi } from "@shared/hooks/useApi"; +import { useTranslation } from "@shared/i18n"; + +type GetDashboardData = (layer: string) => Promise; +type Layer = (typeof LAYERS)[keyof typeof LAYERS]; + +// ✅ Cache Promises so the same one is reused across renders +// required by 'use()', see https://react.dev/reference/react/use#caching-promises-for-client-components +// Cache is scoped by API client (auth token) + layer to avoid leaking data across sessions. +const cache = new WeakMap< + GetDashboardData, + Map> +>(); + +function getPerApiCache(getDashboardData: GetDashboardData) { + const perApiCache = cache.get(getDashboardData); + if (perApiCache) { + return perApiCache; + } + const newPerApiCache = new Map>(); + cache.set(getDashboardData, newPerApiCache); + return newPerApiCache; +} + +function fetchData({ + getDashboardData, + layer, + force, +}: { + getDashboardData: GetDashboardData; + layer: Layer; + force?: boolean; +}): Promise { + const perLayerCache = getPerApiCache(getDashboardData); + const cachedPromise = perLayerCache.get(layer); + + if (cachedPromise && !force) { + return cachedPromise; + } + const promise = getDashboardData(layer).catch((err) => { + // Don't cache failures forever; allow retries (e.g. after navigation / remount). + perLayerCache.delete(layer); + throw err; + }); + perLayerCache.set(layer, promise); + + return promise; +} + +export default function Dashboard() { + const { t } = useTranslation("all4trees"); + const { getDashboardData } = useApi(); + const [reloadKey, setReloadKey] = useState(0); + const dataPromise = useMemo( + () => + fetchData({ + force: reloadKey > 0, + getDashboardData, + layer: LAYERS.INVENTARY, + }), + [getDashboardData, reloadKey], + ); + + const retry = useCallback(() => { + setReloadKey((k) => k + 1); + }, []); + const fallbackRender = useMemo( + () => getFallbackRender({ retry, t }), + [retry, t], + ); + + return ( + { + // Log the error to your error reporting service + console.error("Error in Dashboard:", error, info); + }} + resetKeys={[dataPromise]} + > + }> + + + + ); +} diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx new file mode 100644 index 00000000..5fad46e1 --- /dev/null +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -0,0 +1,37 @@ +import type { TFunction } from "i18next"; +import { type FallbackProps, getErrorMessage } from "react-error-boundary"; + +import { Button } from "@ui/button"; + +// t must be passed to the fallback render function because the error boundary fallback component cannot call hooks like useTranslation +export function getFallbackRender({ + retry, + t, +}: { + retry?: () => void; + t: TFunction<"all4trees", undefined>; +}) { + function FallbackRender({ error }: FallbackProps) { + const errorMessage = + getErrorMessage(error) ?? t("dashboard.error.unknownMessage"); + + return ( +
+

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

+

{errorMessage}

+ {retry && ( + + )} +
+ ); + } + + return FallbackRender; +} diff --git a/webapp/src/widgets/dashboard/loaded-dashboard.tsx b/webapp/src/widgets/dashboard/loaded-dashboard.tsx new file mode 100644 index 00000000..4fc2e0bd --- /dev/null +++ b/webapp/src/widgets/dashboard/loaded-dashboard.tsx @@ -0,0 +1,84 @@ +import { use, useState } from "react"; + +import { DashboardHeader } from "@widgets/dashboard/dashboard-header"; + +import { + ChartForestPotential, + type ChartForestPotentialData, +} from "@features/charts/biodiversity/chart-forest-potential"; + +export type DataField = { value: number | null; error: number | null }; + +const clientType = { + BENEF1: 1, + BENEF2: 2, + TEMOIN: 0, +} as const; + +export type DashboardData = Record< + number, + [ + Record, // temoin + Record, // benef (?) + Record, // benef (?) + ] +>; + +function getValue({ value }: DataField) { + // only keep two decimals, and return 0 if value is null + return value == null ? 0 : Number(value.toFixed(2)); +} + +function formatBeneficiaryData( + beneficiary: Record, +): ChartForestPotentialData { + return { + deadWood: getValue(beneficiary.bio_idx_deadWood), + density: getValue(beneficiary.bio_idx_tree_density), + diameterDistribution: getValue(beneficiary.bio_idx_diametric_distribution), + diversity: getValue(beneficiary.bio_idx_tree_diversity), + dominantHeight: getValue(beneficiary.bio_idx_dominant_height), + microHabitat: getValue(beneficiary.bio_idx_microhabitats), + spatialDistribution: getValue(beneficiary.bio_idx_spatial_distribution), + verticalDistribution: getValue(beneficiary.bio_idx_vertical_distribution), + }; +} + +export default function LoadedDashboard({ + dataPromise, +}: { + dataPromise: Promise; +}) { + const data = use(dataPromise); + + const [selectedYear, setSelectedYear] = useState(2025); + const chartData = data[selectedYear][clientType.BENEF1]; + + const handleYearChange = (year: string) => { + const numericYear = Number(year); + if (!Number.isNaN(numericYear)) { + setSelectedYear(numericYear); + } else { + console.warn("Année sélectionnée invalide:", year); + } + }; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/webapp/src/widgets/dashboard/loading.tsx b/webapp/src/widgets/dashboard/loading.tsx new file mode 100644 index 00000000..14cfed58 --- /dev/null +++ b/webapp/src/widgets/dashboard/loading.tsx @@ -0,0 +1,18 @@ +import { ClipLoader } from "react-spinners"; + +export default function Loading() { + return ( +
+ +
+ ); +}