diff --git a/app/package-lock.json b/app/package-lock.json index 5ca7932b5..d037f3522 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "city-catalyst", - "version": "0.33.0-dev.0", + "version": "0.40.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "city-catalyst", - "version": "0.33.0-dev.0", + "version": "0.40.0-dev.0", "dependencies": { "@ai-sdk/openai": "^1.0.8", "@chakra-ui/icons": "^2.1.0", @@ -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", @@ -39,6 +40,7 @@ "chakra-react-select": "^4.9.1", "char-regex": "^2.0.1", "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "decimal.js": "^10.4.3", @@ -5387,6 +5389,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", @@ -5421,6 +5443,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", @@ -8872,6 +8910,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", @@ -13119,6 +13162,12 @@ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.6.tgz", "integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==" }, + "node_modules/csv-stringify": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.2.tgz", + "integrity": "sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==", + "license": "MIT" + }, "node_modules/cypress": { "version": "13.14.2", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.14.2.tgz", @@ -13248,6 +13297,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", @@ -13683,6 +13743,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", @@ -25545,6 +25613,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 0698d208f..b860248a3 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "city-catalyst", - "version": "0.33.0-dev.0", + "version": "0.40.0-dev.0", "private": true, "type": "module", "scripts": { @@ -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", @@ -65,6 +66,7 @@ "chakra-react-select": "^4.9.1", "char-regex": "^2.0.1", "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "decimal.js": "^10.4.3", diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionBySectorChart.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionBySectorChart.tsx index f58767d1c..a451d9ea1 100644 --- a/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionBySectorChart.tsx +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionBySectorChart.tsx @@ -1,10 +1,10 @@ import { SectorEmission } from "@/util/types"; import { ResponsiveBar } from "@nivo/bar"; -import { SECTORS } from "@/util/constants"; -import { convertKgToKiloTonnes } from "@/util/helpers"; +import { allSectorColors, SECTORS } from "@/util/constants"; +import { convertKgToKiloTonnes, convertKgToTonnes } from "@/util/helpers"; import { useTranslation } from "@/i18n/client"; import { toKebabCaseModified } from "@/app/[lng]/[inventory]/InventoryResultTab/index"; -import { Box, Text } from "@chakra-ui/react"; +import { Badge, Box, Card, HStack, Text } from "@chakra-ui/react"; interface EmissionBySectorChartProps { data: { @@ -46,8 +46,6 @@ const EmissionBySectorChart: React.FC = ({ toKebabCaseModified(sector.name), ); - const colors = ["#5785F4", "#F17105", "#25AC4B", "#BFA937", "#F5D949"]; - return (
@@ -61,9 +59,26 @@ const EmissionBySectorChart: React.FC = ({ layout={"vertical"} margin={{ top: 50, right: 130, bottom: 50, left: 120 }} padding={0.3} + tooltip={({ id, value, color }) => ( + + + + + {tData(id as string)} + {" - "} + {convertKgToTonnes(value)} + + + + )} valueScale={{ type: "linear", min: 0, max: "auto" }} indexScale={{ type: "band", round: true }} - colors={colors} + colors={allSectorColors} borderColor={{ from: "color", modifiers: [["darker", 1.6]], @@ -84,8 +99,8 @@ const EmissionBySectorChart: React.FC = ({ tickRotation: 0, legend: "CO2eq", legendPosition: "middle", - legendOffset: -75, - format: (value) => value, + legendOffset: -100, + format: (value) => convertKgToTonnes(value), }} labelSkipWidth={12} labelSkipHeight={12} @@ -96,7 +111,7 @@ const EmissionBySectorChart: React.FC = ({ role="application" ariaLabel="Nivo bar chart demo" barAriaLabel={function (e) { - return e.id + ": " + e.formattedValue + " in year: " + e.indexValue; + return `${e.id}: ${convertKgToTonnes(e.value!)} in year: ${e.indexValue}`; }} />
@@ -119,7 +134,7 @@ const EmissionBySectorChart: React.FC = ({ > = ({ 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..7306bac63 --- /dev/null +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/EmissionsForecast/EmissionsForecastChart.tsx @@ -0,0 +1,210 @@ +import { EmissionsForecastData } from "@/util/types"; +import { TFunction } from "i18next/typescript/t"; +import { + allSectorColors, + getReferenceNumberByName, + ISector, + SECTORS, +} from "@/util/constants"; +import { + Badge, + Box, + Card, + Heading, + Table, + Tbody, + Td, + Text, + Tfoot, + Th, + Thead, + Tr, +} from "@chakra-ui/react"; +import { convertKgToTonnes, toKebabCase } from "@/util/helpers"; +import { ResponsiveLine } from "@nivo/line"; + +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); + + return ( + (parseInt(value) % 5 === 0 ? value : ""), + }} + axisLeft={{ + tickSize: 5, + tickPadding: 5, + tickRotation: 0, + format: (value: number) => convertKgToTonnes(value), + }} + colors={allSectorColors} + 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( + toKebabCase(point.serieId as string) as keyof ISector, + ); + const yearGrowthRates = + yearData && forecast.growthRates[yearData.x as string]; + const growthRate = yearGrowthRates?.[sectorRefNo!]; + return ( + + + + + + + ); + })} + + + + + + + + + +
{t("sector")}{t("rate")}%{t("total-emissions")}
+ + {series.id} + {growthRate}{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/TopEmissionsWidget.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/TopEmissionsWidget.tsx index b1e648baf..8689e11d2 100644 --- a/app/src/app/[lng]/[inventory]/InventoryResultTab/TopEmissionsWidget.tsx +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/TopEmissionsWidget.tsx @@ -31,6 +31,7 @@ import { SegmentedProgressValues, } from "@/components/SegmentedProgress"; import { EmptyStateCardContent } from "@/app/[lng]/[inventory]/InventoryResultTab/EmptyStateCardContent"; +import { allSectorColors, SECTORS } from "@/util/constants"; const EmissionsTable = ({ topEmissions, @@ -96,11 +97,12 @@ const TopEmissionsWidget = ({ function getPercentagesForProgress(): SegmentedProgressValues[] { const bySector: SectorEmission[] = results?.totalEmissions.bySector ?? []; - return bySector.map(({ sectorName, co2eq, percentage }) => { + return SECTORS.map(({ name }) => { + const sector = bySector.find((sector) => sector.sectorName === name)!; return { - name: sectorName, - value: co2eq, - percentage: percentage, + name, + value: sector?.co2eq || 0, + percentage: sector?.percentage || 0, } as SegmentedProgressValues; }); } @@ -147,6 +149,7 @@ const TopEmissionsWidget = ({ values={getPercentagesForProgress()} total={results?.totalEmissions.total} t={t} + colors={allSectorColors} showLabels showHover /> diff --git a/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx b/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx index 506bbc692..d3ca73c4f 100644 --- a/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx +++ b/app/src/app/[lng]/[inventory]/InventoryResultTab/index.tsx @@ -1,7 +1,6 @@ "use client"; - import { useTranslation } from "@/i18n/client"; -import { InventoryResponse, SectorEmission } from "@/util/types"; +import { CityYearData, InventoryResponse, SectorEmission } from "@/util/types"; import { Box, Card, @@ -17,6 +16,7 @@ import { TabPanels, Tabs, Text, + useToast, } from "@chakra-ui/react"; import { TabHeader } from "@/components/HomePage/TabHeader"; import EmissionsWidget from "@/app/[lng]/[inventory]/InventoryResultTab/EmissionsWidget"; @@ -24,24 +24,28 @@ import TopEmissionsWidget from "@/app/[lng]/[inventory]/InventoryResultTab/TopEm import { BlueSubtitle } from "@/components/blue-subtitle"; import { PopulationAttributes } from "@/models/Population"; import type { TFunction } from "i18next"; -import { capitalizeFirstLetter, toKebabCase } from "@/util/helpers"; -import React, { ChangeEvent, useMemo, useState } from "react"; +import { + capitalizeFirstLetter, + isEmptyObject, + toKebabCase, +} from "@/util/helpers"; +import React, { ChangeEvent, useMemo, useState, useEffect } from "react"; import { api, - useGetCitiesAndYearsQuery, + useGetCityYearsQuery, useGetYearOverYearResultsQuery, } from "@/services/api"; import ByScopeView from "@/app/[lng]/[inventory]/InventoryResultTab/ByScopeView"; import { SectorHeader } from "@/app/[lng]/[inventory]/InventoryResultTab/SectorHeader"; import { ByActivityView } from "@/app/[lng]/[inventory]/InventoryResultTab/ByActivityView"; import { getSectorsForInventory, SECTORS } from "@/util/constants"; -import { Selector } from "@/components/selector"; import { EmptyStateCardContent } from "@/app/[lng]/[inventory]/InventoryResultTab/EmptyStateCardContent"; import { Trans } from "react-i18next/TransWithoutContext"; 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", @@ -73,6 +77,7 @@ function SectorTabs({ const [selectedTableView, setSelectedTableView] = useState( TableView.BY_ACTIVITY, ); + const [isLoadingNewData, setIsLoadingNewData] = useState(false); const getDataForSector = (sectorName: string) => results?.totalEmissions.bySector.find( (e) => @@ -82,11 +87,37 @@ function SectorTabs({ const { data: results, isLoading: isTopEmissionsResponseLoading } = api.useGetResultsQuery(inventory!.inventoryId!); - const { data: sectorBreakdown, isLoading: isResultsLoading } = - api.useGetSectorBreakdownQuery({ - inventoryId: inventory!.inventoryId!, - sector: SECTORS[selectedIndex].name, + const { + data: sectorBreakdown, + isLoading: isResultsLoading, + error, + refetch, + } = api.useGetSectorBreakdownQuery({ + inventoryId: inventory!.inventoryId!, + sector: SECTORS[selectedIndex].name, + }); + const toast = useToast(); + + const makeErrorToast = (title: string, description?: string) => { + toast({ + title, + description, + position: "bottom", + status: "error", + isClosable: true, + duration: 10000, }); + }; + + if (error) { + makeErrorToast(t("something-went-wrong"), t("error-fetching-sector-data")); + console.error("Error fetching sector breakdown:", error); + } + + useEffect(() => { + setIsLoadingNewData(true); + refetch().finally(() => setIsLoadingNewData(false)); + }, [selectedIndex, refetch]); const handleViewChange = (event: ChangeEvent) => { setSelectedTableView(event.target.value as TableView); @@ -124,7 +155,7 @@ function SectorTabs({ selectedIndex === index ? "content.link" : "content.tertiary" } > - {capitalizeFirstLetter(t(name))} + {t(name)}
), @@ -139,7 +170,10 @@ function SectorTabs({ false && // ON-3126 restore view by activity selectedTableView === TableView.BY_ACTIVITY; const shouldShowTableByScope = - !isEmptyInventory && inventory && !isResultsLoading; // && + !isEmptyInventory && + inventory && + !isResultsLoading && + !isLoadingNewData; // && // selectedTableView === TableView.BY_SCOPE; ON-3126 restore view by activity return ( @@ -176,7 +210,9 @@ function SectorTabs({ {***[ON-3126 restore view by activity]*/} - {isResultsLoading && } + {(isResultsLoading || isLoadingNewData) && ( + + )} {isEmptyInventory && ( - | undefined + Record >(() => { - return citiesAndYears - ?.find(({ city }) => inventory.cityId === city.cityId) - ?.years.reduce( - (acc, curr) => { + return ( + cityYears?.years.reduce( + (acc: Record, curr: CityYearData) => { acc[curr.inventoryId] = curr; return acc; }, {} as Record, - ); - }, [citiesAndYears, inventory]); + ) ?? {} + ); + }, [cityYears]); const transformedYearOverYearData = useMemo(() => { - if (yearlyGhgResult && targetYears) { + if (yearlyGhgResult && targetYears && !isEmptyObject(targetYears)) { const yearlyMap: Record = {}; const totalInventoryEmissions: Record = {}; - const response = Object.keys(yearlyGhgResult).map((inventoryId) => { - const yearData = targetYears[inventoryId]; - const totalEmissions = yearlyGhgResult[inventoryId].totalEmissions; - yearlyMap[yearData.year] = totalEmissions.totalEmissionsBySector; - totalInventoryEmissions[yearData.year] = BigInt( - totalEmissions.sumOfEmissions, - ); + const response = Object.keys(yearlyGhgResult) + .map((inventoryId) => { + const year = targetYears[inventoryId]?.year; + if (!year) { + console.error("Target year missing for inventory " + inventoryId); + return null; + } + const totalEmissions = yearlyGhgResult[inventoryId].totalEmissions; + yearlyMap[year] = totalEmissions.totalEmissionsBySector; + totalInventoryEmissions[year] = BigInt(totalEmissions.sumOfEmissions); - return { - bySector: [...totalEmissions.totalEmissionsBySector], - ...yearData, - }; - }); + return { + bySector: [...totalEmissions.totalEmissionsBySector], + year, + inventoryId, + }; + }) + .filter((data) => !!data); // taking the response object let's working on getting the percentage increase for each year return response .map((data) => { const yearWithPercentageIncrease = data.bySector.map((sectorData) => { - const totalInventoryPercentage = Number( - (BigInt(sectorData.co2eq) * 100n) / - totalInventoryEmissions[data.year], - ); + const inventoryEmissions = totalInventoryEmissions[data.year]; + if (!inventoryEmissions) { + console.error( + "Total inventory emissions missing for year " + data.year, + ); + } + + const totalInventoryPercentage = inventoryEmissions + ? Number((BigInt(sectorData.co2eq) * 100n) / inventoryEmissions) + : null; let percentageChange: number | null = null; if (data.year - 1 in yearlyMap) { @@ -339,6 +387,7 @@ export function EmissionPerSectors({ bySector: yearWithPercentageIncrease, }; }) + .filter((data) => !!data) .sort((a, b) => b.year - a.year); } return []; @@ -456,6 +505,11 @@ export default function InventoryResultTab({ isPublic={isPublic} /> + { + let number, unit; + let converted; + if (!!totalEmissionsData && totalEmissionsData !== "?") { + converted = convertKgToTonnes(parseFloat(totalEmissionsData)); + } + const emissionsData = sourceData?.totals?.emissions?.co2eq_100yr; + let totalEmissions = emissionsData + ? ((Number(emissionsData) * sourceData?.scaleFactor) / 1000).toFixed(2) + : "?"; + if (sourceData?.issue) { + totalEmissions = "?"; + } + if (!!totalEmissions && totalEmissions !== "?") { + converted = convertKgToTonnes(parseInt(totalEmissions)); + } + if (!converted) { + return { number: totalEmissionsData ?? totalEmissions, unit: "" }; + } + return { + number: converted.split(" ")[0], + unit: converted.split(" ").slice(1).join(" "), + }; + }; return ( - {t("intlNumber", { - val: totalEmissionsData ?? totalEmissions, - })} + {emissionsToBeIncluded().number} - TCO2e + {emissionsToBeIncluded().unit} diff --git a/app/src/app/[lng]/[inventory]/data/[step]/[subsector]/page.tsx b/app/src/app/[lng]/[inventory]/data/[step]/[subsector]/page.tsx index 2f80dbb6d..317cca7bb 100644 --- a/app/src/app/[lng]/[inventory]/data/[step]/[subsector]/page.tsx +++ b/app/src/app/[lng]/[inventory]/data/[step]/[subsector]/page.tsx @@ -36,6 +36,7 @@ import { import Link from "next/link"; import type { InventoryValueAttributes } from "@/models/InventoryValue"; import { getScopesForInventoryAndSector, SECTORS } from "@/util/constants"; +import { toKebabCase } from "@/util/helpers"; const MotionBox = motion( // the display name is added below, but the linter isn't picking it up @@ -123,7 +124,6 @@ function SubSectorPage({ const sectorData = inventoryProgress?.sectorProgress.find( (sector) => sector.sector.referenceNumber === getSectorRefNo(step), ); - const subSectorData: SubSectorAttributes | undefined = sectorData?.subSectors.find( (subSectorItem) => subSectorItem.subsectorId === subsector, @@ -384,7 +384,10 @@ function SubSectorPage({ fontWeight="normal" color="interactive.control" > - {t("commercial-and-institutional-building-description")} + {t( + toKebabCase(subSectorData?.subsectorName) + + "-description", + )} )} diff --git a/app/src/app/[lng]/[inventory]/data/[step]/page.tsx b/app/src/app/[lng]/[inventory]/data/[step]/page.tsx index afc87f99a..ceecf64e3 100644 --- a/app/src/app/[lng]/[inventory]/data/[step]/page.tsx +++ b/app/src/app/[lng]/[inventory]/data/[step]/page.tsx @@ -519,7 +519,7 @@ export default function AddDataSteps({ async (inventoryValue: InventoryValueAttributes) => { return await disconnectThirdPartyData({ inventoryId: inventoryValue.inventoryId, - subCategoryId: inventoryValue.subCategoryId, + datasourceId: inventoryValue.datasourceId, }); }, ), diff --git a/app/src/app/[lng]/onboarding/setup/page.tsx b/app/src/app/[lng]/onboarding/setup/page.tsx index 1c778d777..1117171b9 100644 --- a/app/src/app/[lng]/onboarding/setup/page.tsx +++ b/app/src/app/[lng]/onboarding/setup/page.tsx @@ -223,7 +223,7 @@ export default function OnboardingSetup({ }} pl={0} > - Go Back + {t("go-back")}
{activeStep === 0 && ( diff --git a/app/src/app/api/v0/city/[city]/boundary/route.ts b/app/src/app/api/v0/city/[city]/boundary/route.ts index 73f076df9..f6a483972 100644 --- a/app/src/app/api/v0/city/[city]/boundary/route.ts +++ b/app/src/app/api/v0/city/[city]/boundary/route.ts @@ -1,37 +1,18 @@ -import { GLOBAL_API_URL } from "@/services/api"; import { logger } from "@/services/logger"; import { apiHandler } from "@/util/api"; -import createHttpError from "http-errors"; import { NextResponse } from "next/server"; -import wellknown from "wellknown"; +import CityBoundaryService from "@/backend/CityBoundaryService"; +import createHttpError from "http-errors"; export const GET = apiHandler(async (_req, { params }) => { - const url = `${GLOBAL_API_URL}/api/v0/cityboundary/city/${params.city}`; - logger.info(`Fetching ${url}`); - try { - const boundary = await fetch(url); - - const data = await boundary.json(); - - if (!data.city_geometry) { - throw new createHttpError.NotFound( - `City boundary for locode ${params.city} not found`, - ); - } - - const wtkData = data.city_geometry; - const geoJson = wellknown.parse(wtkData); - const boundingBox = [ - data.bbox_west, - data.bbox_south, - data.bbox_east, - data.bbox_north, - ]; + const boundaryData = await CityBoundaryService.getCityBoundary(params.city); - return NextResponse.json({ data: geoJson, boundingBox, area: data.area }); + return NextResponse.json({ ...boundaryData }); } catch (error: any) { logger.error(error); - return NextResponse.json({ error: error.message }); + throw new createHttpError.InternalServerError( + "Failed to fetch city boundary", + ); } }); diff --git a/app/src/app/api/v0/city/[city]/population/[year]/route.ts b/app/src/app/api/v0/city/[city]/population/[year]/route.ts index 95850a3be..a6a5f412e 100644 --- a/app/src/app/api/v0/city/[city]/population/[year]/route.ts +++ b/app/src/app/api/v0/city/[city]/population/[year]/route.ts @@ -1,56 +1,17 @@ import UserService from "@/backend/UserService"; -import { db } from "@/models"; -import { PopulationAttributes } from "@/models/Population"; import { apiHandler } from "@/util/api"; -import { PopulationEntry, findClosestYearToInventory } from "@/util/helpers"; import { NextResponse } from "next/server"; -import { Op } from "sequelize"; import { z } from "zod"; - -const maxPopulationYearDifference = 5; +import PopulationService from "@/backend/PopulationService"; export const GET = apiHandler(async (_req: Request, { session, params }) => { const city = await UserService.findUserCity(params.city, session, true); const year = z.coerce.number().parse(params.year); - const populations = await db.models.Population.findAll({ - where: { - cityId: params.city, - year: { - [Op.between]: [ - year! - maxPopulationYearDifference, - year! + maxPopulationYearDifference, - ], - }, - }, - order: [["year", "DESC"]], // favor more recent population entries - }); - const cityPopulations = populations.filter((pop) => !!pop.population); - const cityPopulation = findClosestYearToInventory( - cityPopulations as PopulationEntry[], - year!, - ); - const countryPopulations = populations.filter( - (pop) => !!pop.countryPopulation, - ); - const countryPopulation = findClosestYearToInventory( - countryPopulations as PopulationEntry[], - year!, - ) as PopulationAttributes; - const regionPopulations = populations.filter((pop) => !!pop.regionPopulation); - const regionPopulation = findClosestYearToInventory( - regionPopulations as PopulationEntry[], - year!, - ) as PopulationAttributes; + + const cityPopulationData = + await PopulationService.getPopulationDataForCityYear(city.cityId, year); return NextResponse.json({ - data: { - cityId: city.cityId, - population: cityPopulation?.population, - year: cityPopulation?.year, - countryPopulation: countryPopulation?.population, - countryPopulationYear: countryPopulation?.year, - regionPopulation: regionPopulation?.population, - regionPopulationYear: regionPopulation?.year, - }, + data: cityPopulationData, }); }); diff --git a/app/src/app/api/v0/city/[city]/years/route.ts b/app/src/app/api/v0/city/[city]/years/route.ts new file mode 100644 index 000000000..eb635fc9f --- /dev/null +++ b/app/src/app/api/v0/city/[city]/years/route.ts @@ -0,0 +1,47 @@ +// fetch the years of the inventories attached to a city + +import { apiHandler } from "@/util/api"; +import { NextRequest, NextResponse } from "next/server"; +import createHttpError from "http-errors"; +import { db } from "@/models"; + +export const GET = apiHandler(async (_req: NextRequest, { params }) => { + // TODO implement access control (check if inventory is public) + /* if (!context.session && !inventory.isPublic) { + throw new createHttpError.Unauthorized("Unauthorized"); + } */ + + const city = await db.models.City.findByPk(params.city, { + include: [ + { + model: db.models.Inventory, + as: "inventories", + attributes: ["year", "inventoryId", "lastUpdated"], + }, + ], + }); + + if (!city) { + throw new createHttpError.NotFound("City not found"); + } + + return NextResponse.json({ + data: { + city: { + name: city.name, + locode: city.locode, + area: city.area, + country: city.country, + countryLocode: city.countryLocode, + region: city.region, + regionLocode: city.regionLocode, + cityId: city.cityId, + }, + years: city.inventories.map((inventory) => ({ + year: inventory.year, + inventoryId: inventory.inventoryId, + lastUpdate: inventory.lastUpdated, + })), + }, + }); +}); diff --git a/app/src/app/api/v0/datasource/[inventoryId]/datasource/[datasourceId]/route.ts b/app/src/app/api/v0/datasource/[inventoryId]/datasource/[datasourceId]/route.ts new file mode 100644 index 000000000..46037eb9e --- /dev/null +++ b/app/src/app/api/v0/datasource/[inventoryId]/datasource/[datasourceId]/route.ts @@ -0,0 +1,28 @@ +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; +import UserService from "@/backend/UserService"; + +export const DELETE = apiHandler(async (_req, { params, session }) => { + await UserService.findUserInventory(params.inventoryId, session); + + const inventoryValues = await db.models.InventoryValue.findAll({ + where: { + datasourceId: params.datasourceId, + inventoryId: params.inventoryId, + }, + }); + if (inventoryValues.length === 0) { + throw new createHttpError.NotFound("Inventory value not found"); + } + + await db.models.InventoryValue.destroy({ + where: { + datasourceId: params.datasourceId, + inventoryId: params.inventoryId, + }, + }); + + return NextResponse.json({ data: inventoryValues, deleted: true }); +}); diff --git a/app/src/app/api/v0/inventory/[inventory]/download/route.ts b/app/src/app/api/v0/inventory/[inventory]/download/route.ts index 80a28dfcf..2cc3cc3a9 100644 --- a/app/src/app/api/v0/inventory/[inventory]/download/route.ts +++ b/app/src/app/api/v0/inventory/[inventory]/download/route.ts @@ -7,7 +7,10 @@ import createHttpError from "http-errors"; import type { Inventory } from "@/models/Inventory"; import type { InventoryValue } from "@/models/InventoryValue"; -import type { InventoryResponse } from "@/util/types"; +import type { + InventoryResponse, + InventoryWithInventoryValuesAndActivityValues, +} from "@/util/types"; import type { EmissionsFactor } from "@/models/EmissionsFactor"; import { db } from "@/models"; import { @@ -16,9 +19,8 @@ import { keyBy, PopulationEntry, } from "@/util/helpers"; -import ECRFDownloadService, { - InventoryWithInventoryValuesAndActivityValues, -} from "@/backend/ECRFDownloadService"; +import ECRFDownloadService from "@/backend/ECRFDownloadService"; +import CSVDownloadService from "@/backend/CSVDownloadService"; type InventoryValueWithEF = InventoryValue & { emissionsFactor?: EmissionsFactor; @@ -81,6 +83,11 @@ export const GET = apiHandler(async (req, { params, session }) => { ], as: "dataSource", }, + { + model: db.models.SubSector, + as: "subSector", + attributes: ["subsectorId", "subsectorName"], + }, ], }, ], @@ -126,7 +133,10 @@ export const GET = apiHandler(async (req, { params, session }) => { switch (req.nextUrl.searchParams.get("format")?.toLowerCase()) { case "csv": - body = await inventoryCSV(inventory); + body = await CSVDownloadService.downloadCSV( + output as InventoryWithInventoryValuesAndActivityValues, + lng, + ); headers = { "Content-Type": "text/csv", "Content-Disposition": `attachment; filename="inventory-${inventory.city.locode}-${inventory.year}.csv"`, @@ -161,36 +171,6 @@ export const GET = apiHandler(async (req, { params, session }) => { return new NextResponse(body, { headers }); }); -async function inventoryCSV(inventory: Inventory): Promise { - // TODO better export without UUIDs and merging in data source props, gas values, emission factors - const inventoryValues = await db.models.InventoryValue.findAll({ - where: { - inventoryId: inventory.inventoryId, - }, - }); - const headers = [ - "Inventory Reference", - "GPC Reference Number", - "Total Emissions", - "Activity Units", - "Activity Value", - "Emission Factor Value", - "Datasource ID", - ].join(","); - const inventoryLines = inventoryValues.map((value: InventoryValueWithEF) => { - return [ - value.subCategoryId, - value.gpcReferenceNumber, - value.co2eq, - value.activityUnits, - value.activityValue, - value.emissionsFactor?.emissionsPerActivity ?? "N/A", - value.datasourceId, - ].join(","); - }); - return Buffer.from([headers, ...inventoryLines].join("\n")); -} - function converKgToTons(amount: bigint | null | undefined): string { if (amount == null) { return ""; diff --git a/app/src/app/api/v0/inventory/[inventory]/results/emissions-forecast/route.ts b/app/src/app/api/v0/inventory/[inventory]/results/emissions-forecast/route.ts new file mode 100644 index 000000000..cdd1bbc7f --- /dev/null +++ b/app/src/app/api/v0/inventory/[inventory]/results/emissions-forecast/route.ts @@ -0,0 +1,20 @@ +import UserService from "@/backend/UserService"; +import { apiHandler } from "@/util/api"; +import { NextResponse } from "next/server"; +import { getEmissionsForecasts } from "@/backend/ResultsService"; + +export const GET = apiHandler( + async (_req, { session, params: { inventory } }) => { + // 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/app/api/v0/inventory/[inventory]/value/[subcategory]/route.ts b/app/src/app/api/v0/inventory/[inventory]/value/[subcategory]/route.ts index 302a4d914..b3af20dfd 100644 --- a/app/src/app/api/v0/inventory/[inventory]/value/[subcategory]/route.ts +++ b/app/src/app/api/v0/inventory/[inventory]/value/[subcategory]/route.ts @@ -82,10 +82,30 @@ export const PATCH = apiHandler(async (req, { params, session }) => { ); } + // check if data is marked as not occurring/ otherwise unavailable + if (body.unavailableReason || body.unavailableExplanation) { + if (!body.unavailableReason || !body.unavailableExplanation) { + throw new createHttpError.BadRequest( + "unavailableReason and unavailableExplanation need to both be provided if one is used", + ); + } + + body.co2eq = undefined; + body.co2eqYears = undefined; + + // for existing data, delete left over ActivityValues + if (inventoryValue) { + await db.models.ActivityValue.destroy({ + where: { inventoryValueId: inventoryValue.id }, + }); + } + } + if (inventoryValue) { inventoryValue = await inventoryValue.update({ ...body, id: inventoryValue.id, + datasourceId: body.unavailableReason ? null : inventoryValue.datasourceId, }); } else { inventoryValue = await db.models.InventoryValue.create({ @@ -222,24 +242,3 @@ export const PATCH = apiHandler(async (req, { params, session }) => { return NextResponse.json({ data: inventoryValue }); }); - -export const DELETE = apiHandler(async (_req, { params, session }) => { - const inventory = await UserService.findUserInventory( - params.inventory, - session, - ); - - const subcategoryValue = await db.models.InventoryValue.findOne({ - where: { - subCategoryId: params.subcategory, - inventoryId: inventory.inventoryId, - }, - }); - if (!subcategoryValue) { - throw new createHttpError.NotFound("Inventory value not found"); - } - - await subcategoryValue.destroy(); - - return NextResponse.json({ data: subcategoryValue, deleted: true }); -}); diff --git a/app/src/app/api/v0/inventory/[inventory]/value/subsector/[subsector]/route.ts b/app/src/app/api/v0/inventory/[inventory]/value/subsector/[subsector]/route.ts index 9e4942a2b..5f7dba61a 100644 --- a/app/src/app/api/v0/inventory/[inventory]/value/subsector/[subsector]/route.ts +++ b/app/src/app/api/v0/inventory/[inventory]/value/subsector/[subsector]/route.ts @@ -60,10 +60,30 @@ export const PATCH = apiHandler(async (req, { params, session }) => { }, }); + // check if data is marked as not occurring/ otherwise unavailable + if (body.unavailableReason || body.unavailableExplanation) { + if (!body.unavailableReason || !body.unavailableExplanation) { + throw new createHttpError.BadRequest( + "unavailableReason and unavailableExplanation need to both be provided if one is used", + ); + } + + body.co2eq = undefined; + body.co2eqYears = undefined; + + // for existing data, delete left over ActivityValues + if (inventoryValue) { + await db.models.ActivityValue.destroy({ + where: { inventoryValueId: inventoryValue.id }, + }); + } + } + if (inventoryValue) { inventoryValue = await inventoryValue.update({ ...body, id: inventoryValue.id, + datasourceId: body.unavailableReason ? null : inventoryValue.datasourceId, }); } else { inventoryValue = await db.models.InventoryValue.create({ diff --git a/app/src/app/api/v0/user/cities/[id]/results/route.ts b/app/src/app/api/v0/user/cities/[id]/results/route.ts index 43cebaefd..1ff787521 100644 --- a/app/src/app/api/v0/user/cities/[id]/results/route.ts +++ b/app/src/app/api/v0/user/cities/[id]/results/route.ts @@ -8,9 +8,6 @@ import { getEmissionResultsBatch } from "@/backend/ResultsService"; export const GET = apiHandler(async (_req: NextRequest, context) => { const { id } = context.params; - if (!context.session) { - throw new createHttpError.Unauthorized("Unauthorized"); - } const city = await db.models.City.findOne({ where: { diff --git a/app/src/backend/CSVDownloadService.ts b/app/src/backend/CSVDownloadService.ts new file mode 100644 index 000000000..026b42bcb --- /dev/null +++ b/app/src/backend/CSVDownloadService.ts @@ -0,0 +1,192 @@ +import { InventoryWithInventoryValuesAndActivityValues } from "@/util/types"; +import { sortGpcReferenceNumbers, toDecimal } from "@/util/helpers"; +import Decimal from "decimal.js"; +import { translationFunc } from "@/i18n/server"; +import { stringify } from "csv-stringify/sync"; + +export default class CSVDownloadService { + public static async downloadCSV( + output: InventoryWithInventoryValuesAndActivityValues, + lng: string, + ) { + const headers = [ + "Inventory Reference", + "GPC Reference Number", + "Subsector name", + "Notation Key", + "Total Emissions", + "Total Emission Units", + "Activity Value", + "Activity Units", + "Emission Factor - CO2", + "Emission Factor - CH4", + "Emission Factor - N2O", + "Emission Factor - Unit", + "Datasource ID", + "DataSource name", + ]; + + const { t } = await translationFunc(lng, "data"); + + // prepare the data + const dataDictionary = this.prepDataForCSV(output, t); + + const sortedKeys = sortGpcReferenceNumbers(Object.keys(dataDictionary)); + + const inventoryLines = sortedKeys.flatMap((key) => { + const value = dataDictionary[key]; + return value.activityValues.map((activityValue) => { + return [ + value.inventory_reference || "N/A", + value.gpc_reference_number, + value.subsector_name, + value.notation_key, + activityValue.total_co2e, + "kg CO2e", + activityValue.activity_amount, + activityValue.activity_unit, + activityValue.emission_co2, + activityValue.emission_ch4, + activityValue.emission_n2o, + activityValue.emission_factor_unit, + activityValue.data_source_id, + activityValue.data_source_name, + ]; + }); + }); + + const csvContent = stringify([...inventoryLines], { + header: true, + columns: headers, + quoted: true, + }); + try { + return Buffer.from(csvContent); + } catch (e) { + console.error("Error creating CSV", e); + throw new Error("Error creating CSV"); + } + } + + public static prepDataForCSV( + output: InventoryWithInventoryValuesAndActivityValues, + t: (str: string) => string, + ) { + const inventoryValues = output.inventoryValues; + + const dataDictionary: Record< + string, + { + inventory_reference?: string; + gpc_reference_number?: string; + subsector_name?: string; + notation_key?: string; + activityValues: { + emission_factor_unit?: string | null; + emission_co2?: number | null; + emission_ch4?: number | null; + emission_n2o?: number | null; + activity_amount?: string | null; + activity_unit?: string | null; + data_source_id?: string; + data_source_name?: string; + total_co2e?: number; + }[]; + } + > = {}; + inventoryValues.forEach((inventoryValue) => { + const gpcRefNo = inventoryValue.gpcReferenceNumber; + const activityValues = inventoryValue.activityValues || []; + + // if there is a source, this is third party data + if (inventoryValue.dataSource) { + dataDictionary[gpcRefNo as string] = { + inventory_reference: inventoryValue.subCategoryId, + gpc_reference_number: inventoryValue.gpcReferenceNumber, + subsector_name: inventoryValue.subSector.subsectorName, + notation_key: inventoryValue.unavailableReason?.split("-")[1], + activityValues: [ + { + emission_factor_unit: null, + emission_co2: null, + emission_ch4: null, + emission_n2o: null, + activity_amount: null, + activity_unit: null, + data_source_id: inventoryValue.dataSource?.datasourceId, + data_source_name: inventoryValue.dataSource?.datasourceName, + total_co2e: toDecimal(inventoryValue.co2eq as bigint) + ?.div(new Decimal("1e3")) + .toNumber(), + }, + ], + }; + } else { + dataDictionary[gpcRefNo as string] = { + // InventoryValue fields + inventory_reference: inventoryValue.subCategoryId, + gpc_reference_number: inventoryValue.gpcReferenceNumber, + subsector_name: inventoryValue.subSector.subsectorName, + notation_key: inventoryValue.unavailableReason?.split("-")[1], + activityValues: activityValues.map((activityValue) => { + let activityTitleKey = activityValue.metadata?.activityTitle; + let dataQuality = activityValue.metadata?.dataQuality; + let dataSource = activityValue.activityData?.["data-source"]; + let activityAmount = activityValue.activityData?.[activityTitleKey]; + let activityUnit = t( + activityValue.activityData?.[`${activityTitleKey}-unit`], + ); + let emission_co2 = null; + let emission_ch4 = null; + let emission_n2o = null; + + if (activityValue.gasValues) { + let co2_gas = activityValue.gasValues.find( + (g) => g.gas === "CO2", + ); + let ch4_gas = activityValue.gasValues.find( + (g) => g.gas === "CH4", + ); + let n2o_gas = activityValue.gasValues.find( + (g) => g.gas === "N2O", + ); + + emission_co2 = co2_gas?.emissionsFactor.emissionsPerActivity; + emission_ch4 = ch4_gas?.emissionsFactor.emissionsPerActivity; + emission_n2o = n2o_gas?.emissionsFactor.emissionsPerActivity; + } + // if there is an existing emission factor value + let usesEf = + inventoryValue.gpcReferenceNumber?.split(".")?.includes("I") || + inventoryValue.gpcReferenceNumber?.split(".").includes("II"); + let efUnit = null; + + if (usesEf) { + let scope = inventoryValue.gpcReferenceNumber?.split(".")[2]; + efUnit = scope === "1" ? "kg/m3" : "kg/TJ"; + } + + return { + emission_factor_unit: efUnit, + emission_co2, + emission_ch4, + emission_n2o, + activity_amount: activityAmount, + activity_unit: activityUnit, + data_source_id: "", + data_source_name: dataSource, + total_co2e: toDecimal(activityValue.co2eq as bigint) + ?.div(new Decimal("1e3")) + .toNumber(), + }; + }), + }; + } + + if (dataDictionary[gpcRefNo as string].notation_key) { + dataDictionary[gpcRefNo as string].activityValues = [{}]; + } + }); + return dataDictionary; + } +} diff --git a/app/src/backend/CityBoundaryService.ts b/app/src/backend/CityBoundaryService.ts new file mode 100644 index 000000000..04a78931a --- /dev/null +++ b/app/src/backend/CityBoundaryService.ts @@ -0,0 +1,40 @@ +import { GLOBAL_API_URL } from "@/services/api"; +import { logger } from "@/services/logger"; +import createHttpError from "http-errors"; +import wellknown from "wellknown"; + +export default class CityBoundaryService { + public static async getCityBoundary(cityLocode: string): Promise<{ + data: wellknown.GeoJSONGeometryOrNull; + boundingBox: any[]; + area: number; + }> { + const url = `${GLOBAL_API_URL}/api/v0/cityboundary/city/${cityLocode}`; + logger.info(`Fetching ${url}`); + + const boundary = await fetch(url); + + const data = await boundary.json(); + + if (!data.city_geometry) { + throw new createHttpError.NotFound( + `City boundary for locode ${cityLocode} not found`, + ); + } + + const wtkData = data.city_geometry; + const geoJson = wellknown.parse(wtkData); + const boundingBox = [ + data.bbox_west, + data.bbox_south, + data.bbox_east, + data.bbox_north, + ]; + + return { + data: geoJson, + boundingBox, + area: data.area, + }; + } +} diff --git a/app/src/backend/ECRFDownloadService.ts b/app/src/backend/ECRFDownloadService.ts index db0970fa0..d181f2830 100644 --- a/app/src/backend/ECRFDownloadService.ts +++ b/app/src/backend/ECRFDownloadService.ts @@ -1,22 +1,16 @@ import Excel, { Worksheet } from "exceljs"; -import { InventoryValue } from "@/models/InventoryValue"; -import { ActivityValue } from "@/models/ActivityValue"; import createHttpError from "http-errors"; -import { InventoryResponse } from "@/util/types"; +import { + InventoryValueWithActivityValues, + InventoryWithInventoryValuesAndActivityValues, +} from "@/util/types"; import { findMethodology } from "@/util/form-schema"; import { translationFunc } from "@/i18n/server"; import { toDecimal } from "@/util/helpers"; import Decimal from "decimal.js"; import { bigIntToDecimal } from "@/util/big_int"; - -type InventoryValueWithActivityValues = InventoryValue & { - activityValues: ActivityValue[]; -}; - -export type InventoryWithInventoryValuesAndActivityValues = - InventoryResponse & { - inventoryValues: InventoryValueWithActivityValues[]; - }; +import PopulationService from "@/backend/PopulationService"; +import CityBoundaryService from "@/backend/CityBoundaryService"; const ECRF_TEMPLATE_PATH = "./templates/ecrf_template.xlsx"; @@ -25,10 +19,10 @@ export default class ECRFDownloadService { output: InventoryWithInventoryValuesAndActivityValues, lng: string, ) { - return await this.writeTOECRFFILE(output, lng); + return await this.writeToECRFFILE(output, lng); } - private static async writeTOECRFFILE( + private static async writeToECRFFILE( output: InventoryWithInventoryValuesAndActivityValues, lng: string, ) { @@ -37,48 +31,9 @@ export default class ECRFDownloadService { try { // Load the workbook await workbook.xlsx.readFile(ECRF_TEMPLATE_PATH); - const worksheet = workbook.getWorksheet(3); // Get the worksheet by index (3rd sheet) - // Fetch data from the database - const inventoryValues = output.inventoryValues; - - // Transform data into a dictionary for easy access - const dataDictionary = this.transformDataForTemplate( - inventoryValues as InventoryValueWithActivityValues[], - output.year as number, - t, - ); - - const visitedScopes = {}; - - worksheet?.eachRow((row, rowNumber) => { - // maintain the styling - row.eachCell((cell) => { - cell.style = { ...cell.style }; - }); - - if (rowNumber === 1) return; // Skip the first row (contains the header) - - const referenceNumberCell = row.getCell(2); - const referenceNumberValue = referenceNumberCell.value; - - if (referenceNumberCell && typeof referenceNumberValue === "string") { - const dataSection = dataDictionary[referenceNumberValue]; - // if the activityValues > 1, then we need to add rows - if (dataSection) { - this.replacePlaceholdersInRow( - row, - dataSection, - rowNumber, - visitedScopes, - worksheet, - ); - } else { - this.markRowAsNotEstimated(row); - } - } - this.markRowAsNotEstimated(row); - }); - + await this.writeToSheet1(workbook, output, t); + await this.writeToSheet2(workbook, output, t); + await this.writeToSheet3(workbook, output, t); // Save the modified workbook const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); console.log("Workbook has been generated successfully"); @@ -91,7 +46,252 @@ export default class ECRFDownloadService { } } - private static transformDataForTemplate( + private static async writeToSheet1( + workbook: Excel.Workbook, + output: InventoryWithInventoryValuesAndActivityValues, + t: any, + ) { + // fetch population data + + const city = output.city; + const year = output.year; + + let cityPopulationData = null; + let cityBoundaryData: Record = {}; + try { + cityPopulationData = await PopulationService.getPopulationDataForCityYear( + city.cityId, + year as number, + ); + cityBoundaryData = await CityBoundaryService.getCityBoundary( + city.locode as string, + ); + } catch (e) { + console.warn("Failed to fetch city boundary or population"); + } + + // prepare the data for sheet 1 + const sheetData: Record = { + inventory_type: t?.(output.inventoryType), + city_country: city.country, + city_name: city.name, + city_region: city.region, + inventory_year: year, + city_population: cityPopulationData?.population, + city_area: cityBoundaryData?.area, + }; + + const worksheet = workbook.getWorksheet(1); // Get the worksheet by index (1st sheet) + + worksheet?.eachRow((row, rowNumber) => { + const placeholderCell = row.getCell(3); + if (placeholderCell.value && typeof placeholderCell.value === "string") { + const cellValue = placeholderCell.value as string; + const placeholderMatch = cellValue.match(/{{(.*?)}}/); + if (placeholderMatch) { + const fieldName = placeholderMatch[1] as string; + const replacementValue = sheetData[fieldName]; + placeholderCell.value = replacementValue ?? "N/A"; + } + } + }); + } + + private static async writeToSheet2( + workbook: Excel.Workbook, + output: InventoryWithInventoryValuesAndActivityValues, + t: any, + ) { + const sectorNameMapping: Record = { + I: `stationary`, + II: `transport`, + III: `waste`, + IV: `ippu`, + V: `afolu`, + }; + + const totals: Record = { + stationary1: 0n, + stationary2: 0n, + stationary3: 0n, + transport1: 0n, + transport2: 0n, + transport3: 0n, + waste1: 0n, + waste2: 0n, + waste3: 0n, + afolu1: 0n, + afolu2: 0n, + afolu3: 0n, + ippu1: 0n, + ippu2: 0n, + }; + + // prepare the data for sheet 2 + const dataDictionary = this.transformDataForTemplate2(output, t); + const fugitive_emissions_data = + this.groupFugitiveEmissionData(dataDictionary); + totals.stationary1 = fugitive_emissions_data?.total; + + const updatedDataDictionary: Record = { + ...dataDictionary, + fugitive: fugitive_emissions_data, + }; + + // now loop over the rows and columns. + const worksheet = workbook.getWorksheet(2); + + worksheet?.eachRow((row, rowNumber) => { + // loop over each cell and then check if it's a placeholder. + row.eachCell((cell) => { + const cellValue = cell.value as string; + const placeholderMatch = cellValue.match(/{{(.*?)}}/); + if (placeholderMatch) { + let replacementValue = null; // split the placeholders + const fieldName = placeholderMatch[1]; + + if (fieldName === "explanation-institutional-missing") { + cell.value = t("explanation-institutional-missing"); + return; + } + + const referenceNoIdentifier = fieldName.split("_")[0]; + const targetIdentifier = fieldName.split("_")[1]; + + if (targetIdentifier === "full-total") { + // if sector total placeholder read from totals + replacementValue = totals[referenceNoIdentifier]; + } else if (referenceNoIdentifier in updatedDataDictionary) { + replacementValue = + updatedDataDictionary[referenceNoIdentifier]?.[targetIdentifier]; // eg dataDictionary.I.total or dataDictionary.I.notation-key + + // build up the totals for each sector scope combo + if ( + targetIdentifier === "total" && + !(referenceNoIdentifier === "fugitive") + ) { + const refSplit = referenceNoIdentifier.split("."); + const sectorNo = refSplit[0]; + const sectorName = sectorNameMapping[sectorNo]; + const scopeNo = refSplit[refSplit.length - 1]; + totals[`${sectorName}${scopeNo}`] += replacementValue + ? BigInt(replacementValue) + : 0n; + } + } else if (targetIdentifier === "notation-key") { + // mark as Not estimated + replacementValue = "NE"; + } + cell.value = replacementValue?.toString() ?? ""; + } + }); + }); + + // create a new object + } + + private static async writeToSheet3( + workbook: Excel.Workbook, + output: InventoryWithInventoryValuesAndActivityValues, + t: any, + ) { + const worksheet = workbook.getWorksheet(3); // Get the worksheet by index (3rd sheet) + // Fetch data from the database + const inventoryValues = output.inventoryValues; + + // Transform data into a dictionary for easy access + const dataDictionary = this.transformDataForTemplate3( + inventoryValues as InventoryValueWithActivityValues[], + output.year as number, + t, + ); + + const visitedScopes = {}; + + worksheet?.eachRow((row, rowNumber) => { + // maintain the styling + row.eachCell((cell) => { + cell.style = { ...cell.style }; + }); + + if (rowNumber === 1) return; // Skip the first row (contains the header) + + const referenceNumberCell = row.getCell(2); + const referenceNumberValue = referenceNumberCell.value; + + if (referenceNumberCell && typeof referenceNumberValue === "string") { + const dataSection = dataDictionary[referenceNumberValue]; + // if the activityValues > 1, then we need to add rows + if (dataSection) { + this.replacePlaceholdersInRow( + row, + dataSection, + rowNumber, + visitedScopes, + worksheet, + ); + } else { + this.markRowAsNotEstimated(row); + } + } + this.markRowAsNotEstimated(row); + }); + } + + private static groupFugitiveEmissionData( + subcategoryDataGroup: Record< + string, + { + total?: bigint; + "notation-key"?: string; + } + >, + ) { + const { total: totalI71 = BigInt(0), "notation-key": keyI71 = "" } = + subcategoryDataGroup["I.7.1"] || {}; + const { total: totalI81 = BigInt(0), "notation-key": keyI81 = "" } = + subcategoryDataGroup["I.8.1"] || {}; + + // Calculate fugitive emissions total + const fugitiveEmissionsTotal = totalI71 + totalI81; + + // Combine notation keys + const fugitiveEmissionsNotationKey = [keyI71, keyI81] + .filter(Boolean) + .join(" / "); + + // Build explanation string + const explanationParts = []; + if (keyI71) explanationParts.push(`I.7.1: ${keyI71}`); + if (keyI81) explanationParts.push(`I.8.1: ${keyI81}`); + const explanation = explanationParts.join(","); + + return { + total: fugitiveEmissionsTotal, + "notation-key": fugitiveEmissionsNotationKey, + explanation, + }; + } + + private static transformDataForTemplate2( + output: InventoryWithInventoryValuesAndActivityValues, + t: any, + ): Record { + const dataDictionary: Record = {}; + + output.inventoryValues.map((inventoryValue) => { + dataDictionary[inventoryValue.gpcReferenceNumber as string] = { + "notation-key": inventoryValue.unavailableReason?.split("-")[1], + total: inventoryValue.unavailableReason + ? 0n + : BigInt(inventoryValue.co2eq ?? 0), + }; + }); + + return dataDictionary; + } + + private static transformDataForTemplate3( inventoryValues: InventoryValueWithActivityValues[], inventoryYear: number, t: any, diff --git a/app/src/backend/OpenClimateService.ts b/app/src/backend/OpenClimateService.ts new file mode 100644 index 000000000..4513b4564 --- /dev/null +++ b/app/src/backend/OpenClimateService.ts @@ -0,0 +1,24 @@ +import { EmissionsForecastData } from "@/util/types"; +import { GLOBAL_API_URL } from "@/services/api"; +import { logger } from "@/services/logger"; + +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); + logger.info(`${URL} Response 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/PopulationService.ts b/app/src/backend/PopulationService.ts new file mode 100644 index 000000000..7785568b7 --- /dev/null +++ b/app/src/backend/PopulationService.ts @@ -0,0 +1,63 @@ +import { db } from "@/models"; +import { Op } from "sequelize"; +import { findClosestYearToInventory, PopulationEntry } from "@/util/helpers"; +import { PopulationAttributes } from "@/models/Population"; + +const maxPopulationYearDifference = 5; + +export default class PopulationService { + public static async getPopulationDataForCityYear( + cityId: string, + year: number, + ): Promise<{ + cityId: string; + population?: number | null; + year?: number | null; + countryPopulation?: number | null; + countryPopulationYear: number | null; + regionPopulation?: number | null; + regionPopulationYear: number | null; + }> { + const populations = await db.models.Population.findAll({ + where: { + cityId: cityId, + year: { + [Op.between]: [ + year! - maxPopulationYearDifference, + year! + maxPopulationYearDifference, + ], + }, + }, + order: [["year", "DESC"]], // favor more recent population entries + }); + const cityPopulations = populations.filter((pop) => !!pop.population); + const cityPopulation = findClosestYearToInventory( + cityPopulations as PopulationEntry[], + year!, + ); + const countryPopulations = populations.filter( + (pop) => !!pop.countryPopulation, + ); + const countryPopulation = findClosestYearToInventory( + countryPopulations as PopulationEntry[], + year!, + ) as PopulationAttributes; + const regionPopulations = populations.filter( + (pop) => !!pop.regionPopulation, + ); + const regionPopulation = findClosestYearToInventory( + regionPopulations as PopulationEntry[], + year!, + ) as PopulationAttributes; + + return { + cityId: cityId, + population: cityPopulation?.population, + year: cityPopulation?.year, + countryPopulation: countryPopulation?.population, + countryPopulationYear: countryPopulation?.year, + regionPopulation: regionPopulation?.population, + regionPopulationYear: regionPopulation?.year, + }; + } +} 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/components/Cards/methodology-card.tsx b/app/src/components/Cards/methodology-card.tsx index df946b21e..06479f94e 100644 --- a/app/src/components/Cards/methodology-card.tsx +++ b/app/src/components/Cards/methodology-card.tsx @@ -1,7 +1,8 @@ -import { Badge, Box, Card, Radio, Text } from "@chakra-ui/react"; +import { Badge, Box, Card, Radio, Text, useToast } from "@chakra-ui/react"; import { TFunction } from "i18next"; import React, { FC, useState } from "react"; import type { Methodology } from "@/util/form-schema"; +import { InfoIcon } from "@chakra-ui/icons"; interface MethodologyCardProps { id: string; @@ -29,7 +30,32 @@ const MethodologyCard: FC = ({ }); }; + const toast = useToast(); + const handleCardClick = () => { + if (disabled) { + toast({ + status: "error", + title: t("selected-methodology-disabled"), + render: ({ title }) => ( + + + {title} + + ), + }); + return; + } if (!isSelected) { handleCardSelect({ disabled, diff --git a/app/src/components/ChatBot/chat-bot.tsx b/app/src/components/ChatBot/chat-bot.tsx index edad32d9f..122669233 100644 --- a/app/src/components/ChatBot/chat-bot.tsx +++ b/app/src/components/ChatBot/chat-bot.tsx @@ -38,6 +38,8 @@ interface Message { text: string; } +const SUGGESTION_KEYS = ["gpc", "collect-data", "ipcc"]; + function useEnterSubmit(): { formRef: RefObject; onKeyDown: (event: React.KeyboardEvent) => void; @@ -421,21 +423,12 @@ export default function ChatBot({ const userStyles = "rounded-br-none"; const botStyles = "rounded-bl-none"; - const suggestions = [ - { - preview: "What is GPC?", - message: "What is the GHG Protocol for Cities?", - }, - { - preview: "How can I collect data?", - message: "How can I add new data sources to CityCatalyst?", - }, - { - preview: "What is IPCC?", - message: "What is the Intergovernmental Panel on Climate Change?", - }, - ]; - + const suggestions = SUGGESTION_KEYS.map((name) => { + return { + preview: t(`chat-suggestion-${name}`), + message: t(`chat-suggestion-${name}-message`), + }; + }); ///////////////////// // Utility Helpers // ///////////////////// diff --git a/app/src/components/InventorySelect.tsx b/app/src/components/InventorySelect.tsx index 3a49a5c4b..515c6ef57 100644 --- a/app/src/components/InventorySelect.tsx +++ b/app/src/components/InventorySelect.tsx @@ -1,5 +1,5 @@ import { api, useGetCitiesAndYearsQuery } from "@/services/api"; -import type { CitiesAndYearsResponse } from "@/util/types"; +import type { CityAndYearsResponse } from "@/util/types"; import { Center, Icon, @@ -30,7 +30,7 @@ export const InventorySelect = ({ const { data: citiesAndYears, isLoading } = useGetCitiesAndYearsQuery(); const [setUserInfo] = api.useSetUserInfoMutation(); - const onSelect = async ({ city, years }: CitiesAndYearsResponse) => { + const onSelect = async ({ city, years }: CityAndYearsResponse) => { // get the latest inventory for the city let targetInventory = years[0]; await setUserInfo({ @@ -53,7 +53,8 @@ export const InventorySelect = ({ )} {citiesAndYears?.map(({ city, years }) => { const isCurrent = years.some( - (y) => y.inventoryId === currentInventoryId, + (y: { inventoryId: string }) => + y.inventoryId === currentInventoryId, ); return ( = ({ @@ -37,6 +41,7 @@ const DeleteInventoryModal: FC = ({ onClose, userData, lng, + inventoryId, t, }) => { const { @@ -44,7 +49,67 @@ const DeleteInventoryModal: FC = ({ register, formState: { errors, isSubmitting }, setValue, + reset, } = useForm<{ password: string }>(); + const [requestPasswordConfirm] = api.useRequestVerificationMutation(); + const { data: token } = api.useGetVerifcationTokenQuery({ + skip: !userData, + }); + const [deleteInventory] = api.useDeleteInventoryMutation(); + const [isPasswordCorrect, setIsPasswordCorrect] = useState(true); + const toast = useToast(); + + const onSubmit: SubmitHandler<{ password: string }> = async (data) => { + await requestPasswordConfirm({ + password: data.password!, + token: token?.verificationToken!, + }).then(async (res: any) => { + if (res.data?.comparePassword) { + await deleteInventory({ + inventoryId, + }).then((res: any) => { + reset(); + onClose(); + setIsPasswordCorrect(true); + toast({ + description: t("inventory-deleted"), + status: "success", + duration: 5000, + isClosable: true, + render: () => ( + + + + + + {t("inventory-deleted")} + + + + ), + }); + }); + } else { + setIsPasswordCorrect(false); + } + }); + }; return ( <> @@ -107,7 +172,7 @@ const DeleteInventoryModal: FC = ({ -
+ = ({ error={errors.password} register={register} t={t} - name="Password" + name={t("password")} /> = ({ gap="6px" > - - {t("enter-password-info")} - + {isPasswordCorrect ? ( + + {t("enter-password-info")} + + ) : ( + + {t("incorrect-password")} + + )} - +
@@ -174,7 +252,8 @@ const DeleteInventoryModal: FC = ({ textTransform="uppercase" fontWeight="semibold" fontSize="button.md" - type="button" + type="submit" + onClick={handleSubmit(onSubmit)} p={0} m={0} > diff --git a/app/src/components/SegmentedProgress.tsx b/app/src/components/SegmentedProgress.tsx index 5820676fb..72ad781f8 100644 --- a/app/src/components/SegmentedProgress.tsx +++ b/app/src/components/SegmentedProgress.tsx @@ -25,13 +25,7 @@ export type SegmentedProgressValues = export function SegmentedProgress({ values, - colors = [ - "interactive.connected", - "interactive.tertiary", - "interactive.secondary", - "sentiment.negativeDefault", - "interactive.control", - ], + colors = ["interactive.connected", "interactive.tertiary"], max = 1, height = 4, showLabels = false, diff --git a/app/src/components/Tabs/Activity/external-data-section.tsx b/app/src/components/Tabs/Activity/external-data-section.tsx index 605d09185..c5fa605f7 100644 --- a/app/src/components/Tabs/Activity/external-data-section.tsx +++ b/app/src/components/Tabs/Activity/external-data-section.tsx @@ -66,7 +66,7 @@ const ExternalDataSection = ({ ) => { await disconnectThirdPartyData({ inventoryId: inventoryValue.inventoryId, - subCategoryId: inventoryValue.subCategoryId, + datasourceId: inventoryValue.datasourceId, }); toast({ status: "error", diff --git a/app/src/components/Tabs/my-inventories-tab.tsx b/app/src/components/Tabs/my-inventories-tab.tsx index 95ef22458..19072e995 100644 --- a/app/src/components/Tabs/my-inventories-tab.tsx +++ b/app/src/components/Tabs/my-inventories-tab.tsx @@ -65,6 +65,8 @@ const MyInventoriesTab: FC = ({ }) => { const [tabIndex, setTabIndex] = useState(0); const [cityId, setCityId] = useState(defaultCityId); + const [inventoryId, setInventoryId] = useState(""); + useEffect(() => { if (!cityId && defaultCityId && defaultCityId !== cityId) { setCityId(defaultCityId); @@ -327,6 +329,9 @@ const MyInventoriesTab: FC = ({ color: "white", }} onClick={() => { + setInventoryId( + inventory.inventoryId, + ); onInventoryDeleteModalOpen(); }} > @@ -363,6 +368,7 @@ const MyInventoriesTab: FC = ({ - {t(error.message)} + {t(error.message || "")} )} diff --git a/app/src/components/recent-searches.tsx b/app/src/components/recent-searches.tsx index 8054659aa..48770739c 100644 --- a/app/src/components/recent-searches.tsx +++ b/app/src/components/recent-searches.tsx @@ -1,7 +1,8 @@ import { Box, Text } from "@chakra-ui/react"; +import { TFunction } from "i18next"; import React from "react"; -const RecentSearches = () => { +const RecentSearches = ({ t }: { t: TFunction }) => { const data = [ { id: 1, @@ -31,7 +32,7 @@ const RecentSearches = () => { fontSize="overline" fontFamily="heading" > - RECENT SEARCHES + {t("recent-searches-title")} {hasRecentSearches ? ( @@ -78,7 +79,7 @@ const RecentSearches = () => { lineHeight="24" letterSpacing="wide" > - You have no recent searches + {t("recent-searches-no-results")} )} diff --git a/app/src/components/steps/add-inventory-details-step.tsx b/app/src/components/steps/add-inventory-details-step.tsx index fe0636fb9..14b36baa4 100644 --- a/app/src/components/steps/add-inventory-details-step.tsx +++ b/app/src/components/steps/add-inventory-details-step.tsx @@ -220,9 +220,6 @@ export default function SetInventoryDetailsStep({ - {/* TODO: - only enable basic by default and disable basic+ until we have the feature - */} { + return ( + !isNaN(Number(value)) || t("population-must-be-number") + ); + }, }} + type="number" placeholder={t("country-population-placeholder")} size="lg" shadow="1dp" @@ -239,6 +245,7 @@ export default function SetPopulationDataStep({ px={0} {...register("countryPopulationYear", { required: t("inventory-year-required"), + valueAsNumber: true, })} > {years.map((year: number, i: number) => ( @@ -293,7 +300,13 @@ export default function SetPopulationDataStep({ control={control} rules={{ required: t("population-required"), + validate: (value) => { + return ( + !isNaN(Number(value)) || t("population-must-be-number") + ); + }, }} + type="number" placeholder={t("region-or-province-population-placeholder")} size="lg" shadow="1dp" @@ -327,6 +340,7 @@ export default function SetPopulationDataStep({ px={0} {...register("regionPopulationYear", { required: t("inventory-year-required"), + valueAsNumber: true, })} > {years.map((year: number, i: number) => ( @@ -376,9 +390,15 @@ export default function SetPopulationDataStep({ control={control} rules={{ required: t("population-required"), + validate: (value) => { + return ( + !isNaN(Number(value)) || t("population-must-be-number") + ); + }, }} placeholder={t("city-population-placeholder")} size="lg" + type="number" shadow="1dp" w="400px" fontSize="body.lg" @@ -410,6 +430,7 @@ export default function SetPopulationDataStep({ px={0} {...register("cityPopulationYear", { required: t("inventory-year-required"), + valueAsNumber: true, })} > {years.map((year: number, i: number) => ( diff --git a/app/src/components/steps/select-city-steps.tsx b/app/src/components/steps/select-city-steps.tsx index e3924f681..7d71bea56 100644 --- a/app/src/components/steps/select-city-steps.tsx +++ b/app/src/components/steps/select-city-steps.tsx @@ -27,6 +27,7 @@ import { InputGroup, InputLeftElement, InputRightElement, + Link, Text, useOutsideClick, } from "@chakra-ui/react"; @@ -37,11 +38,11 @@ import { WarningIcon, } from "@chakra-ui/icons"; import RecentSearches from "@/components/recent-searches"; -import Link from "next/link"; import Image from "next/image"; import dynamic from "next/dynamic"; import { NoResultsIcon } from "../icons"; import { useSearchParams } from "next/navigation"; +import { Trans } from "react-i18next"; const CityMap = dynamic(() => import("@/components/CityMap"), { ssr: false }); @@ -319,7 +320,7 @@ export default function SelectCityStep({ shadow="2dp" className="h-auto max-h-[272px] transition-all duration-150 overflow-scroll flex flex-col py-3 gap-3 rounded-lg w-full absolute bg-white z-50 mt-2 border border-[1px solid #E6E7FF]" > - {!isLoading && !cityInputQuery && } + {!isLoading && !cityInputQuery && } {isLoading &&

Fetching Cities...

} {isSuccess && cities && @@ -423,18 +424,17 @@ export default function SelectCityStep({ fontWeight="normal" letterSpacing="wide" > - In case the geographical boundary is not the right one{" "} - - + Contact Us - - + +
@@ -468,7 +468,7 @@ export default function SelectCityStep({ fontFamily="heading" textAlign="center" > - Search and select the city to be shown on the map + {t("unselected-city-boundary-heading")} - You will be able to check the geographical boundary for your - inventory + {t("unselected-city-boundary-description")} diff --git a/app/src/i18n/locales/de/chat.json b/app/src/i18n/locales/de/chat.json index 6f409a423..426ffbec0 100644 --- a/app/src/i18n/locales/de/chat.json +++ b/app/src/i18n/locales/de/chat.json @@ -6,5 +6,12 @@ "initial-message": "Hallo! Ich bin Clima, Ihr virtueller Assistent. Ich bin hier, um Sie durch den Prozess der genauen Messung und Berichterstattung der Treibhausgasemissionen Ihrer Stadt zu führen. Wie kann ich Ihnen heute helfen?", "regenerate": "Regenerieren", "send-message": "Nachricht senden", - "stop-generation": "Generierung stoppen" + "stop-generation": "Generierung stoppen", + + "chat-suggestion-gpc": "Was ist GPC?", + "chat-suggestion-gpc-message": "Was ist das Treibhausgas-Protokoll für Städte (GPC)?", + "chat-suggestion-collect-data": "Wie kann ich Daten sammeln?", + "chat-suggestion-collect-data-message": "Wie kann ich neue Datenquellen zu CityCatalyst hinzufügen?", + "chat-suggestion-ipcc": "Was ist der IPCC?", + "chat-suggestion-ipcc-message": "Was ist der Zwischenstaatliche Ausschuss für Klimaänderungen (IPCC)?" } diff --git a/app/src/i18n/locales/de/dashboard.json b/app/src/i18n/locales/de/dashboard.json index bff4042d6..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", @@ -130,5 +135,18 @@ "add-new-inventory": "Neues Inventar hinzufügen", "table-view": "Tabelle", "chart-view": "Diagramm", - "last-update": "Letztes Update" -} + "last-update": "Letztes Update", + "something-went-wrong": "Etwas ist schief gelaufen", + "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/de/data.json b/app/src/i18n/locales/de/data.json index 7bc1f57f6..c0568525e 100644 --- a/app/src/i18n/locales/de/data.json +++ b/app/src/i18n/locales/de/data.json @@ -309,7 +309,6 @@ "file-deletion-error-description": "Etwas ist während der Dateilöschung schief gelaufen", "file-deletion-success": "Datei erfolgreich gelöscht", "coming-soon": "Bald Verfügbar", - "intlNumber": "{{val, Nummer}}", "fuel-combustion-residential-buildings-direct-measure-methodology": "Direkte Messung", "energy-consumption-residential-buildings-direct-measure-methodology": "Direkte Messung", "fuel-consumption-commercial-buildings-direct-measure-methodology": "Direkte Messung", @@ -1091,5 +1090,25 @@ "activity-name-direct-N2O-from-managed-soils": "Direktes N2O aus bewirtschafteten Böden", "activity-name-indirect-N2O-from-managed-soils": "Indirektes N2O aus bewirtschafteten Böden", "activity-name-rice-cultivations": "Reisanbau", - "activity-name-harvested-wood-products": "Geerntete Holzprodukte" + "activity-name-harvested-wood-products": "Geerntete Holzprodukte", + "residential-buildings-distribution-losses-direct-measure-methodology-description": "Direkte Messung oder Überwachung von Emissionen bei Verteilungsverlusten", + "commercial-buildings-distribution-losses-direct-measure-methodology-description": "Direkte Messung oder Überwachung von Emissionen bei Verteilungsverlusten in Gewerbegebäuden", + "manufacturing-industries-distribution-losses-direct-measure-methodology-description": "Direkte Messung oder Überwachung von Emissionen bei Verteilungsverlusten in der verarbeitenden Industrie", + "energy-industries-distribution-losses-direct-measure-methodology-description": "Direkte Messung oder Überwachung von Emissionen bei Verteilungsverlusten in der Energieindustrie", + "agriculture-distribution-losses-direct-measure-methodology-description": "Direkte Messung oder Überwachung von Emissionen bei Verteilungsverlusten in der Landwirtschaft, Forstwirtschaft und Fischerei", + "non-specified-sources-distribution-losses-direct-measure-methodology-description": "Direkte Messung oder Überwachung von Emissionen bei Verteilungsverlusten für nicht spezifizierte Quellen", + "water-transportation-outside-direct-measure-methodology-description": "Direkte Messung von Emissionen aus dem Wassertransport, der außerhalb der Stadt beginnt oder endet", + "aviation-outside-direct-measure-methodology-description": "Direkte Messung von Emissionen aus der Luftfahrt, die außerhalb der Stadt beginnt oder endet", + "railway-outside-direct-measure-methodology-description": "Direkte Messung von Emissionen aus dem Schienenverkehr, der außerhalb der Stadt beginnt oder endet", + "on-road-transportation-outside-direct-measure-methodology-description": "Direkte Messung von Emissionen aus dem Straßenverkehr, der außerhalb der Stadt beginnt oder endet", + "emissions-from-industrial-processes-occurring-within-the-city-boundary-description": "Umfasst alle Emissionen aus industriellen Prozessen wie chemischen, metallurgischen und mineralischen Umwandlungen, die innerhalb der Stadtgrenzen stattfinden.", + "emissions-from-product-use-occurring-within-the-city-boundary-description": "Umfasst alle Emissionen, die durch die Verwendung von Produkten wie Farben, Lösungsmitteln und Klebstoffen innerhalb der Stadtgrenzen entstehen.", + "emissions-from-livestock-within-the-city-boundary-description": "Umfasst alle Emissionen, die mit Vieh innerhalb der Stadtgrenzen verbunden sind, einschließlich Quellen wie enterische Fermentation und Güllemanagement.", + "emissions-from-land-within-the-city-boundary-description": "Umfasst alle Emissionen aus Böden und anderen landbezogenen Quellen innerhalb der Stadtgrenzen, wie Boden-Kohlenstoff-Veränderungen und Biomasseverbrennung.", + "emissions-from-aggregate-sources-and-non-co-2-emission-sources-on-land-within-the-city-boundary-description": "Umfasst alle Emissionen aus aggregierten Quellen wie Kalkung und Harnstoffanwendung sowie nicht-CO2-Gase, die durch Landnutzungsaktivitäten innerhalb der Stadtgrenzen emittiert werden.", + "livestock-direct-measure-methodology-description": "Direkte Messung der Emissionen von Vieh", + "land-direct-measure-methodology-description": "Direkte Messung der Emissionen vom Land", + "aggregate-sources-and-non-co2-emission-sources-direct-measure-methodology-description": "Direkte Messung der Emissionen aus aggregierten Quellen und Nicht-CO2-Emissionen", + "emissions-from-aggregate-sources-and-non-co2-emission-sources-on-land-within-the-city-boundary-description": "Umfasst alle Emissionen aus aggregierten Quellen wie Kalkung und Harnstoffanwendung sowie nicht-CO2-Gase, die durch Landnutzungsaktivitäten innerhalb der Stadtgrenzen emittiert werden.", + "selected-methodology-disabled": "Die ausgewählte Methodik ist deaktiviert" } diff --git a/app/src/i18n/locales/de/onboarding.json b/app/src/i18n/locales/de/onboarding.json index 6b7f2bff8..5f94b27ad 100644 --- a/app/src/i18n/locales/de/onboarding.json +++ b/app/src/i18n/locales/de/onboarding.json @@ -65,5 +65,10 @@ "done-description": "Tolle Arbeit! Sie können Ihre Inventardaten jederzeit überprüfen, hinzufügen und aktualisieren. Beginnen Sie jetzt mit der Vervollständigung Ihres Treibhausgas-Emissionsinventars!", "create-ghg-inventory": "Erstellen Sie Ihr GHG-Inventar", "inventory-creation-description": "In diesem Schritt konfigurieren Sie das GHG-Emissionsinventar Ihrer Stadt, indem Sie das Inventarjahr auswählen, das Ziel festlegen und kontextbezogene Daten wie die Bevölkerung hinzufügen.", - "start-inventory": "Startinventar" + "start-inventory": "Startinventar", + "city-boundary-info": "Falls die geografische Grenze nicht korrekt ist <0><1>Kontaktieren Sie uns", + "unselected-city-boundary-heading": "Suchen und wählen Sie die Stadt aus, die auf der Karte angezeigt werden soll", + "unselected-city-boundary-description": "Sie können die geografische Grenze für Ihren Bestand überprüfen", + "recent-searches-title": "Letzte Suchen", + "recent-searches-no-results": "Sie haben keine kürzlichen Suchen" } diff --git a/app/src/i18n/locales/de/settings.json b/app/src/i18n/locales/de/settings.json index bfe8adaac..04e4fae6a 100644 --- a/app/src/i18n/locales/de/settings.json +++ b/app/src/i18n/locales/de/settings.json @@ -58,6 +58,7 @@ "email-address": "E-Mail", "remove-user-prompt": "Sind Sie sicher, dass Sie diesen Benutzer <2>dauerhaft entfernen möchten aus Ihrem Team?", "city-deleted": "Stadt erfolgreich gelöscht", + "inventory-deleted": "Inventar erfolgreich gelöscht", "delete-file-prompt": "Sind Sie sicher, dass Sie diese Datei <2>dauerhaft löschen möchten aus dem Repository der Stadt?", "mark-as-completed": "Als abgeschlossen markieren", "password-required": "Password ist ein Pflichtfeld", diff --git a/app/src/i18n/locales/en/chat.json b/app/src/i18n/locales/en/chat.json index a7bc891ed..0bcf827bc 100644 --- a/app/src/i18n/locales/en/chat.json +++ b/app/src/i18n/locales/en/chat.json @@ -6,5 +6,12 @@ "initial-message": "Hello! I'm Clima, your virtual assistant. I'm here to guide you through the process of accurately measuring and reporting your city's greenhouse gas emissions. How can I assist you today?", "regenerate": "Regenerate", "send-message": "Send Message", - "stop-generation": "Stop Generation" + "stop-generation": "Stop Generation", + + "chat-suggestion-gpc": "What is GPC?", + "chat-suggestion-gpc-message": "What is the GHG Protocol for Cities (GPC)?", + "chat-suggestion-collect-data": "How can I collect data?", + "chat-suggestion-collect-data-message": "How can I add new data sources to CityCatalyst?", + "chat-suggestion-ipcc": "What is IPCC?", + "chat-suggestion-ipcc-message": "What is the Intergovernmental Panel on Climate Change?" } diff --git a/app/src/i18n/locales/en/dashboard.json b/app/src/i18n/locales/en/dashboard.json index 890459f6b..0c59a3996 100644 --- a/app/src/i18n/locales/en/dashboard.json +++ b/app/src/i18n/locales/en/dashboard.json @@ -35,7 +35,8 @@ "completed": "completed", "uploaded-data": "Uploaded data", "connect-third-party-data": " Connected third-party data", - "stationary-energy": "Stationary Energy", + "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", @@ -130,5 +135,18 @@ "add-new-inventory": "Add new inventory", "table-view": "Table", "chart-view": "Chart", - "last-update": "Last update" + "last-update": "Last update", + "something-went-wrong": "Something went wrong", + "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/en/data.json b/app/src/i18n/locales/en/data.json index a5b095c53..5655bcfd2 100644 --- a/app/src/i18n/locales/en/data.json +++ b/app/src/i18n/locales/en/data.json @@ -277,7 +277,6 @@ "file-deletion-error-description": "Something went wrong during file deletion", "file-deletion-success": "file deleted successfully", "coming-soon": "Coming Soon", - "intlNumber": "{{val, number}}", "fuel-combustion-residential-buildings-direct-measure-methodology": "Direct Measure", "energy-consumption-residential-buildings-direct-measure-methodology": "Direct Measure", "fuel-consumption-commercial-buildings-direct-measure-methodology": "Direct Measure", @@ -465,7 +464,7 @@ "biological-treatment-inboundary-methodology-description": "Emissions from composting and anaerobic digestion of organic waste (waste treated in the city).", "biological-treatment-outboundary-methodology-description": "Emissions from composting and anaerobic digestion of organic waste (waste treated outside the city).", "direct-measure-incineration-waste-inboundary-methodology-description": "Emissions from controlled, industrial process of incineration or estimates them from uncontrolled open burning activities (waste treated in the city).", - "incineration-waste-outboundary-methodology-description": "Emissions from controlled, industrial process of incineration or estimates them from uncontrolled open burning activities (waste treated in the city). ", + "incineration-waste-outboundary-methodology-description": "Emissions from controlled, industrial process of incineration or estimates them from uncontrolled open burning activities (waste treated in the city).", "total-grid-consumption": "Total fuel consumed amount", "sample-grid-supplied-energy": "Sample grid-supplied energy", "total-fugitive-activity": "Total activity volume", @@ -900,6 +899,7 @@ "industry-type-vegetable-oils": "Vegetable oils", "industry-type-vegetables-fruits-juices": "Vegetables, fruits and juices", "industry-type-wine-and-vinegar": "Wine and vinegar", + "industry-type-other-industries": "Other industries", "units-kilograms-COD": "kg COD/year", "wastewater-inside-industrial-calculator-methane-recovered": "Methane recovered", "wastewater-inside-industrial-calculator-wastewater-generated": "Wastewater generated (Optional)", @@ -1090,5 +1090,28 @@ "wastewater-calculator": "Calculator of emissions", "livestock-direct-measure-methodology": "Direct Measure", "land-direct-measure-methodology": "Direct Measure", - "aggregate-sources-and-non-co2-emission-sources-direct-measure-methodology": "Direct Measure" + "aggregate-sources-and-non-co2-emission-sources-direct-measure-methodology": "Direct Measure", + "gpc_basic": "GPC Basic", + "gpc_basic_plus": "GPC Basic+", + "explanation-institutional-missing": "Institutional emissions are included in commercial emissions record", + "residential-buildings-distribution-losses-direct-measure-methodology-description": "Direct metering or monitoring of emissions in distribution losses", + "commercial-buildings-distribution-losses-direct-measure-methodology-description": "Direct metering or monitoring of emissions in distribution losses for commercial buildings", + "manufacturing-industries-distribution-losses-direct-measure-methodology-description": "Direct metering or monitoring of emissions in distribution losses for manufacturing industries", + "energy-industries-distribution-losses-direct-measure-methodology-description": "Direct metering or monitoring of emissions in distribution losses for energy industries", + "agriculture-distribution-losses-direct-measure-methodology-description": "Direct metering or monitoring of emissions in distribution losses for agriculture, forestry and fishing activities", + "non-specified-sources-distribution-losses-direct-measure-methodology-description": "Direct metering or monitoring of emissions in distribution losses for non specified sources", + "water-transportation-outside-direct-measure-methodology-description": "Direct measurement of emissions from water transportation that begins or finishes outside the city", + "aviation-outside-direct-measure-methodology-description": "Direct measurement of emissions from aviation that begins or finishes outside the city", + "railway-outside-direct-measure-methodology-description": "Direct measurement of emissions from railway that begins or finishes outside the city", + "on-road-transportation-outside-direct-measure-methodology-description": "Direct measurement of emissions from on road transportation that begins or finishes outside the city", + "emissions-from-industrial-processes-occurring-within-the-city-boundary-description": "Includes all emissions arising from industrial processes such as chemical, metallurgical, and mineral transformation that occur within the city's boundaries.", + "emissions-from-product-use-occurring-within-the-city-boundary-description": "Includes all emissions resulting from the use of products such as paints, solvents, and adhesives that occur within the city's boundaries.", + "emissions-from-livestock-within-the-city-boundary-description": "Includes all emissions associated with livestock located within the city's boundaries, encompassing sources such as enteric fermentation and manure management.", + "emissions-from-land-within-the-city-boundary-description": "Includes all emissions from soil and other land-related sources within the city's boundaries, such as soil carbon changes and biomass burning.", + "emissions-from-aggregate-sources-and-non-co-2-emission-sources-on-land-within-the-city-boundary-description": "Includes all emissions from aggregate sources such as liming and urea application, as well as non-CO2 gases emitted from land use activities within the city's boundaries.", + "livestock-direct-measure-methodology-description": "Direct measurement of emissions from livestock", + "land-direct-measure-methodology-description": "Direct measurement of emissions from land", + "aggregate-sources-and-non-co2-emission-sources-direct-measure-methodology-description": "Direct measurement of emissions from aggregate sources and non co2 emissions", + "emissions-from-aggregate-sources-and-non-co2-emission-sources-on-land-within-the-city-boundary-description": "Includes all emissions from aggregate sources such as liming and urea application, as well as non-CO2 gases emitted from land use activities within the city's boundaries.", + "selected-methodology-disabled": "This methodology is disabled" } diff --git a/app/src/i18n/locales/en/onboarding.json b/app/src/i18n/locales/en/onboarding.json index 34fa5136e..0caddb993 100644 --- a/app/src/i18n/locales/en/onboarding.json +++ b/app/src/i18n/locales/en/onboarding.json @@ -65,5 +65,10 @@ "create-inventory": "Create Inventory", "create-ghg-inventory": "Create your GHG Inventory", "inventory-creation-description": "In this step, configure your city's GHG emissions inventory by selecting the inventory year, setting the target, and adding contextual data such as population.", - "start-inventory": "Start Inventory" + "start-inventory": "Start Inventory", + "city-boundary-info": "In case the geographical boundary is not the right one <0><1>Contact Us", + "unselected-city-boundary-heading": "Search and select the city to be shown on the map", + "unselected-city-boundary-description": "You will be able to check the geographical boundary for your inventory", + "recent-searches-title": "Recent Searches", + "recent-searches-no-results": "You have no recent searches" } diff --git a/app/src/i18n/locales/en/settings.json b/app/src/i18n/locales/en/settings.json index ba79f02f5..4c1a0b1ae 100644 --- a/app/src/i18n/locales/en/settings.json +++ b/app/src/i18n/locales/en/settings.json @@ -58,6 +58,7 @@ "email-address": "Email", "remove-user-prompt": " Are you sure you want to <2> permanently remove this user from your team?", "city-deleted": "City deleted successfully", + "inventory-deleted": "Inventory deleted successfully", "delete-file-prompt": "Are you sure you want to <2> permanently delete this file from the city's repository?", "mark-as-completed": "Mark as completed", "password-required": "Password is required", diff --git a/app/src/i18n/locales/es/chat.json b/app/src/i18n/locales/es/chat.json index 92d514c50..dc96ae7d7 100644 --- a/app/src/i18n/locales/es/chat.json +++ b/app/src/i18n/locales/es/chat.json @@ -6,5 +6,12 @@ "initial-message": "Hola! Soy Clima, tu asistente virtual. Estoy aquí para guiarte a través del proceso de medición y reporte de las emisiones GEI de tu ciudad. ¿Cómo te puedo ayudar hoy?", "regenerate": "Regenerar", "send-message": "Enviar mensaje", - "stop-generation": "Detener generación" + "stop-generation": "Detener generación", + + "chat-suggestion-gpc": "¿Qué es el GPC?", + "chat-suggestion-gpc-message": "¿Qué es el Protocolo de Gases de Efecto Invernadero para Ciudades (GPC)?", + "chat-suggestion-collect-data": "¿Cómo puedo recolectar datos?", + "chat-suggestion-collect-data-message": "¿Cómo puedo agregar nuevas fuentes de datos a CityCatalyst?", + "chat-suggestion-ipcc": "¿Qué es el IPCC?", + "chat-suggestion-ipcc-message": "¿Qué es el Panel Intergubernamental sobre Cambio Climático (IPCC)?" } diff --git a/app/src/i18n/locales/es/dashboard.json b/app/src/i18n/locales/es/dashboard.json index 1dfa2742e..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", @@ -133,5 +138,18 @@ "add-new-inventory": "Agregar nuevo inventario", "table-view": "Tabla", "chart-view": "Gráfico", - "last-update": "Última actualización" + "last-update": "Última actualización", + "something-went-wrong": "Algo salió mal", + "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/es/data.json b/app/src/i18n/locales/es/data.json index 7c2228c12..77aeb9ccb 100644 --- a/app/src/i18n/locales/es/data.json +++ b/app/src/i18n/locales/es/data.json @@ -80,7 +80,7 @@ "refrigiration-chp": "Refrigeración CHP", "scope-required-for-basic": "Alcance Requerido para Inventario GHGI Básico:", "scope-required-for-basic-+": "Alcance Requerido para Inventario GHGI Básico+:", - "stationary-energy": "Energía Estacionaria", + "stationary-energy": "Energía estacionaria", "stationary-energy-details": "Este sector trata las emisiones que resultan de la generación de electricidad, calor y vapor, así como de su consumo.", "transportation": "Transporte", "transportation-details": "Este sector trata las emisiones del transporte de bienes y personas dentro del límite de la ciudad.", @@ -271,7 +271,6 @@ "file-deletion-error-description": "Algo salió mal durante la eliminación del archivo", "file-deletion-success": "archivo eliminado con éxito", "coming-soon": "Próximamente", - "intlNumber": "{{val, number}}", "fuel-combustion-residential-buildings-direct-measure-methodology": "Medida Directa", "energy-consumption-residential-buildings-direct-measure-methodology": "Medida Directa", "fuel-consumption-commercial-buildings-direct-measure-methodology": "Medida Directa", @@ -790,7 +789,6 @@ "product-use-direct-measure-methodology": "Medición directa", "product-use-direct-measure-methodology-description": "Medición directa de las emisiones del uso de productos.", "custom-emission-factor-reference": "Factor de Emisión Personalizado", - "custom-emission-factor-name": "Factor de emisión personalizado", "percentage-emissions": "% de emisiones", "based-on-previous-year": "Basado en el año anterior", "stationary-energy-description": "Este sector se ocupa de las emisiones que resultan de la generación de electricidad, calor y vapor, así como de su consumo.", @@ -1075,5 +1073,29 @@ "activity-name-direct-N2O-from-managed-soils": "N2O directo de suelos gestionados", "activity-name-indirect-N2O-from-managed-soils": "N2O indirecto de suelos gestionados", "activity-name-rice-cultivations": "Cultivos de arroz", - "activity-name-harvested-wood-products": "Productos de madera cosechados" + "activity-name-harvested-wood-products": "Productos de madera cosechados", + "custom-emission-factor-name": "Factor de emisión personalizado", + "gpc_basic": "GPC Básico", + "gpc_basic_plus": "GPC Básico +", + "explanation-institutional-missing": "Las emisiones institucionales están incluidas en el registro de emisiones comerciales.", + "residential-buildings-distribution-losses-direct-measure-methodology-description": "Medición o monitoreo directo de emisiones en pérdidas de distribución", + "commercial-buildings-distribution-losses-direct-measure-methodology-description": "Medición o monitoreo directo de emisiones en pérdidas de distribución para edificios comerciales", + "manufacturing-industries-distribution-losses-direct-measure-methodology-description": "Medición o monitoreo directo de emisiones en pérdidas de distribución para industrias manufactureras", + "energy-industries-distribution-losses-direct-measure-methodology-description": "Medición o monitoreo directo de emisiones en pérdidas de distribución para industrias energéticas", + "agriculture-distribution-losses-direct-measure-methodology-description": "Medición o monitoreo directo de emisiones en pérdidas de distribución para actividades agrícolas, forestales y pesqueras", + "non-specified-sources-distribution-losses-direct-measure-methodology-description": "Medición o monitoreo directo de emisiones en pérdidas de distribución para fuentes no especificadas", + "water-transportation-outside-direct-measure-methodology-description": "Medición directa de emisiones del transporte acuático que comienza o termina fuera de la ciudad", + "aviation-outside-direct-measure-methodology-description": "Medición directa de emisiones de la aviación que comienza o termina fuera de la ciudad", + "railway-outside-direct-measure-methodology-description": "Medición directa de emisiones del ferrocarril que comienza o termina fuera de la ciudad", + "on-road-transportation-outside-direct-measure-methodology-description": "Medición directa de emisiones del transporte por carretera que comienza o termina fuera de la ciudad", + "emissions-from-industrial-processes-occurring-within-the-city-boundary-description": "Incluye todas las emisiones derivadas de procesos industriales como la transformación química, metalúrgica y mineral que ocurren dentro de los límites de la ciudad.", + "emissions-from-product-use-occurring-within-the-city-boundary-description": "Incluye todas las emisiones resultantes del uso de productos como pinturas, solventes y adhesivos que ocurren dentro de los límites de la ciudad.", + "emissions-from-livestock-within-the-city-boundary-description": "Incluye todas las emisiones asociadas con el ganado ubicado dentro de los límites de la ciudad, abarcando fuentes como la fermentación entérica y la gestión del estiércol.", + "emissions-from-land-within-the-city-boundary-description": "Incluye todas las emisiones del suelo y otras fuentes relacionadas con la tierra dentro de los límites de la ciudad, como cambios en el carbono del suelo y quema de biomasa.", + "emissions-from-aggregate-sources-and-non-co-2-emission-sources-on-land-within-the-city-boundary-description": "Incluye todas las emisiones de fuentes agregadas como encalado y aplicación de urea, as�� como gases no CO2 emitidos por actividades de uso de la tierra dentro de los límites de la ciudad.", + "livestock-direct-measure-methodology-description": "Medición directa de emisiones del ganado", + "land-direct-measure-methodology-description": "Medición directa de emisiones de la tierra", + "aggregate-sources-and-non-co2-emission-sources-direct-measure-methodology-description": "Medición directa de emisiones de fuentes agregadas y emisiones no CO2", + "emissions-from-aggregate-sources-and-non-co2-emission-sources-on-land-within-the-city-boundary-description": "Incluye todas las emisiones de fuentes agregadas como encalado y aplicación de urea, así como gases no CO2 emitidos por actividades de uso de la tierra dentro de los límites de la ciudad.", + "selected-methodology-disabled": "La metodología seleccionada está deshabilitada" } diff --git a/app/src/i18n/locales/es/onboarding.json b/app/src/i18n/locales/es/onboarding.json index 1d36a3948..f64e222f1 100644 --- a/app/src/i18n/locales/es/onboarding.json +++ b/app/src/i18n/locales/es/onboarding.json @@ -66,5 +66,10 @@ "create-inventory": "Crea Inventario", "create-ghg-inventory": "Crea tu Inventario de GEI", "inventory-creation-description": "En este paso, configure el inventario de emisiones de GEI de su ciudad seleccionando el año del inventario, estableciendo el objetivo y agregando datos contextuales como la población.", - "start-inventory": "Inicio de Inventario" + "start-inventory": "Inicio de Inventario", + "city-boundary-info": "En caso de que el límite geográfico no sea el correcto <0><1>Contáctenos", + "unselected-city-boundary-heading": "Busque y seleccione la ciudad que se mostrará en el mapa", + "unselected-city-boundary-description": "Podrá verificar el límite geográfico de su inventario", + "recent-searches-title": "Búsquedas recientes", + "recent-searches-no-results": "No tienes búsquedas recientes" } diff --git a/app/src/i18n/locales/es/settings.json b/app/src/i18n/locales/es/settings.json index 5f127f5d5..c6ed81338 100644 --- a/app/src/i18n/locales/es/settings.json +++ b/app/src/i18n/locales/es/settings.json @@ -58,6 +58,7 @@ "email-address": "Correo electrónico", "remove-user-prompt": "¿Está seguro de que desea <2>eliminar permanentemente a este usuario de su equipo?", "city-deleted": "Ciudad eliminada exitosamente", + "inventory-deleted": "Inventorio eliminado exitosamente", "delete-file-prompt": "¿Está seguro de que desea <2>eliminar permanentemente este archivo del repositorio de la ciudad?", "password-required": "Se requiere contraseña", "min-length": "La longitud mínima debe ser {{length}}", diff --git a/app/src/i18n/locales/pt/chat.json b/app/src/i18n/locales/pt/chat.json index e87d60e57..ef1d93c28 100644 --- a/app/src/i18n/locales/pt/chat.json +++ b/app/src/i18n/locales/pt/chat.json @@ -6,5 +6,12 @@ "initial-message": "Olá! Sou Clima, sua assistente virtual. Estou aqui para guiá-lo no processo de medir e reportar com precisão as emissões de gases de efeito estufa da sua cidade. Como posso ajudá-lo hoje?", "regenerate": "Regenerar", "send-message": "Enviar mensagem", - "stop-generation": "Parar geração" + "stop-generation": "Parar geração", + + "chat-suggestion-gpc": "O que é o GPC?", + "chat-suggestion-gpc-message": "O que é o Protocolo de Gases de Efeito Estufa para Cidades (GPC)?", + "chat-suggestion-collect-data": "Como posso coletar dados?", + "chat-suggestion-collect-data-message": "Como posso adicionar novas fontes de dados ao CityCatalyst?", + "chat-suggestion-ipcc": "O que é o IPCC?", + "chat-suggestion-ipcc-message": "O que é o Painel Intergovernamental sobre Mudanças Climáticas (IPCC)?" } diff --git a/app/src/i18n/locales/pt/dashboard.json b/app/src/i18n/locales/pt/dashboard.json index a5cf8e333..07387754d 100644 --- a/app/src/i18n/locales/pt/dashboard.json +++ b/app/src/i18n/locales/pt/dashboard.json @@ -34,7 +34,8 @@ "completed": "Concluído", "uploaded-data": "Dados enviados", "connect-third-party-data": "Dados de terceiros conectados", - "stationary-energy": "Energia Estacionária", + "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", @@ -131,5 +136,18 @@ "add-new-inventory": "Adicionar novo inventário", "table-view": "Tabela", "chart-view": "Gráfico", - "last-update": "Última atualização" + "last-update": "Última atualização", + "something-went-wrong": "Algo deu errado", + "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 b8404efe6..a9351a81d 100644 --- a/app/src/i18n/locales/pt/data.json +++ b/app/src/i18n/locales/pt/data.json @@ -79,9 +79,9 @@ "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+:", - "stationary-energy": "Energia Estacionária", + "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", "transportation-description": "Este setor trata das emissões do transporte de bens e pessoas dentro dos limites da cidade.", @@ -273,7 +273,6 @@ "file-deletion-error-description": "Algo deu errado durante a exclusão do arquivo", "file-deletion-success": "Arquivo Excluído com Sucesso", "coming-soon": "Em Breve", - "intlNumber": "{{val, number}}", "fuel-combustion-residential-buildings-direct-measure-methodology": "Medição Direta", "energy-consumption-residential-buildings-direct-measure-methodology": "Medição Direta", "fuel-consumption-commercial-buildings-direct-measure-methodology": "Medição Direta", @@ -931,7 +930,6 @@ "product-use-direct-measure-methodology": "Medição direta", "product-use-direct-measure-methodology-description": "Medição direta das emissões do uso de produtos.", "custom-emission-factor-reference": "Fator de Emissão Personalizado", - "custom-emission-factor-name": "Fator de emissão personalizado", "percentage-emissions": "% das emissões", "based-on-previous-year": "Com base no ano anterior", "value-too-low": "O valor deve ser maior ou igual a {{min}}", @@ -1067,5 +1065,29 @@ "activity-name-direct-N2O-from-managed-soils": "N2O direto de solos gerenciados", "activity-name-indirect-N2O-from-managed-soils": "N2O indireto de solos gerenciados", "activity-name-rice-cultivations": "Cultivos de arroz", - "activity-name-harvested-wood-products": "Produtos de madeira colhidos" + "activity-name-harvested-wood-products": "Produtos de madeira colhidos", + "custom-emission-factor-name": "Fator de emissão personalizado", + "gpc_basic": "GPC Básico", + "gpc_basic_plus": "GPC Básico +", + "explanation-institutional-missing": "As emissões institucionais estão incluídas no registro de emissões comerciais.", + "residential-buildings-distribution-losses-direct-measure-methodology-description": "Medição ou monitoramento direto de emissões em perdas de distribuição", + "commercial-buildings-distribution-losses-direct-measure-methodology-description": "Medição ou monitoramento direto de emissões em perdas de distribuição para edif��cios comerciais", + "manufacturing-industries-distribution-losses-direct-measure-methodology-description": "Medição ou monitoramento direto de emissões em perdas de distribuição para indústrias de manufatura", + "energy-industries-distribution-losses-direct-measure-methodology-description": "Medição ou monitoramento direto de emissões em perdas de distribuição para indústrias de energia", + "agriculture-distribution-losses-direct-measure-methodology-description": "Medição ou monitoramento direto de emissões em perdas de distribuição para atividades agrícolas, florestais e pesqueiras", + "non-specified-sources-distribution-losses-direct-measure-methodology-description": "Medição ou monitoramento direto de emissões em perdas de distribuição para fontes não especificadas", + "water-transportation-outside-direct-measure-methodology-description": "Medição direta de emissões do transporte aquático que começa ou termina fora da cidade", + "aviation-outside-direct-measure-methodology-description": "Medição direta de emissões da aviação que começa ou termina fora da cidade", + "railway-outside-direct-measure-methodology-description": "Medição direta de emissões do transporte ferroviário que começa ou termina fora da cidade", + "on-road-transportation-outside-direct-measure-methodology-description": "Medição direta de emissões do transporte rodoviário que começa ou termina fora da cidade", + "emissions-from-industrial-processes-occurring-within-the-city-boundary-description": "Inclui todas as emissões decorrentes de processos industriais como transformação química, metalúrgica e mineral que ocorrem dentro dos limites da cidade.", + "emissions-from-product-use-occurring-within-the-city-boundary-description": "Inclui todas as emissões resultantes do uso de produtos como tintas, solventes e adesivos que ocorrem dentro dos limites da cidade.", + "emissions-from-livestock-within-the-city-boundary-description": "Inclui todas as emissões associadas ao gado localizado dentro dos limites da cidade, abrangendo fontes como fermentação entérica e manejo de esterco.", + "emissions-from-land-within-the-city-boundary-description": "Inclui todas as emissões do solo e outras fontes relacionadas à terra dentro dos limites da cidade, como mudanças no carbono do solo e queima de biomassa.", + "emissions-from-aggregate-sources-and-non-co-2-emission-sources-on-land-within-the-city-boundary-description": "Inclui todas as emissões de fontes agregadas como calagem e aplicação de ureia, bem como gases não CO2 emitidos por atividades de uso da terra dentro dos limites da cidade.", + "livestock-direct-measure-methodology-description": "Medição direta de emissões do gado", + "land-direct-measure-methodology-description": "Medição direta de emissões da terra", + "aggregate-sources-and-non-co2-emission-sources-direct-measure-methodology-description": "Medição direta de emissões de fontes agregadas e emissões não CO2", + "emissions-from-aggregate-sources-and-non-co2-emission-sources-on-land-within-the-city-boundary-description": "Inclui todas as emissões de fontes agregadas, como calagem e aplicação de ureia, bem como gases não CO2 emitidos por atividades de uso da terra dentro dos limites da cidade.", + "selected-methodology-disabled": "Esta metodologia foi desativada" } diff --git a/app/src/i18n/locales/pt/onboarding.json b/app/src/i18n/locales/pt/onboarding.json index 9467e2ebf..9750bf445 100644 --- a/app/src/i18n/locales/pt/onboarding.json +++ b/app/src/i18n/locales/pt/onboarding.json @@ -65,5 +65,10 @@ "create-inventory": "Criar Inventário", "create-ghg-inventory": "Crie o seu Inventário de GEE", "inventory-creation-description": "Nesta etapa, configure o inventário de emissões de GEE da sua cidade, selecionando o ano do inventário, definindo o objetivo e adicionando dados contextuais, como a população.", - "start-inventory": "Iniciar Inventário" + "start-inventory": "Iniciar Inventário", + "city-boundary-info": "Caso a fronteira geográfica não seja a correta <0><1>Contate-nos", + "unselected-city-boundary-heading": "Pesquise e selecione a cidade a ser exibida no mapa", + "unselected-city-boundary-description": "Você poderá verificar a fronteira geográfica do seu inventário", + "recent-searches-title": "Pesquisas Recentes", + "recent-searches-no-results": "Você não tem pesquisas recentes" } diff --git a/app/src/i18n/locales/pt/settings.json b/app/src/i18n/locales/pt/settings.json index 7973635c6..89dbb94ac 100644 --- a/app/src/i18n/locales/pt/settings.json +++ b/app/src/i18n/locales/pt/settings.json @@ -58,6 +58,7 @@ "email-address": "E-mail", "remove-user-prompt": "Tem certeza de que deseja <2>remover permanentemente este usuário da sua equipe?", "city-deleted": "Cidade excluída com sucesso", + "inventory-deleted": "Inventário excluído com sucesso", "delete-file-prompt": "Tem certeza de que deseja <2>excluir permanentemente este arquivo do repositório da cidade?", "mark-as-completed": "Marcar como concluído", "password-required": "A senha é obrigatória", diff --git a/app/src/lib/app-theme.ts b/app/src/lib/app-theme.ts index f890dcd94..29613b674 100644 --- a/app/src/lib/app-theme.ts +++ b/app/src/lib/app-theme.ts @@ -1,5 +1,13 @@ import { extendTheme, theme } from "@chakra-ui/react"; +export enum SectorColors { + I = "#575BF4", + II = "#DF2222", + III = "#F28C37", + IV = "#2D0D58", + V = "#CC6B1D", +} + export const appTheme = extendTheme({ colors: { brand: { @@ -15,6 +23,14 @@ export const appTheme = extendTheme({ alternative: "#001EA7", }, + sectors: { + I: SectorColors.I, + II: SectorColors.II, + III: SectorColors.III, + IV: SectorColors.IV, + V: SectorColors.V, + }, + semantic: { success: "#24BE00", successOverlay: "#EFFDE5", 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 5a6f512b2..8b5352684 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -7,11 +7,13 @@ import { } from "@/models/init-models"; import type { BoundingBox } from "@/util/geojson"; import { - CitiesAndYearsResponse, + CityAndYearsResponse, ConnectDataSourceQuery, ConnectDataSourceResponse, + EmissionsForecastData, EmissionsFactorResponse, GetDataSourcesResult, + InventoryDeleteQuery, InventoryProgressResponse, InventoryResponse, InventoryUpdateQuery, @@ -48,619 +50,693 @@ export const api = createApi({ "SectorBreakdown", "Inventory", "CitiesAndInventories", + "Inventories", ], baseQuery: fetchBaseQuery({ baseUrl: "/api/v0/", credentials: "include" }), - endpoints: (builder) => ({ - getCitiesAndYears: builder.query({ - query: () => "user/cities", - transformResponse: (response: { data: CitiesAndYearsResponse[] }) => - response.data.map(({ city, years }) => ({ - city, - years: years.sort((a, b) => b.year - a.year), - })), - 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: ["InventoryProgress", "InventoryValue"], - }), - connectDataSource: builder.mutation< - ConnectDataSourceResponse, - ConnectDataSourceQuery - >({ - query: (data) => ({ - url: `/datasource/${data.inventoryId}`, - method: "POST", - body: { dataSourceIds: data.dataSourceIds }, - }), - transformResponse: (response: { data: ConnectDataSourceResponse }) => - response.data, - invalidatesTags: ["InventoryProgress"], - }), - 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: ["InventoryProgress", "InventoryValue"], - }), - 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, }), - 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, subCategoryId }) => ({ - method: "DELETE", - url: `inventory/${inventoryId}/value/${subCategoryId}`, - }), - invalidatesTags: ["InventoryValue", "InventoryProgress"], - 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: [ - "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: [ - "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: [ - "ActivityValue", - "InventoryValue", - "InventoryProgress", - "ReportResults", - "SectorBreakdown", - "YearlyReportResults", - ], - }), - 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: [ - "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({ @@ -694,6 +770,7 @@ export const GLOBAL_API_URL = // hooks are automatically generated export const { useGetCityQuery, + useGetCityYearsQuery, useGetCitiesAndYearsQuery, useGetYearOverYearResultsQuery, useAddCityMutation, @@ -725,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..5af1b4354 100644 --- a/app/src/util/constants.ts +++ b/app/src/util/constants.ts @@ -3,6 +3,7 @@ import { PiPlant, PiTrashLight } from "react-icons/pi"; import { TbBuildingCommunity } from "react-icons/tb"; import { IconBaseProps } from "react-icons"; import { LiaIndustrySolid } from "react-icons/lia"; +import { appTheme, SectorColors } from "@/lib/app-theme"; // Import the appTheme export const maxPopulationYearDifference = 5; @@ -28,6 +29,7 @@ export interface ISector { scopes: number[]; }; }; + color: string; } export const getSectorsForInventory = (inventoryType?: InventoryType) => { @@ -39,12 +41,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`, @@ -61,6 +67,7 @@ export const SECTORS: ISector[] = [ name: "stationary-energy", description: "stationary-energy-description", icon: TbBuildingCommunity, + color: SectorColors.I, inventoryTypes: { [InventoryTypeEnum.GPC_BASIC]: { scopes: [1, 2] }, [InventoryTypeEnum.GPC_BASIC_PLUS]: { scopes: [1, 2, 3] }, @@ -73,6 +80,7 @@ export const SECTORS: ISector[] = [ name: "transportation", description: "transportation-description", icon: BsTruck, + color: SectorColors.II, inventoryTypes: { [InventoryTypeEnum.GPC_BASIC]: { scopes: [1, 2] }, [InventoryTypeEnum.GPC_BASIC_PLUS]: { scopes: [1, 2, 3] }, @@ -85,6 +93,7 @@ export const SECTORS: ISector[] = [ name: "waste", description: "waste-description", icon: PiTrashLight, + color: SectorColors.III, inventoryTypes: { [InventoryTypeEnum.GPC_BASIC]: { scopes: [1, 3] }, [InventoryTypeEnum.GPC_BASIC_PLUS]: { scopes: [1, 3] }, @@ -97,6 +106,7 @@ export const SECTORS: ISector[] = [ name: "ippu", description: "ippu-description", icon: LiaIndustrySolid, + color: SectorColors.IV, testId: "ippu-sector-card", inventoryTypes: { [InventoryTypeEnum.GPC_BASIC]: { scopes: [] }, @@ -109,6 +119,7 @@ export const SECTORS: ISector[] = [ name: "afolu", description: "afolu-description", icon: PiPlant, + color: SectorColors.V, testId: "afolu-sector-card", inventoryTypes: { [InventoryTypeEnum.GPC_BASIC]: { scopes: [] }, @@ -116,3 +127,12 @@ export const SECTORS: ISector[] = [ }, }, ]; + +export const allSectorColors = SECTORS.map((sector) => { + return sector.color; +}); +export const getSectorByName = (name: string) => + SECTORS.find((s) => s.name === name); + +export const getReferenceNumberByName = (name: keyof ISector) => + findBy("name", name)?.referenceNumber; diff --git a/app/src/util/helpers.ts b/app/src/util/helpers.ts index a02edfd55..e0992e276 100644 --- a/app/src/util/helpers.ts +++ b/app/src/util/helpers.ts @@ -344,3 +344,29 @@ export const convertSectorReferenceNumberToNumber = ( return 1; } }; + +const compareGpcRefNumbers = (a: string, b: string) => { + const aSplit = a.split("."); + const bSplit = b.split("."); + + const aSector = convertSectorReferenceNumberToNumber(aSplit[0]).toString(); + aSplit[0] = aSector.toString(); + const bSector = convertSectorReferenceNumberToNumber(bSplit[0]).toString(); + bSplit[0] = bSector.toString(); + + for (let i = 0; i < Math.min(aSplit.length, bSplit.length); i++) { + if (aSplit[i] !== bSplit[i]) { + return parseInt(aSplit[i]) - parseInt(bSplit[i]); + } + } + + return 0; +}; + +export const sortGpcReferenceNumbers = (refNumbers: string[]): string[] => { + return [...refNumbers].sort(compareGpcRefNumbers); +}; + +export const isEmptyObject = (obj: Record) => { + return Object.keys(obj).length === 0 && obj.constructor === Object; +}; diff --git a/app/src/util/types.ts b/app/src/util/types.ts index 8adffaf5f..0d46452da 100644 --- a/app/src/util/types.ts +++ b/app/src/util/types.ts @@ -7,7 +7,10 @@ import type { ScopeAttributes } from "@/models/Scope"; import type { SectorAttributes } from "@/models/Sector"; import type { SubCategoryAttributes } from "@/models/SubCategory"; import { DataSourceI18nAttributes as DataSourceAttributes } from "@/models/DataSourceI18n"; -import type { InventoryValueAttributes } from "@/models/InventoryValue"; +import { + InventoryValue, + InventoryValueAttributes, +} from "@/models/InventoryValue"; import type { SubSectorAttributes } from "@/models/SubSector"; import type { InventoryAttributes } from "@/models/Inventory"; import type { CityAttributes } from "@/models/City"; @@ -18,11 +21,17 @@ import { FailedSourceResult, RemovedSourceResult, } from "@/backend/DataSourceService"; -import { InventoryType } from "./constants"; +import { ActivityValue } from "@/models/ActivityValue"; -export interface CitiesAndYearsResponse { +export interface CityAndYearsResponse { city: CityAttributes; - years: { year: number; inventoryId: string; lastUpdate: Date }[]; + years: CityYearData[]; +} + +export interface CityYearData { + year: number; + inventoryId: string; + lastUpdate: Date; } interface RequiredInventoryAttributes extends Required {} @@ -110,6 +119,10 @@ export interface InventoryValueInSubSectorDeleteQuery { inventoryId: string; } +export interface InventoryDeleteQuery { + inventoryId: string; +} + export interface InventoryUpdateQuery { inventoryId: string; data: { isPublic: boolean }; @@ -199,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; @@ -256,3 +286,12 @@ export type SectorBreakdownResponse = BreakdownByActivity & { byActivity: BreakdownByActivity; byScope: ActivityDataByScope[]; }; + +export type InventoryValueWithActivityValues = InventoryValue & { + activityValues: ActivityValue[]; +}; + +export type InventoryWithInventoryValuesAndActivityValues = + InventoryResponse & { + inventoryValues: InventoryValueWithActivityValues[]; + }; diff --git a/app/templates/ecrf_template.xlsx b/app/templates/ecrf_template.xlsx index 8a8986346..f4f67ea32 100644 Binary files a/app/templates/ecrf_template.xlsx and b/app/templates/ecrf_template.xlsx differ 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 d1f3938a6..10977e749 100644 --- a/app/tests/api/datasource.jest.ts +++ b/app/tests/api/datasource.jest.ts @@ -1,19 +1,27 @@ import { GET as getDataSourcesForSector } from "@/app/api/v0/datasource/[inventoryId]/[sectorId]/route"; import { GET as getAllDataSources } from "@/app/api/v0/datasource/[inventoryId]/route"; +import { DELETE as deleteInventoryValue } from "@/app/api/v0/datasource/[inventoryId]/datasource/[datasourceId]/route"; import { db } from "@/models"; import { randomUUID } from "node:crypto"; import { literal, Op } from "sequelize"; -import { cascadeDeleteDataSource, mockRequest, setupTests } from "../helpers"; +import { + cascadeDeleteDataSource, + expectStatusCode, + mockRequest, + setupTests, + testUserID, +} from "../helpers"; import { City } from "@/models/City"; import { CreateInventoryRequest } from "@/util/validation"; import { Sector } from "@/models/Sector"; import { Inventory } from "@/models/Inventory"; import fetchMock from "fetch-mock"; -import { beforeAll, afterAll, describe, it, expect } from "@jest/globals"; +import { afterAll, beforeAll, describe, expect, it, jest } from "@jest/globals"; import { GlobalWarmingPotentialTypeEnum, InventoryTypeEnum, } from "@/util/enums"; +import { AppSession, Auth } from "@/lib/auth"; const locode = "XX_DATASOURCE_CITY"; const sectorName = "XX_DATASOURCE_TEST_1"; @@ -34,6 +42,11 @@ const sourceLocations = [ "DE_BLN,US_NY,XX_DATASOURCE_CITY", ]; +const mockSession: AppSession = { + user: { id: testUserID, role: "user" }, + expires: "1h", +}; + const apiEndpoint = "http://localhost:4000/api/v0/climatetrace/city/:locode/:year/:gpcReferenceNumber"; @@ -59,9 +72,12 @@ describe("DataSource API", () => { let city: City; let inventory: Inventory; let sector: Sector; + let prevGetServerSession = Auth.getServerSession; beforeAll(async () => { setupTests(); + Auth.getServerSession = jest.fn(() => Promise.resolve(mockSession)); + await db.initialize(); await db.models.Inventory.destroy({ where: { year: inventoryData.year } }); @@ -74,7 +90,11 @@ describe("DataSource API", () => { locode, name: "CC_", }); - + await db.models.CityUser.create({ + cityUserId: randomUUID(), + userId: testUserID, + cityId: city.cityId, + }); await db.models.SubCategory.destroy({ where: { subcategoryName } }); await db.models.SubSector.destroy({ where: { subsectorName } }); await db.models.Sector.destroy({ where: { sectorName } }); @@ -126,6 +146,7 @@ describe("DataSource API", () => { }); afterAll(async () => { + Auth.getServerSession = prevGetServerSession; if (db.sequelize) await db.sequelize.close(); }); @@ -157,4 +178,48 @@ describe("DataSource API", () => { }); it.todo("should apply data sources"); + + it("should delete an inventory value", async () => { + const datasource = await db.models.DataSource.findOne({ + // @ts-ignore + where: { + url: { + [Op.ne]: null, + }, + }, + }); + + const { datasourceId } = datasource; + const inventoryValueId = randomUUID(); + await db.models.InventoryValue.create({ + id: inventoryValueId, + datasourceId, + inventoryId: inventory.inventoryId, + }); + const req = mockRequest(); + const res = await deleteInventoryValue(req, { + params: { + inventoryId: inventory.inventoryId, + datasourceId, + }, + }); + await expectStatusCode(res, 200); + const { deleted } = await res.json(); + expect(deleted).toEqual(true); + const deletedInventoryValue = await db.models.InventoryValue.findOne({ + where: { id: inventoryValueId }, + }); + expect(deletedInventoryValue).toBeNull(); + }); + + it("should not delete a non-existing inventory value", async () => { + const req = mockRequest(); + const res = await deleteInventoryValue(req, { + params: { + inventoryId: randomUUID(), + datasourceId: randomUUID(), + }, + }); + await expectStatusCode(res, 404); + }); }); 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/inventory_value.jest.ts b/app/tests/api/inventory_value.jest.ts index ef8841b33..49c1bb14f 100644 --- a/app/tests/api/inventory_value.jest.ts +++ b/app/tests/api/inventory_value.jest.ts @@ -1,5 +1,4 @@ import { - DELETE as deleteInventoryValue, GET as findInventoryValue, PATCH as upsertInventoryValue, } from "@/app/api/v0/inventory/[inventory]/value/[subcategory]/route"; @@ -282,31 +281,4 @@ describe("Inventory Value API", () => { } = await res.json(); expect(issues.length).toEqual(3); }); - - it("should delete an inventory value", async () => { - const req = mockRequest(inventoryValue2); - const res = await deleteInventoryValue(req, { - params: { - inventory: inventory.inventoryId, - subcategory: subCategory.subcategoryId, - }, - }); - await expectStatusCode(res, 200); - const { data, deleted } = await res.json(); - expect(deleted).toEqual(true); - expectToBeLooselyEqual(data.co2eq, co2eq); - expect(data.activityUnits).toEqual(activityUnits); - expectToBeLooselyEqual(data.activityValue, activityValue); - }); - - it("should not delete a non-existing inventory value", async () => { - const req = mockRequest(inventoryValue2); - const res = await deleteInventoryValue(req, { - params: { - inventory: randomUUID(), - subcategory: randomUUID(), - }, - }); - await expectStatusCode(res, 404); - }); }); 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, + }, + }, +}; diff --git a/global-api/main.py b/global-api/main.py index 3af0c54e4..9cf15f539 100644 --- a/global-api/main.py +++ b/global-api/main.py @@ -19,6 +19,7 @@ from routes.citywide_emission_endpoint import api_router as citywide_route from routes.ghgi_emissions import api_router as actor_emissions_route from routes.ccra_assessment import api_router as ccra_assessment +from routes.ghgi_emission_forecast import api_router as emission_forecast """ Logger instance initialized and configured @@ -119,6 +120,11 @@ def read_root(): tags=["GHGI Emissions"], ) +app.include_router( + emission_forecast, + tags=["GHGI Emissions"], +) + app.include_router( crosswalk_city_locode_route, tags=["GHGI Emissions"], diff --git a/global-api/requirements.txt b/global-api/requirements.txt index e018a6927..834f95a67 100644 --- a/global-api/requirements.txt +++ b/global-api/requirements.txt @@ -5,20 +5,20 @@ fastapi==0.115.6 flake8==7.1.1 fsspec==2024.* geopandas>=0.12,<1.1 -mypy==1.13.0 -osmnx==2.0.0 +mypy==1.14.1 +osmnx==2.0.1 pandas==2.2.3 psycopg2-binary==2.9.10 pydantic-settings==2.* pytest==8.3.4 rioxarray==0.18.* -scipy==1.14.* +scipy==1.15.* shapely==2.0.6 SQLAlchemy==2.0.36 tqdm==4.67.* -uvicorn==0.32.1 +uvicorn==0.34.0 xarray==2024.* -geojson==3.1.* +geojson==3.2.* openclimate==0.1.* nest_asyncio==1.6.* xlrd==2.0.* diff --git a/global-api/routes/ghgi_emission_forecast.py b/global-api/routes/ghgi_emission_forecast.py new file mode 100644 index 000000000..761fe4b33 --- /dev/null +++ b/global-api/routes/ghgi_emission_forecast.py @@ -0,0 +1,196 @@ +from fastapi import APIRouter, HTTPException +from sqlalchemy import text +from db.database import SessionLocal + +api_router = APIRouter(prefix="/api/v0") + +def db_city_emission_forecast(actor_id, forecast_year, spatial_granularity): + with SessionLocal() as session: + # This is a hard coded based on the cluster 3 results + # TO DO: once the data has been loaded into the database we need to update the query + query = text( + """ + SELECT locode,cluster_id,cluster_name,cluster_description,gpc_sector,forecast_year,future_year,growth_rate + FROM ( + SELECT 'BR SER' as locode, + 3 as cluster_id, + '{"en": "Medium-sized cities with IPPU GHG emissions industries", + "es": "Ciudades de tamaño medio con industrias de emisiones de GHG IPPU", + "pt": "Cidades de médio porte com indústrias de emissões de GHG IPPU"}'::json AS cluster_name, + '{"en": "24 municipalities, 79% of which are small (<150k pop); high participation of services and industry in the GDP; GDP with high emissions intensity; high level of emissions per capita; high participation of industry in emissions. Medium-sized municipalities with specialization in emission-intensive industrial sectors.", + "es": "24 municipios, 79% de los cuales son pequeños (<150k pop); alta participación de los servicios y la industria en el PIB; PIB con alta intensidad de emisiones; alto nivel de emisiones per cápita; alta participación de la industria en las emisiones. Municipios de tamaño medio con especialización en sectores industriales intensivos en emisiones.", + "pt": "24 municípios, 79% dos quais são pequenos (<150k pop); alta participação de serviços e indústria no PIB; PIB com alta intensidade de emissões; alto nível de emissões per capita; alta participação da indústria nas emissões. Municípios de médio porte com especialização em setores industriais intensivos em emissões." + }'::json cluster_description, + TRIM(unnest(STRING_TO_ARRAY(gpc_sector,','))) AS gpc_sector, + 2023 as forecast_year, + year as future_year, + value/100 as growth_rate + FROM ( + SELECT 'III' AS gpc_sector, 2024 AS year, 0.670 AS value UNION ALL + SELECT 'III', 2025, 0.440 UNION ALL + SELECT 'III', 2026, 0.460 UNION ALL + SELECT 'III', 2027, 0.430 UNION ALL + SELECT 'III', 2028, 0.430 UNION ALL + SELECT 'III', 2029, 0.430 UNION ALL + SELECT 'III', 2030, 0.430 UNION ALL + SELECT 'III', 2031, 0.430 UNION ALL + SELECT 'III', 2032, 0.430 UNION ALL + SELECT 'III', 2033, 0.430 UNION ALL + SELECT 'III', 2034, 0.430 UNION ALL + SELECT 'III', 2035, 0.430 UNION ALL + SELECT 'III', 2036, 0.430 UNION ALL + SELECT 'III', 2037, 0.430 UNION ALL + SELECT 'III', 2038, 0.430 UNION ALL + SELECT 'III', 2039, 0.430 UNION ALL + SELECT 'III', 2040, 0.430 UNION ALL + SELECT 'III', 2041, 0.430 UNION ALL + SELECT 'III', 2042, 0.430 UNION ALL + SELECT 'III', 2043, 0.430 UNION ALL + SELECT 'III', 2044, 0.430 UNION ALL + SELECT 'III', 2045, 0.430 UNION ALL + SELECT 'III', 2046, 0.430 UNION ALL + SELECT 'III', 2047, 0.430 UNION ALL + SELECT 'III', 2048, 0.430 UNION ALL + SELECT 'III', 2049, 0.430 UNION ALL + SELECT 'III', 2050, 0.430 UNION ALL + SELECT 'I, II', 2024, 2.500 UNION ALL + SELECT 'I, II', 2025, 1.630 UNION ALL + SELECT 'I, II', 2026, 1.710 UNION ALL + SELECT 'I, II', 2027, 1.590 UNION ALL + SELECT 'I, II', 2028, 1.590 UNION ALL + SELECT 'I, II', 2029, 1.590 UNION ALL + SELECT 'I, II', 2030, 1.590 UNION ALL + SELECT 'I, II', 2031, 1.590 UNION ALL + SELECT 'I, II', 2032, 1.590 UNION ALL + SELECT 'I, II', 2033, 1.590 UNION ALL + SELECT 'I, II', 2034, 1.590 UNION ALL + SELECT 'I, II', 2035, 1.590 UNION ALL + SELECT 'I, II', 2036, 1.590 UNION ALL + SELECT 'I, II', 2037, 1.590 UNION ALL + SELECT 'I, II', 2038, 1.590 UNION ALL + SELECT 'I, II', 2039, 1.590 UNION ALL + SELECT 'I, II', 2040, 1.590 UNION ALL + SELECT 'I, II', 2041, 1.590 UNION ALL + SELECT 'I, II', 2042, 1.590 UNION ALL + SELECT 'I, II', 2043, 1.590 UNION ALL + SELECT 'I, II', 2044, 1.590 UNION ALL + SELECT 'I, II', 2045, 1.590 UNION ALL + SELECT 'I, II', 2046, 1.590 UNION ALL + SELECT 'I, II', 2047, 1.590 UNION ALL + SELECT 'I, II', 2048, 1.590 UNION ALL + SELECT 'I, II', 2049, 1.590 UNION ALL + SELECT 'I, II', 2050, 1.590 UNION all + SELECT 'V', 2024, 1.230 UNION ALL + SELECT 'V', 2025, 0.800 UNION ALL + SELECT 'V', 2026, 0.840 UNION ALL + SELECT 'V', 2027, 0.780 UNION ALL + SELECT 'V', 2028, 0.780 UNION ALL + SELECT 'V', 2029, 0.780 UNION ALL + SELECT 'V', 2030, 0.780 UNION ALL + SELECT 'V', 2031, 0.780 UNION ALL + SELECT 'V', 2032, 0.780 UNION ALL + SELECT 'V', 2033, 0.780 UNION ALL + SELECT 'V', 2034, 0.780 UNION ALL + SELECT 'V', 2035, 0.780 UNION ALL + SELECT 'V', 2036, 0.780 UNION ALL + SELECT 'V', 2037, 0.780 UNION ALL + SELECT 'V', 2038, 0.780 UNION ALL + SELECT 'V', 2039, 0.780 UNION ALL + SELECT 'V', 2040, 0.780 UNION ALL + SELECT 'V', 2041, 0.780 UNION ALL + SELECT 'V', 2042, 0.780 UNION ALL + SELECT 'V', 2043, 0.780 UNION ALL + SELECT 'V', 2044, 0.780 UNION ALL + SELECT 'V', 2045, 0.780 UNION ALL + SELECT 'V', 2046, 0.780 UNION ALL + SELECT 'V', 2047, 0.780 UNION ALL + SELECT 'V', 2048, 0.780 UNION ALL + SELECT 'V', 2049, 0.780 UNION ALL + SELECT 'V', 2050, 0.780 UNION ALL + SELECT 'IV', 2024, 1.960 UNION ALL + SELECT 'IV', 2025, 1.270 UNION ALL + SELECT 'IV', 2026, 1.330 UNION ALL + SELECT 'IV', 2027, 1.240 UNION ALL + SELECT 'IV', 2028, 1.240 UNION ALL + SELECT 'IV', 2029, 1.240 UNION ALL + SELECT 'IV', 2030, 1.240 UNION ALL + SELECT 'IV', 2031, 1.240 UNION ALL + SELECT 'IV', 2032, 1.240 UNION ALL + SELECT 'IV', 2033, 1.240 UNION ALL + SELECT 'IV', 2034, 1.240 UNION ALL + SELECT 'IV', 2035, 1.240 UNION ALL + SELECT 'IV', 2036, 1.240 UNION ALL + SELECT 'IV', 2037, 1.240 UNION ALL + SELECT 'IV', 2038, 1.240 UNION ALL + SELECT 'IV', 2039, 1.240 UNION ALL + SELECT 'IV', 2040, 1.240 UNION ALL + SELECT 'IV', 2041, 1.240 UNION ALL + SELECT 'IV', 2042, 1.240 UNION ALL + SELECT 'IV', 2043, 1.240 UNION ALL + SELECT 'IV', 2044, 1.240 UNION ALL + SELECT 'IV', 2045, 1.240 UNION ALL + SELECT 'IV', 2046, 1.240 UNION ALL + SELECT 'IV', 2047, 1.240 UNION ALL + SELECT 'IV', 2048, 1.240 UNION ALL + SELECT 'IV', 2049, 1.240 UNION ALL + SELECT 'IV', 2050, 1.240) t ) t + WHERE locode = :actor_id + AND forecast_year = :forecast_year + AND 'city' = :spatial_granularity + """ + ) + + params = { + "actor_id": actor_id, + "forecast_year": forecast_year, + "spatial_granularity":spatial_granularity + } + result = session.execute(query, params).mappings().all() + + return result + + + +@api_router.get("/ghgi/emissions_forecast/{spatial_granularity}/{actor_id}/{forecast_year}", summary="Get no action emission projection") +def get_city_risk_assessment( + actor_id: str, # This is the city locode + spatial_granularity:str, # This can be city, country, region + forecast_year: str, + ): + """ + Retrieve ccra risk assessment based on specified parameters. + + - `actor_id`: Unique identifier for the entity contributing to or associated with emissions. + - `forecast_year`: This is based the base year the forecasting was completed eg. 2023. + + Returns a structured response containing risk assessment data for the specified city and scenario. + """ + + records = db_city_emission_forecast(actor_id, forecast_year, spatial_granularity) + + if not records: + raise HTTPException(status_code=404, detail="No data available") + + # Transform database records into the desired format for the response + response = { + "cluster": { + "id": records[0]["cluster_id"], + "name": records[0]["cluster_name"], + "description": records[0]["cluster_description"] + }, + "growth_rates": {} + } + + # Fill the growth_rates dictionary based on records + for record in records: + future_year = record["future_year"] + gpc_sector = record["gpc_sector"] + growth_rate = record["growth_rate"] + + if future_year not in response["growth_rates"]: + response["growth_rates"][future_year] = {} + + # Add the growth rate under the relevant sector + response["growth_rates"][future_year][gpc_sector] = growth_rate + + return response