-
Notifications
You must be signed in to change notification settings - Fork 0
Refactor dashboard logic #175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0e85287
7f240b8
d1a0611
472c49a
999d325
cf1a631
9b359c4
c83facb
a5f950c
8a667dd
7d21509
c90ee9a
3a5b3f6
a85f5fb
62d807b
00170a6
5af383e
e66a023
1200153
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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 />; | ||
| } |
| 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) { | ||
| 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("common"); | ||
| 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> | ||
| ); | ||
| } | ||
| 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 | ||
| // See https://react.dev/warnings/invalid-hook-call-warning | ||
| // > 🔴 Do not call Hooks in class components. | ||
| // while ErrorBoundary is a class component: https://github.com/bvaughn/react-error-boundary/blob/main/lib/components/ErrorBoundary.tsx#L45 | ||
| export function getFallbackRender({ | ||
| retry, | ||
| t, | ||
| }: { | ||
| retry?: () => void; | ||
| t: TFunction<"common", undefined>; | ||
| }) { | ||
| function FallbackRender({ error }: FallbackProps) { | ||
| const errorMessage = getErrorMessage(error) ?? t("error.unknownMessage"); | ||
|
|
||
| return ( | ||
| <div className="flex flex-col items-center pt-24 gap-4"> | ||
| <h1 className="text-2xl font-bold text-accent">{t("error.title")}</h1> | ||
| <p className="mt-2">{errorMessage}</p> | ||
| {retry && ( | ||
| <Button | ||
| onClick={retry} | ||
| type="button" | ||
| > | ||
| {t("error.retry")} | ||
| </Button> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return FallbackRender; | ||
| } |
| 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 (?) | ||
| ] | ||
| >; | ||
|
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)); | ||
| } | ||
|
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]; | ||
|
|
||
|
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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be translated
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmm I did it on purpose, because we never translate what we put in the console. Ideally, we should not put these in the console, but collect them by calling an API (like sentry). Otherwise, they are printed in the user browser, and never reporteed, so we never know when they fire. What do you propose to do?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're absolutely right, we'll do it later. |
||
| } | ||
| }; | ||
|
|
||
| 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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