-
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
Open
severo
wants to merge
16
commits into
main
Choose a base branch
from
refactor-dashboard-logic
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
9d202cf
use safer Number.isNaN (no coertion)
severo d68cc64
replace useEffect with (i18n'ed) ErrorBoundary + Suspense + use()
severo d4b42ce
use a theme color (light green) instead of blue
severo ff28c05
rename
severo 95e73b8
move components to widgets
severo c5588df
improve promises cache (per API token, and don't cache errors)
severo 2483ed1
fix fetch logic and error management
severo 362f5c5
adapt data to the new backend format
severo 04f97b2
format
severo c4ca0a9
force fetching the data in case of retry
severo 97b5fcc
use button component
severo 84a63f4
log the error in console
severo 4f08c05
adapt to the data format change in the backend
severo a8d52de
Potential fix for pull request finding
severo a0428fe
Potential fix for pull request finding
severo 98105d8
refactor
severo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
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.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| }; | ||
|
|
||
| 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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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