From 0e8528757b63e781c460a88385bbf84d58c1b1d8 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 09:12:41 +0200 Subject: [PATCH 01/18] use safer Number.isNaN (no coertion) --- webapp/src/pages/all4trees/dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index b8e056f3..cffa98ec 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -71,7 +71,7 @@ export default function DashboardPage() { const handleYearChange = (year: string) => { const numericYear = Number(year); - if (!isNaN(numericYear)) { + if (!Number.isNaN(numericYear)) { setSelectedYear(numericYear); setChartData(data[numericYear]?.beneficiary ?? {}); } else { From 7f240b8c7193062ad3b0db1d516187e85f5a0247 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 10:40:02 +0200 Subject: [PATCH 02/18] replace useEffect with (i18n'ed) ErrorBoundary + Suspense + use() --- webapp/package-lock.json | 10 ++ webapp/package.json | 1 + webapp/src/pages/all4trees/dashboard.tsx | 127 +++++++++++++----- .../i18n/translations/en/all4trees.json | 4 + .../i18n/translations/fr/all4trees.json | 4 + 5 files changed, 111 insertions(+), 35 deletions(-) diff --git a/webapp/package-lock.json b/webapp/package-lock.json index b059af07..afa5ddb9 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 e5429eb4..c81d006d 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 cffa98ec..69cc1b16 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -1,4 +1,10 @@ -import { useEffect, useState } from "react"; +import type { TFunction } from "i18next"; +import { Suspense, use, useState } from "react"; +import { + ErrorBoundary, + type FallbackProps, + getErrorMessage, +} from "react-error-boundary"; import { ClipLoader } from "react-spinners"; import { DashboardHeader } from "@widgets/dashboard/dashboard-header"; @@ -10,6 +16,7 @@ import { import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; +import { useTranslation } from "@shared/i18n"; export type DataField = { value: number | null; error: number | null }; @@ -45,52 +52,71 @@ function formatBeneficiaryData( }; } -export default function DashboardPage() { +function Loading() { + return ( +
+ +
+ ); +} + +// ✅ Cache the Promise so the same one is reused across renders +// required by 'use()', see https://react.dev/reference/react/use#caching-promises-for-client-components +const cache = new Map< + (typeof LAYERS)[keyof typeof LAYERS], + Promise +>(); + +// TODO: bettter typing (no "as") +export function fetchData({ + getDashboardData, + layer, +}: { + getDashboardData: (layerId: string) => Promise; + layer: (typeof LAYERS)[keyof typeof LAYERS]; +}): Promise { + const cachedPromise = cache.get(layer); + if (cachedPromise) { + return cachedPromise; + } + const promise = getDashboardData(layer); + cache.set(layer, promise); + return promise; +} + +function Dashboard() { const api = useApi(); + + const data = use( + fetchData({ + getDashboardData: api.getDashboardData, + layer: LAYERS.INVENTARY, + }), + ); + + return ; +} + +function YearDashboard({ data }: { data: DashboardData }) { 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 chartData = (data[selectedYear]?.beneficiary ?? {}) as Record< + string, + DataField + >; const handleYearChange = (year: string) => { const numericYear = Number(year); if (!Number.isNaN(numericYear)) { setSelectedYear(numericYear); - setChartData(data[numericYear]?.beneficiary ?? {}); } else { console.warn("Année sélectionnée invalide:", year); } }; - if (loading) { - return ( -
- -
- ); - } - return (
); } + +// t must be passed to the fallback render function because the error boundary fallback component cannot call hooks like useTranslation +function getFallbackRender({ t }: { t: TFunction<"all4trees", undefined> }) { + function FallbackRender({ error }: FallbackProps) { + const errorMessage = + getErrorMessage(error) ?? t("dashboard.error.unknownMessage"); + + return ( +
+

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

+

{errorMessage}

