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 64412313..28a9a2aa 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -32,12 +32,14 @@ "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", "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", @@ -6997,6 +6999,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", @@ -8097,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 2b94f3de..65f7f4d7 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -49,12 +49,14 @@ "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", "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/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/entities/dashboard/biodiversity-index.ts b/webapp/src/entities/dashboard/biodiversity-index.ts new file mode 100644 index 00000000..d8089f35 --- /dev/null +++ b/webapp/src/entities/dashboard/biodiversity-index.ts @@ -0,0 +1,18 @@ +// Tropical Biodiversity Index + +import * as z from "zod"; + +import { ValueAndErrorSchema } from "@entities/dashboard/generic"; + +export const BiodiversityIndexSchema = 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 BiodiversityIndex = z.infer; diff --git a/webapp/src/entities/dashboard/generic.ts b/webapp/src/entities/dashboard/generic.ts new file mode 100644 index 00000000..acdba6f3 --- /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.coerce.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 new file mode 100644 index 00000000..6dee3419 --- /dev/null +++ b/webapp/src/features/api/client.ts @@ -0,0 +1,20 @@ +import { + type DashboardData, + DashboardDataSchema, +} from "@entities/dashboard/generic"; + +import { fetchJSONWithAuth } from "@shared/api/client"; + +export const createApiClient = (authToken: string | null) => ({ + // Bases + 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..d9f83261 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 { BiodiversityIndex } from "@entities/dashboard/biodiversity-index"; -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,20 @@ type ChartForestPotentialProps = { temoin?: ChartForestPotentialData; }; +export function fromBiodiversityIndex( + data: BiodiversityIndex, +): 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/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]], ]; diff --git a/webapp/src/features/charts/soil/lib/taxon.ts b/webapp/src/features/charts/soil/lib/taxon.ts index 0bca6ce3..c25c31f6 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -2,35 +2,18 @@ import type { LayerMetadata } from "coordo"; import { findCategoricalLabel } from "@shared/lib/utils"; -export function getTaxonLabels( - element: string, - metadata: LayerMetadata, - dataType: "tsbf" | "barbA", -): [string, string, string] { - const [taxon1, taxon2, taxon3] = element.split("-"); - const taxon1Label = - findCategoricalLabel(metadata, `${dataType}_tax1`, taxon1) || taxon1; - const taxon2Label = - findCategoricalLabel(metadata, `${dataType}_tax2`, taxon2) || taxon2; - const taxon3Label = - findCategoricalLabel(metadata, `${dataType}_tax3`, taxon3) || taxon3; - return [taxon1Label, taxon2Label, taxon3Label]; -} - export function formatTaxonLevelLabel( element: string, metadata: LayerMetadata, dataType: "tsbf" | "barbA", ): string { - const [taxon1Label, taxon2Label, taxon3Label] = getTaxonLabels( - element, - metadata, - dataType, - ); - const parts = element.split("-"); - return parts.length === 1 - ? taxon1Label - : parts.length === 2 - ? taxon2Label - : taxon3Label; + // 'taxon1 = element' is for typescript... taxon1 should never be undefined, but typescript doesn't know that + const [taxon1 = element, taxon2, taxon3] = element.split("-"); + if (taxon2 === undefined) { + return findCategoricalLabel(metadata, `${dataType}_tax1`, taxon1) || taxon1; + } + if (taxon3 === undefined) { + return findCategoricalLabel(metadata, `${dataType}_tax2`, taxon2) || taxon2; + } + return findCategoricalLabel(metadata, `${dataType}_tax3`, taxon3) || taxon3; } 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/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; diff --git a/webapp/src/pages/all4trees/dashboard.tsx b/webapp/src/pages/all4trees/dashboard.tsx index b8e056f3..4ac6920c 100644 --- a/webapp/src/pages/all4trees/dashboard.tsx +++ b/webapp/src/pages/all4trees/dashboard.tsx @@ -1,114 +1,5 @@ -import { useEffect, useState } from "react"; -import { ClipLoader } from "react-spinners"; - -import { DashboardHeader } from "@widgets/dashboard/dashboard-header"; - -import { - ChartForestPotential, - type ChartForestPotentialData, -} from "@features/charts/biodiversity/chart-forest-potential"; - -import { LAYERS } from "@shared/api/layers"; -import { useApi } from "@shared/hooks/useApi"; - -export type DataField = { value: number | null; error: number | null }; - -export type DashboardData = Record< - number, - { beneficiary: Record; control: Record } ->; - -function twoDecimals(data: Record) { - return Object.fromEntries( - Object.entries(data).map(([key, { value, error }]) => [ - key, - { - error: error == null ? 0 : Number(error.toFixed(2)), - value: value == null ? 0 : Number(value.toFixed(2)), - }, - ]), - ) as Record; -} - -function formatBeneficiaryData( - beneficiary: Record, -): ChartForestPotentialData { - return { - deadWood: beneficiary.epf_deadWood.value ?? 0, - density: beneficiary.epf_tree_density.value ?? 0, - diameterDistribution: beneficiary.epf_diameter_distribution.value ?? 0, - diversity: beneficiary.epf_tree_diversity.value ?? 0, - dominantHeight: beneficiary.epf_dominant_height.value ?? 0, - microHabitat: beneficiary.epf_microhabitats.value ?? 0, - spatialDistribution: beneficiary.epf_spatial_distribution.value ?? 0, - verticalDistribution: beneficiary.epf_vertical_distribution.value ?? 0, - }; -} +import Dashboard from "@widgets/dashboard/dashboard"; export default function DashboardPage() { - const api = useApi(); - const [selectedYear, setSelectedYear] = useState(2024); - const [data, setData] = useState({}); - const [chartData, setChartData] = useState>({}); - const [loading, setLoading] = useState(true); - - // biome-ignore lint/correctness/useExhaustiveDependencies : - useEffect(() => { - loadDashboardData(); - }, []); - - const loadDashboardData = async () => { - try { - const dashboardData = await api.getDashboardData(LAYERS.INVENTARY); - setData(dashboardData); - setChartData(dashboardData[selectedYear]?.beneficiary ?? {}); - } catch (error) { - console.error("Erreur lors du chargement des données:", error); - } finally { - setLoading(false); - } - }; - - const handleYearChange = (year: string) => { - const numericYear = Number(year); - if (!isNaN(numericYear)) { - setSelectedYear(numericYear); - setChartData(data[numericYear]?.beneficiary ?? {}); - } else { - console.warn("Année sélectionnée invalide:", year); - } - }; - - if (loading) { - return ( -
- -
- ); - } - - return ( -
- -
- -
-
- ); + return ; } diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index bfed61be..4e4bf875 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -1,7 +1,7 @@ export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; -export const fetchWithAuth = async ( +const fetchWithAuth = async ( endpoint: string, options: RequestInit = {}, authToken: string | null, @@ -37,12 +37,5 @@ export const fetchJSONWithAuth = async ( endpoint: string, options: RequestInit = {}, authToken: string | null, -) => (await fetchWithAuth(endpoint, options, authToken)).json(); - -export const createApiClient = (authToken: string | null) => ({ - // Bases - getDashboardData: (layerId: string) => - fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), -}); - -export type ApiClient = ReturnType; +): Promise => + (await fetchWithAuth(endpoint, options, authToken)).json(); diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index 08232962..279d4ba9 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -1,5 +1,16 @@ { "dashboard": { + "error": { + "retry": "Retry", + "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 82472bfa..00af7ad7 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -1,5 +1,16 @@ { "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." + }, + "header": { + "catalog": "Catalogue des graphiques", + "greeting": "Bonjour {{username}} ! 👋", + "temporaryBeneficiary": "⚠ Bénéficiaire", + "temporaryFilter": "⚠ Filtre" + }, "select": { "year": "Année" } 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 new file mode 100644 index 00000000..0c0c36ae --- /dev/null +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -0,0 +1,90 @@ +import { Suspense, useCallback, useMemo, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import { getFallbackRender } from "@widgets/dashboard/error-boundary-fallback"; +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"; + +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< + FetchDashboardData, + Map> +>(); + +function getPerApiCache(fetchDashboardData: FetchDashboardData) { + const perApiCache = cache.get(fetchDashboardData); + if (perApiCache) { + return perApiCache; + } + const newPerApiCache = new Map>(); + cache.set(fetchDashboardData, newPerApiCache); + return newPerApiCache; +} + +function fetchData({ + fetchDashboardData, + layer, +}: { + fetchDashboardData: FetchDashboardData; + layer: Layer; +}): Promise { + const cache = getPerApiCache(fetchDashboardData); + const cachedPromise = cache.get(layer); + + if (cachedPromise) { + return cachedPromise; + } + const promise = fetchDashboardData(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; +} + +export default function Dashboard() { + const { t } = useTranslation("all4trees"); + const { fetchDashboardData } = useApi(); + const fetch = useCallback( + () => + fetchData({ + fetchDashboardData, + layer: LAYERS.INVENTARY, + }), + [fetchDashboardData], + ); + const [dataPromise, setDataPromise] = useState(fetch); + + const retry = useCallback(() => { + setDataPromise(fetch()); + }, [fetch]); + const fallbackRender = useMemo( + () => getFallbackRender({ retry, t }), + [retry, t], + ); + + 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..d2ab8704 --- /dev/null +++ b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx @@ -0,0 +1,36 @@ +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({ + retry, + t, +}: { + retry?: () => void; + t: TFunction<"all4trees", undefined>; +}) { + function FallbackRender({ error }: FallbackProps) { + const errorMessage = + getErrorMessage(error) ?? t("dashboard.error.unknownMessage"); + + return ( +
+

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

+

{errorMessage}

+ {retry && ( + + )} +
+ ); + } + + return FallbackRender; +} diff --git a/webapp/src/widgets/dashboard/dashboard-header.tsx b/webapp/src/widgets/dashboard/header.tsx similarity index 61% rename from webapp/src/widgets/dashboard/dashboard-header.tsx rename to webapp/src/widgets/dashboard/header.tsx index 59a9a4fa..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,35 +11,48 @@ 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 (

- 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")}

diff --git a/webapp/src/widgets/dashboard/loaded-dashboard.tsx b/webapp/src/widgets/dashboard/loaded-dashboard.tsx new file mode 100644 index 00000000..486e959a --- /dev/null +++ b/webapp/src/widgets/dashboard/loaded-dashboard.tsx @@ -0,0 +1,59 @@ +import { use, useCallback, useState } from "react"; + +import Header from "@widgets/dashboard/header"; +import YearDashboard from "@widgets/dashboard/year-dashboard"; + +import type { DashboardData } from "@entities/dashboard/generic"; + +const INITIAL_YEAR = 2024; + +export default function LoadedDashboard({ + dataPromise, +}: { + dataPromise: Promise; +}) { + const data = use(dataPromise); + const sortedYears = Object.keys(data) + .map(Number) + .filter((year) => !Number.isNaN(year)) + .sort((a, b) => a - b); + const initialYear = sortedYears.includes(INITIAL_YEAR) + ? INITIAL_YEAR + : sortedYears[0]; + const [selectedYear, setSelectedYear] = useState( + initialYear, + ); + + const handleYearChange = useCallback( + (year: number) => { + if (data[year]) { + setSelectedYear(year); + } else { + console.warn("Année sélectionnée non disponible:", year); + } + }, + [data], + ); + + if (selectedYear === undefined || !data[selectedYear]) { + // TODO: maybe we could ensure this never occurs by validating the data structure and using types (such as [T, ...T[]] for the years array) + return
No dashboard data available.
; + } + + 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 ( +
+ +
+ ); +} diff --git a/webapp/src/widgets/dashboard/year-dashboard.tsx b/webapp/src/widgets/dashboard/year-dashboard.tsx new file mode 100644 index 00000000..a4f1469c --- /dev/null +++ b/webapp/src/widgets/dashboard/year-dashboard.tsx @@ -0,0 +1,42 @@ +import { + ChartForestPotential, + fromBiodiversityIndex, +} from "@features/charts/biodiversity/chart-forest-potential"; + +import { BiodiversityIndexSchema } from "@entities/dashboard/biodiversity-index"; +import type { YearData } from "@entities/dashboard/generic"; + +// TODO: don't do that! Pass the original values, and truncate only when displaying them, +// because otherwise we might still use them as numbers and get unexpected results (e.g. 0.1 + 0.2 = 0.30000000000000004) +// function twoDecimals(data: DictionaryData): DictionaryData { +// 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)), +// }, +// ]), +// ); +// } + +export default function YearDashboard({ data }: { data: YearData }) { + // TODO: what to do if the data is not valid? Should we throw an error, or just display a message? + // Note: it should not happen anyway, thanks to the defaults + const benefBiodiversityIndex = BiodiversityIndexSchema.parse( + data.beneficiary, + ); + + // TODO: ensure we have no need for calling twoDecimals here + // const benefBiodiversityIndex = twoDecimals(BiodiversityIndexSchema.safeParse(data.beneficiary)) + // See item.value.toLocaleString() and following TODOs in shared/ui/chart.tsx for formatting details + // pass a prop to format the item value? (e.g. d => d.toFixed(2).toLocaleString() to get the same result as before) + + return ( +
+ +
+ ); +} diff --git a/webapp/tsconfig.app.json b/webapp/tsconfig.app.json index c99cecb2..f85ee404 100644 --- a/webapp/tsconfig.app.json +++ b/webapp/tsconfig.app.json @@ -21,7 +21,8 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "noUncheckedIndexedAccess": true }, "include": ["src"] }