From c0e574dff7d02192552b259a7c3df30760f271ea Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 12:53:48 +0200 Subject: [PATCH 1/9] feat(back): add cascade deletion of projects feat(front): add modal to call deletion endpoints for projects --- webapp/src/components/ui/calendar.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/ui/calendar.tsx b/webapp/src/components/ui/calendar.tsx index 99ac94c4e..097490794 100644 --- a/webapp/src/components/ui/calendar.tsx +++ b/webapp/src/components/ui/calendar.tsx @@ -56,12 +56,10 @@ function Calendar({ ...classNames, }} components={{ - Chevron: ({ className, orientation, ...props }) => { + Chevron: ({ orientation, ...props }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight; - return ( - - ); + return ; }, }} {...props} From d24dfe7a1a0ece4138af18421a1f1a47ac3360a7 Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 16:06:05 +0200 Subject: [PATCH 2/9] refactor: move dashboard logic to custom hook & helper --- .../projects/[projectId]/page.tsx | 150 +++--------------- .../app/public/projects/[projectId]/page.tsx | 140 +++------------- webapp/src/helpers/dashboard-calculations.ts | 91 +++++++++++ webapp/src/hooks/useProjectDashboard.ts | 140 ++++++++++++++++ 4 files changed, 274 insertions(+), 247 deletions(-) create mode 100644 webapp/src/helpers/dashboard-calculations.ts create mode 100644 webapp/src/hooks/useProjectDashboard.ts diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx index 73303e1dc..5e4153144 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx @@ -2,22 +2,13 @@ import BreadcrumbHeader from "@/components/breadcrumb"; import ProjectDashboard from "@/components/project-dashboard"; -import { - getEquivalentCarKm, - getEquivalentCitizenPercentage, - getEquivalentTvTime, -} from "@/helpers/constants"; import { getDefaultDateRange } from "@/helpers/date-utils"; -import { - getExperiments, - getProjectEmissionsByExperiment, -} from "@/server-functions/experiments"; +import { getProjectEmissionsByExperiment } from "@/server-functions/experiments"; import { getOneProject } from "@/server-functions/projects"; -import { Experiment } from "@/types/experiment"; -import { ExperimentReport } from "@/types/experiment-report"; import { Project } from "@/types/project"; -import { use, useCallback, useEffect, useState } from "react"; +import { use, useEffect, useState } from "react"; import { DateRange } from "react-day-picker"; +import { useProjectDashboard } from "@/hooks/useProjectDashboard"; export default function ProjectPage({ params, @@ -28,7 +19,6 @@ export default function ProjectPage({ }>; }>) { const { projectId, organizationId } = use(params); - const [isLoading, setIsLoading] = useState(true); const [project, setProject] = useState({ name: "", @@ -50,41 +40,22 @@ export default function ProjectPage({ const default_date = getDefaultDateRange(); const [date, setDate] = useState(default_date); - const [radialChartData, setRadialChartData] = useState({ - energy: { label: "kWh", value: 0 }, - emissions: { label: "kg eq CO2", value: 0 }, - duration: { label: "days", value: 0 }, - }); - // The experiments of the current project. We need this because experimentReport only contains the experiments that have been run - const [projectExperiments, setProjectExperiments] = useState( - [], - ); - // The reports (if any) of the experiments - const [experimentsReportData, setExperimentsReportData] = useState< - ExperimentReport[] - >([]); - - const [runData, setRunData] = useState({ - experimentId: "", - startDate: default_date.from.toISOString(), - endDate: default_date.to.toISOString(), - }); - - const [convertedValues, setConvertedValues] = useState({ - citizen: "0", - transportation: "0", - tvTime: "0", - }); - - const [selectedExperimentId, setSelectedExperimentId] = - useState(""); - const [selectedRunId, setSelectedRunId] = useState(""); - - const refreshExperimentList = useCallback(async () => { - // Logic to refresh experiments if needed - const experiments: Experiment[] = await getExperiments(projectId); - setProjectExperiments(experiments); - }, [projectId]); + // Use custom hook for dashboard state and logic + const { + radialChartData, + convertedValues, + experimentsReportData, + projectExperiments, + runData, + selectedExperimentId, + selectedRunId, + isLoading, + handleExperimentClick, + handleRunClick, + refreshExperimentList, + setExperimentsReportData, + setIsLoading, + } = useProjectDashboard(projectId, date); /** Use effect functions */ useEffect(() => { @@ -103,6 +74,7 @@ export default function ProjectPage({ fetchProjectDetails(); refreshExperimentList(); }, [projectId, refreshExperimentList]); + // Fetch the experiment report of the current project useEffect(() => { async function fetchData() { @@ -112,63 +84,7 @@ export default function ProjectPage({ projectId, date, ); - - const newRadialChartData = { - energy: { - label: "kWh", - value: parseFloat( - report - .reduce( - (n, { energy_consumed }) => - n + energy_consumed, - 0, - ) - .toFixed(2), - ), - }, - emissions: { - label: "kg eq CO2", - value: parseFloat( - report - .reduce((n, { emissions }) => n + emissions, 0) - .toFixed(2), - ), - }, - duration: { - label: "days", - value: parseFloat( - report - .reduce( - (n, { duration }) => n + duration / 86400, - 0, - ) - .toFixed(2), - ), - }, - }; - setRadialChartData(newRadialChartData); - setExperimentsReportData(report); - - setRunData({ - experimentId: report[0]?.experiment_id ?? "", - startDate: date?.from?.toISOString() ?? "", - endDate: date?.to?.toISOString() ?? "", - }); - - setSelectedExperimentId(report[0]?.experiment_id ?? ""); - - setConvertedValues({ - citizen: getEquivalentCitizenPercentage( - newRadialChartData.emissions.value, - ).toFixed(2), - transportation: getEquivalentCarKm( - newRadialChartData.emissions.value, - ).toFixed(2), - tvTime: getEquivalentTvTime( - newRadialChartData.energy.value, - ).toFixed(2), - }); } catch (error) { console.error("Error fetching project data:", error); } finally { @@ -179,31 +95,7 @@ export default function ProjectPage({ if (projectId) { fetchData(); } - }, [projectId, date]); - - const handleExperimentClick = useCallback( - (experimentId: string) => { - if (experimentId === selectedExperimentId) { - setSelectedExperimentId(""); - setSelectedRunId(""); - return; - } - setSelectedExperimentId(experimentId); - setSelectedRunId(""); - }, - [selectedExperimentId], - ); - - const handleRunClick = useCallback( - (runId: string) => { - if (runId === selectedRunId) { - setSelectedRunId(""); - return; - } - setSelectedRunId(runId); - }, - [selectedRunId], - ); + }, [projectId, date, setExperimentsReportData, setIsLoading]); return (
diff --git a/webapp/src/app/public/projects/[projectId]/page.tsx b/webapp/src/app/public/projects/[projectId]/page.tsx index 93a6f42f5..60cd7c1b3 100644 --- a/webapp/src/app/public/projects/[projectId]/page.tsx +++ b/webapp/src/app/public/projects/[projectId]/page.tsx @@ -1,16 +1,11 @@ "use client"; -import { useState, useEffect, useCallback, use } from "react"; +import { useState, useEffect, use } from "react"; import { useRouter } from "next/navigation"; import { DateRange } from "react-day-picker"; import { decryptProjectId } from "@/utils/crypto"; import { ExperimentReport } from "@/types/experiment-report"; import PublicProjectDashboard from "@/components/public-project-dashboard"; -import { - getEquivalentCarKm, - getEquivalentCitizenPercentage, - getEquivalentTvTime, -} from "@/helpers/constants"; import { fetchApi } from "@/utils/api"; import { Project } from "@/types/project"; import ErrorMessage from "@/components/error-message"; @@ -18,8 +13,7 @@ import Loader from "@/components/loader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertCircle } from "lucide-react"; import { getDefaultDateRange } from "@/helpers/date-utils"; -import { Experiment } from "@/types/experiment"; -import { getExperiments } from "@/server-functions/experiments"; +import { useProjectDashboard } from "@/hooks/useProjectDashboard"; export default function PublicProjectPage({ params, @@ -29,7 +23,6 @@ export default function PublicProjectPage({ const { projectId: encryptedId } = use(params); const router = useRouter(); - const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [projectId, setProjectId] = useState(null); const [project, setProject] = useState(null); @@ -37,41 +30,23 @@ export default function PublicProjectPage({ // Dashboard state const default_date = getDefaultDateRange(); const [date, setDate] = useState(default_date); - // The experiments of the current project. We need this because experimentReport only contains the experiments that have been run - const [projectExperiments, setProjectExperiments] = useState( - [], - ); - // The reports (if any) of the experiments - const [experimentsReportData, setExperimentsReportData] = useState< - ExperimentReport[] - >([]); - - const [radialChartData, setRadialChartData] = useState({ - energy: { label: "kWh", value: 0 }, - emissions: { label: "kg eq CO2", value: 0 }, - duration: { label: "days", value: 0 }, - }); - - const [runData, setRunData] = useState({ - experimentId: "", - startDate: default_date.from.toISOString(), - endDate: default_date.to.toISOString(), - }); - const [convertedValues, setConvertedValues] = useState({ - citizen: "0", - transportation: "0", - tvTime: "0", - }); - const [selectedExperimentId, setSelectedExperimentId] = - useState(""); - const [selectedRunId, setSelectedRunId] = useState(""); - const refreshExperimentList = useCallback(async () => { - if (!projectId) return; - // Logic to refresh experiments if needed - const experiments: Experiment[] = await getExperiments(projectId); - setProjectExperiments(experiments); - }, [projectId]); + // Use custom hook for dashboard state and logic + const { + radialChartData, + convertedValues, + experimentsReportData, + projectExperiments, + runData, + selectedExperimentId, + selectedRunId, + isLoading, + handleExperimentClick, + handleRunClick, + refreshExperimentList, + setExperimentsReportData, + setIsLoading, + } = useProjectDashboard(projectId, date); // Decrypt the project ID useEffect(() => { @@ -89,7 +64,7 @@ export default function PublicProjectPage({ }; decrypt(); - }, [encryptedId]); + }, [encryptedId, setIsLoading]); // Fetch project data useEffect(() => { @@ -122,12 +97,12 @@ export default function PublicProjectPage({ fetchProjectData(); refreshExperimentList(); } - }, [projectId, project, refreshExperimentList]); + }, [projectId, project, refreshExperimentList, setIsLoading]); // Fetch experiments and emissions data useEffect(() => { async function fetchData() { - if (!projectId) return; + if (!projectId || !project) return; setIsLoading(true); try { @@ -140,64 +115,6 @@ export default function PublicProjectPage({ } setExperimentsReportData(report); - - const newRadialChartData = { - energy: { - label: "kWh", - value: parseFloat( - report - .reduce( - (n, { energy_consumed }) => - n + energy_consumed, - 0, - ) - .toFixed(2), - ), - }, - emissions: { - label: "kg eq CO2", - value: parseFloat( - report - .reduce((n, { emissions }) => n + emissions, 0) - .toFixed(2), - ), - }, - duration: { - label: "days", - value: parseFloat( - report - .reduce( - (n, { duration }) => n + duration / 86400, - 0, - ) - .toFixed(2), - ), - }, - }; - - setRadialChartData(newRadialChartData); - - if (report.length > 0) { - setRunData({ - experimentId: report[0]?.experiment_id ?? "", - startDate: date?.from?.toISOString() ?? "", - endDate: date?.to?.toISOString() ?? "", - }); - - setSelectedExperimentId(report[0]?.experiment_id ?? ""); - } - - setConvertedValues({ - citizen: getEquivalentCitizenPercentage( - newRadialChartData.emissions.value, - ).toFixed(2), - transportation: getEquivalentCarKm( - newRadialChartData.emissions.value, - ).toFixed(2), - tvTime: getEquivalentTvTime( - newRadialChartData.energy.value, - ).toFixed(2), - }); } catch (error) { console.error("Error fetching data:", error); } finally { @@ -208,20 +125,7 @@ export default function PublicProjectPage({ if (projectId && project) { fetchData(); } - }, [projectId, project, date]); - - const handleExperimentClick = useCallback((experimentId: string) => { - setSelectedExperimentId(experimentId); - setRunData((prevData) => ({ - ...prevData, - experimentId: experimentId, - })); - setSelectedRunId(""); // Reset the run ID - }, []); - - const handleRunClick = useCallback((runId: string) => { - setSelectedRunId(runId); - }, []); + }, [projectId, project, date, setExperimentsReportData, setIsLoading]); // Show full page loader only during initial load if (isLoading && !project) { diff --git a/webapp/src/helpers/dashboard-calculations.ts b/webapp/src/helpers/dashboard-calculations.ts new file mode 100644 index 000000000..76e871543 --- /dev/null +++ b/webapp/src/helpers/dashboard-calculations.ts @@ -0,0 +1,91 @@ +import { ExperimentReport } from "@/types/experiment-report"; +import { + getEquivalentCarKm, + getEquivalentCitizenPercentage, + getEquivalentTvTime, +} from "./constants"; + +export type RadialChartData = { + energy: { label: string; value: number }; + emissions: { label: string; value: number }; + duration: { label: string; value: number }; +}; + +export type ConvertedValues = { + citizen: string; + transportation: string; + tvTime: string; +}; + +/** + * Calculate radial chart data from experiment reports + */ +export function calculateRadialChartData( + report: ExperimentReport[], +): RadialChartData { + return { + energy: { + label: "kWh", + value: parseFloat( + report + .reduce((n, { energy_consumed }) => n + energy_consumed, 0) + .toFixed(2), + ), + }, + emissions: { + label: "kg eq CO2", + value: parseFloat( + report + .reduce((n, { emissions }) => n + emissions, 0) + .toFixed(2), + ), + }, + duration: { + label: "days", + value: parseFloat( + report + .reduce((n, { duration }) => n + duration / 86400, 0) + .toFixed(2), + ), + }, + }; +} + +/** + * Calculate converted equivalent values from radial chart data + */ +export function calculateConvertedValues( + radialChartData: RadialChartData, +): ConvertedValues { + return { + citizen: getEquivalentCitizenPercentage( + radialChartData.emissions.value, + ).toFixed(2), + transportation: getEquivalentCarKm( + radialChartData.emissions.value, + ).toFixed(2), + tvTime: getEquivalentTvTime(radialChartData.energy.value).toFixed(2), + }; +} + +/** + * Get default radial chart data (all zeros) + */ +export function getDefaultRadialChartData(): RadialChartData { + return { + energy: { label: "kWh", value: 0 }, + emissions: { label: "kg eq CO2", value: 0 }, + duration: { label: "days", value: 0 }, + }; +} + +/** + * Get default converted values (all zeros) + */ +export function getDefaultConvertedValues(): ConvertedValues { + return { + citizen: "0", + transportation: "0", + tvTime: "0", + }; +} diff --git a/webapp/src/hooks/useProjectDashboard.ts b/webapp/src/hooks/useProjectDashboard.ts new file mode 100644 index 000000000..90f1ef668 --- /dev/null +++ b/webapp/src/hooks/useProjectDashboard.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useState } from "react"; +import { DateRange } from "react-day-picker"; +import { Experiment } from "@/types/experiment"; +import { ExperimentReport } from "@/types/experiment-report"; +import { + calculateConvertedValues, + calculateRadialChartData, + ConvertedValues, + getDefaultConvertedValues, + getDefaultRadialChartData, + RadialChartData, +} from "@/helpers/dashboard-calculations"; +import { getExperiments } from "@/server-functions/experiments"; + +export type RunData = { + experimentId: string; + startDate: string; + endDate: string; +}; + +export type ProjectDashboardData = { + radialChartData: RadialChartData; + convertedValues: ConvertedValues; + experimentsReportData: ExperimentReport[]; + projectExperiments: Experiment[]; + runData: RunData; + selectedExperimentId: string; + selectedRunId: string; + isLoading: boolean; + setSelectedExperimentId: (id: string) => void; + setSelectedRunId: (id: string) => void; + handleExperimentClick: (experimentId: string) => void; + handleRunClick: (runId: string) => void; + refreshExperimentList: () => Promise; + setExperimentsReportData: (data: ExperimentReport[]) => void; + setIsLoading: (loading: boolean) => void; +}; + +/** + * Custom hook for managing project dashboard state and logic + * Extracts common logic shared between authenticated and public dashboard pages + */ +export function useProjectDashboard( + projectId: string | null, + date: DateRange, +): ProjectDashboardData { + const [isLoading, setIsLoading] = useState(true); + const [radialChartData, setRadialChartData] = useState( + getDefaultRadialChartData(), + ); + const [projectExperiments, setProjectExperiments] = useState( + [], + ); + const [experimentsReportData, setExperimentsReportData] = useState< + ExperimentReport[] + >([]); + const [runData, setRunData] = useState({ + experimentId: "", + startDate: date.from?.toISOString() || "", + endDate: date.to?.toISOString() || "", + }); + const [convertedValues, setConvertedValues] = useState( + getDefaultConvertedValues(), + ); + const [selectedExperimentId, setSelectedExperimentId] = + useState(""); + const [selectedRunId, setSelectedRunId] = useState(""); + + const refreshExperimentList = useCallback(async () => { + if (!projectId) return; + const experiments: Experiment[] = await getExperiments(projectId); + setProjectExperiments(experiments); + }, [projectId]); + + const handleExperimentClick = useCallback( + (experimentId: string) => { + if (experimentId === selectedExperimentId) { + setSelectedExperimentId(""); + setSelectedRunId(""); + return; + } + setSelectedExperimentId(experimentId); + setSelectedRunId(""); + }, + [selectedExperimentId], + ); + + const handleRunClick = useCallback( + (runId: string) => { + if (runId === selectedRunId) { + setSelectedRunId(""); + return; + } + setSelectedRunId(runId); + }, + [selectedRunId], + ); + + /** + * Process experiment report data and update all derived state + */ + const processReportData = useCallback( + (report: ExperimentReport[]) => { + setExperimentsReportData(report); + + const newRadialChartData = calculateRadialChartData(report); + setRadialChartData(newRadialChartData); + + setRunData({ + experimentId: report[0]?.experiment_id ?? "", + startDate: date?.from?.toISOString() ?? "", + endDate: date?.to?.toISOString() ?? "", + }); + + setSelectedExperimentId(report[0]?.experiment_id ?? ""); + + const newConvertedValues = calculateConvertedValues(newRadialChartData); + setConvertedValues(newConvertedValues); + }, + [date], + ); + + return { + radialChartData, + convertedValues, + experimentsReportData, + projectExperiments, + runData, + selectedExperimentId, + selectedRunId, + isLoading, + setSelectedExperimentId, + setSelectedRunId, + handleExperimentClick, + handleRunClick, + refreshExperimentList, + setExperimentsReportData: processReportData, + setIsLoading, + }; +} From 845f55606d61e35795b54b3c66fdf21d509e9ce7 Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 12:53:48 +0200 Subject: [PATCH 3/9] chore: fix date-range picker, remove console.log when not needed --- .../projects/[projectId]/page.tsx | 150 +++++++++++++++--- .../projects/[projectId]/settings/page.tsx | 5 +- .../app/public/projects/[projectId]/page.tsx | 140 +++++++++++++--- .../src/components/createExperimentModal.tsx | 2 - webapp/src/components/createProjectModal.tsx | 6 - webapp/src/components/ui/calendar.tsx | 4 +- webapp/src/helpers/api-client.ts | 2 +- webapp/src/helpers/api-server.ts | 61 ++----- webapp/src/server-functions/organizations.ts | 15 +- webapp/src/server-functions/projects.ts | 4 - webapp/src/server-functions/runs.ts | 3 +- 11 files changed, 267 insertions(+), 125 deletions(-) diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx index 5e4153144..73303e1dc 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/page.tsx @@ -2,13 +2,22 @@ import BreadcrumbHeader from "@/components/breadcrumb"; import ProjectDashboard from "@/components/project-dashboard"; +import { + getEquivalentCarKm, + getEquivalentCitizenPercentage, + getEquivalentTvTime, +} from "@/helpers/constants"; import { getDefaultDateRange } from "@/helpers/date-utils"; -import { getProjectEmissionsByExperiment } from "@/server-functions/experiments"; +import { + getExperiments, + getProjectEmissionsByExperiment, +} from "@/server-functions/experiments"; import { getOneProject } from "@/server-functions/projects"; +import { Experiment } from "@/types/experiment"; +import { ExperimentReport } from "@/types/experiment-report"; import { Project } from "@/types/project"; -import { use, useEffect, useState } from "react"; +import { use, useCallback, useEffect, useState } from "react"; import { DateRange } from "react-day-picker"; -import { useProjectDashboard } from "@/hooks/useProjectDashboard"; export default function ProjectPage({ params, @@ -19,6 +28,7 @@ export default function ProjectPage({ }>; }>) { const { projectId, organizationId } = use(params); + const [isLoading, setIsLoading] = useState(true); const [project, setProject] = useState({ name: "", @@ -40,22 +50,41 @@ export default function ProjectPage({ const default_date = getDefaultDateRange(); const [date, setDate] = useState(default_date); - // Use custom hook for dashboard state and logic - const { - radialChartData, - convertedValues, - experimentsReportData, - projectExperiments, - runData, - selectedExperimentId, - selectedRunId, - isLoading, - handleExperimentClick, - handleRunClick, - refreshExperimentList, - setExperimentsReportData, - setIsLoading, - } = useProjectDashboard(projectId, date); + const [radialChartData, setRadialChartData] = useState({ + energy: { label: "kWh", value: 0 }, + emissions: { label: "kg eq CO2", value: 0 }, + duration: { label: "days", value: 0 }, + }); + // The experiments of the current project. We need this because experimentReport only contains the experiments that have been run + const [projectExperiments, setProjectExperiments] = useState( + [], + ); + // The reports (if any) of the experiments + const [experimentsReportData, setExperimentsReportData] = useState< + ExperimentReport[] + >([]); + + const [runData, setRunData] = useState({ + experimentId: "", + startDate: default_date.from.toISOString(), + endDate: default_date.to.toISOString(), + }); + + const [convertedValues, setConvertedValues] = useState({ + citizen: "0", + transportation: "0", + tvTime: "0", + }); + + const [selectedExperimentId, setSelectedExperimentId] = + useState(""); + const [selectedRunId, setSelectedRunId] = useState(""); + + const refreshExperimentList = useCallback(async () => { + // Logic to refresh experiments if needed + const experiments: Experiment[] = await getExperiments(projectId); + setProjectExperiments(experiments); + }, [projectId]); /** Use effect functions */ useEffect(() => { @@ -74,7 +103,6 @@ export default function ProjectPage({ fetchProjectDetails(); refreshExperimentList(); }, [projectId, refreshExperimentList]); - // Fetch the experiment report of the current project useEffect(() => { async function fetchData() { @@ -84,7 +112,63 @@ export default function ProjectPage({ projectId, date, ); + + const newRadialChartData = { + energy: { + label: "kWh", + value: parseFloat( + report + .reduce( + (n, { energy_consumed }) => + n + energy_consumed, + 0, + ) + .toFixed(2), + ), + }, + emissions: { + label: "kg eq CO2", + value: parseFloat( + report + .reduce((n, { emissions }) => n + emissions, 0) + .toFixed(2), + ), + }, + duration: { + label: "days", + value: parseFloat( + report + .reduce( + (n, { duration }) => n + duration / 86400, + 0, + ) + .toFixed(2), + ), + }, + }; + setRadialChartData(newRadialChartData); + setExperimentsReportData(report); + + setRunData({ + experimentId: report[0]?.experiment_id ?? "", + startDate: date?.from?.toISOString() ?? "", + endDate: date?.to?.toISOString() ?? "", + }); + + setSelectedExperimentId(report[0]?.experiment_id ?? ""); + + setConvertedValues({ + citizen: getEquivalentCitizenPercentage( + newRadialChartData.emissions.value, + ).toFixed(2), + transportation: getEquivalentCarKm( + newRadialChartData.emissions.value, + ).toFixed(2), + tvTime: getEquivalentTvTime( + newRadialChartData.energy.value, + ).toFixed(2), + }); } catch (error) { console.error("Error fetching project data:", error); } finally { @@ -95,7 +179,31 @@ export default function ProjectPage({ if (projectId) { fetchData(); } - }, [projectId, date, setExperimentsReportData, setIsLoading]); + }, [projectId, date]); + + const handleExperimentClick = useCallback( + (experimentId: string) => { + if (experimentId === selectedExperimentId) { + setSelectedExperimentId(""); + setSelectedRunId(""); + return; + } + setSelectedExperimentId(experimentId); + setSelectedRunId(""); + }, + [selectedExperimentId], + ); + + const handleRunClick = useCallback( + (runId: string) => { + if (runId === selectedRunId) { + setSelectedRunId(""); + return; + } + setSelectedRunId(runId); + }, + [selectedRunId], + ); return (
diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx index 28cd87413..4df47eec5 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx @@ -16,14 +16,11 @@ async function updateProjectAction(projectId: string, formData: FormData) { const description = formData.get("description") as string; const isPublic = formData.has("isPublic"); - console.log("SAVING PROJECT:", { name, description, public: isPublic }); - - const response = await updateProject(projectId, { + await updateProject(projectId, { name, description, public: isPublic, }); - console.log("RESPONSE:", response); revalidatePath(`/projects/${projectId}/settings`); } diff --git a/webapp/src/app/public/projects/[projectId]/page.tsx b/webapp/src/app/public/projects/[projectId]/page.tsx index 60cd7c1b3..93a6f42f5 100644 --- a/webapp/src/app/public/projects/[projectId]/page.tsx +++ b/webapp/src/app/public/projects/[projectId]/page.tsx @@ -1,11 +1,16 @@ "use client"; -import { useState, useEffect, use } from "react"; +import { useState, useEffect, useCallback, use } from "react"; import { useRouter } from "next/navigation"; import { DateRange } from "react-day-picker"; import { decryptProjectId } from "@/utils/crypto"; import { ExperimentReport } from "@/types/experiment-report"; import PublicProjectDashboard from "@/components/public-project-dashboard"; +import { + getEquivalentCarKm, + getEquivalentCitizenPercentage, + getEquivalentTvTime, +} from "@/helpers/constants"; import { fetchApi } from "@/utils/api"; import { Project } from "@/types/project"; import ErrorMessage from "@/components/error-message"; @@ -13,7 +18,8 @@ import Loader from "@/components/loader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertCircle } from "lucide-react"; import { getDefaultDateRange } from "@/helpers/date-utils"; -import { useProjectDashboard } from "@/hooks/useProjectDashboard"; +import { Experiment } from "@/types/experiment"; +import { getExperiments } from "@/server-functions/experiments"; export default function PublicProjectPage({ params, @@ -23,6 +29,7 @@ export default function PublicProjectPage({ const { projectId: encryptedId } = use(params); const router = useRouter(); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [projectId, setProjectId] = useState(null); const [project, setProject] = useState(null); @@ -30,23 +37,41 @@ export default function PublicProjectPage({ // Dashboard state const default_date = getDefaultDateRange(); const [date, setDate] = useState(default_date); + // The experiments of the current project. We need this because experimentReport only contains the experiments that have been run + const [projectExperiments, setProjectExperiments] = useState( + [], + ); + // The reports (if any) of the experiments + const [experimentsReportData, setExperimentsReportData] = useState< + ExperimentReport[] + >([]); + + const [radialChartData, setRadialChartData] = useState({ + energy: { label: "kWh", value: 0 }, + emissions: { label: "kg eq CO2", value: 0 }, + duration: { label: "days", value: 0 }, + }); + + const [runData, setRunData] = useState({ + experimentId: "", + startDate: default_date.from.toISOString(), + endDate: default_date.to.toISOString(), + }); + const [convertedValues, setConvertedValues] = useState({ + citizen: "0", + transportation: "0", + tvTime: "0", + }); + const [selectedExperimentId, setSelectedExperimentId] = + useState(""); + const [selectedRunId, setSelectedRunId] = useState(""); - // Use custom hook for dashboard state and logic - const { - radialChartData, - convertedValues, - experimentsReportData, - projectExperiments, - runData, - selectedExperimentId, - selectedRunId, - isLoading, - handleExperimentClick, - handleRunClick, - refreshExperimentList, - setExperimentsReportData, - setIsLoading, - } = useProjectDashboard(projectId, date); + const refreshExperimentList = useCallback(async () => { + if (!projectId) return; + // Logic to refresh experiments if needed + const experiments: Experiment[] = await getExperiments(projectId); + setProjectExperiments(experiments); + }, [projectId]); // Decrypt the project ID useEffect(() => { @@ -64,7 +89,7 @@ export default function PublicProjectPage({ }; decrypt(); - }, [encryptedId, setIsLoading]); + }, [encryptedId]); // Fetch project data useEffect(() => { @@ -97,12 +122,12 @@ export default function PublicProjectPage({ fetchProjectData(); refreshExperimentList(); } - }, [projectId, project, refreshExperimentList, setIsLoading]); + }, [projectId, project, refreshExperimentList]); // Fetch experiments and emissions data useEffect(() => { async function fetchData() { - if (!projectId || !project) return; + if (!projectId) return; setIsLoading(true); try { @@ -115,6 +140,64 @@ export default function PublicProjectPage({ } setExperimentsReportData(report); + + const newRadialChartData = { + energy: { + label: "kWh", + value: parseFloat( + report + .reduce( + (n, { energy_consumed }) => + n + energy_consumed, + 0, + ) + .toFixed(2), + ), + }, + emissions: { + label: "kg eq CO2", + value: parseFloat( + report + .reduce((n, { emissions }) => n + emissions, 0) + .toFixed(2), + ), + }, + duration: { + label: "days", + value: parseFloat( + report + .reduce( + (n, { duration }) => n + duration / 86400, + 0, + ) + .toFixed(2), + ), + }, + }; + + setRadialChartData(newRadialChartData); + + if (report.length > 0) { + setRunData({ + experimentId: report[0]?.experiment_id ?? "", + startDate: date?.from?.toISOString() ?? "", + endDate: date?.to?.toISOString() ?? "", + }); + + setSelectedExperimentId(report[0]?.experiment_id ?? ""); + } + + setConvertedValues({ + citizen: getEquivalentCitizenPercentage( + newRadialChartData.emissions.value, + ).toFixed(2), + transportation: getEquivalentCarKm( + newRadialChartData.emissions.value, + ).toFixed(2), + tvTime: getEquivalentTvTime( + newRadialChartData.energy.value, + ).toFixed(2), + }); } catch (error) { console.error("Error fetching data:", error); } finally { @@ -125,7 +208,20 @@ export default function PublicProjectPage({ if (projectId && project) { fetchData(); } - }, [projectId, project, date, setExperimentsReportData, setIsLoading]); + }, [projectId, project, date]); + + const handleExperimentClick = useCallback((experimentId: string) => { + setSelectedExperimentId(experimentId); + setRunData((prevData) => ({ + ...prevData, + experimentId: experimentId, + })); + setSelectedRunId(""); // Reset the run ID + }, []); + + const handleRunClick = useCallback((runId: string) => { + setSelectedRunId(runId); + }, []); // Show full page loader only during initial load if (isLoading && !project) { diff --git a/webapp/src/components/createExperimentModal.tsx b/webapp/src/components/createExperimentModal.tsx index 6f1225eaf..4f789bce3 100644 --- a/webapp/src/components/createExperimentModal.tsx +++ b/webapp/src/components/createExperimentModal.tsx @@ -27,7 +27,6 @@ export default function CreateExperimentModal({ onClose: () => void; onExperimentCreated: () => void; }) { - console.log("projectId", projectId); const [isCopied, setIsCopied] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isCreated, setIsCreated] = useState(false); @@ -74,7 +73,6 @@ export default function CreateExperimentModal({ setIsSaving(true); try { - console.log("experimentData", experimentData); const newExperiment = await createExperiment(experimentData); setCreatedExperiment(newExperiment); setIsCreated(true); diff --git a/webapp/src/components/createProjectModal.tsx b/webapp/src/components/createProjectModal.tsx index c61753ca0..1191fbd6e 100644 --- a/webapp/src/components/createProjectModal.tsx +++ b/webapp/src/components/createProjectModal.tsx @@ -36,8 +36,6 @@ const CreateProjectModal: React.FC = ({ name: "", description: "", }); - const [isCreated, setIsCreated] = useState(false); - const [createdProject, setCreatedProject] = useState(null); const [isLoading, setIsLoading] = useState(false); const handleSave = async () => { @@ -49,8 +47,6 @@ const CreateProjectModal: React.FC = ({ organizationId, formData, ); - setCreatedProject(newProject); - setIsCreated(true); await onProjectCreated(); // Call the callback to refresh the project list handleClose(); // Automatically close the modal after successful creation return newProject; // Return for the success message @@ -72,8 +68,6 @@ const CreateProjectModal: React.FC = ({ const handleClose = () => { // Reset state when closing setFormData({ name: "", description: "" }); - setIsCreated(false); - setCreatedProject(null); onClose(); }; diff --git a/webapp/src/components/ui/calendar.tsx b/webapp/src/components/ui/calendar.tsx index 097490794..87f9d0a24 100644 --- a/webapp/src/components/ui/calendar.tsx +++ b/webapp/src/components/ui/calendar.tsx @@ -56,10 +56,10 @@ function Calendar({ ...classNames, }} components={{ - Chevron: ({ orientation, ...props }) => { + Chevron: ({ className, orientation, ...props }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight; - return ; + return ; }, }} {...props} diff --git a/webapp/src/helpers/api-client.ts b/webapp/src/helpers/api-client.ts index 867d5b0a5..5a2d6cb3b 100644 --- a/webapp/src/helpers/api-client.ts +++ b/webapp/src/helpers/api-client.ts @@ -36,7 +36,7 @@ export async function fetchApiClient( } catch (e) { // Ignore JSON parsing errors } - console.log(errorMessage); + console.error(errorMessage); return null; } diff --git a/webapp/src/helpers/api-server.ts b/webapp/src/helpers/api-server.ts index ee1ec2c21..f83d12602 100644 --- a/webapp/src/helpers/api-server.ts +++ b/webapp/src/helpers/api-server.ts @@ -39,7 +39,7 @@ export async function fetchApiServer( } catch (e) { // Ignore JSON parsing errors } - console.log(errorMessage); + console.error(errorMessage); return null; } @@ -48,35 +48,16 @@ export async function fetchApiServer( return null; } - // Special handling for endpoints that might return null - if ( - endpoint.includes("/organizations/") && - endpoint.includes("/sums") - ) { - // For organization sums endpoint that might return null - try { - return await response.json(); - } catch (e) { - // If JSON parsing fails (e.g., empty response), return default values - console.warn( - "Empty response from organization sums endpoint, using default values", - ); - return { - name: "", - description: "", - emissions: 0, - energy_consumed: 0, - duration: 0, - cpu_power: 0, - gpu_power: 0, - ram_power: 0, - emissions_rate: 0, - emissions_count: 0, - } as unknown as T; - } + // Parse JSON response + try { + return await response.json(); + } catch (e) { + // If JSON parsing fails (e.g., empty response body), return null + console.warn( + `Empty or invalid JSON response from ${endpoint}, returning null`, + ); + return null; } - - return await response.json(); } catch (error) { // Log server-side error with more details console.error("API server request failed:", { @@ -84,25 +65,7 @@ export async function fetchApiServer( error: error instanceof Error ? error.message : String(error), }); - // For organization sums endpoint, return default values instead of throwing - if ( - endpoint.includes("/organizations/") && - endpoint.includes("/sums") - ) { - return { - name: "", - description: "", - emissions: 0, - energy_consumed: 0, - duration: 0, - cpu_power: 0, - gpu_power: 0, - ram_power: 0, - emissions_rate: 0, - emissions_count: 0, - } as unknown as T; - } - - throw new Error("API request failed. Please try again."); + // Return null to let callers handle defaults appropriately + return null; } } diff --git a/webapp/src/server-functions/organizations.ts b/webapp/src/server-functions/organizations.ts index 8b274dc9e..eddc25437 100644 --- a/webapp/src/server-functions/organizations.ts +++ b/webapp/src/server-functions/organizations.ts @@ -14,14 +14,10 @@ export async function getOrganizationEmissionsByProject( endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`; } - const result = await fetchApiServer(endpoint); - - if (!result) { - return null; - } + const result = await fetchApiServer(endpoint); // Handle case when no emissions data is found - if (!result || result === null) { + if (!result) { // Return zeros for all metrics return { name: "", @@ -31,12 +27,7 @@ export async function getOrganizationEmissionsByProject( }; } - return { - name: result.name || "", - emissions: result.emissions || 0, - energy_consumed: result.energy_consumed || 0, - duration: result.duration || 0, - }; + return result; } catch (error) { console.error("Error fetching organization emissions:", error); // Return default values if there's an error diff --git a/webapp/src/server-functions/projects.ts b/webapp/src/server-functions/projects.ts index 16824ca46..329b3135f 100644 --- a/webapp/src/server-functions/projects.ts +++ b/webapp/src/server-functions/projects.ts @@ -49,10 +49,6 @@ export const getOneProject = async ( projectId: string, ): Promise => { const project = await fetchApiServer(`/projects/${projectId}`); - console.log("project", JSON.stringify(project, null, 2)); - if (!project) { - return null; - } return project; }; diff --git a/webapp/src/server-functions/runs.ts b/webapp/src/server-functions/runs.ts index cee56c3ca..93f6c36ce 100644 --- a/webapp/src/server-functions/runs.ts +++ b/webapp/src/server-functions/runs.ts @@ -23,8 +23,7 @@ export async function getRunEmissionsByExperiment( const res = await fetch(url); if (!res.ok) { - // Log error waiting for a better error management - console.log("Failed to fetch data"); + console.error("Failed to fetch run data:", res.statusText); return []; } const result = await res.json(); From e153fded05b02d3759cf305ac9e56154829acc4d Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 17:15:35 +0200 Subject: [PATCH 4/9] chore: replace magic numbers by functional values, standardise api call with fetchApi instead of fetch + injected url, refactor Modal usage with custom hook --- .../[organizationId]/members/members-list.tsx | 30 ++----- .../app/(dashboard)/[organizationId]/page.tsx | 7 +- .../[organizationId]/projects/page.tsx | 20 ++--- webapp/src/app/(dashboard)/profile/page.tsx | 12 +-- webapp/src/components/navbar.tsx | 9 ++- webapp/src/components/project-dashboard.tsx | 9 ++- webapp/src/helpers/dashboard-calculations.ts | 3 +- webapp/src/helpers/time-constants.ts | 28 +++++++ webapp/src/hooks/useModal.ts | 18 +++++ webapp/src/server-functions/experiments.ts | 21 ++--- webapp/src/server-functions/projectTokens.ts | 79 ++++++------------- webapp/src/server-functions/runs.ts | 17 ++-- webapp/src/utils/api.ts | 10 --- 13 files changed, 120 insertions(+), 143 deletions(-) create mode 100644 webapp/src/helpers/time-constants.ts create mode 100644 webapp/src/hooks/useModal.ts diff --git a/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx b/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx index f058c5e49..7986531ac 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { z } from "zod"; import { toast } from "sonner"; +import { fetchApi } from "@/utils/api"; export default function MembersList({ users, @@ -44,36 +45,17 @@ export default function MembersList({ await toast .promise( - fetch( - `${process.env.NEXT_PUBLIC_API_URL}/organizations/${organizationId}/add-user`, + fetchApi( + `/organizations/${organizationId}/add-user`, { method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, body: body, }, ).then(async (result) => { - const data = await result.json(); - if (result.status !== 200) { - const errorObject = data.detail; - let errorMessage = "Failed to add user"; - - if ( - Array.isArray(errorObject) && - errorObject.length > 0 - ) { - errorMessage = errorObject - .map((error: any) => error.msg) - .join("\n"); - } else if (errorObject) { - errorMessage = JSON.stringify(errorObject); - } - - throw new Error(errorMessage); + if (!result) { + throw new Error("Failed to add user"); } - return data; + return result; }), { loading: `Adding user ${email}...`, diff --git a/webapp/src/app/(dashboard)/[organizationId]/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/page.tsx index 54d871c01..9f230cfff 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/page.tsx @@ -11,6 +11,7 @@ import { getEquivalentCitizenPercentage, getEquivalentTvTime, } from "@/helpers/constants"; +import { REFRESH_INTERVAL_ONE_MINUTE, THIRTY_DAYS_MS, SECONDS_PER_DAY } from "@/helpers/time-constants"; import { fetcher } from "@/helpers/swr"; import { getOrganizationEmissionsByProject } from "@/server-functions/organizations"; import { Organization } from "@/types/organization"; @@ -29,12 +30,12 @@ export default function OrganizationPage({ isLoading, error, } = useSWR(`/organizations/${organizationId}`, fetcher, { - refreshInterval: 1000 * 60, // Refresh every minute + refreshInterval: REFRESH_INTERVAL_ONE_MINUTE, }); const today = new Date(); const [date, setDate] = useState({ - from: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000), + from: new Date(today.getTime() - THIRTY_DAYS_MS), to: today, }); const [organizationReport, setOrganizationReport] = useState< @@ -86,7 +87,7 @@ export default function OrganizationPage({ label: "days", value: organizationReport?.duration ? parseFloat( - (organizationReport.duration / 86400, 0).toFixed(2), + (organizationReport.duration / SECONDS_PER_DAY).toFixed(2), ) : 0, }, diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx index 5c2cb9671..eb3ae2a42 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx @@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Table, TableBody } from "@/components/ui/table"; import { fetcher } from "@/helpers/swr"; +import { REFRESH_INTERVAL_ONE_MINUTE } from "@/helpers/time-constants"; +import { useModal } from "@/hooks/useModal"; import { getProjects, deleteProject } from "@/server-functions/projects"; import { Project } from "@/types/project"; import { use, useEffect, useState } from "react"; @@ -22,15 +24,15 @@ export default function ProjectsPage({ params: Promise<{ organizationId: string }>; }) { const { organizationId } = use(params); - const [isModalOpen, setIsModalOpen] = useState(false); + const createModal = useModal(); + const deleteModal = useModal(); const [projectList, setProjectList] = useState([]); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [projectToDelete, setProjectToDelete] = useState( null, ); const handleClick = async () => { - setIsModalOpen(true); + createModal.open(); }; const refreshProjectList = async () => { @@ -41,7 +43,7 @@ export default function ProjectsPage({ const handleDeleteClick = (project: Project) => { setProjectToDelete(project); - setDeleteModalOpen(true); + deleteModal.open(); }; const handleDeleteConfirm = async (projectId: string) => { @@ -61,7 +63,7 @@ export default function ProjectsPage({ error, isLoading, } = useSWR(`/projects?organization=${organizationId}`, fetcher, { - refreshInterval: 1000 * 60, // Refresh every minute + refreshInterval: REFRESH_INTERVAL_ONE_MINUTE, }); useEffect(() => { @@ -104,8 +106,8 @@ export default function ProjectsPage({ setIsModalOpen(false)} + isOpen={createModal.isOpen} + onClose={createModal.close} onProjectCreated={refreshProjectList} />
@@ -141,8 +143,8 @@ export default function ProjectsPage({ {projectToDelete && ( { const userId = await fiefAuth.getUserId(); if (!userId) { return null; } - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/users/${userId}`, - ); - - if (!res.ok) { - // This will activate the closest `error.js` Error Boundary - console.error("Failed to fetch user", res.statusText); - return null; - } - return res.json(); + return await fetchApiServer(`/users/${userId}`); } export default async function ProfilePage() { diff --git a/webapp/src/components/navbar.tsx b/webapp/src/components/navbar.tsx index 8ae42cc63..95000ad5e 100644 --- a/webapp/src/components/navbar.tsx +++ b/webapp/src/components/navbar.tsx @@ -25,6 +25,7 @@ import { import CreateOrganizationModal from "./createOrganizationModal"; import { getOrganizations } from "@/server-functions/organizations"; import { Button } from "./ui/button"; +import { useModal } from "@/hooks/useModal"; const USER_PROFILE_URL = process.env.NEXT_PUBLIC_FIEF_BASE_URL; // Redirect to Fief profile to handle profile updates there export default function NavBar({ @@ -40,7 +41,7 @@ export default function NavBar({ const [selectedOrg, setSelectedOrg] = useState(null); const iconStyles = "h-4 w-4 flex-shrink-0 text-muted-foreground"; const pathname = usePathname(); - const [isNewOrgModalOpen, setNewOrgModalOpen] = useState(false); + const newOrgModal = useModal(); const [organizationList, setOrganizationList] = useState< Organization[] | undefined >([]); @@ -120,7 +121,7 @@ export default function NavBar({ }, [pathname, organizationList, selectedOrg]); const handleNewOrgClick = async () => { - setNewOrgModalOpen(true); + newOrgModal.open(); setDropdownOpen(false); // Close the dropdown menu }; @@ -247,8 +248,8 @@ export default function NavBar({ )} setNewOrgModalOpen(false)} + isOpen={newOrgModal.isOpen} + onClose={newOrgModal.close} onOrganizationCreated={refreshOrgList} /> { @@ -163,7 +164,7 @@ export default function ProjectDashboard({ className="p-1 rounded-full" variant="outline" size="icon" - onClick={() => setIsSettingsModalOpen(true)} + onClick={settingsModal.open} > @@ -192,8 +193,8 @@ export default function ProjectDashboard({ /> { // Call the original onSettingsClick to refresh the data diff --git a/webapp/src/helpers/dashboard-calculations.ts b/webapp/src/helpers/dashboard-calculations.ts index 76e871543..27324412d 100644 --- a/webapp/src/helpers/dashboard-calculations.ts +++ b/webapp/src/helpers/dashboard-calculations.ts @@ -4,6 +4,7 @@ import { getEquivalentCitizenPercentage, getEquivalentTvTime, } from "./constants"; +import { SECONDS_PER_DAY } from "./time-constants"; export type RadialChartData = { energy: { label: string; value: number }; @@ -44,7 +45,7 @@ export function calculateRadialChartData( label: "days", value: parseFloat( report - .reduce((n, { duration }) => n + duration / 86400, 0) + .reduce((n, { duration }) => n + duration / SECONDS_PER_DAY, 0) .toFixed(2), ), }, diff --git a/webapp/src/helpers/time-constants.ts b/webapp/src/helpers/time-constants.ts new file mode 100644 index 000000000..33c1fbe10 --- /dev/null +++ b/webapp/src/helpers/time-constants.ts @@ -0,0 +1,28 @@ +/** + * Time-related constants to avoid magic numbers + */ + +// Base time units in milliseconds +export const MILLISECONDS_PER_SECOND = 1000; +export const SECONDS_PER_MINUTE = 60; +export const MINUTES_PER_HOUR = 60; +export const HOURS_PER_DAY = 24; +export const DAYS_PER_WEEK = 7; +export const WEEKS_PER_YEAR = 52; + +// Composite time units in milliseconds +export const ONE_SECOND_MS = MILLISECONDS_PER_SECOND; +export const ONE_MINUTE_MS = ONE_SECOND_MS * SECONDS_PER_MINUTE; +export const ONE_HOUR_MS = ONE_MINUTE_MS * MINUTES_PER_HOUR; +export const ONE_DAY_MS = ONE_HOUR_MS * HOURS_PER_DAY; +export const ONE_WEEK_MS = ONE_DAY_MS * DAYS_PER_WEEK; + +// Common time intervals +export const THIRTY_DAYS_MS = 30 * ONE_DAY_MS; + +// Seconds conversions +export const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; +export const SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY; + +// Common refresh intervals +export const REFRESH_INTERVAL_ONE_MINUTE = ONE_MINUTE_MS; diff --git a/webapp/src/hooks/useModal.ts b/webapp/src/hooks/useModal.ts new file mode 100644 index 000000000..51c784ffc --- /dev/null +++ b/webapp/src/hooks/useModal.ts @@ -0,0 +1,18 @@ +import { useCallback, useState } from "react"; + +/** + * Custom hook for managing modal open/close state + * Reduces boilerplate for modal state management + * + * @param defaultOpen - Initial open state (default: false) + * @returns Object with isOpen state and open/close/toggle functions + */ +export function useModal(defaultOpen = false) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + + return { isOpen, open, close, toggle, setIsOpen }; +} diff --git a/webapp/src/server-functions/experiments.ts b/webapp/src/server-functions/experiments.ts index 0a4ddcbe3..986313be2 100644 --- a/webapp/src/server-functions/experiments.ts +++ b/webapp/src/server-functions/experiments.ts @@ -6,33 +6,26 @@ import { DateRange } from "react-day-picker"; export async function createExperiment( experiment: Experiment, ): Promise { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/experiments`, { + const result = await fetchApi("/experiments", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...experiment, - }), + body: JSON.stringify(experiment), }); - if (!res.ok) { + if (!result) { throw new Error("Failed to create experiment"); } - const result = await res.json(); return result; } export async function getExperiments(projectId: string): Promise { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/experiments`, + const result = await fetchApi( + `/projects/${projectId}/experiments`, ); - if (!res.ok) { - throw new Error("Failed to fetch experiments"); + if (!result) { + return []; } - const result = await res.json(); return result.map((experiment: Experiment) => { return { id: experiment.id, diff --git a/webapp/src/server-functions/projectTokens.ts b/webapp/src/server-functions/projectTokens.ts index 3a593861a..66cc8e9ac 100644 --- a/webapp/src/server-functions/projectTokens.ts +++ b/webapp/src/server-functions/projectTokens.ts @@ -1,4 +1,5 @@ import { IProjectToken } from "@/types/project"; +import { fetchApi } from "@/utils/api"; /** * Retrieves the list of tokens for a given project @@ -6,20 +7,15 @@ import { IProjectToken } from "@/types/project"; export async function getProjectTokens( projectId: string, ): Promise { - try { - const URL = `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/api-tokens`; - const res = await fetch(URL); - if (!res.ok) { - // This will activate the closest `error.js` Error Boundary - console.error("Failed to fetch data", res.statusText); - throw new Error("Failed to fetch data"); - } - const data = await res.json(); - return data; - } catch (error) { - // This will activate the closest `error.js` Error Boundary - throw new Error("Failed to fetch data"); + const data = await fetchApi( + `/projects/${projectId}/api-tokens`, + ); + + if (!data) { + throw new Error("Failed to fetch project tokens"); } + + return data; } export async function createProjectToken( @@ -27,52 +23,25 @@ export async function createProjectToken( tokenName: string, access?: Number, ): Promise { - try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/api-tokens`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: tokenName, access }), - }, - ); - if (!res.ok) { - // This will activate the closest `error.js` Error Boundary - console.error("Failed to fetch data", res.statusText); - throw new Error("Failed to fetch data"); - } - return res.json(); - } catch (error) { - // This will activate the closest `error.js` Error Boundary - console.error("Failed to fetch data", error); - throw new Error("Failed to fetch data"); + const result = await fetchApi( + `/projects/${projectId}/api-tokens`, + { + method: "POST", + body: JSON.stringify({ name: tokenName, access }), + }, + ); + + if (!result) { + throw new Error("Failed to create project token"); } + + return result; } export async function deleteProjectToken( projectId: string, tokenId: string, ): Promise { - try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/api-tokens/${tokenId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - }, - ); - if (res.status !== 204) { - // This will activate the closest `error.js` Error Boundary - console.error("Failed to fetch data", res.statusText); - throw new Error("Failed to fetch data"); - } - return; - } catch (error) { - // This will activate the closest `error.js` Error Boundary - console.error("Failed to fetch data", error); - throw new Error("Failed to fetch data"); - } + await fetchApi(`/projects/${projectId}/api-tokens/${tokenId}`, { + method: "DELETE", + }); } diff --git a/webapp/src/server-functions/runs.ts b/webapp/src/server-functions/runs.ts index 93f6c36ce..5a6665602 100644 --- a/webapp/src/server-functions/runs.ts +++ b/webapp/src/server-functions/runs.ts @@ -4,10 +4,9 @@ import { RunMetadata } from "@/types/run-metadata"; import { fetchApi } from "@/utils/api"; import { RunReport } from "@/types/run-report"; -export async function getRunMetadata(runId: string): Promise { - const url = `${process.env.NEXT_PUBLIC_API_URL}/runs/${runId}`; - const res = await fetch(url); - return await res.json(); +export async function getRunMetadata(runId: string): Promise { + const result = await fetchApi(`/runs/${runId}`); + return result; } export async function getRunEmissionsByExperiment( @@ -19,14 +18,14 @@ export async function getRunEmissionsByExperiment( return []; } - const url = `${process.env.NEXT_PUBLIC_API_URL}/experiments/${experimentId}/runs/sums?start_date=${startDate}&end_date=${endDate}`; - const res = await fetch(url); + const result = await fetchApi( + `/experiments/${experimentId}/runs/sums?start_date=${startDate}&end_date=${endDate}`, + ); - if (!res.ok) { - console.error("Failed to fetch run data:", res.statusText); + if (!result) { return []; } - const result = await res.json(); + return result.map((runReport: any) => { return { runId: runReport.run_id, diff --git a/webapp/src/utils/api.ts b/webapp/src/utils/api.ts index 566aae071..66b8d6e98 100644 --- a/webapp/src/utils/api.ts +++ b/webapp/src/utils/api.ts @@ -25,13 +25,3 @@ export async function fetchApi( throw error; } } - -// Helper function to check if we're running on the client -export function isClient(): boolean { - return typeof window !== "undefined"; -} - -// Helper function to check if we're running on the server -export function isServer(): boolean { - return typeof window === "undefined"; -} From 621585acd7b29ae2d1ecb4896ab7bac4420e87f4 Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 17:33:52 +0200 Subject: [PATCH 5/9] chore: standardize error handling --- .../[organizationId]/members/members-list.tsx | 11 +- .../app/(dashboard)/[organizationId]/page.tsx | 10 +- webapp/src/components/date-range-picker.tsx | 1 - webapp/src/components/ui/calendar.tsx | 4 +- webapp/src/helpers/dashboard-calculations.ts | 5 +- webapp/src/hooks/useProjectDashboard.ts | 3 +- webapp/src/server-functions/ERROR_HANDLING.md | 151 ++++++++++++++++++ webapp/src/server-functions/organizations.ts | 73 ++++----- webapp/src/server-functions/projects.ts | 13 +- webapp/src/server-functions/runs.ts | 100 ++++++------ 10 files changed, 260 insertions(+), 111 deletions(-) create mode 100644 webapp/src/server-functions/ERROR_HANDLING.md diff --git a/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx b/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx index 7986531ac..f50a7527f 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx @@ -45,13 +45,10 @@ export default function MembersList({ await toast .promise( - fetchApi( - `/organizations/${organizationId}/add-user`, - { - method: "POST", - body: body, - }, - ).then(async (result) => { + fetchApi(`/organizations/${organizationId}/add-user`, { + method: "POST", + body: body, + }).then(async (result) => { if (!result) { throw new Error("Failed to add user"); } diff --git a/webapp/src/app/(dashboard)/[organizationId]/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/page.tsx index 9f230cfff..4a10d3e07 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/page.tsx @@ -11,7 +11,11 @@ import { getEquivalentCitizenPercentage, getEquivalentTvTime, } from "@/helpers/constants"; -import { REFRESH_INTERVAL_ONE_MINUTE, THIRTY_DAYS_MS, SECONDS_PER_DAY } from "@/helpers/time-constants"; +import { + REFRESH_INTERVAL_ONE_MINUTE, + THIRTY_DAYS_MS, + SECONDS_PER_DAY, +} from "@/helpers/time-constants"; import { fetcher } from "@/helpers/swr"; import { getOrganizationEmissionsByProject } from "@/server-functions/organizations"; import { Organization } from "@/types/organization"; @@ -87,7 +91,9 @@ export default function OrganizationPage({ label: "days", value: organizationReport?.duration ? parseFloat( - (organizationReport.duration / SECONDS_PER_DAY).toFixed(2), + (organizationReport.duration / SECONDS_PER_DAY).toFixed( + 2, + ), ) : 0, }, diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx index a05782c4a..8f39839d6 100644 --- a/webapp/src/components/date-range-picker.tsx +++ b/webapp/src/components/date-range-picker.tsx @@ -69,7 +69,6 @@ export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) { defaultMonth={date?.from} selected={tempDateRange} onSelect={(range) => { - console.log("onSelect called with:", range); setTempDateRange(range); }} numberOfMonths={2} diff --git a/webapp/src/components/ui/calendar.tsx b/webapp/src/components/ui/calendar.tsx index 87f9d0a24..99ac94c4e 100644 --- a/webapp/src/components/ui/calendar.tsx +++ b/webapp/src/components/ui/calendar.tsx @@ -59,7 +59,9 @@ function Calendar({ Chevron: ({ className, orientation, ...props }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight; - return ; + return ( + + ); }, }} {...props} diff --git a/webapp/src/helpers/dashboard-calculations.ts b/webapp/src/helpers/dashboard-calculations.ts index 27324412d..8b4b67e27 100644 --- a/webapp/src/helpers/dashboard-calculations.ts +++ b/webapp/src/helpers/dashboard-calculations.ts @@ -45,7 +45,10 @@ export function calculateRadialChartData( label: "days", value: parseFloat( report - .reduce((n, { duration }) => n + duration / SECONDS_PER_DAY, 0) + .reduce( + (n, { duration }) => n + duration / SECONDS_PER_DAY, + 0, + ) .toFixed(2), ), }, diff --git a/webapp/src/hooks/useProjectDashboard.ts b/webapp/src/hooks/useProjectDashboard.ts index 90f1ef668..6f38dd7ad 100644 --- a/webapp/src/hooks/useProjectDashboard.ts +++ b/webapp/src/hooks/useProjectDashboard.ts @@ -114,7 +114,8 @@ export function useProjectDashboard( setSelectedExperimentId(report[0]?.experiment_id ?? ""); - const newConvertedValues = calculateConvertedValues(newRadialChartData); + const newConvertedValues = + calculateConvertedValues(newRadialChartData); setConvertedValues(newConvertedValues); }, [date], diff --git a/webapp/src/server-functions/ERROR_HANDLING.md b/webapp/src/server-functions/ERROR_HANDLING.md new file mode 100644 index 000000000..7928252a9 --- /dev/null +++ b/webapp/src/server-functions/ERROR_HANDLING.md @@ -0,0 +1,151 @@ +# Error Handling Standards for Server Functions + +## Overview + +This document defines the standard error handling patterns for all server-side API functions in the codebase. + +## Core Principles + +1. **User feedback comes from the UI layer** - Server functions focus on data retrieval/mutation +2. **Consistent patterns** - Similar operations should handle errors the same way +3. **Graceful degradation** - Read operations should fail gracefully with empty data +4. **Clear failure signals** - Write operations should throw errors for the UI to catch + +--- + +## Standard Patterns + +### Pattern A: Read Operations (GET) + +**Use for:** Fetching data, list operations, queries + +```typescript +export async function getData(id: string): Promise { + const result = await fetchApi(`/endpoint/${id}`); + + // Return empty array/null on failure - UI will show "no data" state + if (!result) { + return []; // or null for single items + } + + return result; +} +``` + +**Why:** + +- Users can still use the app even if one data source fails +- UI naturally shows "no data" or "empty" states +- Errors are already logged by `fetchApi` + +### Pattern B: Write Operations (POST/PUT/PATCH/DELETE) + +**Use for:** Creating, updating, deleting data + +```typescript +export async function createData(data: Data): Promise { + const result = await fetchApi("/endpoint", { + method: "POST", + body: JSON.stringify(data), + }); + + // Throw error - UI will catch and show toast/error message + if (!result) { + throw new Error("Failed to create data"); + } + + return result; +} +``` + +**Why:** + +- Write operations are user-initiated actions that need feedback +- UI layer can catch the error and show appropriate toast/modal +- Clear signal that the operation failed + +### Pattern C: Critical Read Operations + +**Use for:** Data required for the page to function (rare) + +```typescript +export async function getCriticalData(id: string): Promise { + const result = await fetchApi(`/critical/${id}`); + + if (!result) { + throw new Error("Failed to load required data"); + } + + return result; +} +``` + +**Why:** + +- Some data is essential for the page to work +- UI can show error boundary or redirect + +--- + +## Migration Guide + +### ❌ Avoid Try-Catch in Server Functions + +```typescript +// DON'T DO THIS - fetchApi already handles errors +try { + const result = await fetchApi(endpoint); + return result || []; +} catch (error) { + console.error(error); + return []; +} +``` + +```typescript +// DO THIS - Let fetchApi handle errors, check result +const result = await fetchApi(endpoint); +return result || []; +``` + +### UI Layer Responsibilities + +The UI components should handle errors from write operations: + +```typescript +// In React component +const handleCreate = async () => { + try { + await createData(formData); + toast.success("Created successfully"); + } catch (error) { + toast.error(error.message || "Failed to create"); + } +}; +``` + +--- + +## Examples by Function Type + +| Function Type | Pattern | Return on Error | Example | +| -------------------- | ------- | --------------- | ------------------ | +| `getProjects()` | A | `[]` | List of projects | +| `getOneProject()` | A | `null` | Single project | +| `createProject()` | B | `throw` | Create new project | +| `updateProject()` | B | `throw` | Update project | +| `deleteProject()` | B | `void` (throws) | Delete project | +| `getOrganizations()` | A | `[]` | List of orgs | +| `getUserProfile()` | C | `throw` | Required for auth | + +--- + +## Decision Tree + +``` +Is this a READ operation? +├─ Yes: Is the data critical for the page? +│ ├─ Yes: Use Pattern C (throw) +│ └─ No: Use Pattern A (return empty) +└─ No (WRITE operation): Use Pattern B (throw) +``` diff --git a/webapp/src/server-functions/organizations.ts b/webapp/src/server-functions/organizations.ts index eddc25437..68c240937 100644 --- a/webapp/src/server-functions/organizations.ts +++ b/webapp/src/server-functions/organizations.ts @@ -6,31 +6,17 @@ import { fetchApiServer } from "@/helpers/api-server"; export async function getOrganizationEmissionsByProject( organizationId: string, dateRange: DateRange | undefined, -): Promise { - try { - let endpoint = `/organizations/${organizationId}/sums`; +): Promise { + let endpoint = `/organizations/${organizationId}/sums`; - if (dateRange?.from && dateRange?.to) { - endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`; - } - - const result = await fetchApiServer(endpoint); + if (dateRange?.from && dateRange?.to) { + endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`; + } - // Handle case when no emissions data is found - if (!result) { - // Return zeros for all metrics - return { - name: "", - emissions: 0, - energy_consumed: 0, - duration: 0, - }; - } + const result = await fetchApiServer(endpoint); - return result; - } catch (error) { - console.error("Error fetching organization emissions:", error); - // Return default values if there's an error + // Return empty data on failure (Pattern A - Read operation) + if (!result) { return { name: "", emissions: 0, @@ -38,44 +24,45 @@ export async function getOrganizationEmissionsByProject( duration: 0, }; } + + return result; } export async function getDefaultOrgId(): Promise { - try { - const orgs = await fetchApiServer("/organizations"); - if (!orgs) { - return null; - } + const orgs = await fetchApiServer("/organizations"); - if (orgs.length > 0) { - return orgs[0].id; - } - } catch (err) { - console.warn("error processing organizations list", err); + // Return null on failure (Pattern A - Read operation) + if (!orgs || orgs.length === 0) { + return null; } - return null; + + return orgs[0].id; } export async function getOrganizations(): Promise { - try { - const orgs = await fetchApiServer("/organizations"); - if (!orgs) { - return []; - } + const orgs = await fetchApiServer("/organizations"); - return orgs; - } catch (err) { - console.warn("error fetching organizations list", err); + // Return empty array on failure (Pattern A - Read operation) + if (!orgs) { return []; } + + return orgs; } export const createOrganization = async (organization: { name: string; description: string; -}): Promise => { - return fetchApiServer("/organizations", { +}): Promise => { + const result = await fetchApiServer("/organizations", { method: "POST", body: JSON.stringify(organization), }); + + // Throw on failure (Pattern B - Write operation) + if (!result) { + throw new Error("Failed to create organization"); + } + + return result; }; diff --git a/webapp/src/server-functions/projects.ts b/webapp/src/server-functions/projects.ts index 329b3135f..056d0ac9b 100644 --- a/webapp/src/server-functions/projects.ts +++ b/webapp/src/server-functions/projects.ts @@ -4,7 +4,7 @@ import { fetchApiServer } from "@/helpers/api-server"; export const createProject = async ( organizationId: string, project: { name: string; description: string }, -): Promise => { +): Promise => { const result = await fetchApiServer("/projects", { method: "POST", body: JSON.stringify({ @@ -13,23 +13,28 @@ export const createProject = async ( }), }); + // Throw on failure (Pattern B - Write operation) if (!result) { - return null; + throw new Error("Failed to create project"); } + return result; }; export const updateProject = async ( projectId: string, project: ProjectInputs, -): Promise => { +): Promise => { const result = await fetchApiServer(`/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(project), }); + + // Throw on failure (Pattern B - Write operation) if (!result) { - return null; + throw new Error("Failed to update project"); } + return result; }; diff --git a/webapp/src/server-functions/runs.ts b/webapp/src/server-functions/runs.ts index 5a6665602..c8b7b7804 100644 --- a/webapp/src/server-functions/runs.ts +++ b/webapp/src/server-functions/runs.ts @@ -4,7 +4,9 @@ import { RunMetadata } from "@/types/run-metadata"; import { fetchApi } from "@/utils/api"; import { RunReport } from "@/types/run-report"; -export async function getRunMetadata(runId: string): Promise { +export async function getRunMetadata( + runId: string, +): Promise { const result = await fetchApi(`/runs/${runId}`); return result; } @@ -40,59 +42,55 @@ export async function getRunEmissionsByExperiment( export async function getEmissionsTimeSeries( runId: string, ): Promise { - try { - const runMetadataData = await fetchApi(`/runs/${runId}`); - const emissionsData = await fetchApi<{ items: Emission[] }>( - `/runs/${runId}/emissions`, - ); - - if (!runMetadataData || !emissionsData) { - return { - runId, - emissions: [], - metadata: null, - }; - } - - const metadata: RunMetadata = { - timestamp: runMetadataData.timestamp, - experiment_id: runMetadataData.experiment_id, - os: runMetadataData.os, - python_version: runMetadataData.python_version, - codecarbon_version: runMetadataData.codecarbon_version, - cpu_count: runMetadataData.cpu_count, - cpu_model: runMetadataData.cpu_model, - gpu_count: runMetadataData.gpu_count, - gpu_model: runMetadataData.gpu_model, - longitude: runMetadataData.longitude, - latitude: runMetadataData.latitude, - region: runMetadataData.region, - provider: runMetadataData.provider, - ram_total_size: runMetadataData.ram_total_size, - tracking_mode: runMetadataData.tracking_mode, - }; - - const emissions: Emission[] = emissionsData.items.map((item: any) => ({ - emission_id: item.run_id, - timestamp: item.timestamp, - emissions_sum: item.emissions_sum, - emissions_rate: item.emissions_rate, - cpu_power: item.cpu_power, - gpu_power: item.gpu_power, - ram_power: item.ram_power, - cpu_energy: item.cpu_energy, - gpu_energy: item.gpu_energy, - ram_energy: item.ram_energy, - energy_consumed: item.energy_consumed, - })); + const runMetadataData = await fetchApi(`/runs/${runId}`); + const emissionsData = await fetchApi<{ items: Emission[] }>( + `/runs/${runId}/emissions`, + ); + // Return empty data on failure (Pattern A - Read operation) + if (!runMetadataData || !emissionsData) { return { runId, - emissions, - metadata, + emissions: [], + metadata: null, }; - } catch (error) { - console.error("Failed to fetch emissions time series:", error); - throw error; } + + const metadata: RunMetadata = { + timestamp: runMetadataData.timestamp, + experiment_id: runMetadataData.experiment_id, + os: runMetadataData.os, + python_version: runMetadataData.python_version, + codecarbon_version: runMetadataData.codecarbon_version, + cpu_count: runMetadataData.cpu_count, + cpu_model: runMetadataData.cpu_model, + gpu_count: runMetadataData.gpu_count, + gpu_model: runMetadataData.gpu_model, + longitude: runMetadataData.longitude, + latitude: runMetadataData.latitude, + region: runMetadataData.region, + provider: runMetadataData.provider, + ram_total_size: runMetadataData.ram_total_size, + tracking_mode: runMetadataData.tracking_mode, + }; + + const emissions: Emission[] = emissionsData.items.map((item: any) => ({ + emission_id: item.run_id, + timestamp: item.timestamp, + emissions_sum: item.emissions_sum, + emissions_rate: item.emissions_rate, + cpu_power: item.cpu_power, + gpu_power: item.gpu_power, + ram_power: item.ram_power, + cpu_energy: item.cpu_energy, + gpu_energy: item.gpu_energy, + ram_energy: item.ram_energy, + energy_consumed: item.energy_consumed, + })); + + return { + runId, + emissions, + metadata, + }; } From 0acd4b18678eb15285b878a1799a037fb1a38f55 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Wed, 8 Oct 2025 19:31:44 +0200 Subject: [PATCH 6/9] Test Python 3.14 --- .github/workflows/test-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml index c38f3e988..9840294eb 100644 --- a/.github/workflows/test-package.yml +++ b/.github/workflows/test-package.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.12", "3.14", "3.14"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv From cfa5d834f05c3c4b2db74532966c159ab9f69bc6 Mon Sep 17 00:00:00 2001 From: Amine Saboni <43726203+SaboniAmine@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:07:29 +0200 Subject: [PATCH 7/9] feat(back): add cascade deletion of projects (#945) feat(front): add modal to call deletion endpoints for projects --- .../[organizationId]/projects/page.tsx | 7 ++-- webapp/src/helpers/api-server.ts | 38 +++++++++++++++---- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx index eb3ae2a42..9d3e95055 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx @@ -27,6 +27,7 @@ export default function ProjectsPage({ const createModal = useModal(); const deleteModal = useModal(); const [projectList, setProjectList] = useState([]); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [projectToDelete, setProjectToDelete] = useState( null, ); @@ -43,7 +44,7 @@ export default function ProjectsPage({ const handleDeleteClick = (project: Project) => { setProjectToDelete(project); - deleteModal.open(); + setDeleteModalOpen(true); }; const handleDeleteConfirm = async (projectId: string) => { @@ -143,8 +144,8 @@ export default function ProjectsPage({ {projectToDelete && ( ( return null; } - // Parse JSON response - try { - return await response.json(); - } catch (e) { - // If JSON parsing fails (e.g., empty response body), return null - console.warn( - `Empty or invalid JSON response from ${endpoint}, returning null`, - ); + // Handle 204 No Content responses (e.g., DELETE operations) + if (response.status === 204) { return null; } + + // Special handling for endpoints that might return null + if ( + endpoint.includes("/organizations/") && + endpoint.includes("/sums") + ) { + // For organization sums endpoint that might return null + try { + return await response.json(); + } catch (e) { + // If JSON parsing fails (e.g., empty response), return default values + console.warn( + "Empty response from organization sums endpoint, using default values", + ); + return { + name: "", + description: "", + emissions: 0, + energy_consumed: 0, + duration: 0, + cpu_power: 0, + gpu_power: 0, + ram_power: 0, + emissions_rate: 0, + emissions_count: 0, + } as unknown as T; + } + } } catch (error) { // Log server-side error with more details console.error("API server request failed:", { From 6551bdf749b35aa263148e68eb6bc5dc54b3864d Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 12:53:48 +0200 Subject: [PATCH 8/9] feat(back): add cascade deletion of projects feat(front): add modal to call deletion endpoints for projects --- webapp/src/components/ui/calendar.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/ui/calendar.tsx b/webapp/src/components/ui/calendar.tsx index 99ac94c4e..097490794 100644 --- a/webapp/src/components/ui/calendar.tsx +++ b/webapp/src/components/ui/calendar.tsx @@ -56,12 +56,10 @@ function Calendar({ ...classNames, }} components={{ - Chevron: ({ className, orientation, ...props }) => { + Chevron: ({ orientation, ...props }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight; - return ( - - ); + return ; }, }} {...props} From fbd47ab02f1bcae1a3b65a555253bfa0661435cb Mon Sep 17 00:00:00 2001 From: Amine Saboni Date: Sun, 5 Oct 2025 12:53:48 +0200 Subject: [PATCH 9/9] chore: fix date-range picker, remove console.log when not needed --- .github/workflows/test-package.yml | 2 +- webapp/src/components/ui/calendar.tsx | 4 +-- webapp/src/helpers/api-server.ts | 35 +++++--------------- webapp/src/server-functions/organizations.ts | 23 +++++++------ 4 files changed, 24 insertions(+), 40 deletions(-) diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml index 9840294eb..c38f3e988 100644 --- a/.github/workflows/test-package.yml +++ b/.github/workflows/test-package.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ["3.12", "3.14", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv diff --git a/webapp/src/components/ui/calendar.tsx b/webapp/src/components/ui/calendar.tsx index 097490794..87f9d0a24 100644 --- a/webapp/src/components/ui/calendar.tsx +++ b/webapp/src/components/ui/calendar.tsx @@ -56,10 +56,10 @@ function Calendar({ ...classNames, }} components={{ - Chevron: ({ orientation, ...props }) => { + Chevron: ({ className, orientation, ...props }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight; - return ; + return ; }, }} {...props} diff --git a/webapp/src/helpers/api-server.ts b/webapp/src/helpers/api-server.ts index 0c325182a..6887c7b6f 100644 --- a/webapp/src/helpers/api-server.ts +++ b/webapp/src/helpers/api-server.ts @@ -53,32 +53,15 @@ export async function fetchApiServer( return null; } - // Special handling for endpoints that might return null - if ( - endpoint.includes("/organizations/") && - endpoint.includes("/sums") - ) { - // For organization sums endpoint that might return null - try { - return await response.json(); - } catch (e) { - // If JSON parsing fails (e.g., empty response), return default values - console.warn( - "Empty response from organization sums endpoint, using default values", - ); - return { - name: "", - description: "", - emissions: 0, - energy_consumed: 0, - duration: 0, - cpu_power: 0, - gpu_power: 0, - ram_power: 0, - emissions_rate: 0, - emissions_count: 0, - } as unknown as T; - } + // Parse JSON response + try { + return await response.json(); + } catch (e) { + // If JSON parsing fails (e.g., empty response body), return null + console.warn( + `Empty or invalid JSON response from ${endpoint}, returning null`, + ); + return null; } } catch (error) { // Log server-side error with more details diff --git a/webapp/src/server-functions/organizations.ts b/webapp/src/server-functions/organizations.ts index 68c240937..9b299f7c9 100644 --- a/webapp/src/server-functions/organizations.ts +++ b/webapp/src/server-functions/organizations.ts @@ -13,19 +13,20 @@ export async function getOrganizationEmissionsByProject( endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`; } - const result = await fetchApiServer(endpoint); + const result = await fetchApiServer(endpoint); - // Return empty data on failure (Pattern A - Read operation) - if (!result) { - return { - name: "", - emissions: 0, - energy_consumed: 0, - duration: 0, - }; - } + // Handle case when no emissions data is found + if (!result) { + // Return zeros for all metrics + return { + name: "", + emissions: 0, + energy_consumed: 0, + duration: 0, + }; + } - return result; + return result; } export async function getDefaultOrgId(): Promise {