+
+ ); + } + + return FallbackRender; +} + +export default function DashboardPage() { + const { t } = useTranslation("all4trees"); + + return ( + + }> + + + + ); +} diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index 08232962..6eed8053 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -1,5 +1,9 @@ { "dashboard": { + "error": { + "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..8f311087 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -1,5 +1,9 @@ { "dashboard": { + "error": { + "title": "Erreur lors du chargement des données", + "unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard." + }, "select": { "year": "Année" } From d1a061176df9bf3c80d83ba62d2b4992aef4d979 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 10:40:51 +0200 Subject: [PATCH 03/18] use a theme color (light green) instead of blue --- webapp/src/pages/all4trees/dashboard.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index 69cc1b16..b87ec6e8 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -56,7 +56,12 @@ function Loading() { return (
From 472c49afcede42bd3861fc4ca5037a4dc602ad1e Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 11:32:52 +0200 Subject: [PATCH 04/18] rename --- webapp/src/pages/all4trees/dashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index b87ec6e8..aa2a8fd6 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -103,10 +103,10 @@ function Dashboard() { }), ); - return ; + return ; } -function YearDashboard({ data }: { data: DashboardData }) { +function LoadedDashboard({ data }: { data: DashboardData }) { const [selectedYear, setSelectedYear] = useState(2024); const chartData = (data[selectedYear]?.beneficiary ?? {}) as Record< string, From 999d32507d6d8cc5caf1a1291861f2f02e03fb90 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 11:42:57 +0200 Subject: [PATCH 05/18] move components to widgets --- webapp/src/pages/all4trees/dashboard.tsx | 166 +----------------- webapp/src/widgets/dashboard/dashboard.tsx | 45 +++++ .../dashboard/error-boundary-fallback.tsx | 25 +++ .../widgets/dashboard/loaded-dashboard.tsx | 80 +++++++++ webapp/src/widgets/dashboard/loading.tsx | 18 ++ 5 files changed, 173 insertions(+), 161 deletions(-) create mode 100644 webapp/src/widgets/dashboard/dashboard.tsx create mode 100644 webapp/src/widgets/dashboard/error-boundary-fallback.tsx create mode 100644 webapp/src/widgets/dashboard/loaded-dashboard.tsx create mode 100644 webapp/src/widgets/dashboard/loading.tsx diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index aa2a8fd6..f0d44da0 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -1,168 +1,12 @@ -import type { TFunction } from "i18next"; -import { Suspense, use, useState } from "react"; -import { - ErrorBoundary, - type FallbackProps, - getErrorMessage, -} from "react-error-boundary"; -import { ClipLoader } from "react-spinners"; +import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; -import { DashboardHeader } from "@widgets/dashboard/dashboard-header"; +import Dashboard from "@widgets/dashboard/dashboard"; +import { getFallbackRender } from "@widgets/dashboard/error-boundary-fallback"; +import Loading from "@widgets/dashboard/loading"; -import { - ChartForestPotential, - type ChartForestPotentialData, -} from "@features/charts/biodiversity/chart-forest-potential"; - -import { LAYERS } from "@shared/api/layers"; -import { useApi } from "@shared/hooks/useApi"; import { useTranslation } from "@shared/i18n"; -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, - }; -} - -function Loading() { - return ( -
- -
- ); -} - -// ✅ Cache the Promise so the same one is reused across renders -// required by 'use()', see https://react.dev/reference/react/use#caching-promises-for-client-components -const cache = new Map< - (typeof LAYERS)[keyof typeof LAYERS], - Promise ->(); - -// TODO: bettter typing (no "as") -export function fetchData({ - getDashboardData, - layer, -}: { - getDashboardData: (layerId: string) => Promise; - layer: (typeof LAYERS)[keyof typeof LAYERS]; -}): Promise { - const cachedPromise = cache.get(layer); - if (cachedPromise) { - return cachedPromise; - } - const promise = getDashboardData(layer); - cache.set(layer, promise); - return promise; -} - -function Dashboard() { - const api = useApi(); - - const data = use( - fetchData({ - getDashboardData: api.getDashboardData, - layer: LAYERS.INVENTARY, - }), - ); - - return ; -} - -function LoadedDashboard({ data }: { data: DashboardData }) { - const [selectedYear, setSelectedYear] = useState(2024); - const chartData = (data[selectedYear]?.beneficiary ?? {}) as Record< - string, - DataField - >; - - 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 ( -
- -
- -
-
- ); -} - -// t must be passed to the fallback render function because the error boundary fallback component cannot call hooks like useTranslation -function getFallbackRender({ t }: { t: TFunction<"all4trees", undefined> }) { - function FallbackRender({ error }: FallbackProps) { - const errorMessage = - getErrorMessage(error) ?? t("dashboard.error.unknownMessage"); - - return ( -
-

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

