diff --git a/app/package-lock.json b/app/package-lock.json index 0ab28efbb..946a6403c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -15,6 +15,7 @@ "@huggingface/inference": "^2.8.0", "@next/env": "^14.2.5", "@nivo/bar": "^0.88.0", + "@nivo/line": "^0.88.0", "@react-email/components": "^0.0.19", "@reduxjs/toolkit": "^2.2.7", "@storybook/cli": "^8.3.4", @@ -5333,6 +5334,26 @@ "react": ">= 16.14.0 < 19.0.0" } }, + "node_modules/@nivo/line": { + "version": "0.88.0", + "resolved": "https://registry.npmjs.org/@nivo/line/-/line-0.88.0.tgz", + "integrity": "sha512-hFTyZ3BdAZvq2HwdwMj2SJGUeodjEW+7DLtFMIIoVIxmjZlAs3z533HcJ9cJd3it928fDm8SF/rgHs0TztYf9Q==", + "dependencies": { + "@nivo/annotations": "0.88.0", + "@nivo/axes": "0.88.0", + "@nivo/colors": "0.88.0", + "@nivo/core": "0.88.0", + "@nivo/legends": "0.88.0", + "@nivo/scales": "0.88.0", + "@nivo/tooltip": "0.88.0", + "@nivo/voronoi": "0.88.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, "node_modules/@nivo/scales": { "version": "0.88.0", "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.88.0.tgz", @@ -5367,6 +5388,22 @@ "react": ">= 16.14.0 < 19.0.0" } }, + "node_modules/@nivo/voronoi": { + "version": "0.88.0", + "resolved": "https://registry.npmjs.org/@nivo/voronoi/-/voronoi-0.88.0.tgz", + "integrity": "sha512-MyiNLvODthFoMjQ7Wjp693nogbTmVEx8Yn/7QkJhyPQbFyyA37TF/D1a/ox4h2OslXtP6K9QFN+42gB/zu7ixw==", + "dependencies": { + "@nivo/core": "0.88.0", + "@nivo/tooltip": "0.88.0", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-scale": "^4.0.8", + "d3-delaunay": "^6.0.4", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8805,6 +8842,11 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, "node_modules/@types/d3-format": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz", @@ -13284,6 +13326,17 @@ "node": ">=12" } }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", @@ -13671,6 +13724,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -25243,6 +25304,11 @@ "inherits": "^2.0.1" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/app/package.json b/app/package.json index f4bd72c71..5b4c6b20c 100644 --- a/app/package.json +++ b/app/package.json @@ -41,6 +41,7 @@ "@huggingface/inference": "^2.8.0", "@next/env": "^14.2.5", "@nivo/bar": "^0.88.0", + "@nivo/line": "^0.88.0", "@react-email/components": "^0.0.19", "@reduxjs/toolkit": "^2.2.7", "@storybook/cli": "^8.3.4", diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastCard.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastCard.tsx new file mode 100644 index 000000000..8c3288454 --- /dev/null +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastCard.tsx @@ -0,0 +1,66 @@ +import { EmissionsForecastData } from "@/util/types"; +import { TFunction } from "i18next/typescript/t"; +import { useState } from "react"; +import { GrowthRatesExplanationModal } from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModal"; +import { + Card, + CardBody, + CardHeader, + HStack, + IconButton, + Text, +} from "@chakra-ui/react"; +import { InfoOutlineIcon } from "@chakra-ui/icons"; +import { EmissionsForecastChart } from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastChart"; + +export const EmissionsForecastCard = ({ + forecast, + t, + lng, +}: { + forecast: EmissionsForecastData; + t: TFunction; + lng: string; +}) => { + const [isExplanationModalOpen, setIsExplanationModalOpen] = useState(false); + + return ( + <> + setIsExplanationModalOpen(false)} + emissionsForecast={forecast} + lng={lng} + /> + + + + + + {t("breakdown-of-sub-sector-emissions")} + + setIsExplanationModalOpen(true)} + icon={} + aria-label={"growth-rates-explanation"} + /> + + + + + + + + ); +}; diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastChart.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastChart.tsx new file mode 100644 index 000000000..0e4137644 --- /dev/null +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastChart.tsx @@ -0,0 +1,197 @@ +import { EmissionsForecastData } from "@/util/types"; +import { TFunction } from "i18next/typescript/t"; +import { getReferenceNumberByName, SECTORS, ISector } from "@/util/constants"; +import { + Badge, + Box, + Card, + Heading, + Table, + Tbody, + Td, + Text, + Th, + Tr, +} from "@chakra-ui/react"; +import { ResponsiveLine } from "@nivo/line"; +import { convertKgToTonnes } from "@/util/helpers"; + +interface LineChartData { + id: string; + data: { x: string; y: number }[]; +} + +export const EmissionsForecastChart = ({ + forecast, + t, +}: { + forecast: EmissionsForecastData; + t: TFunction; +}) => { + const convertToLineChartData = ( + forecastData: EmissionsForecastData, + ): LineChartData[] => { + const sectors = Object.keys( + forecastData.forecast[Object.keys(forecastData.forecast)[0]], + ); + + return sectors + .map((sector) => ({ + id: t( + SECTORS.find((s) => s.referenceNumber === sector)?.name + "-short" || + sector, + ), + data: Object.entries(forecastData.forecast).map( + ([year, sectorsData]) => { + return { + x: year, + y: sectorsData[sector] || 0, + }; + }, + ), + })) + .reverse(); + }; + + const data = convertToLineChartData(forecast); + + const colors = ["#FFAB51", "#5162FF", "#51ABFF", "#D45252", "#CFAE53"]; + return ( + (parseInt(value) % 5 === 0 ? value : ""), + }} + axisLeft={{ + tickSize: 5, + tickPadding: 5, + tickRotation: 0, + format: (value: number) => convertKgToTonnes(value), + }} + colors={colors} + tooltip={({ point }) => { + const year = point.data.x; + const sumOfYs = data.reduce((sum, series) => { + const yearData = series.data.find(({ x }) => x === year); + return sum + parseInt((yearData?.y as unknown as string) || "0"); + }, 0); + + return ( + + + {t("year")} + + {year as unknown as string} + + + + + + {data.map((series, index) => { + const yearData = series.data.find( + ({ x }) => x === point.data.x, + ); + const percentage = yearData + ? ((yearData.y / sumOfYs) * 100).toFixed(2) + : 0; + const sectorRefNo = getReferenceNumberByName( + point.serieId as keyof ISector, + ); + + return ( + + + + + + + ); + })} + + + + + + +
+ + {series.id} + + { + forecast.growthRates[point.data.x as number]?.[ + sectorRefNo! + ] + } + {percentage}% + {convertKgToTonnes( + parseInt(yearData?.y as unknown as string), + )} +
{t("total")}{convertKgToTonnes(sumOfYs)}
+
+
+ ); + }} + enableGridX={false} + enableGridY={false} + enablePoints={false} + pointSize={10} + pointColor={{ theme: "background" }} + pointBorderWidth={2} + pointBorderColor={{ from: "serieColor" }} + pointLabel="data.yFormatted" + pointLabelYOffset={-12} + enableArea={true} + areaOpacity={1} + enableTouchCrosshair={true} + useMesh={true} + legends={[ + { + anchor: "bottom", + direction: "row", + justify: false, + translateX: 0, + translateY: 60, + itemWidth: 140, + itemHeight: 20, + itemsSpacing: 4, + symbolSize: 20, + symbolShape: "circle", + itemDirection: "left-to-right", + itemTextColor: "#777", + effects: [ + { + on: "hover", + style: { + itemBackground: "rgba(0, 0, 0, .03)", + itemOpacity: 1, + }, + }, + ], + }, + ]} + /> + ); +}; diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastSection.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastSection.tsx new file mode 100644 index 000000000..acef4ae58 --- /dev/null +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastSection.tsx @@ -0,0 +1,47 @@ +import { Box, Heading, Text } from "@chakra-ui/react"; +import { api } from "@/services/api"; +import { BlueSubtitle } from "@/components/blue-subtitle"; +import { TFunction } from "i18next/typescript/t"; +import { EmissionsForecastCard } from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastCard"; + +export const EmissionsForecastSection = ({ + inventoryId, + t, + lng, +}: { + inventoryId: string; + t: TFunction; + lng: string; +}) => { + const { data: forecast, isLoading: isForecastLoading } = + api.useGetEmissionsForecastQuery(inventoryId); + + if (!forecast?.forecast || isForecastLoading) { + return
; + } + return ( + + + + {t("sector-emissions-forecast")} + + + {t("sector-emissions-forecast-description")} + + + + + + ); +}; diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModal.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModal.tsx new file mode 100644 index 000000000..eabc8c3dd --- /dev/null +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModal.tsx @@ -0,0 +1,156 @@ +import { TFunction } from "i18next/typescript/t"; +import { + Divider, + Heading, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + Text, + Box, + VStack, +} from "@chakra-ui/react"; +import { MdBarChart } from "react-icons/md"; +import { GrowthRatesExplanationModalTable } from "./GrowthRatesExplanationModalTable"; +import { EmissionsForecastData } from "@/util/types"; + +export function GrowthRatesExplanationModal({ + t, + isOpen, + onClose, + emissionsForecast, + lng, +}: { + t: TFunction; + isOpen: boolean; + onClose: () => void; + emissionsForecast: EmissionsForecastData; + lng: string; +}) { + const { cluster, growthRates } = emissionsForecast; + return ( + + + + + + + {t("about-growth-rates")} + + + + + + + + {t("city-typology-and-clusters")} + + + {t("city-typology-and-clusters-description")} + + + + + {cluster?.id} + + + {t("cluster-#")} + + + + + {cluster?.description?.[lng]} + + + {t("description")} + + + + + {t("methodology-and-assumptions")} + + + {t("methodology-and-assumptions-description")} + + + + + + + + + + ); +} diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModalTable.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModalTable.tsx new file mode 100644 index 000000000..b84058951 --- /dev/null +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/GrowthRatesExplanationModalTable.tsx @@ -0,0 +1,55 @@ +import { + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@chakra-ui/react"; +import { SECTORS } from "@/util/constants"; +import { TFunction } from "i18next"; +import { ProjectionData } from "@/util/types"; + +export const GrowthRatesExplanationModalTable = ({ + growthRates, + t, +}: { + growthRates: ProjectionData; + t: TFunction; +}) => { + return ( + + + + + + {Object.keys(growthRates) + .slice(0, 4) + .map((year) => ( + + ))} + + + + + + {SECTORS.map((sector) => ( + + + {Object.keys(growthRates) + .slice(0, 4) + .map((year) => ( + + ))} + + + + ))} + + +
{t("sector")}{year}{"2030"}{"2050"}
{t(sector.name + "-short")} + {growthRates[year][sector.referenceNumber]} + {growthRates["2030"][sector.referenceNumber]}{growthRates["2050"][sector.referenceNumber]}
+ ); +}; diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx index c840f6829..d3ca73c4f 100644 --- a/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx @@ -45,6 +45,7 @@ import ButtonGroupToggle from "@/components/button-group-toggle"; import { MdBarChart, MdTableChart } from "react-icons/md"; import EmissionBySectorTableSection from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionBySectorTable"; import EmissionBySectorChart from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionBySectorChart"; +import { EmissionsForecastSection } from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastSection"; enum TableView { BY_ACTIVITY = "by-activity", @@ -504,6 +505,11 @@ export default function InventoryResultTab({ isPublic={isPublic} /> + { + // ensure inventory belongs to user + const inventoryData = await UserService.findUserInventory( + inventory, + session, + [], + true, + ); + const forecast = await getEmissionsForecasts(inventoryData); + return NextResponse.json({ + data: forecast, + }); + }, +); diff --git a/app/src/backend/OpenClimateService.ts b/app/src/backend/OpenClimateService.ts new file mode 100644 index 000000000..c40fc45f5 --- /dev/null +++ b/app/src/backend/OpenClimateService.ts @@ -0,0 +1,24 @@ +import { EmissionsForecastData } from "@/util/types"; +import { GLOBAL_API_URL } from "@/services/api"; + +export type GrowthRatesResponse = Omit; + +export const getGrowthRatesFromOC = async ( + locode: string, + forecastYear: number, +): Promise => { + try { + const URL = `${GLOBAL_API_URL}/api/v0/ghgi/emissions_forecast/city/${encodeURIComponent(locode)}/${forecastYear}`; + const response = await fetch(URL); + + console.info(`getGrowthRatesFromOC Status: ${response.status}`); + const data = await response.json(); + return { + ...data, + growthRates: data.growth_rates, + }; + } catch (error) { + console.error(`Error fetching growth rates: ${error}`); + return undefined; + } +}; diff --git a/app/src/backend/ResultsService.ts b/app/src/backend/ResultsService.ts index 89ef2206f..5594f39ef 100644 --- a/app/src/backend/ResultsService.ts +++ b/app/src/backend/ResultsService.ts @@ -8,6 +8,17 @@ import { ActivityDataByScope, GroupedActivity } from "@/util/types"; import Decimal from "decimal.js"; import { bigIntToDecimal } from "@/util/big_int"; import createHttpError from "http-errors"; +import { getGrowthRatesFromOC } from "./OpenClimateService"; +import { Inventory } from "@/models/Inventory"; + +function multiplyBigIntByFraction( + stringValue: string, + fraction: number, +): string { + const decimalValue = new Decimal(stringValue); + const result = decimalValue.times(fraction); + return result.toFixed(0); // Convert back to bigint, rounding if necessary +} function sumBigIntBy(array: any[], fieldName: string): bigint { return array.reduce((sum, item) => sum + BigInt(item[fieldName]), 0n); @@ -142,25 +153,31 @@ const SectorMappingsFromDBToFE = { "Agriculture, Forestry, and Other Land Use (AFOLU)": "afolu", }; -async function fetchTotalEmissionsBulk( - inventoryIds: string[], -): Promise { +export async function getTotalEmissionsBySector(inventoryIds: string[]) { const rawQuery = ` - SELECT iv.inventory_id, SUM(iv.co2eq) AS co2eq, s.sector_name + SELECT iv.inventory_id, SUM(iv.co2eq) AS co2eq, s.sector_name, s.reference_number FROM "InventoryValue" iv JOIN "Sector" s ON iv.sector_id = s.sector_id WHERE iv.inventory_id IN (:inventoryIds) - GROUP BY iv.inventory_id, s.sector_name + GROUP BY iv.inventory_id, s.sector_name, s.reference_number ORDER BY iv.inventory_id, SUM(iv.co2eq) DESC `; - const totalEmissionsRaw: TotalEmissionsRecord[] = await db.sequelize!.query( - rawQuery, - { - replacements: { inventoryIds }, - type: QueryTypes.SELECT, - }, - ); + return (await db.sequelize!.query(rawQuery, { + replacements: { inventoryIds }, + type: QueryTypes.SELECT, + })) as { + co2eq: bigint; + inventory_id: string; + sector_name: SectorNamesInDB; + reference_number: string; + }[]; +} + +async function fetchTotalEmissionsBulk( + inventoryIds: string[], +): Promise { + const totalEmissionsRaw = await getTotalEmissionsBySector(inventoryIds); // Group by inventory_id const grouped = groupBy(totalEmissionsRaw, "inventory_id"); @@ -371,7 +388,8 @@ const fetchInventoryValuesBySector = async ( JOIN "SubSector" ss ON iv.sub_sector_id = ss.subsector_id LEFT JOIN "SubCategory" sc ON iv.sub_category_id = sc.subcategory_id JOIN "Scope" scope ON scope.scope_id = sc.scope_id OR ss.scope_id = scope.scope_id - WHERE iv.inventory_id = (:inventoryId) and iv.co2eq IS NOT NULL + WHERE iv.inventory_id = (:inventoryId) + and iv.co2eq IS NOT NULL AND (s.sector_name) = (:sectorName) GROUP BY ss.subsector_name, scope.scope_name `; @@ -595,15 +613,11 @@ const groupActivities = ( "subsectorName", ); - console.log(groupedBySubsector); - return mapValues(groupedBySubsector, (subsectorActivities) => { const groupedByActivity = groupBy(subsectorActivities, (e) => toKebabCase(e.activityTitle), ); - console.log(groupedByActivity); - return mapValues(groupedByActivity, (activityGroup) => { const groupedByUnit = groupBy(activityGroup, "activityUnits"); @@ -735,3 +749,49 @@ export async function getEmissionResults(inventory: string): Promise<{ topEmissionsBySubSector: inventoryEmissionResults.topEmissionsBySubSector, }; } + +export const getEmissionsForecasts = async (inventoryData: Inventory) => { + const OCResponse = await getGrowthRatesFromOC( + inventoryData.city.locode!, + inventoryData.created!.getFullYear(), + ); + if (!OCResponse) { + return { + forecast: null, + cluster: null, + growthRates: null, + }; + } + const { growthRates, cluster } = OCResponse; + const totalEmissionsBySector = await getTotalEmissionsBySector([ + inventoryData.inventoryId, + ]); + const projectedEmissions: { [year: string]: { [sector: string]: string } } = + {}; + // Initialize projected emissions with the base year emissions + const baseYear = inventoryData.created!.getFullYear().toString(); + projectedEmissions[baseYear] = {}; + totalEmissionsBySector.forEach((sector) => { + projectedEmissions[baseYear][sector.reference_number] = + sector.co2eq.toString(); + }); + + // Calculate projected emissions year over year + for (let year = parseInt(baseYear) + 1; year <= 2050; year++) { + projectedEmissions[year] = {}; + totalEmissionsBySector.forEach((emissionsInSector) => { + const previousYear = year - 1; + const referenceNumber = emissionsInSector.reference_number; + const growthRate = growthRates[year][referenceNumber]; + projectedEmissions[year][referenceNumber] = multiplyBigIntByFraction( + projectedEmissions[previousYear][referenceNumber], + 1 + growthRate, + ); + }); + } + return { + forecast: projectedEmissions, + cluster: cluster, + growthRates: growthRates, + }; +}; diff --git a/app/src/i18n/locales/de/dashboard.json b/app/src/i18n/locales/de/dashboard.json index 2050b66fc..845826ebb 100644 --- a/app/src/i18n/locales/de/dashboard.json +++ b/app/src/i18n/locales/de/dashboard.json @@ -36,6 +36,7 @@ "uploaded-data": "Hochgeladene Daten", "connect-third-party-data": "Verbundene Daten von Drittanbietern", "stationary-energy": "Stationäre Energie", + "stationary-energy-short": "Stationäre Energie", "stationary-energy-description": "Dieser Sektor befasst sich mit Emissionen, die durch die Erzeugung von Elektrizität, Wärme und Dampf sowie deren Verbrauch entstehen.", "add-data-to-sector": "Daten hinzufügen", "scope-required-for-gpc": "Erforderlicher Umfang für GPC-Basisinventar", @@ -44,12 +45,16 @@ "view-more": "MEHR ANSEHEN", "view-less": "WENIGER ANZEIGEN", "transportation": "Verkehr", + "transportation-short": "Verkehr", "transportation-description": "Dieser Sektor befasst sich mit den Emissionen aus dem Transport von Waren und Personen innerhalb der Stadtgrenze.", "waste": "Abfall und Abwasser", + "waste-short": "Abfall und Abwasser", "waste-description": "Dieser Sektor umfasst Emissionen, die aus Abfallmanagementprozessen erzeugt werden.", "ippu": "Industrielle Prozesse und Produktnutzung (IPPU)", + "ippu-short": "IPPU", "ippu-description": "Dieser Sektor umfasst die Treibhausgasemissionen aus industriellen Prozessen, die Materialien umwandeln, wie beispielsweise bei der Stahlproduktion und chemischen Herstellung.", "afolu": "Landwirtschaft, Forstwirtschaft und Landnutzung (AFOLU)", + "afolu-short": "AFOLU", "afolu-description": "Dieser Sektor umfasst Emissionen aus Landwirtschaft, Forstwirtschaft und Landnutzungsänderungen, einschließlich Viehzucht, Rodung und Aktivitäten wie Düngemittelanwendung und Reisanbau.", "unnamed-sector": "Unbenannter Sektor", "try-again": "Versuchen Sie es erneut", @@ -132,5 +137,16 @@ "chart-view": "Diagramm", "last-update": "Letztes Update", "something-went-wrong": "Etwas ist schief gelaufen", - "error-fetching-sector-data": "Fehler beim Abrufen der Sektordaten" -} + "error-fetching-sector-data": "Fehler beim Abrufen der Sektordaten", + "projections-data": "Projektionen Daten", + "sector-emissions-forecast": "Sektor Emissionsprognose", + "sector-emissions-forecast-description": "Basierend auf einem No-Action-Szenario und einem hybriden Ansatz, einschließlich Wirtschafts- und Emissionswachstumsraten.", + "no-action-emissions-forecast-by-sector": "No-Action-Emissionsprognose nach Sektor", + "about-growth-rates": "Über Wachstumsraten", + "city-typology-and-clusters": "Stadttypologie und Cluster", + "city-typology-and-clusters-description": "Die empirische Analyse der Klimarisiken durch brasilianische Gemeinden verwendete eine robuste Clustermethodik, um Gemeinden mit ähnlichen Merkmalen zu gruppieren und sie von anderen zu unterscheiden. Dieser Ansatz zielte darauf ab, vielfältige Gemeindeprofile zu erfassen, um maßgeschneiderte Einblicke in Klimaschutzmöglichkeiten zu ermöglichen.", + "cluster-#": "Cluster #", + "description": "Beschreibung", + "methodology-and-assumptions": "Methodik und Annahmen", + "methodology-and-assumptions-description": "Das No-Action-Emissionsprognosemodell für Brasilien geht von einer starken, direkten Korrelation zwischen Emissionen und Wirtschaftswachstum aus, was den aktuellen Zustand Brasiliens widerspiegelt, in dem wirtschaftliche Expansion und Emissionen noch nicht entkoppelt sind." +} \ No newline at end of file diff --git a/app/src/i18n/locales/en/dashboard.json b/app/src/i18n/locales/en/dashboard.json index d3f062008..0c59a3996 100644 --- a/app/src/i18n/locales/en/dashboard.json +++ b/app/src/i18n/locales/en/dashboard.json @@ -36,6 +36,7 @@ "uploaded-data": "Uploaded data", "connect-third-party-data": " Connected third-party data", "stationary-energy": "Stationary energy", + "stationary-energy-short": "Stationary energy", "stationary-energy-description": "This sector deals with emissions that result from the generation of electricity, heat, and steam, as well as their consumption.", "add-data-to-sector": "Add data", "scope-required-for-gpc": "Scope Required for GPC Basic Inventory", @@ -44,12 +45,16 @@ "view-more": "VIEW MORE", "view-less": "VIEW LESS", "transportation": "Transportation", + "transportation-short": "Transportation", "transportation-description": "This sector deals with emissions from the transportation of goods and people within the city boundary.", "waste": "Waste and wastewater", + "waste-short": "Waste and wastewater", "waste-description": "This sector covers emissions generated from waste management processes.", "ippu": "Industrial processes and product use (IPPU)", + "ippu-short": "IPPU", "ippu-description": "This sector covers GHG emissions from industrial processes that transform materials, such as in steel production and chemical manufacturing.", "afolu": "Agriculture, forestry and land use (AFOLU)", + "afolu-short": "AFOLU", "afolu-description": "This sector covers emissions from agriculture, forestry, and land use changes, including livestock, land clearing, and activities like fertilizer application and rice cultivation.", "unnamed-sector": "Unnamed Sector", "try-again": "Try again", @@ -132,5 +137,16 @@ "chart-view": "Chart", "last-update": "Last update", "something-went-wrong": "Something went wrong", - "error-fetching-sector-data": "Error fetching sector data" + "error-fetching-sector-data": "Error fetching sector data", + "projections-data": "Projections data", + "sector-emissions-forecast": "Sector Emissions Forecast", + "sector-emissions-forecast-description": "Based on a no-action scenario and a hybrid approach, including economic and emissions growth rates.", + "no-action-emissions-forecast-by-sector": "No-action emissions forecast by sector", + "about-growth-rates": "About growth rates", + "city-typology-and-clusters": "City typology and clusters", + "city-typology-and-clusters-description": "The empirical analysis of climate risks by Brazilian municipalities employed a robust clustering methodology to group municipalities with similar characteristics and differentiate them from others. This approach aimed to capture diverse municipal profiles, allowing for more tailored insights into climate mitigation opportunities.", + "cluster-#": "Cluster #", + "description": "Description", + "methodology-and-assumptions": "Methodology and assumptions", + "methodology-and-assumptions-description": "The no-action emissions forecast model for Brazil assumes a strong, direct correlation between emissions and economic growth, reflecting Brazil's current state where economic expansion and emissions have not yet decoupled." } diff --git a/app/src/i18n/locales/es/dashboard.json b/app/src/i18n/locales/es/dashboard.json index 426df8dd0..fa7f50e74 100644 --- a/app/src/i18n/locales/es/dashboard.json +++ b/app/src/i18n/locales/es/dashboard.json @@ -23,6 +23,7 @@ "uploaded-data": "Datos subidos", "connect-third-party-data": "Datos de terceros conectados", "stationary-energy": "Energía estacionaria", + "stationary-energy-short": "Energía estacionaria", "stationary-energy-description": "Este sector trata sobre las emisiones que resultan de la generación y consumo de electricidad, calefacción y vapor.", "add-data-to-sector": "Agregar datos", "scope-required-for-gpc": "Alcance requerido para el inventario básico de GPC", @@ -31,12 +32,16 @@ "view-more": "VER MÁS", "view-less": "VER MENOS", "transportation": "Transporte", + "transportation-short": "Transporte", "transportation-description": "Este sector trata sobre las emisiones del transporte de bienes y personas dentro de los límites de la ciudad.", "waste": "Residuos y aguas residuales", + "waste-short": "Residuos y aguas residuales", "waste-and-wastewater-description": "Este sector cubre las emisiones generadas por los procesos de gestión de residuos.", "ippu": "Procesos industriales y uso de productos (IPPU)", + "ippu-short": "IPPU", "ippu-description": "Este sector cubre las emisiones de GEI de procesos industriales que transforman materiales, como en la producción de acero y la fabricación de productos químicos.", "afolu": "Agricultura, silvicultura y uso de la tierra (AFOLU)", + "afolu-short": "AFOLU", "afolu-description": "Este sector cubre las emisiones de la agricultura, silvicultura y cambios en el uso de la tierra, incluyendo la ganadería, la deforestación y actividades como la aplicación de fertilizantes y el cultivo de arroz.", "unnamed-sector": "Sector sin nombre", "try-again": "Intentar nuevamente", @@ -135,6 +140,16 @@ "chart-view": "Gráfico", "last-update": "Última actualización", "something-went-wrong": "Algo salió mal", - "error-fetching-sector-data": "Error al obtener datos del sector" - + "error-fetching-sector-data": "Error al obtener datos del sector", + "projections-data": "Datos de proyecciones", +"sector-emissions-forecast": "Pronóstico de emisiones por sector", +"sector-emissions-forecast-description": "Basado en un escenario de inacción y un enfoque híbrido, incluyendo tasas de crecimiento económico y de emisiones.", +"no-action-emissions-forecast-by-sector": "Pronóstico de emisiones por sector sin acción", +"about-growth-rates": "Sobre las tasas de crecimiento", +"city-typology-and-clusters": "Tipología y clústeres de la ciudad", +"city-typology-and-clusters-description": "El análisis empírico de los riesgos climáticos por parte de los municipios brasileños empleó una metodología de agrupamiento robusta para agrupar municipios con características similares y diferenciarlos de otros. Este enfoque buscó capturar perfiles municipales diversos, permitiendo obtener conocimientos más personalizados sobre las oportunidades de mitigación climática.", +"cluster-#": "Clúster #", +"description": "Descripción", +"methodology-and-assumptions": "Metodología y suposiciones", +"methodology-and-assumptions-description": "El modelo de pronóstico de emisiones sin acción para Brasil asume una fuerte correlación directa entre las emisiones y el crecimiento económico, reflejando el estado actual de Brasil donde la expansión económica y las emisiones aún no se han desacoplado." } diff --git a/app/src/i18n/locales/pt/dashboard.json b/app/src/i18n/locales/pt/dashboard.json index 53608c7ef..07387754d 100644 --- a/app/src/i18n/locales/pt/dashboard.json +++ b/app/src/i18n/locales/pt/dashboard.json @@ -35,6 +35,7 @@ "uploaded-data": "Dados enviados", "connect-third-party-data": "Dados de terceiros conectados", "stationary-energy": "Energia estacionária", + "stationary-energy-short": "Energia estacionária", "stationary-energy-description": "Este setor trata das emissões resultantes da geração de eletricidade, calor e vapor, bem como de seu consumo.", "add-data-to-sector": "Adicionar dados", "scope-required-for-gpc": "Escopo necessário para Inventário Básico GPC", @@ -43,12 +44,16 @@ "view-more": "VER MAIS", "view-less": "VER MENOS", "transportation": "Transporte", + "transportation-short": "Transporte", "transportation-description": "Este setor lida com emissões do transporte de bens e pessoas dentro dos limites da cidade.", "waste": "Resíduos e águas residuais", + "waste-short": "Resíduos e águas residuais", "waste-and-wastewater-description": "Este setor abrange emissões geradas a partir de processos de gerenciamento de resíduos.", "ippu": "Processos industriais e uso de produtos (IPPU)", + "ippu-short": "IPPU", "ippu-description": "Este setor cobre as emissões de GEE de processos industriais que transformam materiais, como na produção de aço e na fabricação de produtos químicos.", "afolu": "Agricultura, silvicultura e uso da terra (AFOLU)", + "afolu-short": "AFOLU", "afolu-description": "Este setor cobre as emissões da agricultura, silvicultura e mudanças no uso da terra, incluindo pecuária, desmatamento e atividades como aplicação de fertilizantes e cultivo de arroz.", "unnamed-sector": "Setor sem nome", "try-again": "Tente novamente", @@ -133,5 +138,16 @@ "chart-view": "Gráfico", "last-update": "Última atualização", "something-went-wrong": "Algo deu errado", - "error-fetching-sector-data": "Erro ao buscar dados do setor" + "error-fetching-sector-data": "Erro ao buscar dados do setor", + "projections-data": "Dados de projeções", + "sector-emissions-forecast": "Previsão de emissões por setor", + "sector-emissions-forecast-description": "Baseado em um cenário de inação e uma abordagem híbrida, incluindo taxas de crescimento econômico e de emissões.", + "no-action-emissions-forecast-by-sector": "Previsão de emissões por setor sem ação", + "about-growth-rates": "Sobre as taxas de crescimento", + "city-typology-and-clusters": "Tipologia e clusters da cidade", + "city-typology-and-clusters-description": "A análise empírica dos riscos climáticos pelos municípios brasileiros empregou uma metodologia robusta de agrupamento para agrupar municípios com características semelhantes e diferenciá-los de outros. Esta abordagem visou capturar perfis municipais diversos, permitindo obter insights mais personalizados sobre as oportunidades de mitigação climática.", + "cluster-#": "Cluster #", + "description": "Descrição", + "methodology-and-assumptions": "Metodologia e suposições", + "methodology-and-assumptions-description": "O modelo de previsão de emissões sem ação para o Brasil assume uma forte correlação direta entre emissões e crescimento econômico, refletindo o estado atual do Brasil onde a expansão econômica e as emissões ainda não se desacoplaram." } diff --git a/app/src/i18n/locales/pt/data.json b/app/src/i18n/locales/pt/data.json index 21da7455f..a9351a81d 100644 --- a/app/src/i18n/locales/pt/data.json +++ b/app/src/i18n/locales/pt/data.json @@ -79,8 +79,8 @@ "steam": "Vapor", "refrigiration": "Refrigeração", "refrigiration-chp": "Refrigeração CHP", - "scope-required-for-basic": "Escopo Necessário para GHGI Básico GPC Básico:", - "scope-required-for-basic-+": "Escopo Necessário para GHGI Básico GPC Básico+:", + "scope-required-for-basic": "Escopo Necessário para GHGI Básico GPC Básico", + "scope-required-for-basic-+": "Escopo Necessário para GHGI Básico GPC Básico+", "stationary-energy": "Energia estacionária", "stationary-energy-description": "Este setor trata das emissões que resultam da geração de eletricidade, calor e vapor, bem como de seu consumo.", "transportation": "Transporte", diff --git a/app/src/models/Inventory.ts b/app/src/models/Inventory.ts index 69c90f914..dc22c0061 100644 --- a/app/src/models/Inventory.ts +++ b/app/src/models/Inventory.ts @@ -53,6 +53,7 @@ export class Inventory totalCountryEmissions?: number; isPublic?: boolean; publishedAt?: Date | null; + created?: Date; lastUpdated?: Date | null; inventoryType?: InventoryTypeEnum; globalWarmingPotentialType?: GlobalWarmingPotentialTypeEnum; diff --git a/app/src/services/api.ts b/app/src/services/api.ts index 64dc25784..8b5352684 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -10,6 +10,7 @@ import { CityAndYearsResponse, ConnectDataSourceQuery, ConnectDataSourceResponse, + EmissionsForecastData, EmissionsFactorResponse, GetDataSourcesResult, InventoryDeleteQuery, @@ -52,659 +53,690 @@ export const api = createApi({ "Inventories", ], baseQuery: fetchBaseQuery({ baseUrl: "/api/v0/", credentials: "include" }), - endpoints: (builder) => ({ - getCitiesAndYears: builder.query({ - query: () => "user/cities", - transformResponse: (response: { data: CityAndYearsResponse[] }) => - response.data.map(({ city, years }) => ({ - city, - years: years.sort((a, b) => b.year - a.year), - })), - providesTags: ["CitiesAndInventories"], - }), - getCityYears: builder.query({ - query: (cityId) => `city/${cityId}/years`, - transformResponse: (response: { data: CityAndYearsResponse }) => - response.data, - providesTags: ["CitiesAndInventories"], - }), - getCity: builder.query({ - query: (cityId) => `city/${cityId}`, - transformResponse: (response: { data: CityAttributes }) => response.data, - }), - getCityBoundary: builder.query< - { data: GeoJSON; boundingBox: BoundingBox; area: number }, - string - >({ - query: (cityId) => `city/${cityId}/boundary`, - transformResponse: (response: { - data: GeoJSON; - boundingBox: BoundingBox; - area: number; - }) => response, - }), - getInventory: builder.query({ - query: (inventoryId: string) => `inventory/${inventoryId}`, - transformResponse: (response: { data: InventoryResponse }) => - response.data, - providesTags: ["Inventory"], - }), - getRequiredScopes: builder.query({ - query: (sectorId) => `sector/${sectorId}/required-scopes`, - transformResponse: (response: { data: RequiredScopesResponse }) => - response.data, - }), - getResults: builder.query({ - query: (inventoryId: string) => `inventory/${inventoryId}/results`, - transformResponse: (response: { data: ResultsResponse }) => response.data, - providesTags: ["ReportResults"], - }), - getYearOverYearResults: builder.query({ - query: (cityId: string) => `user/cities/${cityId}/results`, - transformResponse: (response: { data: YearOverYearResultsResponse }) => - response.data, - providesTags: ["YearlyReportResults"], - }), - getSectorBreakdown: builder.query< - SectorBreakdownResponse, - { - inventoryId: string; - sector: string; - } - >({ - query: ({ - inventoryId, - sector, - }: { - inventoryId: string; - sector: string; - }) => { - return `inventory/${inventoryId}/results/${sector}`; - }, - transformResponse: (response: { data: SectorBreakdownResponse }) => - response.data, - providesTags: ["SectorBreakdown"], - }), - getInventoryProgress: builder.query({ - query: (inventoryId) => `inventory/${inventoryId}/progress`, - transformResponse: (response: { data: InventoryProgressResponse }) => - response.data, - providesTags: ["InventoryProgress"], - }), - addCity: builder.mutation< - CityAttributes, - { - name: string; - locode: string; - area: number | undefined; - region: string; - country: string; - regionLocode: string; - countryLocode: string; - } - >({ - query: (data) => ({ - url: `/city`, - method: "POST", - body: data, - }), - transformResponse: (response: { data: CityAttributes }) => response.data, - invalidatesTags: ["CityData"], - }), - addInventory: builder.mutation< - InventoryAttributes, - { - cityId: string; - year: number; - inventoryName: string; - totalCountryEmissions: number; - globalWarmingPotentialType: string; - inventoryType: string; - } - >({ - query: (data) => ({ - url: `/city/${data.cityId}/inventory`, - method: "POST", - body: data, - }), - transformResponse: (response: { data: InventoryAttributes }) => - response.data, - invalidatesTags: ["UserInventories", "CitiesAndInventories"], - }), - setUserInfo: builder.mutation< - UserAttributes, - { cityId: string; defaultInventoryId: string } - >({ - query: (data) => ({ - url: "/user", - method: "PATCH", - body: data, - }), - invalidatesTags: ["UserInfo"], - }), - getUserInfo: builder.query({ - query: () => "/user", - transformResponse: (response: { data: UserInfoResponse }) => - response.data, - providesTags: ["UserInfo"], - }), - getAllDataSources: builder.query< - GetDataSourcesResult, - { inventoryId: string } - >({ - query: ({ inventoryId }) => `datasource/${inventoryId}`, - transformResponse: (response: GetDataSourcesResult) => response, - }), - getInventoryValue: builder.query< - InventoryValueResponse, - { subCategoryId: string; inventoryId: string } - >({ - query: ({ subCategoryId, inventoryId }) => - `/inventory/${inventoryId}/value/${subCategoryId}`, - transformResponse: (response: { data: InventoryValueResponse }) => - response.data, - providesTags: ["InventoryValue"], - }), - getInventoryValues: builder.query< - InventoryValueResponse[], - { subCategoryIds: string[]; inventoryId: string } - >({ - query: ({ subCategoryIds, inventoryId }) => ({ - url: `/inventory/${inventoryId}/value`, - method: "GET", - params: { subCategoryIds: subCategoryIds.join(",") }, - }), - transformResponse: (response: { data: InventoryValueResponse[] }) => - response.data, - providesTags: ["InventoryValue"], - }), - getInventoryValuesBySubsector: builder.query< - InventoryValueResponse[], - { - inventoryId: string; - subSectorId: string; - } - >({ - query: ({ inventoryId, subSectorId }) => ({ - url: `/inventory/${inventoryId}/value/subsector/${subSectorId}`, - method: "GET", - }), - transformResponse: (response: { data: InventoryValueResponse[] }) => - response.data, - providesTags: ["InventoryValue"], - }), - setInventoryValue: builder.mutation< - InventoryValueAttributes, - InventoryValueUpdateQuery - >({ - query: (data) => ({ - url: `/inventory/${data.inventoryId}/value/${data.subCategoryId}`, - method: "PATCH", - body: data.data, - }), - transformResponse: (response: { data: InventoryValueAttributes }) => - response.data, - invalidatesTags: [ - "Inventory", - "InventoryProgress", - "InventoryValue", - "ReportResults", - "YearlyReportResults", - ], - }), - connectDataSource: builder.mutation< - ConnectDataSourceResponse, - ConnectDataSourceQuery - >({ - query: (data) => ({ - url: `/datasource/${data.inventoryId}`, - method: "POST", - body: { dataSourceIds: data.dataSourceIds }, - }), - transformResponse: (response: { data: ConnectDataSourceResponse }) => - response.data, - invalidatesTags: [ - "Inventory", - "InventoryProgress", - "InventoryValue", - "ReportResults", - "YearlyReportResults", - ], - }), - updateOrCreateInventoryValue: builder.mutation< - InventoryValueAttributes, - InventoryValueInSubSectorScopeUpdateQuery - >({ - query: (data) => ({ - url: `/inventory/${data.inventoryId}/value/subsector/${data.subSectorId}`, - method: "PATCH", - body: data.data, - }), - transformResponse: (response: { data: InventoryValueAttributes }) => - response.data, - invalidatesTags: [ - "Inventory", - "InventoryProgress", - "InventoryValue", - "ActivityValue", // because they are deleted when IV is marked as not available - "ReportResults", - "YearlyReportResults", - ], - }), - deleteInventory: builder.mutation< - InventoryAttributes, - InventoryDeleteQuery - >({ - query: (data) => ({ - url: `/inventory/${data.inventoryId}`, - method: "DELETE", - }), - transformResponse: (response: { data: InventoryAttributes }) => - response.data, - invalidatesTags: ["InventoryProgress", "InventoryValue", "Inventories", "Inventory", "ActivityValue", "InventoryValue", "ReportResults", "YearlyReportResults"], - }), - deleteInventoryValue: builder.mutation< - InventoryValueAttributes, - InventoryValueInSubSectorDeleteQuery - >({ - query: (data) => ({ - url: `/inventory/${data.inventoryId}/value/subsector/${data.subSectorId}`, - method: "DELETE", - }), - transformResponse: (response: { data: InventoryValueAttributes }) => - response.data, - invalidatesTags: ["InventoryProgress", "InventoryValue"], - }), - getUserInventories: builder.query({ - query: () => "/user/inventories", - transformResponse: (response: { data: InventoryWithCity[] }) => - response.data, - providesTags: ["UserInventories"], - }), - addCityPopulation: builder.mutation< - PopulationAttributes, - { - cityId: string; - locode: string; - cityPopulation: number; - regionPopulation: number; - countryPopulation: number; - cityPopulationYear: number; - regionPopulationYear: number; - countryPopulationYear: number; - } - >({ - query: (data) => { - return { - url: `/city/${data.cityId}/population`, - method: `POST`, - body: data, - }; - }, - }), - getCityPopulation: builder.query< - PopulationAttributes, - { - year: number; - cityId: string; - } - >({ - query: (data) => `/city/${data.cityId}/population/${data.year}`, - transformResponse: (response: { data: PopulationAttributes }) => - response.data, - }), - getUser: builder.query< - UserAttributes, - { - userId: string; - cityId: string; - } - >({ - query: (data) => `/city/${data.cityId}/user/${data.userId}`, - transformResponse: (response: { data: any }) => response.data, - providesTags: ["UserData"], - }), - - setCurrentUserData: builder.mutation< - UserAttributes, - { - name: string; - email: string; - role: string; - userId: string; - cityId: string; - } - >({ - query: (data) => ({ - url: `/city/${data.cityId}/user/${data.userId}`, - method: "PATCH", - body: data, + endpoints: (builder) => { + return { + getCitiesAndYears: builder.query({ + query: () => "user/cities", + transformResponse: (response: { data: CityAndYearsResponse[] }) => + response.data.map(({ city, years }) => ({ + city, + years: years.sort((a, b) => b.year - a.year), + })), + providesTags: ["CitiesAndInventories"], }), - }), - checkUser: builder.mutation< - UserAttributes, - { - email: string; - cityId: string; - } - >({ - query: (data) => ({ - url: `/city/${data.cityId}/user/`, - method: "POST", - body: data, - }), - transformResponse: (response: { data: any }) => response.data, - invalidatesTags: ["UserData"], - }), - getCityUsers: builder.query< - UserAttributes, - { - cityId: string; - } - >({ - query: (data) => `/city/${data.cityId}/user/`, - transformResponse: (response: { data: any }) => response.data, - providesTags: ["UserData"], - }), - setUserData: builder.mutation< - UserAttributes, - Partial & - Pick & { cityId: string } - >({ - query: ({ userId, cityId, email, ...rest }) => ({ - url: `/city/${cityId}/user/${userId}`, - method: "PATCH", - body: rest, - }), - invalidatesTags: ["UserData"], - }), - removeUser: builder.mutation< - UserAttributes, - { userId: string; cityId: string } - >({ - query: ({ cityId, userId }) => ({ - url: `/city/${cityId}/user/${userId}`, - method: "DELETE", - }), - transformResponse: (response: { data: any }) => response.data, - invalidatesTags: ["UserData"], - }), - getVerifcationToken: builder.query({ - query: () => ({ - url: "auth/verify", - method: "GET", + getCityYears: builder.query({ + query: (cityId) => `city/${cityId}/years`, + transformResponse: (response: { data: CityAndYearsResponse }) => + response.data, + providesTags: ["CitiesAndInventories"], }), - }), - - requestVerification: builder.mutation< - string, - { password: string; token: string } - >({ - query: ({ password, token }) => ({ - url: `/auth/verify`, - method: "POST", - body: { password, token }, + getCity: builder.query({ + query: (cityId) => `city/${cityId}`, + transformResponse: (response: { data: CityAttributes }) => + response.data, }), - }), - getCities: builder.query({ - query: () => ({ - url: "/city", - method: "GET", + getCityBoundary: builder.query< + { data: GeoJSON; boundingBox: BoundingBox; area: number }, + string + >({ + query: (cityId) => `city/${cityId}/boundary`, + transformResponse: (response: { + data: GeoJSON; + boundingBox: BoundingBox; + area: number; + }) => response, }), - transformResponse: (response: { data: any }) => response.data, - providesTags: ["CityData"], - }), - removeCity: builder.mutation({ - query: ({ cityId }) => ({ - url: `/city/${cityId}`, - method: "DELETE", + getInventory: builder.query({ + query: (inventoryId: string) => `inventory/${inventoryId}`, + transformResponse: (response: { data: InventoryResponse }) => + response.data, + providesTags: ["Inventory"], }), - transformResponse: (response: { data: any }) => response.data, - invalidatesTags: ["CityData"], - }), - getInventories: builder.query({ - query: ({ cityId }) => ({ - url: `/city/${cityId}/inventory`, - method: "GET", + getRequiredScopes: builder.query({ + query: (sectorId) => `sector/${sectorId}/required-scopes`, + transformResponse: (response: { data: RequiredScopesResponse }) => + response.data, }), - providesTags: ["Inventories"], - transformResponse: (response: { data: any }) => response.data, - }), - addUserFile: builder.mutation({ - query: ({ formData, cityId }) => { - return { - method: "POST", - url: `city/${cityId}/file`, - body: formData, - }; - }, - transformResponse: (response: { data: UserFileResponse }) => - response.data, - invalidatesTags: ["FileData"], - }), - getUserFiles: builder.query({ - query: (cityId: string) => ({ - method: "GET", - url: `/city/${cityId}/file`, + getResults: builder.query({ + query: (inventoryId: string) => `inventory/${inventoryId}/results`, + transformResponse: (response: { data: ResultsResponse }) => + response.data, + providesTags: ["ReportResults"], + }), + getEmissionsForecast: builder.query({ + query: (inventoryId: string) => + `inventory/${inventoryId}/results/emissions-forecast`, + transformResponse: (response: { data: EmissionsForecastData }) => { + return response.data; + }, + providesTags: ["ReportResults"], }), - transformResponse: (response: { data: UserFileResponse }) => { - return response.data; - }, - providesTags: ["FileData"], - }), - deleteUserFile: builder.mutation({ - query: (params) => ({ - method: "DELETE", - url: `/city/${params.cityId}/file/${params.fileId}`, - }), - transformResponse: (response: { data: UserFileResponse }) => - response.data, - invalidatesTags: ["FileData"], - }), - getEmissionsFactors: builder.query< - EmissionsFactorResponse, - { - methodologyId: string; - inventoryId: string; - referenceNumber: string; - metadata?: Record; - } - >({ - query: (params) => { - return { - url: `/emissions-factor`, + getYearOverYearResults: builder.query< + YearOverYearResultsResponse, + string + >({ + query: (cityId: string) => `user/cities/${cityId}/results`, + transformResponse: (response: { data: YearOverYearResultsResponse }) => + response.data, + providesTags: ["YearlyReportResults"], + }), + getSectorBreakdown: builder.query< + SectorBreakdownResponse, + { + inventoryId: string; + sector: string; + } + >({ + query: ({ + inventoryId, + sector, + }: { + inventoryId: string; + sector: string; + }) => { + return `inventory/${inventoryId}/results/${sector}`; + }, + transformResponse: (response: { data: SectorBreakdownResponse }) => + response.data, + providesTags: ["SectorBreakdown"], + }), + getInventoryProgress: builder.query({ + query: (inventoryId) => `inventory/${inventoryId}/progress`, + transformResponse: (response: { data: InventoryProgressResponse }) => + response.data, + providesTags: ["InventoryProgress"], + }), + addCity: builder.mutation< + CityAttributes, + { + name: string; + locode: string; + area: number | undefined; + region: string; + country: string; + regionLocode: string; + countryLocode: string; + } + >({ + query: (data) => ({ + url: `/city`, method: "POST", - body: params, - }; - }, - transformResponse: (response: { data: EmissionsFactorResponse }) => { - return response.data; - }, - }), - disconnectThirdPartyData: builder.mutation({ - query: ({ inventoryId, datasourceId }) => ({ - method: "DELETE", - url: `datasource/${inventoryId}/datasource/${datasourceId}`, - }), - invalidatesTags: ["InventoryValue", "InventoryProgress", "ReportResults"], - transformResponse: (response: { data: EmissionsFactorResponse }) => - response.data, - }), - // User invitation to city - inviteUser: builder.mutation< - UserInviteResponse, - { - cityId: string; - name?: string; - email: string; - userId: string; - invitingUserId: string; - inventoryId: string; - } - >({ - query: (data) => { - return { + body: data, + }), + transformResponse: (response: { data: CityAttributes }) => + response.data, + invalidatesTags: ["CityData"], + }), + addInventory: builder.mutation< + InventoryAttributes, + { + cityId: string; + year: number; + inventoryName: string; + totalCountryEmissions: number; + globalWarmingPotentialType: string; + inventoryType: string; + } + >({ + query: (data) => ({ + url: `/city/${data.cityId}/inventory`, method: "POST", - url: `/city/invite`, body: data, - }; - }, + }), + transformResponse: (response: { data: InventoryAttributes }) => + response.data, + invalidatesTags: ["UserInventories", "CitiesAndInventories"], + }), + setUserInfo: builder.mutation< + UserAttributes, + { cityId: string; defaultInventoryId: string } + >({ + query: (data) => ({ + url: "/user", + method: "PATCH", + body: data, + }), + invalidatesTags: ["UserInfo"], + }), + getUserInfo: builder.query({ + query: () => "/user", + transformResponse: (response: { data: UserInfoResponse }) => + response.data, + providesTags: ["UserInfo"], + }), + getAllDataSources: builder.query< + GetDataSourcesResult, + { inventoryId: string } + >({ + query: ({ inventoryId }) => `datasource/${inventoryId}`, + transformResponse: (response: GetDataSourcesResult) => response, + }), + getInventoryValue: builder.query< + InventoryValueResponse, + { subCategoryId: string; inventoryId: string } + >({ + query: ({ subCategoryId, inventoryId }) => + `/inventory/${inventoryId}/value/${subCategoryId}`, + transformResponse: (response: { data: InventoryValueResponse }) => + response.data, + providesTags: ["InventoryValue"], + }), + getInventoryValues: builder.query< + InventoryValueResponse[], + { subCategoryIds: string[]; inventoryId: string } + >({ + query: ({ subCategoryIds, inventoryId }) => ({ + url: `/inventory/${inventoryId}/value`, + method: "GET", + params: { subCategoryIds: subCategoryIds.join(",") }, + }), + transformResponse: (response: { data: InventoryValueResponse[] }) => + response.data, + providesTags: ["InventoryValue"], + }), + getInventoryValuesBySubsector: builder.query< + InventoryValueResponse[], + { + inventoryId: string; + subSectorId: string; + } + >({ + query: ({ inventoryId, subSectorId }) => ({ + url: `/inventory/${inventoryId}/value/subsector/${subSectorId}`, + method: "GET", + }), + transformResponse: (response: { data: InventoryValueResponse[] }) => + response.data, + providesTags: ["InventoryValue"], + }), + setInventoryValue: builder.mutation< + InventoryValueAttributes, + InventoryValueUpdateQuery + >({ + query: (data) => ({ + url: `/inventory/${data.inventoryId}/value/${data.subCategoryId}`, + method: "PATCH", + body: data.data, + }), + transformResponse: (response: { data: InventoryValueAttributes }) => + response.data, + invalidatesTags: [ + "Inventory", + "InventoryProgress", + "InventoryValue", + "ReportResults", + "YearlyReportResults", + ], + }), + connectDataSource: builder.mutation< + ConnectDataSourceResponse, + ConnectDataSourceQuery + >({ + query: (data) => ({ + url: `/datasource/${data.inventoryId}`, + method: "POST", + body: { dataSourceIds: data.dataSourceIds }, + }), + transformResponse: (response: { data: ConnectDataSourceResponse }) => + response.data, + invalidatesTags: [ + "Inventory", + "InventoryProgress", + "InventoryValue", + "ReportResults", + "YearlyReportResults", + ], + }), + updateOrCreateInventoryValue: builder.mutation< + InventoryValueAttributes, + InventoryValueInSubSectorScopeUpdateQuery + >({ + query: (data) => ({ + url: `/inventory/${data.inventoryId}/value/subsector/${data.subSectorId}`, + method: "PATCH", + body: data.data, + }), + transformResponse: (response: { data: InventoryValueAttributes }) => + response.data, + invalidatesTags: [ + "Inventory", + "InventoryProgress", + "InventoryValue", + "ActivityValue", // because they are deleted when IV is marked as not available + "ReportResults", + "YearlyReportResults", + ], + }), + deleteInventory: builder.mutation< + InventoryAttributes, + InventoryDeleteQuery + >({ + query: (data) => ({ + url: `/inventory/${data.inventoryId}`, + method: "DELETE", + }), + transformResponse: (response: { data: InventoryAttributes }) => + response.data, + invalidatesTags: [ + "InventoryProgress", + "InventoryValue", + "Inventories", + "Inventory", + "ActivityValue", + "InventoryValue", + "ReportResults", + "YearlyReportResults", + ], + }), + deleteInventoryValue: builder.mutation< + InventoryValueAttributes, + InventoryValueInSubSectorDeleteQuery + >({ + query: (data) => ({ + url: `/inventory/${data.inventoryId}/value/subsector/${data.subSectorId}`, + method: "DELETE", + }), + transformResponse: (response: { data: InventoryValueAttributes }) => + response.data, + invalidatesTags: ["InventoryProgress", "InventoryValue"], + }), + getUserInventories: builder.query({ + query: () => "/user/inventories", + transformResponse: (response: { data: InventoryWithCity[] }) => + response.data, + providesTags: ["UserInventories"], + }), + addCityPopulation: builder.mutation< + PopulationAttributes, + { + cityId: string; + locode: string; + cityPopulation: number; + regionPopulation: number; + countryPopulation: number; + cityPopulationYear: number; + regionPopulationYear: number; + countryPopulationYear: number; + } + >({ + query: (data) => { + return { + url: `/city/${data.cityId}/population`, + method: `POST`, + body: data, + }; + }, + }), + getCityPopulation: builder.query< + PopulationAttributes, + { + year: number; + cityId: string; + } + >({ + query: (data) => `/city/${data.cityId}/population/${data.year}`, + transformResponse: (response: { data: PopulationAttributes }) => + response.data, + }), + getUser: builder.query< + UserAttributes, + { + userId: string; + cityId: string; + } + >({ + query: (data) => `/city/${data.cityId}/user/${data.userId}`, + transformResponse: (response: { data: any }) => response.data, + providesTags: ["UserData"], + }), - transformResponse: (response: { data: UserInviteResponse }) => - response.data, - }), - mockData: builder.query({ - query: () => { - return { + setCurrentUserData: builder.mutation< + UserAttributes, + { + name: string; + email: string; + role: string; + userId: string; + cityId: string; + } + >({ + query: (data) => ({ + url: `/city/${data.cityId}/user/${data.userId}`, + method: "PATCH", + body: data, + }), + }), + checkUser: builder.mutation< + UserAttributes, + { + email: string; + cityId: string; + } + >({ + query: (data) => ({ + url: `/city/${data.cityId}/user/`, + method: "POST", + body: data, + }), + transformResponse: (response: { data: any }) => response.data, + invalidatesTags: ["UserData"], + }), + getCityUsers: builder.query< + UserAttributes, + { + cityId: string; + } + >({ + query: (data) => `/city/${data.cityId}/user/`, + transformResponse: (response: { data: any }) => response.data, + providesTags: ["UserData"], + }), + setUserData: builder.mutation< + UserAttributes, + Partial & + Pick & { cityId: string } + >({ + query: ({ userId, cityId, email, ...rest }) => ({ + url: `/city/${cityId}/user/${userId}`, + method: "PATCH", + body: rest, + }), + invalidatesTags: ["UserData"], + }), + removeUser: builder.mutation< + UserAttributes, + { userId: string; cityId: string } + >({ + query: ({ cityId, userId }) => ({ + url: `/city/${cityId}/user/${userId}`, + method: "DELETE", + }), + transformResponse: (response: { data: any }) => response.data, + invalidatesTags: ["UserData"], + }), + getVerifcationToken: builder.query({ + query: () => ({ + url: "auth/verify", method: "GET", - url: "/mock", - }; - }, - transformResponse: (response: { data: [] }) => response.data, - }), - connectToCDP: builder.mutation({ - query: ({ inventoryId }) => { - return { + }), + }), + + requestVerification: builder.mutation< + string, + { password: string; token: string } + >({ + query: ({ password, token }) => ({ + url: `/auth/verify`, method: "POST", - url: `/inventory/${inventoryId}/cdp`, - }; - }, - }), + body: { password, token }, + }), + }), + getCities: builder.query({ + query: () => ({ + url: "/city", + method: "GET", + }), + transformResponse: (response: { data: any }) => response.data, + providesTags: ["CityData"], + }), + removeCity: builder.mutation({ + query: ({ cityId }) => ({ + url: `/city/${cityId}`, + method: "DELETE", + }), + transformResponse: (response: { data: any }) => response.data, + invalidatesTags: ["CityData"], + }), + getInventories: builder.query({ + query: ({ cityId }) => ({ + url: `/city/${cityId}/inventory`, + method: "GET", + }), + providesTags: ["Inventories"], + transformResponse: (response: { data: any }) => response.data, + }), + addUserFile: builder.mutation({ + query: ({ formData, cityId }) => { + return { + method: "POST", + url: `city/${cityId}/file`, + body: formData, + }; + }, + transformResponse: (response: { data: UserFileResponse }) => + response.data, + invalidatesTags: ["FileData"], + }), + getUserFiles: builder.query({ + query: (cityId: string) => ({ + method: "GET", + url: `/city/${cityId}/file`, + }), + transformResponse: (response: { data: UserFileResponse }) => { + return response.data; + }, - // ActivityValue CRUD - getActivityValues: builder.query({ - query: ({ - inventoryId, - subCategoryIds, - subSectorId, - methodologyId, - }: { - inventoryId: string; - subCategoryIds?: string[]; - subSectorId?: string; - methodologyId?: string; - }) => ({ - url: `/inventory/${inventoryId}/activity-value`, - params: { - subCategoryIds: subCategoryIds?.join(",") ?? undefined, - subSectorId: subSectorId ?? undefined, - methodologyId: methodologyId ?? undefined, + providesTags: ["FileData"], + }), + deleteUserFile: builder.mutation({ + query: (params) => ({ + method: "DELETE", + url: `/city/${params.cityId}/file/${params.fileId}`, + }), + transformResponse: (response: { data: UserFileResponse }) => + response.data, + invalidatesTags: ["FileData"], + }), + getEmissionsFactors: builder.query< + EmissionsFactorResponse, + { + methodologyId: string; + inventoryId: string; + referenceNumber: string; + metadata?: Record; + } + >({ + query: (params) => { + return { + url: `/emissions-factor`, + method: "POST", + body: params, + }; + }, + transformResponse: (response: { data: EmissionsFactorResponse }) => { + return response.data; }, - method: "GET", }), - transformResponse: (response: any) => response.data, - providesTags: ["ActivityValue"], - }), - createActivityValue: builder.mutation({ - query: (data) => ({ - method: "POST", - url: `/inventory/${data.inventoryId}/activity-value`, - body: data.requestData, - }), - transformResponse: (response: any) => response.data, - invalidatesTags: [ - "Inventory", - "ActivityValue", - "InventoryValue", - "InventoryProgress", - "YearlyReportResults", - "ReportResults", - "SectorBreakdown", - ], - }), - getActivityValue: builder.query({ - query: (data: { inventoryId: string; valueId: string }) => ({ - method: "GET", - url: `/inventory/${data.inventoryId}/activity-value/${data.valueId}`, + disconnectThirdPartyData: builder.mutation({ + query: ({ inventoryId, datasourceId }) => ({ + method: "DELETE", + url: `datasource/${inventoryId}/datasource/${datasourceId}`, + }), + invalidatesTags: [ + "InventoryValue", + "InventoryProgress", + "ReportResults", + ], + transformResponse: (response: { data: EmissionsFactorResponse }) => + response.data, }), - transformResponse: (response: any) => response.data, - providesTags: ["ActivityValue"], - }), - updateActivityValue: builder.mutation({ - query: (data) => ({ - method: "PATCH", - url: `/inventory/${data.inventoryId}/activity-value/${data.valueId}`, - body: data.data, - }), - transformResponse: (response: any) => response.data, - invalidatesTags: [ - "Inventory", - "ActivityValue", - "InventoryValue", - "InventoryProgress", - "ReportResults", - "YearlyReportResults", - "SectorBreakdown", - ], - }), - deleteActivityValue: builder.mutation({ - query: (data: { activityValueId: string; inventoryId: string }) => ({ - method: "DELETE", - url: `/inventory/${data.inventoryId}/activity-value/${data.activityValueId}`, - }), - transformResponse: (response: { success: boolean }) => response, - invalidatesTags: [ - "Inventory", - "ActivityValue", - "InventoryValue", - "InventoryProgress", - "ReportResults", - "YearlyReportResults", - "SectorBreakdown", - ], - }), - deleteAllActivityValues: builder.mutation({ - query: (data: { - inventoryId: string; - subSectorId?: string; - gpcReferenceNumber?: string; - }) => ({ - method: "DELETE", - url: `/inventory/${data.inventoryId}/activity-value`, - params: { - subSectorId: data.subSectorId, - gpcReferenceNumber: data.gpcReferenceNumber, + // User invitation to city + inviteUser: builder.mutation< + UserInviteResponse, + { + cityId: string; + name?: string; + email: string; + userId: string; + invitingUserId: string; + inventoryId: string; + } + >({ + query: (data) => { + return { + method: "POST", + url: `/city/invite`, + body: data, + }; }, + + transformResponse: (response: { data: UserInviteResponse }) => + response.data, }), - transformResponse: (response: any) => response.data, - invalidatesTags: [ - "Inventory", - "ActivityValue", - "InventoryValue", - "InventoryProgress", - "ReportResults", - "YearlyReportResults", - "SectorBreakdown", - ], - }), - createThreadId: builder.mutation({ - query: (data: { inventoryId: string; content: string }) => ({ - url: `/assistants/threads/${data.inventoryId}`, - method: "POST", - headers: { - "Content-Type": "application/json", + mockData: builder.query({ + query: () => { + return { + method: "GET", + url: "/mock", + }; }, - body: JSON.stringify({ - content: data.content, + transformResponse: (response: { data: [] }) => response.data, + }), + connectToCDP: builder.mutation({ + query: ({ inventoryId }) => { + return { + method: "POST", + url: `/inventory/${inventoryId}/cdp`, + }; + }, + }), + + // ActivityValue CRUD + getActivityValues: builder.query({ + query: ({ + inventoryId, + subCategoryIds, + subSectorId, + methodologyId, + }: { + inventoryId: string; + subCategoryIds?: string[]; + subSectorId?: string; + methodologyId?: string; + }) => ({ + url: `/inventory/${inventoryId}/activity-value`, + params: { + subCategoryIds: subCategoryIds?.join(",") ?? undefined, + subSectorId: subSectorId ?? undefined, + methodologyId: methodologyId ?? undefined, + }, + method: "GET", }), + transformResponse: (response: any) => response.data, + providesTags: ["ActivityValue"], }), - transformResponse: (response: { threadId: string }) => response.threadId, - }), - updateInventory: builder.mutation< - InventoryAttributes, - InventoryUpdateQuery - >({ - query: (data) => ({ - url: `/inventory/${data.inventoryId}`, - method: "PATCH", - body: data.data, - }), - transformResponse: (response: { data: InventoryAttributes }) => - response.data, - invalidatesTags: ["Inventory"], - }), - }), + createActivityValue: builder.mutation({ + query: (data) => ({ + method: "POST", + url: `/inventory/${data.inventoryId}/activity-value`, + body: data.requestData, + }), + transformResponse: (response: any) => response.data, + invalidatesTags: [ + "Inventory", + "ActivityValue", + "InventoryValue", + "InventoryProgress", + "YearlyReportResults", + "ReportResults", + "SectorBreakdown", + ], + }), + getActivityValue: builder.query({ + query: (data: { inventoryId: string; valueId: string }) => ({ + method: "GET", + url: `/inventory/${data.inventoryId}/activity-value/${data.valueId}`, + }), + transformResponse: (response: any) => response.data, + providesTags: ["ActivityValue"], + }), + updateActivityValue: builder.mutation({ + query: (data) => ({ + method: "PATCH", + url: `/inventory/${data.inventoryId}/activity-value/${data.valueId}`, + body: data.data, + }), + transformResponse: (response: any) => response.data, + invalidatesTags: [ + "Inventory", + "ActivityValue", + "InventoryValue", + "InventoryProgress", + "ReportResults", + "YearlyReportResults", + "SectorBreakdown", + ], + }), + deleteActivityValue: builder.mutation({ + query: (data: { activityValueId: string; inventoryId: string }) => ({ + method: "DELETE", + url: `/inventory/${data.inventoryId}/activity-value/${data.activityValueId}`, + }), + transformResponse: (response: { success: boolean }) => response, + invalidatesTags: [ + "Inventory", + "ActivityValue", + "InventoryValue", + "InventoryProgress", + "ReportResults", + "YearlyReportResults", + "SectorBreakdown", + ], + }), + deleteAllActivityValues: builder.mutation({ + query: (data: { + inventoryId: string; + subSectorId?: string; + gpcReferenceNumber?: string; + }) => ({ + method: "DELETE", + url: `/inventory/${data.inventoryId}/activity-value`, + params: { + subSectorId: data.subSectorId, + gpcReferenceNumber: data.gpcReferenceNumber, + }, + }), + transformResponse: (response: any) => response.data, + invalidatesTags: [ + "Inventory", + "ActivityValue", + "InventoryValue", + "InventoryProgress", + "ReportResults", + "YearlyReportResults", + "SectorBreakdown", + ], + }), + createThreadId: builder.mutation({ + query: (data: { inventoryId: string; content: string }) => ({ + url: `/assistants/threads/${data.inventoryId}`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: data.content, + }), + }), + transformResponse: (response: { threadId: string }) => + response.threadId, + }), + updateInventory: builder.mutation< + InventoryAttributes, + InventoryUpdateQuery + >({ + query: (data) => ({ + url: `/inventory/${data.inventoryId}`, + method: "PATCH", + body: data.data, + }), + transformResponse: (response: { data: InventoryAttributes }) => + response.data, + invalidatesTags: ["Inventory"], + }), + }; + }, }); export const openclimateAPI = createApi({ @@ -770,6 +802,7 @@ export const { useGetInventoryValuesBySubsectorQuery, useDeleteInventoryValueMutation, useGetResultsQuery, + useGetEmissionsForecastQuery, useUpdateInventoryMutation, useUpdateOrCreateInventoryValueMutation, } = api; diff --git a/app/src/util/constants.ts b/app/src/util/constants.ts index ad376c95b..e8feba72a 100644 --- a/app/src/util/constants.ts +++ b/app/src/util/constants.ts @@ -39,12 +39,16 @@ export const getSectorsForInventory = (inventoryType?: InventoryType) => { }); }; +function findBy(field: keyof ISector, referenceNumber: string) { + return SECTORS.find((s) => s[field] === referenceNumber); +} + export const getScopesForInventoryAndSector = ( inventoryType: InventoryType, referenceNumber: string, ) => { if (!inventoryType) return []; - const sector = SECTORS.find((s) => s.referenceNumber === referenceNumber); + const sector = findBy("referenceNumber", referenceNumber); if (!sector) { console.error( `Sector ${referenceNumber} for inventoryType ${inventoryType} not found`, @@ -116,3 +120,6 @@ export const SECTORS: ISector[] = [ }, }, ]; + +export const getReferenceNumberByName = (name: keyof ISector) => + findBy("name", name)?.referenceNumber; diff --git a/app/src/util/types.ts b/app/src/util/types.ts index 07679a152..0d46452da 100644 --- a/app/src/util/types.ts +++ b/app/src/util/types.ts @@ -212,6 +212,23 @@ export interface ResultsResponse { topEmissions: { bySubSector: TopEmission[] }; } +export interface ProjectionData { + [year: string]: { + [sector: string]: number; + }; +} + +export interface EmissionsForecastData { + growthRates: ProjectionData; + forecast: ProjectionData; + cluster: { + id: number; + description: { + [lng: string]: string; + }; + }; +} + export interface YearOverYearResultResponse { totalEmissions: { sumOfEmissions: bigint; diff --git a/app/tests/api/__snapshots__/emissions_forecast.jest.ts.snap b/app/tests/api/__snapshots__/emissions_forecast.jest.ts.snap new file mode 100644 index 000000000..7ab90b799 --- /dev/null +++ b/app/tests/api/__snapshots__/emissions_forecast.jest.ts.snap @@ -0,0 +1,337 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Emissions Forecast API should calculate projected emissions correctly 1`] = ` +{ + "data": { + "cluster": { + "description": { + "en": "Emerging urban centers of industry (Non IPPU industry sectors)", + }, + "id": 5, + }, + "forecast": { + "2025": { + "I": "40399", + "II": "22388", + "III": "10581", + }, + "2026": { + "I": "42419", + "II": "24403", + "III": "11427", + }, + "2027": { + "I": "44964", + "II": "25135", + "III": "11770", + }, + "2028": { + "I": "47212", + "II": "27397", + "III": "12476", + }, + "2029": { + "I": "48156", + "II": "30137", + "III": "12601", + }, + "2030": { + "I": "51045", + "II": "31945", + "III": "13483", + }, + "2031": { + "I": "53087", + "II": "34181", + "III": "13618", + }, + "2032": { + "I": "56272", + "II": "34865", + "III": "14163", + }, + "2033": { + "I": "61336", + "II": "37654", + "III": "15438", + }, + "2034": { + "I": "61949", + "II": "40290", + "III": "15747", + }, + "2035": { + "I": "66285", + "II": "42707", + "III": "16062", + }, + "2036": { + "I": "68274", + "II": "44842", + "III": "17347", + }, + "2037": { + "I": "71688", + "II": "46187", + "III": "18388", + }, + "2038": { + "I": "78857", + "II": "48496", + "III": "19675", + }, + "2039": { + "I": "80434", + "II": "48981", + "III": "20069", + }, + "2040": { + "I": "85260", + "II": "52899", + "III": "21474", + }, + "2041": { + "I": "86965", + "II": "54486", + "III": "22977", + }, + "2042": { + "I": "93053", + "II": "58300", + "III": "23207", + }, + "2043": { + "I": "99567", + "II": "58883", + "III": "24831", + }, + "2044": { + "I": "103550", + "II": "64182", + "III": "26817", + }, + "2045": { + "I": "110799", + "II": "70600", + "III": "27622", + }, + "2046": { + "I": "118555", + "II": "77660", + "III": "28174", + }, + "2047": { + "I": "129225", + "II": "78437", + "III": "29301", + }, + "2048": { + "I": "135686", + "II": "81574", + "III": "30766", + }, + "2049": { + "I": "138400", + "II": "84837", + "III": "31381", + }, + "2050": { + "I": "143936", + "II": "89079", + "III": "32322", + }, + }, + "growthRates": { + "2024": { + "I": 0.04, + "II": 0.05, + "III": 0.01, + "IV": 0.05, + "V": 0.08, + }, + "2025": { + "I": 0.04, + "II": 0.05, + "III": 0.01, + "IV": 0.05, + "V": 0.08, + }, + "2026": { + "I": 0.05, + "II": 0.09, + "III": 0.08, + "IV": 0.06, + "V": 0.05, + }, + "2027": { + "I": 0.06, + "II": 0.03, + "III": 0.03, + "IV": 0.08, + "V": 0.1, + }, + "2028": { + "I": 0.05, + "II": 0.09, + "III": 0.06, + "IV": 0.02, + "V": 0.05, + }, + "2029": { + "I": 0.02, + "II": 0.1, + "III": 0.01, + "IV": 0.09, + "V": 0.08, + }, + "2030": { + "I": 0.06, + "II": 0.06, + "III": 0.07, + "IV": 0.04, + "V": 0.03, + }, + "2031": { + "I": 0.04, + "II": 0.07, + "III": 0.01, + "IV": 0.1, + "V": 0.07, + }, + "2032": { + "I": 0.06, + "II": 0.02, + "III": 0.04, + "IV": 0.04, + "V": 0.06, + }, + "2033": { + "I": 0.09, + "II": 0.08, + "III": 0.09, + "IV": 0.06, + "V": 0.02, + }, + "2034": { + "I": 0.01, + "II": 0.07, + "III": 0.02, + "IV": 0.06, + "V": 0.09, + }, + "2035": { + "I": 0.07, + "II": 0.06, + "III": 0.02, + "IV": 0.02, + "V": 0.01, + }, + "2036": { + "I": 0.03, + "II": 0.05, + "III": 0.08, + "IV": 0.06, + "V": 0.08, + }, + "2037": { + "I": 0.05, + "II": 0.03, + "III": 0.06, + "IV": 0.02, + "V": 0.05, + }, + "2038": { + "I": 0.1, + "II": 0.05, + "III": 0.07, + "IV": 0.06, + "V": 0.02, + }, + "2039": { + "I": 0.02, + "II": 0.01, + "III": 0.02, + "IV": 0.03, + "V": 0.08, + }, + "2040": { + "I": 0.06, + "II": 0.08, + "III": 0.07, + "IV": 0.04, + "V": 0.03, + }, + "2041": { + "I": 0.02, + "II": 0.03, + "III": 0.07, + "IV": 0.1, + "V": 0.05, + }, + "2042": { + "I": 0.07, + "II": 0.07, + "III": 0.01, + "IV": 0.1, + "V": 0.09, + }, + "2043": { + "I": 0.07, + "II": 0.01, + "III": 0.07, + "IV": 0.02, + "V": 0.04, + }, + "2044": { + "I": 0.04, + "II": 0.09, + "III": 0.08, + "IV": 0.04, + "V": 0.09, + }, + "2045": { + "I": 0.07, + "II": 0.1, + "III": 0.03, + "IV": 0.02, + "V": 0.1, + }, + "2046": { + "I": 0.07, + "II": 0.1, + "III": 0.02, + "IV": 0.06, + "V": 0.07, + }, + "2047": { + "I": 0.09, + "II": 0.01, + "III": 0.04, + "IV": 0.09, + "V": 0.07, + }, + "2048": { + "I": 0.05, + "II": 0.04, + "III": 0.05, + "IV": 0.03, + "V": 0.02, + }, + "2049": { + "I": 0.02, + "II": 0.04, + "III": 0.02, + "IV": 0.05, + "V": 0.05, + }, + "2050": { + "I": 0.04, + "II": 0.05, + "III": 0.03, + "IV": 0.08, + "V": 0.01, + }, + }, + }, +} +`; diff --git a/app/tests/api/datasource.jest.ts b/app/tests/api/datasource.jest.ts index a676e6e28..10977e749 100644 --- a/app/tests/api/datasource.jest.ts +++ b/app/tests/api/datasource.jest.ts @@ -188,7 +188,7 @@ describe("DataSource API", () => { }, }, }); - console.log("datasource", JSON.stringify(datasource, null, 2)); // TODO NINA + const { datasourceId } = datasource; const inventoryValueId = randomUUID(); await db.models.InventoryValue.create({ diff --git a/app/tests/api/emissions_forecast.jest.ts b/app/tests/api/emissions_forecast.jest.ts new file mode 100644 index 000000000..d06740589 --- /dev/null +++ b/app/tests/api/emissions_forecast.jest.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { + baseInventory, + growth_rates_response, + inventoryId, + inventoryValues as inventoryValuesData, +} from "./results.data"; +import { GET as getResults } from "@/app/api/v0/inventory/[inventory]/results/emissions-forecast/route"; +import { db } from "@/models"; +import { randomUUID } from "node:crypto"; +import { mockRequest, setupTests, testUserID } from "../helpers"; +import { + GlobalWarmingPotentialTypeEnum, + InventoryTypeEnum, +} from "@/util/enums"; +import { City } from "@/models/City"; +import { Inventory } from "@/models/Inventory"; + +const locode = "XX_SUBCATEGORY_CITY"; + +// TODO UNSKIP when we can mock getGrowthRatesFromOC +describe.skip("Emissions Forecast API", () => { + let city: City; + let inventory: Inventory; + beforeAll(async () => { + setupTests(); + await db.initialize(); + city = await db.models.City.create({ + cityId: randomUUID(), + locode, + }); + await db.models.User.upsert({ userId: testUserID, name: "TEST_USER" }); + await city.addUser(testUserID); + inventory = await db.models.Inventory.create({ + inventoryId: inventoryId, + ...baseInventory, + inventoryName: "EmissionsForecastInventory", + cityId: city.cityId, + inventoryType: InventoryTypeEnum.GPC_BASIC, + globalWarmingPotentialType: GlobalWarmingPotentialTypeEnum.ar6, + }); + + await db.models.InventoryValue.bulkCreate(inventoryValuesData); + }); + + afterAll(async () => { + await db.models.InventoryValue.destroy({ where: { inventoryId } }); + await db.models.Inventory.destroy({ where: { inventoryId } }); + await db.models.City.destroy({ where: { cityId: city.cityId } }); + if (db.sequelize) await db.sequelize.close(); + }); + + it("should calculate projected emissions correctly", async () => { + jest.mock("@/backend/OpenClimateService", () => { + return { + getGrowthRatesFromOC: jest + .fn() + .mockImplementation(() => growth_rates_response), + }; + }); + const req = mockRequest(); + const result = await getResults(req, { + params: { inventory: inventory.inventoryId }, + }); + + expect(await result.json()).toMatchSnapshot(); + }); + + it("should handle empty growth factors", async () => { + jest.mock("@/backend/OpenClimateService", () => { + return { + getGrowthRatesFromOC: jest.fn().mockImplementation(() => undefined), + }; + }); + const req = mockRequest(); + const result = await getResults(req, { + params: { inventory: inventory.inventoryId }, + }); + + expect(await result.json()).toEqual({ + data: { + cluster: null, + forecast: null, + growthRates: null, + }, + }); + }); +}); diff --git a/app/tests/api/results.data.ts b/app/tests/api/results.data.ts index 4a14207b3..d786d4aa3 100644 --- a/app/tests/api/results.data.ts +++ b/app/tests/api/results.data.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { GrowthRatesResponse } from "@/backend/OpenClimateService"; export const inventoryValueId = randomUUID(); export const inventoryValueId1 = randomUUID(); @@ -188,3 +189,203 @@ export const baseInventory = { regionPopulationYear: 0, countryPopulationYear: 0, }; + +export const growth_rates_response: GrowthRatesResponse = { + cluster: { + id: 5, + description: { + en: "Emerging urban centers of industry (Non IPPU industry sectors)", + }, + }, + growthRates: { + "2024": { + I: 0.04, + II: 0.05, + III: 0.01, + IV: 0.05, + V: 0.08, + }, + "2025": { + I: 0.04, + II: 0.05, + III: 0.01, + IV: 0.05, + V: 0.08, + }, + "2026": { + I: 0.05, + II: 0.09, + III: 0.08, + IV: 0.06, + V: 0.05, + }, + "2027": { + I: 0.06, + II: 0.03, + III: 0.03, + IV: 0.08, + V: 0.1, + }, + "2028": { + I: 0.05, + II: 0.09, + III: 0.06, + IV: 0.02, + V: 0.05, + }, + "2029": { + I: 0.02, + II: 0.1, + III: 0.01, + IV: 0.09, + V: 0.08, + }, + "2030": { + I: 0.06, + II: 0.06, + III: 0.07, + IV: 0.04, + V: 0.03, + }, + "2031": { + I: 0.04, + II: 0.07, + III: 0.01, + IV: 0.1, + V: 0.07, + }, + "2032": { + I: 0.06, + II: 0.02, + III: 0.04, + IV: 0.04, + V: 0.06, + }, + "2033": { + I: 0.09, + II: 0.08, + III: 0.09, + IV: 0.06, + V: 0.02, + }, + "2034": { + I: 0.01, + II: 0.07, + III: 0.02, + IV: 0.06, + V: 0.09, + }, + "2035": { + I: 0.07, + II: 0.06, + III: 0.02, + IV: 0.02, + V: 0.01, + }, + "2036": { + I: 0.03, + II: 0.05, + III: 0.08, + IV: 0.06, + V: 0.08, + }, + "2037": { + I: 0.05, + II: 0.03, + III: 0.06, + IV: 0.02, + V: 0.05, + }, + "2038": { + I: 0.1, + II: 0.05, + III: 0.07, + IV: 0.06, + V: 0.02, + }, + "2039": { + I: 0.02, + II: 0.01, + III: 0.02, + IV: 0.03, + V: 0.08, + }, + "2040": { + I: 0.06, + II: 0.08, + III: 0.07, + IV: 0.04, + V: 0.03, + }, + "2041": { + I: 0.02, + II: 0.03, + III: 0.07, + IV: 0.1, + V: 0.05, + }, + "2042": { + I: 0.07, + II: 0.07, + III: 0.01, + IV: 0.1, + V: 0.09, + }, + "2043": { + I: 0.07, + II: 0.01, + III: 0.07, + IV: 0.02, + V: 0.04, + }, + "2044": { + I: 0.04, + II: 0.09, + III: 0.08, + IV: 0.04, + V: 0.09, + }, + "2045": { + I: 0.07, + II: 0.1, + III: 0.03, + IV: 0.02, + V: 0.1, + }, + "2046": { + I: 0.07, + II: 0.1, + III: 0.02, + IV: 0.06, + V: 0.07, + }, + "2047": { + I: 0.09, + II: 0.01, + III: 0.04, + IV: 0.09, + V: 0.07, + }, + "2048": { + I: 0.05, + II: 0.04, + III: 0.05, + IV: 0.03, + V: 0.02, + }, + "2049": { + I: 0.02, + II: 0.04, + III: 0.02, + IV: 0.05, + V: 0.05, + }, + "2050": { + I: 0.04, + II: 0.05, + III: 0.03, + IV: 0.08, + V: 0.01, + }, + }, +};