From b5db8d2c9b3bd6e3bd8f3df0034c22e96a81955e Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 09:12:41 +0200 Subject: [PATCH 01/23] 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 a5ceede5f78117512bf81864216fd59b35aa034d Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 10:40:02 +0200 Subject: [PATCH 02/23] 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 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 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 d0e37d924d7eaed12314a72f42c3d2b60e886e47 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 10:40:51 +0200 Subject: [PATCH 03/23] 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 d971122e168ba86445ef442f9d85e14cf282cf2d Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 11:32:52 +0200 Subject: [PATCH 04/23] 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 e47e5c66006f9d62954d905ed8191c05cf2c54e9 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 11:42:57 +0200 Subject: [PATCH 05/23] 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 9d725621f908b01fa7f882fd86d4c25dde1210a3 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 16:17:25 +0200 Subject: [PATCH 06/23] 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 e6573d181f7ec1fa7efe07114acc2dad0da64740 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 16:08:56 +0200 Subject: [PATCH 07/23] 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 8f9ba3f56caf14cfc22545aa1f2521943fc3fe87 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 16:09:16 +0200 Subject: [PATCH 08/23] 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 9e1374063e9a24b4f37f8cc35c670947552abe20 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Tue, 30 Jun 2026 16:25:31 +0200 Subject: [PATCH 09/23] 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 b17174a3056c706ba5e3558b8497ae67824bc428 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 10:52:43 +0200 Subject: [PATCH 10/23] rename get to fetch because it's a promise --- webapp/src/shared/api/client.ts | 2 +- webapp/src/widgets/dashboard/dashboard.tsx | 24 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index bfed61be..181b5c16 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -41,7 +41,7 @@ export const fetchJSONWithAuth = async ( export const createApiClient = (authToken: string | null) => ({ // Bases - getDashboardData: (layerId: string) => + fetchDashboardData: (layerId: string) => fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), }); diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 7a7e505f..d3f6544a 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -11,41 +11,41 @@ import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; import { useTranslation } from "@shared/i18n"; -type GetDashboardData = (layer: string) => Promise; +type FetchDashboardData = (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, + FetchDashboardData, Map> >(); -function getPerApiCache(getDashboardData: GetDashboardData) { - const perApiCache = cache.get(getDashboardData); +function getPerApiCache(fetchDashboardData: FetchDashboardData) { + const perApiCache = cache.get(fetchDashboardData); if (perApiCache) { return perApiCache; } const newPerApiCache = new Map>(); - cache.set(getDashboardData, newPerApiCache); + cache.set(fetchDashboardData, newPerApiCache); return newPerApiCache; } function fetchData({ - getDashboardData, + fetchDashboardData, layer, }: { - getDashboardData: GetDashboardData; + fetchDashboardData: FetchDashboardData; layer: Layer; }): Promise { - const cache = getPerApiCache(getDashboardData); + const cache = getPerApiCache(fetchDashboardData); const cachedPromise = cache.get(layer); if (cachedPromise) { return cachedPromise; } - const promise = getDashboardData(layer).catch((err) => { + const promise = fetchDashboardData(layer).catch((err) => { // Don't cache failures forever; allow retries (e.g. after navigation / remount). cache.delete(layer); throw err; @@ -57,14 +57,14 @@ function fetchData({ export default function Dashboard() { const { t } = useTranslation("all4trees"); - const api = useApi(); + const { fetchDashboardData } = useApi(); const fetch = useCallback( () => fetchData({ - getDashboardData: api.getDashboardData, + fetchDashboardData, layer: LAYERS.INVENTARY, }), - [api], + [fetchDashboardData], ); const [dataPromise, setDataPromise] = useState(fetch); From 06ca01e026dd6c33efb9c3764fe7e4ac2e88bc62 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 11:01:15 +0200 Subject: [PATCH 11/23] move ApiContext from shared to features to later improve types ('metier') --- webapp/src/app/providers/ApiProvider.tsx | 5 +- webapp/src/features/api/client.ts | 47 +++++++++++++++++++ .../contexts/ApiContext.ts | 2 +- .../src/{shared => features}/hooks/useApi.ts | 2 +- webapp/src/shared/api/client.ts | 46 ------------------ webapp/src/widgets/dashboard/dashboard.tsx | 3 +- 6 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 webapp/src/features/api/client.ts rename webapp/src/{shared => features}/contexts/ApiContext.ts (67%) rename webapp/src/{shared => features}/hooks/useApi.ts (79%) diff --git a/webapp/src/app/providers/ApiProvider.tsx b/webapp/src/app/providers/ApiProvider.tsx index bedf9447..aaf2888b 100644 --- a/webapp/src/app/providers/ApiProvider.tsx +++ b/webapp/src/app/providers/ApiProvider.tsx @@ -1,9 +1,8 @@ import { type ReactNode, useMemo } from "react"; +import { createApiClient } from "@features/api/client"; import { useAuth } from "@features/auth/useAuth"; - -import { createApiClient } from "@shared/api/client"; -import { ApiContext } from "@shared/contexts/ApiContext"; +import { ApiContext } from "@features/contexts/ApiContext"; interface ApiProviderProps { children: ReactNode; diff --git a/webapp/src/features/api/client.ts b/webapp/src/features/api/client.ts new file mode 100644 index 00000000..a4ce00d0 --- /dev/null +++ b/webapp/src/features/api/client.ts @@ -0,0 +1,47 @@ +import { API_URL } from "@shared/api/client"; + +const fetchWithAuth = async ( + endpoint: string, + options: RequestInit = {}, + authToken: string | null, +) => { + const headers = new Headers(options.headers); + headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); + + const res = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!res.ok) { + console.error(`Erreur API: ${res.status} ${res.statusText}`); + const errorData = await res.json().catch(() => ({ + details: [res.statusText], + error: "Erreur de communication", + })); + console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); + + const error = new Error(`Erreur API: ${res.status}`); + + // @ts-expect-error Property 'response' does not exist on type 'Error'. + error.response = { data: errorData }; + + throw error; + } + + return res; +}; + +const fetchJSONWithAuth = async ( + endpoint: string, + options: RequestInit = {}, + authToken: string | null, +) => (await fetchWithAuth(endpoint, options, authToken)).json(); + +export const createApiClient = (authToken: string | null) => ({ + // Bases + fetchDashboardData: (layerId: string) => + fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), +}); + +export type ApiClient = ReturnType; diff --git a/webapp/src/shared/contexts/ApiContext.ts b/webapp/src/features/contexts/ApiContext.ts similarity index 67% rename from webapp/src/shared/contexts/ApiContext.ts rename to webapp/src/features/contexts/ApiContext.ts index eaec3e46..08423f89 100644 --- a/webapp/src/shared/contexts/ApiContext.ts +++ b/webapp/src/features/contexts/ApiContext.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import type { ApiClient } from "@shared/api/client"; +import type { ApiClient } from "@features/api/client"; export const ApiContext = createContext(undefined); diff --git a/webapp/src/shared/hooks/useApi.ts b/webapp/src/features/hooks/useApi.ts similarity index 79% rename from webapp/src/shared/hooks/useApi.ts rename to webapp/src/features/hooks/useApi.ts index 85b4a7c8..0df5bf46 100644 --- a/webapp/src/shared/hooks/useApi.ts +++ b/webapp/src/features/hooks/useApi.ts @@ -1,6 +1,6 @@ import { useContext } from "react"; -import { ApiContext } from "@shared/contexts/ApiContext"; +import { ApiContext } from "@features/contexts/ApiContext"; export function useApi() { const context = useContext(ApiContext); diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index 181b5c16..28e8c6ca 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -1,48 +1,2 @@ export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; - -export const fetchWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => { - const headers = new Headers(options.headers); - headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); - - const res = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); - - if (!res.ok) { - console.error(`Erreur API: ${res.status} ${res.statusText}`); - const errorData = await res.json().catch(() => ({ - details: [res.statusText], - error: "Erreur de communication", - })); - console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); - - const error = new Error(`Erreur API: ${res.status}`); - - // @ts-expect-error Property 'response' does not exist on type 'Error'. - error.response = { data: errorData }; - - throw error; - } - - return res; -}; - -export const fetchJSONWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => (await fetchWithAuth(endpoint, options, authToken)).json(); - -export const createApiClient = (authToken: string | null) => ({ - // Bases - fetchDashboardData: (layerId: string) => - fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), -}); - -export type ApiClient = ReturnType; diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index d3f6544a..ae7b52c0 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -7,8 +7,9 @@ import LoadedDashboard, { } from "@widgets/dashboard/loaded-dashboard"; import Loading from "@widgets/dashboard/loading"; +import { useApi } from "@features/hooks/useApi"; + import { LAYERS } from "@shared/api/layers"; -import { useApi } from "@shared/hooks/useApi"; import { useTranslation } from "@shared/i18n"; type FetchDashboardData = (layer: string) => Promise; From fab137c674e0dfe7691cbb21a7c47091d09727f3 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 13:08:08 +0200 Subject: [PATCH 12/23] i18n dashboard header --- webapp/src/shared/i18n/translations/en/all4trees.json | 6 ++++++ webapp/src/shared/i18n/translations/fr/all4trees.json | 6 ++++++ webapp/src/widgets/dashboard/dashboard-header.tsx | 8 ++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index a1a079b7..279d4ba9 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -5,6 +5,12 @@ "title": "Error while loading data", "unknownMessage": "An unknown error occurred. Please try again later." }, + "header": { + "catalog": "Charts catalog", + "greeting": "Hello {{username}}! 👋", + "temporaryBeneficiary": "⚠ Beneficiary", + "temporaryFilter": "⚠ Filter" + }, "select": { "year": "Year" } diff --git a/webapp/src/shared/i18n/translations/fr/all4trees.json b/webapp/src/shared/i18n/translations/fr/all4trees.json index f53adf8e..00af7ad7 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -5,6 +5,12 @@ "title": "Erreur lors du chargement des données", "unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard." }, + "header": { + "catalog": "Catalogue des graphiques", + "greeting": "Bonjour {{username}} ! 👋", + "temporaryBeneficiary": "⚠ Bénéficiaire", + "temporaryFilter": "⚠ Filtre" + }, "select": { "year": "Année" } diff --git a/webapp/src/widgets/dashboard/dashboard-header.tsx b/webapp/src/widgets/dashboard/dashboard-header.tsx index 59a9a4fa..345c980d 100644 --- a/webapp/src/widgets/dashboard/dashboard-header.tsx +++ b/webapp/src/widgets/dashboard/dashboard-header.tsx @@ -27,14 +27,14 @@ export function DashboardHeader({

- Bonjour{` ${username}`} ! 👋 + {t("dashboard.header.greeting", { username })}

-

⚠ Filtre

+

{t("dashboard.header.temporaryFilter")}

-

Catalogue des graphiques

+

{t("dashboard.header.catalog")}

-

⚠ Bénéficiaire

+

{t("dashboard.header.temporaryBeneficiary")}

From f6817c60866b55088048b24d5ddcec740a8b9e84 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 14:55:19 +0200 Subject: [PATCH 13/23] remove type, as typescript infers it better --- .../features/popup/forest-inventory/popup-forest-inventory.tsx | 2 +- webapp/src/features/popup/socio-eco/popup-socio-eco.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index bf34fd61..60995812 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -20,7 +20,7 @@ type ForestInventoryPopupContentProps = RenderPopupProps; type TabKind = "biodiversity" | "soil"; -const TABS: Record = { +const TABS = { BIODIVERSITY: "biodiversity", SOIL: "soil", } as const; diff --git a/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx b/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx index f432b1db..50d6508e 100644 --- a/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx +++ b/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx @@ -21,7 +21,7 @@ type SocioEcoIndicatorProps = RenderPopupProps; type TabKind = "resources" | "economy"; -const TABS: Record = { +const TABS = { ECONOMY: "economy", RESOURCES: "resources", } as const; From c998fa61067a6e8af25dc14b33c25b87b80347ac Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 15:37:28 +0200 Subject: [PATCH 14/23] improve type --- webapp/src/features/charts/soil/lib/sunburst.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webapp/src/features/charts/soil/lib/sunburst.ts b/webapp/src/features/charts/soil/lib/sunburst.ts index dcfa5844..2954ddd2 100644 --- a/webapp/src/features/charts/soil/lib/sunburst.ts +++ b/webapp/src/features/charts/soil/lib/sunburst.ts @@ -48,11 +48,18 @@ export function buildSunburstNodes( return nodes; } -export const getLevelPalettes = () => { +type ThreeColorsPalette = [string, string, string]; +type ThreePalettes = [ + ThreeColorsPalette, + ThreeColorsPalette, + ThreeColorsPalette, +]; + +export const getLevelPalettes = (): ThreePalettes => { const palette = getChartPalette(); return [ - palette.slice(0, 3), + [palette[0], palette[1], palette[2]], [palette[3], palette[4], palette[0]], [palette[1], palette[2], palette[3]], ]; From 87542a696656409b5b59982d84a6c3e424464f4a Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 15:39:49 +0200 Subject: [PATCH 15/23] fix types by throwing an error if a taxon is undefined (bug) --- webapp/src/features/charts/soil/lib/taxon.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webapp/src/features/charts/soil/lib/taxon.ts b/webapp/src/features/charts/soil/lib/taxon.ts index 0bca6ce3..3bfecb1b 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -8,6 +8,11 @@ export function getTaxonLabels( dataType: "tsbf" | "barbA", ): [string, string, string] { const [taxon1, taxon2, taxon3] = element.split("-"); + if (taxon1 === undefined || taxon2 === undefined || taxon3 === undefined) { + throw new Error( + `Invalid taxon element: ${element}. It should have three parts separated by a dash '-'.`, + ); + } const taxon1Label = findCategoricalLabel(metadata, `${dataType}_tax1`, taxon1) || taxon1; const taxon2Label = From 7709f659d6034993af67eea8ce923e840ff13a5a Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 15:54:49 +0200 Subject: [PATCH 16/23] refactor to extract logic to entities and add type safety --- webapp/biome.json | 3 + webapp/package-lock.json | 12 ++- webapp/package.json | 3 +- webapp/src/entities/dashboard/epf.ts | 18 ++++ webapp/src/entities/dashboard/generic.ts | 30 ++++++ webapp/src/features/api/client.ts | 53 +++-------- .../biodiversity/chart-forest-potential.tsx | 20 +++- .../charts/components/radar-benef-control.tsx | 7 +- webapp/src/shared/api/client.ts | 39 ++++++++ webapp/src/shared/lib/palette.ts | 9 +- webapp/src/shared/lib/utils.ts | 2 + webapp/src/shared/ui/chart.tsx | 4 + webapp/src/widgets/dashboard/dashboard.tsx | 6 +- .../{dashboard-header.tsx => header.tsx} | 27 ++++-- .../widgets/dashboard/loaded-dashboard.tsx | 91 +++++++------------ .../src/widgets/dashboard/year-dashboard.tsx | 38 ++++++++ webapp/tsconfig.app.json | 3 +- 17 files changed, 249 insertions(+), 116 deletions(-) create mode 100644 webapp/src/entities/dashboard/epf.ts create mode 100644 webapp/src/entities/dashboard/generic.ts rename webapp/src/widgets/dashboard/{dashboard-header.tsx => header.tsx} (74%) create mode 100644 webapp/src/widgets/dashboard/year-dashboard.tsx diff --git a/webapp/biome.json b/webapp/biome.json index ea96cdd8..cee73349 100644 --- a/webapp/biome.json +++ b/webapp/biome.json @@ -16,6 +16,7 @@ "!@pages/**", "!@widgets/**", "!@features/**", + "!@entities/**", "!@shared/**", "!@lib/**", "!@i18n", @@ -31,6 +32,8 @@ ":BLANK_LINE:", "@features/**", ":BLANK_LINE:", + "@entities/**", + ":BLANK_LINE:", "@shared/**", "@lib/**", "@i18n", diff --git a/webapp/package-lock.json b/webapp/package-lock.json index cbbf1c35..28a9a2aa 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -38,7 +38,8 @@ "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.0", "react-spinners": "^0.17.0", - "recharts": "^2.15.4" + "recharts": "^2.15.4", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.4", @@ -8107,6 +8108,15 @@ "version": "3.1.1", "dev": true, "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/webapp/package.json b/webapp/package.json index 4647beeb..65f7f4d7 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -55,7 +55,8 @@ "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.0", "react-spinners": "^0.17.0", - "recharts": "^2.15.4" + "recharts": "^2.15.4", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.4", diff --git a/webapp/src/entities/dashboard/epf.ts b/webapp/src/entities/dashboard/epf.ts new file mode 100644 index 00000000..5cd43734 --- /dev/null +++ b/webapp/src/entities/dashboard/epf.ts @@ -0,0 +1,18 @@ +// TODO: rename epf to something more meaningful, once I understand what it means. + +import * as z from "zod"; + +import { ValueAndErrorSchema } from "@entities/dashboard/generic"; + +export const EpfDataSchema = z.object({ + bio_idx_deadWood: ValueAndErrorSchema, + bio_idx_diametric_distribution: ValueAndErrorSchema, + bio_idx_dominant_height: ValueAndErrorSchema, + bio_idx_microhabitats: ValueAndErrorSchema, + bio_idx_spatial_distribution: ValueAndErrorSchema, + bio_idx_tree_density: ValueAndErrorSchema, + bio_idx_tree_diversity: ValueAndErrorSchema, + bio_idx_vertical_distribution: ValueAndErrorSchema, +}); + +export type EpfData = z.infer; diff --git a/webapp/src/entities/dashboard/generic.ts b/webapp/src/entities/dashboard/generic.ts new file mode 100644 index 00000000..ddb6b88e --- /dev/null +++ b/webapp/src/entities/dashboard/generic.ts @@ -0,0 +1,30 @@ +import * as z from "zod"; + +export const ValueAndErrorSchema = z + .object({ + error: z.number().nullable(), + value: z.number().nullable(), + }) + .default(() => ({ + error: null, + value: null, + })); + +const DictionaryDataSchema = z.record(z.string(), ValueAndErrorSchema); + +export type DictionaryData = z.infer; + +const MIN_YEAR = 1900; +const MAX_YEAR = 2100; +const YearSchema = z.number().int().min(MIN_YEAR).max(MAX_YEAR); + +const YearDataSchema = z.object({ + beneficiary: DictionaryDataSchema, + control: DictionaryDataSchema, +}); + +export type YearData = z.infer; + +export const DashboardDataSchema = z.record(YearSchema, YearDataSchema); + +export type DashboardData = z.infer; diff --git a/webapp/src/features/api/client.ts b/webapp/src/features/api/client.ts index a4ce00d0..6dee3419 100644 --- a/webapp/src/features/api/client.ts +++ b/webapp/src/features/api/client.ts @@ -1,47 +1,20 @@ -import { API_URL } from "@shared/api/client"; +import { + type DashboardData, + DashboardDataSchema, +} from "@entities/dashboard/generic"; -const fetchWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => { - const headers = new Headers(options.headers); - headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); - - const res = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); - - if (!res.ok) { - console.error(`Erreur API: ${res.status} ${res.statusText}`); - const errorData = await res.json().catch(() => ({ - details: [res.statusText], - error: "Erreur de communication", - })); - console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); - - const error = new Error(`Erreur API: ${res.status}`); - - // @ts-expect-error Property 'response' does not exist on type 'Error'. - error.response = { data: errorData }; - - throw error; - } - - return res; -}; - -const fetchJSONWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => (await fetchWithAuth(endpoint, options, authToken)).json(); +import { fetchJSONWithAuth } from "@shared/api/client"; export const createApiClient = (authToken: string | null) => ({ // Bases - fetchDashboardData: (layerId: string) => - fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), + fetchDashboardData: async (layerId: string): Promise => { + const json = await fetchJSONWithAuth( + `/maps/dashboard/${layerId}`, + {}, + authToken, + ); + return DashboardDataSchema.parse(json); + }, }); export type ApiClient = ReturnType; diff --git a/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx b/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx index c28f40cd..3ae7970b 100644 --- a/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx +++ b/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx @@ -1,7 +1,9 @@ -import { useTranslation } from "@i18n"; +import type { ChartComponentType } from "@features/charts/components/chart-component"; +import { ChartRadarWithBenefAndControl } from "@features/charts/components/radar-benef-control"; + +import type { EpfData } from "@entities/dashboard/epf"; -import type { ChartComponentType } from "../components/chart-component"; -import { ChartRadarWithBenefAndControl } from "../components/radar-benef-control"; +import { useTranslation } from "@i18n"; export type ChartForestPotentialData = { density: number; @@ -19,6 +21,18 @@ type ChartForestPotentialProps = { temoin?: ChartForestPotentialData; }; +export function fromEpfData(data: EpfData): ChartForestPotentialData { + return { + deadWood: data.bio_idx_deadWood.value ?? 0, + density: data.bio_idx_tree_density.value ?? 0, + diameterDistribution: data.bio_idx_diametric_distribution.value ?? 0, + diversity: data.bio_idx_tree_diversity.value ?? 0, + dominantHeight: data.bio_idx_dominant_height.value ?? 0, + microHabitat: data.bio_idx_microhabitats.value ?? 0, + spatialDistribution: data.bio_idx_spatial_distribution.value ?? 0, + verticalDistribution: data.bio_idx_vertical_distribution.value ?? 0, + }; +} export const ChartForestPotential: ChartComponentType< ChartForestPotentialProps > = ({ benef, temoin }) => { diff --git a/webapp/src/features/charts/components/radar-benef-control.tsx b/webapp/src/features/charts/components/radar-benef-control.tsx index b58c2819..b1c0e9e9 100644 --- a/webapp/src/features/charts/components/radar-benef-control.tsx +++ b/webapp/src/features/charts/components/radar-benef-control.tsx @@ -57,7 +57,12 @@ export const ChartRadarWithBenefAndControl: ChartComponentType< outerRadius="68%" > } + content={ + `AAA ${value} BBB`} + /> + } cursor={true} /> { + const headers = new Headers(options.headers); + headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); + + const res = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!res.ok) { + console.error(`Erreur API: ${res.status} ${res.statusText}`); + const errorData = await res.json().catch(() => ({ + details: [res.statusText], + error: "Erreur de communication", + })); + console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); + + const error = new Error(`Erreur API: ${res.status}`); + + // @ts-expect-error Property 'response' does not exist on type 'Error'. + error.response = { data: errorData }; + + throw error; + } + + return res; +}; + +export const fetchJSONWithAuth = async ( + endpoint: string, + options: RequestInit = {}, + authToken: string | null, +): Promise => + (await fetchWithAuth(endpoint, options, authToken)).json(); diff --git a/webapp/src/shared/lib/palette.ts b/webapp/src/shared/lib/palette.ts index ff7b4bc6..f51e7a62 100644 --- a/webapp/src/shared/lib/palette.ts +++ b/webapp/src/shared/lib/palette.ts @@ -6,7 +6,14 @@ const getCssVarColor = (name: string, fallback: string) => { ); }; -export const getChartPalette = () => [ +export const getChartPalette = (): [ + string, + string, + string, + string, + string, + string, +] => [ getCssVarColor("--chart-1", "#97cf17"), getCssVarColor("--chart-2", "#f98038"), getCssVarColor("--chart-3", "#2d6db4"), diff --git a/webapp/src/shared/lib/utils.ts b/webapp/src/shared/lib/utils.ts index 06bc3f27..d3e67ba4 100644 --- a/webapp/src/shared/lib/utils.ts +++ b/webapp/src/shared/lib/utils.ts @@ -10,6 +10,8 @@ export function cn(...inputs: ClassValue[]) { export function precise(value?: number | null) { if (!value || Number.isNaN(value)) { + // TODO: missing or erroneous values should not be considered as 0, but rather as null or undefined. + // when displayed, they should be shown as "N/A" or "No data", or the point could be omitted from the chart. return "0"; } if (value > 999) { diff --git a/webapp/src/shared/ui/chart.tsx b/webapp/src/shared/ui/chart.tsx index 7b319fd6..b001ab42 100644 --- a/webapp/src/shared/ui/chart.tsx +++ b/webapp/src/shared/ui/chart.tsx @@ -244,6 +244,10 @@ const ChartTooltipContent = React.forwardRef< { /* Force a space between item label and value*/ "\xa0" + item.value.toLocaleString() + // ^ TODO: why not using CSS to add a space between the label and the value? + // ^ TODO: pass a prop to format the item value (e.g.: no more than 2 decimals, or other formatting rules) + // ^ TODO: use the current locale to format the item value (e.g.: 1,000.00 in en-US, 1 000,00 in fr-FR, etc.) + // ^ TODO: add the unit? } )} diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index ae7b52c0..0c0c36ae 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -2,13 +2,13 @@ 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 LoadedDashboard from "@widgets/dashboard/loaded-dashboard"; import Loading from "@widgets/dashboard/loading"; import { useApi } from "@features/hooks/useApi"; +import type { DashboardData } from "@entities/dashboard/generic"; + import { LAYERS } from "@shared/api/layers"; import { useTranslation } from "@shared/i18n"; diff --git a/webapp/src/widgets/dashboard/dashboard-header.tsx b/webapp/src/widgets/dashboard/header.tsx similarity index 74% rename from webapp/src/widgets/dashboard/dashboard-header.tsx rename to webapp/src/widgets/dashboard/header.tsx index 345c980d..0ca63f29 100644 --- a/webapp/src/widgets/dashboard/dashboard-header.tsx +++ b/webapp/src/widgets/dashboard/header.tsx @@ -1,3 +1,5 @@ +import { useCallback } from "react"; + import { useTranslation } from "@shared/i18n"; import { @@ -9,19 +11,32 @@ import { SelectValue, } from "@ui/select"; -export type DashboardHeaderProps = { +export type HeaderProps = { years: number[]; selectedYear: number; - onValueChange: (year: string) => void; + onYearChange: (year: number) => void; }; -export function DashboardHeader({ +export default function Header({ years, selectedYear, - onValueChange: onvalueChange, -}: DashboardHeaderProps) { + onYearChange, +}: HeaderProps) { const { t } = useTranslation("all4trees"); const username = localStorage.getItem("username") || ""; + + const onStringYearChange = useCallback( + (year: string) => { + const numericYear = Number(year); + if (!Number.isNaN(numericYear)) { + onYearChange(numericYear); + } else { + console.warn("Année sélectionnée invalide:", year); + } + }, + [onYearChange], + ); + return (
@@ -37,7 +52,7 @@ export function DashboardHeader({

{t("dashboard.header.catalog")}