-

{errorMessage}

-
- ); - } - - return FallbackRender; -} - export default function DashboardPage() { const { t } = useTranslation("all4trees"); diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx new file mode 100644 index 00000000..db25f380 --- /dev/null +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -0,0 +1,45 @@ +import { use } from "react"; + +import LoadedDashboard, { + type DashboardData, +} from "@widgets/dashboard/loaded-dashboard"; + +import { LAYERS } from "@shared/api/layers"; +import { useApi } from "@shared/hooks/useApi"; + +// ✅ Cache the Promise so the same one is reused across renders +// required by 'use()', see https://react.dev/reference/react/use#caching-promises-for-client-components +const cache = new Map< + (typeof LAYERS)[keyof typeof LAYERS], + Promise +>(); + +// TODO: bettter typing (no "as") +export function fetchData({ + getDashboardData, + layer, +}: { + getDashboardData: (layerId: string) => Promise; + layer: (typeof LAYERS)[keyof typeof LAYERS]; +}): Promise { + const cachedPromise = cache.get(layer); + if (cachedPromise) { + return cachedPromise; + } + const promise = getDashboardData(layer); + cache.set(layer, promise); + return promise; +} + +export default function Dashboard() { + const api = useApi(); + + const data = use( + fetchData({ + getDashboardData: api.getDashboardData, + layer: LAYERS.INVENTARY, + }), + ); + + return ; +} 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..caf5e143 --- /dev/null +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -0,0 +1,25 @@ +import type { TFunction } from "i18next"; +import { type FallbackProps, getErrorMessage } from "react-error-boundary"; + +// t must be passed to the fallback render function because the error boundary fallback component cannot call hooks like useTranslation +export function getFallbackRender({ + t, +}: { + t: TFunction<"all4trees", undefined>; +}) { + function FallbackRender({ error }: FallbackProps) { + const errorMessage = + getErrorMessage(error) ?? t("dashboard.error.unknownMessage"); + + return ( +
+

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

+

{errorMessage}

+
+ ); + } + + 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..5e1af443 --- /dev/null +++ b/webapp/src/widgets/dashboard/loaded-dashboard.tsx @@ -0,0 +1,80 @@ +import { 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 }; + +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, + }; +} + +export default function LoadedDashboard({ data }: { data: DashboardData }) { + const [selectedYear, setSelectedYear] = useState(2024); + const chartData = (data[selectedYear]?.beneficiary ?? {}) as Record< + string, + DataField + >; + + 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 ( +
+ +
+ ); +} From cf1a6319ac28686280c659f994f0ff48ac49a63e Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 16:17:25 +0200 Subject: [PATCH 06/18] improve promises cache (per API token, and don't cache errors) --- webapp/src/widgets/dashboard/dashboard.tsx | 36 +++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index db25f380..75cf6f6d 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -7,27 +7,47 @@ import LoadedDashboard, { import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; -// ✅ Cache the Promise so the same one is reused across renders +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 -const cache = new Map< - (typeof LAYERS)[keyof typeof LAYERS], - Promise +// Cache is scoped by API client (auth token) + layer to avoid leaking data across sessions. +const cache = new WeakMap< + GetDashboardData, + Map> >(); -// TODO: bettter typing (no "as") +function getPerApiCache(getDashboardData: GetDashboardData) { + const perApiCache = cache.get(getDashboardData); + if (perApiCache) { + return perApiCache; + } + const newPerApiCache = new Map>(); + cache.set(getDashboardData, newPerApiCache); + return newPerApiCache; +} + export function fetchData({ getDashboardData, layer, }: { - getDashboardData: (layerId: string) => Promise; - layer: (typeof LAYERS)[keyof typeof LAYERS]; + getDashboardData: GetDashboardData; + layer: Layer; }): Promise { + const cache = getPerApiCache(getDashboardData); const cachedPromise = cache.get(layer); + if (cachedPromise) { return cachedPromise; } - const promise = getDashboardData(layer); + const promise = getDashboardData(layer).catch((err) => { + // Don't cache failures forever; allow retries (e.g. after navigation / remount). + cache.delete(layer); + throw err; + }); cache.set(layer, promise); + return promise; } From 9b359c4351516ec15ccb036c76d99669b4123995 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 16:08:56 +0200 Subject: [PATCH 07/18] fix fetch logic and error management --- webapp/src/pages/all4trees/dashboard.tsx | 17 +-------- .../i18n/translations/en/all4trees.json | 1 + .../i18n/translations/fr/all4trees.json | 1 + webapp/src/widgets/dashboard/dashboard.tsx | 37 +++++++++++++++---- .../dashboard/error-boundary-fallback.tsx | 13 ++++++- .../widgets/dashboard/loaded-dashboard.tsx | 10 ++++- 6 files changed, 52 insertions(+), 27 deletions(-) diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index f0d44da0..4ac6920c 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -1,20 +1,5 @@ -import { Suspense } from "react"; -import { ErrorBoundary } from "react-error-boundary"; - import Dashboard from "@widgets/dashboard/dashboard"; -import { getFallbackRender } from "@widgets/dashboard/error-boundary-fallback"; -import Loading from "@widgets/dashboard/loading"; - -import { useTranslation } from "@shared/i18n"; export default function DashboardPage() { - const { t } = useTranslation("all4trees"); - - return ( - - }> - - - - ); + return ; } diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index 6eed8053..a1a079b7 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -1,6 +1,7 @@ { "dashboard": { "error": { + "retry": "Retry", "title": "Error while loading data", "unknownMessage": "An unknown error occurred. Please try again later." }, diff --git a/webapp/src/shared/i18n/translations/fr/all4trees.json b/webapp/src/shared/i18n/translations/fr/all4trees.json index 8f311087..f53adf8e 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -1,6 +1,7 @@ { "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." }, diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 75cf6f6d..8be7c85c 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -1,11 +1,15 @@ -import { use } from "react"; +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]; @@ -28,7 +32,7 @@ function getPerApiCache(getDashboardData: GetDashboardData) { return newPerApiCache; } -export function fetchData({ +function fetchData({ getDashboardData, layer, }: { @@ -52,14 +56,31 @@ export function fetchData({ } export default function Dashboard() { + const { t } = useTranslation("all4trees"); const api = useApi(); + const fetch = useCallback( + () => + fetchData({ + getDashboardData: api.getDashboardData, + layer: LAYERS.INVENTARY, + }), + [api], + ); + const [dataPromise, setDataPromise] = useState(fetch); - const data = use( - fetchData({ - getDashboardData: api.getDashboardData, - layer: LAYERS.INVENTARY, - }), + const retry = useCallback(() => { + setDataPromise(fetch()); + }, [fetch]); + const fallbackRender = useMemo( + () => getFallbackRender({ retry, t }), + [retry, t], ); - return ; + return ( + + }> + + + + ); } diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx index caf5e143..d2ab8704 100644 --- a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -3,8 +3,10 @@ import { type FallbackProps, getErrorMessage } from "react-error-boundary"; // 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) { @@ -13,10 +15,19 @@ export function getFallbackRender({ return (
-

+

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

{errorMessage}

+ {retry && ( + + )}
); } diff --git a/webapp/src/widgets/dashboard/loaded-dashboard.tsx b/webapp/src/widgets/dashboard/loaded-dashboard.tsx index 5e1af443..66bee46d 100644 --- a/webapp/src/widgets/dashboard/loaded-dashboard.tsx +++ b/webapp/src/widgets/dashboard/loaded-dashboard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { use, useState } from "react"; import { DashboardHeader } from "@widgets/dashboard/dashboard-header"; @@ -41,7 +41,13 @@ function formatBeneficiaryData( }; } -export default function LoadedDashboard({ data }: { data: DashboardData }) { +export default function LoadedDashboard({ + dataPromise, +}: { + dataPromise: Promise; +}) { + const data = use(dataPromise); + const [selectedYear, setSelectedYear] = useState(2024); const chartData = (data[selectedYear]?.beneficiary ?? {}) as Record< string, From c83facba52d8d66521f1e52dd6281bca1d1511da Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 16:09:16 +0200 Subject: [PATCH 08/18] adapt data to the new backend format --- .../src/widgets/dashboard/loaded-dashboard.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/webapp/src/widgets/dashboard/loaded-dashboard.tsx b/webapp/src/widgets/dashboard/loaded-dashboard.tsx index 66bee46d..b8255d34 100644 --- a/webapp/src/widgets/dashboard/loaded-dashboard.tsx +++ b/webapp/src/widgets/dashboard/loaded-dashboard.tsx @@ -30,14 +30,14 @@ 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, + deadWood: beneficiary.bio_idx_deadWood.value ?? 0, + density: beneficiary.bio_idx_tree_density.value ?? 0, + diameterDistribution: beneficiary.bio_idx_diametric_distribution.value ?? 0, + diversity: beneficiary.bio_idx_tree_diversity.value ?? 0, + dominantHeight: beneficiary.bio_idx_dominant_height.value ?? 0, + microHabitat: beneficiary.bio_idx_microhabitats.value ?? 0, + spatialDistribution: beneficiary.bio_idx_spatial_distribution.value ?? 0, + verticalDistribution: beneficiary.bio_idx_vertical_distribution.value ?? 0, }; } From a5f950cf46de620b4f7af9c0e010dd09c6bac467 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 16:25:31 +0200 Subject: [PATCH 09/18] format --- webapp/src/widgets/dashboard/dashboard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 8be7c85c..7a7e505f 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -77,7 +77,10 @@ export default function Dashboard() { ); return ( - + }> From 8a667ddff63cc920e5b616d595c706c9b0ff2d37 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 17:30:10 +0200 Subject: [PATCH 10/18] force fetching the data in case of retry --- webapp/src/widgets/dashboard/dashboard.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 7a7e505f..acec8176 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -35,14 +35,16 @@ function getPerApiCache(getDashboardData: GetDashboardData) { function fetchData({ getDashboardData, layer, + force, }: { getDashboardData: GetDashboardData; layer: Layer; + force?: boolean; }): Promise { const cache = getPerApiCache(getDashboardData); const cachedPromise = cache.get(layer); - if (cachedPromise) { + if (cachedPromise && !force) { return cachedPromise; } const promise = getDashboardData(layer).catch((err) => { @@ -59,8 +61,9 @@ export default function Dashboard() { const { t } = useTranslation("all4trees"); const api = useApi(); const fetch = useCallback( - () => + ({ force }: { force?: boolean } = {}) => fetchData({ + force, getDashboardData: api.getDashboardData, layer: LAYERS.INVENTARY, }), @@ -69,7 +72,7 @@ export default function Dashboard() { const [dataPromise, setDataPromise] = useState(fetch); const retry = useCallback(() => { - setDataPromise(fetch()); + setDataPromise(fetch({ force: true })); }, [fetch]); const fallbackRender = useMemo( () => getFallbackRender({ retry, t }), From 7d215091ade97a1c0c82238b3f7aeed95a47c7be Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 17:30:46 +0200 Subject: [PATCH 11/18] use button component --- webapp/src/widgets/dashboard/error-boundary-fallback.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx index d2ab8704..5fad46e1 100644 --- a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -1,6 +1,8 @@ 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, @@ -20,13 +22,12 @@ export function getFallbackRender({

{errorMessage}

{retry && ( - + )}
); From c90ee9a1faecbc797bebc30efd338767e6f17fc4 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 18:19:24 +0200 Subject: [PATCH 12/18] log the error in console --- webapp/src/widgets/dashboard/dashboard.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index acec8176..e7e9c642 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -82,6 +82,10 @@ export default function Dashboard() { return ( { + // Log the error to your error reporting service + console.error("Error in Dashboard:", error, info); + }} resetKeys={[dataPromise]} > }> From 3a5b3f6f8063182987d2bb51e1768bb080346894 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 18:19:55 +0200 Subject: [PATCH 13/18] adapt to the data format change in the backend --- .../widgets/dashboard/loaded-dashboard.tsx | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/webapp/src/widgets/dashboard/loaded-dashboard.tsx b/webapp/src/widgets/dashboard/loaded-dashboard.tsx index b8255d34..4fc2e0bd 100644 --- a/webapp/src/widgets/dashboard/loaded-dashboard.tsx +++ b/webapp/src/widgets/dashboard/loaded-dashboard.tsx @@ -9,35 +9,38 @@ import { export type DataField = { value: number | null; error: number | null }; +const clientType = { + BENEF1: 1, + BENEF2: 2, + TEMOIN: 0, +} as const; + export type DashboardData = Record< number, - { beneficiary: Record; control: Record } + [ + Record, // temoin + Record, // benef (?) + Record, // benef (?) + ] >; -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 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: beneficiary.bio_idx_deadWood.value ?? 0, - density: beneficiary.bio_idx_tree_density.value ?? 0, - diameterDistribution: beneficiary.bio_idx_diametric_distribution.value ?? 0, - diversity: beneficiary.bio_idx_tree_diversity.value ?? 0, - dominantHeight: beneficiary.bio_idx_dominant_height.value ?? 0, - microHabitat: beneficiary.bio_idx_microhabitats.value ?? 0, - spatialDistribution: beneficiary.bio_idx_spatial_distribution.value ?? 0, - verticalDistribution: beneficiary.bio_idx_vertical_distribution.value ?? 0, + 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), }; } @@ -48,11 +51,8 @@ export default function LoadedDashboard({ }) { const data = use(dataPromise); - const [selectedYear, setSelectedYear] = useState(2024); - const chartData = (data[selectedYear]?.beneficiary ?? {}) as Record< - string, - DataField - >; + const [selectedYear, setSelectedYear] = useState(2025); + const chartData = data[selectedYear][clientType.BENEF1]; const handleYearChange = (year: string) => { const numericYear = Number(year); @@ -77,9 +77,7 @@ export default function LoadedDashboard({ years={Object.keys(data).map(Number)} />
- +
); From a85f5fb954ef5762816f222b3f0562fbc230f567 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 12:41:54 -0400 Subject: [PATCH 14/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- webapp/src/widgets/dashboard/dashboard.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index e7e9c642..8c7e352d 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -69,11 +69,17 @@ export default function Dashboard() { }), [api], ); - const [dataPromise, setDataPromise] = useState(fetch); + + const [reloadKey, setReloadKey] = useState(0); + + const dataPromise = useMemo( + () => fetch({ force: reloadKey > 0 }), + [fetch, reloadKey], + ); const retry = useCallback(() => { - setDataPromise(fetch({ force: true })); - }, [fetch]); + setReloadKey((k) => k + 1); + }, []); const fallbackRender = useMemo( () => getFallbackRender({ retry, t }), [retry, t], From 62d807bada1b8f107d5ef237305282aefe93a123 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 12:42:25 -0400 Subject: [PATCH 15/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- webapp/src/widgets/dashboard/dashboard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 8c7e352d..6723a132 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -41,18 +41,18 @@ function fetchData({ layer: Layer; force?: boolean; }): Promise { - const cache = getPerApiCache(getDashboardData); - const cachedPromise = cache.get(layer); + 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). - cache.delete(layer); + perLayerCache.delete(layer); throw err; }); - cache.set(layer, promise); + perLayerCache.set(layer, promise); return promise; } From 00170a635849edcf4000a1049531d4028abb3c92 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 18:47:17 +0200 Subject: [PATCH 16/18] refactor --- webapp/src/widgets/dashboard/dashboard.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 6723a132..b749a8c6 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -59,22 +59,16 @@ function fetchData({ export default function Dashboard() { const { t } = useTranslation("all4trees"); - const api = useApi(); - const fetch = useCallback( - ({ force }: { force?: boolean } = {}) => + const { getDashboardData } = useApi(); + const [reloadKey, setReloadKey] = useState(0); + const dataPromise = useMemo( + () => fetchData({ - force, - getDashboardData: api.getDashboardData, + force: reloadKey > 0, + getDashboardData, layer: LAYERS.INVENTARY, }), - [api], - ); - - const [reloadKey, setReloadKey] = useState(0); - - const dataPromise = useMemo( - () => fetch({ force: reloadKey > 0 }), - [fetch, reloadKey], + [getDashboardData, reloadKey], ); const retry = useCallback(() => { From 5af383e7948010a6c7833fef2bee31ef9cacfd5f Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 2 Jul 2026 06:26:55 +0200 Subject: [PATCH 17/18] move error messages to 'common' --- webapp/src/shared/i18n/translations/en/all4trees.json | 5 ----- webapp/src/shared/i18n/translations/en/common.json | 5 +++++ webapp/src/shared/i18n/translations/fr/all4trees.json | 5 ----- webapp/src/shared/i18n/translations/fr/common.json | 5 +++++ webapp/src/widgets/dashboard/dashboard.tsx | 2 +- .../src/widgets/dashboard/error-boundary-fallback.tsx | 11 ++++------- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index a1a079b7..08232962 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -1,10 +1,5 @@ { "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/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index 7b8c2c4d..25e8ef70 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -21,6 +21,11 @@ "noData": "No data available", "undefined": "not found" }, + "error": { + "retry": "Retry", + "title": "Error while loading data", + "unknownMessage": "An unknown error occurred. Please try again later." + }, "header": { "colorModeSelector": { "dark": "Dark", diff --git a/webapp/src/shared/i18n/translations/fr/all4trees.json b/webapp/src/shared/i18n/translations/fr/all4trees.json index f53adf8e..82472bfa 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -1,10 +1,5 @@ { "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/shared/i18n/translations/fr/common.json b/webapp/src/shared/i18n/translations/fr/common.json index 92c67e41..dd830b09 100644 --- a/webapp/src/shared/i18n/translations/fr/common.json +++ b/webapp/src/shared/i18n/translations/fr/common.json @@ -21,6 +21,11 @@ "noData": "Données non disponibles", "undefined": "non trouvée" }, + "error": { + "retry": "Réessayer", + "title": "Erreur lors du chargement des données", + "unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard." + }, "header": { "colorModeSelector": { "dark": "Sombre", diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index b749a8c6..3d496a1c 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -58,7 +58,7 @@ function fetchData({ } export default function Dashboard() { - const { t } = useTranslation("all4trees"); + const { t } = useTranslation("common"); const { getDashboardData } = useApi(); const [reloadKey, setReloadKey] = useState(0); const dataPromise = useMemo( diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx index 5fad46e1..1e0f4db3 100644 --- a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -9,24 +9,21 @@ export function getFallbackRender({ t, }: { retry?: () => void; - t: TFunction<"all4trees", undefined>; + t: TFunction<"common", undefined>; }) { function FallbackRender({ error }: FallbackProps) { - const errorMessage = - getErrorMessage(error) ?? t("dashboard.error.unknownMessage"); + const errorMessage = getErrorMessage(error) ?? t("error.unknownMessage"); return (
-

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

+

{t("error.title")}

{errorMessage}

{retry && ( )}
From e66a023111c51e84f357d93fb35ea68980ab55c8 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 2 Jul 2026 06:45:05 +0200 Subject: [PATCH 18/18] improve the comment on why t must be passed as a prop --- webapp/src/widgets/dashboard/error-boundary-fallback.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx index 1e0f4db3..29bb61d1 100644 --- a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -4,6 +4,9 @@ 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 +// See https://react.dev/warnings/invalid-hook-call-warning +// > 🔴 Do not call Hooks in class components. +// while ErrorBoundary is a class component: https://github.com/bvaughn/react-error-boundary/blob/main/lib/components/ErrorBoundary.tsx#L45 export function getFallbackRender({ retry, t,