Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 2 additions & 111 deletions webapp/src/pages/all4trees/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, DataField>; control: Record<string, DataField> }
>;

function twoDecimals(data: Record<string, DataField>) {
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<string, DataField>;
}

function formatBeneficiaryData(
beneficiary: Record<string, DataField>,
): 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<number>(2024);
const [data, setData] = useState<DashboardData>({});
const [chartData, setChartData] = useState<Record<string, DataField>>({});
const [loading, setLoading] = useState(true);

// biome-ignore lint/correctness/useExhaustiveDependencies : <no need to add dependency>
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 (
<div className="flex items-center justify-center h-screen">
<ClipLoader
color="#4A90E2"
loading={loading}
size={50}
/>
</div>
);
}

return (
<div
className="px-7 overflow-y-scroll h-full pb-4 custom-scrollbar"
style={{
"--scrollbar-thumb": "var(--info-foreground)",
"--scrollbar-track": "var(--background)",
}}
>
<DashboardHeader
onValueChange={handleYearChange}
selectedYear={selectedYear}
years={Object.keys(data).map(Number)}
/>
<div className="mt-4 space-y-4">
<ChartForestPotential
benef={formatBeneficiaryData(twoDecimals(chartData))}
/>
</div>
</div>
);
return <Dashboard />;
}
5 changes: 5 additions & 0 deletions webapp/src/shared/i18n/translations/en/all4trees.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"dashboard": {
"error": {
"retry": "Retry",
"title": "Error while loading data",
"unknownMessage": "An unknown error occurred. Please try again later."
},
"select": {
"year": "Year"
}
Expand Down
5 changes: 5 additions & 0 deletions webapp/src/shared/i18n/translations/fr/all4trees.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"dashboard": {
"error": {
"retry": "Réessayer",
"title": "Erreur lors du chargement des données",
"unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard."
},
"select": {
"year": "Année"
}
Expand Down
96 changes: 96 additions & 0 deletions webapp/src/widgets/dashboard/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Suspense, useCallback, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";

import { getFallbackRender } from "@widgets/dashboard/error-boundary-fallback";
import LoadedDashboard, {
type DashboardData,
} from "@widgets/dashboard/loaded-dashboard";
import Loading from "@widgets/dashboard/loading";

import { LAYERS } from "@shared/api/layers";
import { useApi } from "@shared/hooks/useApi";
import { useTranslation } from "@shared/i18n";

type GetDashboardData = (layer: string) => Promise<DashboardData>;
type Layer = (typeof LAYERS)[keyof typeof LAYERS];

// ✅ Cache Promises so the same one is reused across renders
// required by 'use()', see https://react.dev/reference/react/use#caching-promises-for-client-components
// Cache is scoped by API client (auth token) + layer to avoid leaking data across sessions.
const cache = new WeakMap<
GetDashboardData,
Map<Layer, Promise<DashboardData>>
>();

function getPerApiCache(getDashboardData: GetDashboardData) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that caching mechanisms, when will be dashboard data recomputed by the backend ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the page is reloaded. It's only kept in memory (no server, no localStorage). It simply avoids downloading the data again during the same navigation session. I guess it matches the expectation that the backend values will not change often (ie: everyday at most, for example)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also added a "Retry" button in case of error

Capture.video.du.2026-06-30.16-28-00.mp4

const perApiCache = cache.get(getDashboardData);
if (perApiCache) {
return perApiCache;
}
const newPerApiCache = new Map<Layer, Promise<DashboardData>>();
cache.set(getDashboardData, newPerApiCache);
return newPerApiCache;
}

function fetchData({
getDashboardData,
layer,
force,
}: {
getDashboardData: GetDashboardData;
layer: Layer;
force?: boolean;
}): Promise<DashboardData> {
const perLayerCache = getPerApiCache(getDashboardData);
const cachedPromise = perLayerCache.get(layer);

if (cachedPromise && !force) {
return cachedPromise;
}
const promise = getDashboardData(layer).catch((err) => {
// Don't cache failures forever; allow retries (e.g. after navigation / remount).
perLayerCache.delete(layer);
throw err;
});
perLayerCache.set(layer, promise);

return promise;
}

export default function Dashboard() {
const { t } = useTranslation("all4trees");
const { getDashboardData } = useApi();
const [reloadKey, setReloadKey] = useState(0);
const dataPromise = useMemo(
() =>
fetchData({
force: reloadKey > 0,
getDashboardData,
layer: LAYERS.INVENTARY,
}),
[getDashboardData, reloadKey],
);

const retry = useCallback(() => {
setReloadKey((k) => k + 1);
}, []);
const fallbackRender = useMemo(
() => getFallbackRender({ retry, t }),
[retry, t],
);

return (
<ErrorBoundary
fallbackRender={fallbackRender}
onError={(error, info) => {
// Log the error to your error reporting service
console.error("Error in Dashboard:", error, info);
}}
resetKeys={[dataPromise]}
>
<Suspense fallback={<Loading />}>
<LoadedDashboard dataPromise={dataPromise} />
</Suspense>
</ErrorBoundary>
);
}
37 changes: 37 additions & 0 deletions webapp/src/widgets/dashboard/error-boundary-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { TFunction } from "i18next";
import { type FallbackProps, getErrorMessage } from "react-error-boundary";

import { Button } from "@ui/button";

// t must be passed to the fallback render function because the error boundary fallback component cannot call hooks like useTranslation
export function getFallbackRender({
retry,
t,
}: {
retry?: () => void;
t: TFunction<"all4trees", undefined>;
}) {
function FallbackRender({ error }: FallbackProps) {
const errorMessage =
getErrorMessage(error) ?? t("dashboard.error.unknownMessage");

return (
<div className="flex flex-col items-center pt-24 gap-4">
<h1 className="text-2xl font-bold text-accent">
{t("dashboard.error.title")}
</h1>
<p className="mt-2">{errorMessage}</p>
{retry && (
<Button
onClick={retry}
type="button"
>
{t("dashboard.error.retry")}
</Button>
)}
</div>
);
}

return FallbackRender;
}
84 changes: 84 additions & 0 deletions webapp/src/widgets/dashboard/loaded-dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { use, useState } from "react";

import { DashboardHeader } from "@widgets/dashboard/dashboard-header";

import {
ChartForestPotential,
type ChartForestPotentialData,
} from "@features/charts/biodiversity/chart-forest-potential";

export type DataField = { value: number | null; error: number | null };

const clientType = {
BENEF1: 1,
BENEF2: 2,
TEMOIN: 0,
} as const;

export type DashboardData = Record<
number,
[
Record<string, DataField>, // temoin
Record<string, DataField>, // benef (?)
Record<string, DataField>, // benef (?)
]
>;
Comment thread
severo marked this conversation as resolved.

function getValue({ value }: DataField) {
// only keep two decimals, and return 0 if value is null
return value == null ? 0 : Number(value.toFixed(2));
}
Comment thread
severo marked this conversation as resolved.

function formatBeneficiaryData(
beneficiary: Record<string, DataField>,
): ChartForestPotentialData {
return {
deadWood: getValue(beneficiary.bio_idx_deadWood),
density: getValue(beneficiary.bio_idx_tree_density),
diameterDistribution: getValue(beneficiary.bio_idx_diametric_distribution),
diversity: getValue(beneficiary.bio_idx_tree_diversity),
dominantHeight: getValue(beneficiary.bio_idx_dominant_height),
microHabitat: getValue(beneficiary.bio_idx_microhabitats),
spatialDistribution: getValue(beneficiary.bio_idx_spatial_distribution),
verticalDistribution: getValue(beneficiary.bio_idx_vertical_distribution),
};
}

export default function LoadedDashboard({
dataPromise,
}: {
dataPromise: Promise<DashboardData>;
}) {
const data = use(dataPromise);

const [selectedYear, setSelectedYear] = useState<number>(2025);
const chartData = data[selectedYear][clientType.BENEF1];

Comment thread
severo marked this conversation as resolved.
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 (
<div
className="px-7 overflow-y-scroll h-full pb-4 custom-scrollbar"
style={{
"--scrollbar-thumb": "var(--info-foreground)",
"--scrollbar-track": "var(--background)",
}}
>
<DashboardHeader
onValueChange={handleYearChange}
selectedYear={selectedYear}
years={Object.keys(data).map(Number)}
/>
<div className="mt-4 space-y-4">
<ChartForestPotential benef={formatBeneficiaryData(chartData)} />
</div>
</div>
);
}
Loading
Loading