From b50f587335bc68d20d982a5bfb64fa288a9e2fff Mon Sep 17 00:00:00 2001 From: Tero Tikkanen Date: Thu, 28 Nov 2024 23:55:08 +0200 Subject: [PATCH] Refactor category card and split paths node content --- components/paths/ActionNodeSummary.tsx | 97 ++++++ components/paths/CategoryCard.tsx | 321 ++---------------- components/paths/InventoryNodeSummary.tsx | 238 +++++++++++++ .../contentblocks/CategoryTypeListBlock.tsx | 2 +- 4 files changed, 362 insertions(+), 296 deletions(-) create mode 100644 components/paths/ActionNodeSummary.tsx create mode 100644 components/paths/InventoryNodeSummary.tsx diff --git a/components/paths/ActionNodeSummary.tsx b/components/paths/ActionNodeSummary.tsx new file mode 100644 index 00000000..52032d94 --- /dev/null +++ b/components/paths/ActionNodeSummary.tsx @@ -0,0 +1,97 @@ +import React, { useEffect } from 'react'; + +import { ActionNode } from 'common/__generated__/paths/graphql'; + +import ActionParameters from 'components/paths/ActionParameters'; +import { useFormatter, useTranslations } from 'next-intl'; + +import styled from 'styled-components'; + +import HighlightValue from '@/components/paths/HighlightValue'; +import { activeGoalVar, yearRangeVar } from '@/context/paths/cache'; +import PathsActionNode from '@/utils/paths/PathsActionNode'; +import { useReactiveVar } from '@apollo/client'; + +const CardContentBlock = styled.div<{ $disabled?: boolean }>` + margin: ${({ theme }) => `0 ${theme.spaces.s100} ${theme.spaces.s100}`}; + opacity: ${({ $disabled = false }) => ($disabled ? 0.5 : 1)}; +`; + +const Values = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px 10px; + align-items: stretch; + height: 100%; +`; + +const SubValue = styled.div` + flex: 45% 1 0; + + > div { + height: 100%; + } +`; + +const ParametersWrapper = styled.div` + display: flex; + flex-direction: column; + flex: 45% 1 0; + align-items: flex-end; + height: 100%; +`; + +type PathsActionNodeContentProps = { + categoryId: string; + node: ActionNode; + refetching: boolean; + onLoaded: (id: string, impact: number) => void; +}; + +const ActionNodeSummary = (props: PathsActionNodeContentProps) => { + const { categoryId, node, refetching = false, onLoaded } = props; + const t = useTranslations(); + const format = useFormatter(); + const yearRange = useReactiveVar(yearRangeVar); + const activeGoal = useReactiveVar(activeGoalVar); + const pathsAction = new PathsActionNode(node); + const impact = pathsAction.getYearlyImpact(yearRange[1]) || 0; + + const hideForecast = activeGoal?.hideForecast; + useEffect(() => { + onLoaded(categoryId, impact); + // Using exhaustive deps here causes an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [yearRange[1]]); + + if (hideForecast) return -; + return ( + + + + + + + + + + + ); +}; + +export default ActionNodeSummary; diff --git a/components/paths/CategoryCard.tsx b/components/paths/CategoryCard.tsx index a9021aac..d16d598a 100644 --- a/components/paths/CategoryCard.tsx +++ b/components/paths/CategoryCard.tsx @@ -1,30 +1,26 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Category, CategoryFragmentFragment, + AttributeRichText, } from 'common/__generated__/graphql'; -import { - ActionNode, - CausalGridNodeFragment, -} from 'common/__generated__/paths/graphql'; +import { InstanceType } from 'common/__generated__/paths/graphql'; import { Link } from 'common/links'; -import ActionParameters from 'components/paths/ActionParameters'; -import { useFormatter, useTranslations } from 'next-intl'; import { readableColor, transparentize } from 'polished'; import ContentLoader from 'react-content-loader'; import styled, { useTheme } from 'styled-components'; -import HighlightValue from '@/components/paths/HighlightValue'; -import { activeGoalVar, yearRangeVar } from '@/context/paths/cache'; +import { activeGoalVar } from '@/context/paths/cache'; import { GET_NODE_CONTENT } from '@/queries/paths/get-paths-node'; -import { DimensionalMetric, type SliceConfig } from '@/utils/paths/metric'; import { getHttpHeaders } from '@/utils/paths/paths.utils'; -import PathsActionNode from '@/utils/paths/PathsActionNode'; + import { NetworkStatus, useQuery, useReactiveVar } from '@apollo/client'; import IndicatorSparkline from './graphs/IndicatorSparkline'; +import InventoryNodeSummary from './InventoryNodeSummary'; +import ActionNodeSummary from './ActionNodeSummary'; const GroupIdentifierHeader = styled.div<{ $color?: string | null | undefined; @@ -74,30 +70,6 @@ const Identifier = styled.span` color: ${(props) => props.theme.textColor.tertiary}; `; -const Values = styled.div` - display: flex; - flex-wrap: wrap; - gap: 10px 10px; - align-items: stretch; - height: 100%; -`; - -const SubValue = styled.div` - flex: 45% 1 0; - - > div { - height: 100%; - } -`; - -const ParametersWrapper = styled.div` - display: flex; - flex-direction: column; - flex: 45% 1 0; - align-items: flex-end; - height: 100%; -`; - const CardGoalBlock = styled.div` margin: ${({ theme }) => `0 0 ${theme.spaces.s100}`}; line-height: ${(props) => props.theme.lineHeightMd}; @@ -134,265 +106,23 @@ const PathsContentLoader = (props) => { ); }; -const getTotalValues = (yearData) => { - const totals: number[] = []; - yearData.categoryTypes[1].options.forEach((colId, cIdx) => { - const pieSegmentValues: (number | null)[] = []; - yearData.categoryTypes[0].options.forEach((rowId, rIdx) => { - const datum = yearData.rows[rIdx][cIdx]; - if (datum != 0) { - pieSegmentValues.push(datum ? Math.abs(datum) : null); - } - }); - // Calculate total and percentages - const total = - pieSegmentValues.reduce((sum, value) => { - const numSum = sum === null ? 0 : sum; - const numValue = value === null ? 0 : value; - return numSum + numValue; - }, 0) || 0; - totals.push(total); - }); - return totals; -}; - -type PathsBasicNodeContentProps = { - categoryId: string; - node: CausalGridNodeFragment; - onLoaded: (id: string, impact: number) => void; -}; - -type EmissionDisplay = { - value: number | null; - label: string | null; - year: number | null; - change?: number | null; -}; -type Emissions = { - total: { latest: EmissionDisplay; reference: EmissionDisplay }; -}; - -const PathsBasicNodeContent = (props: PathsBasicNodeContentProps) => { - const { categoryId, node, onLoaded } = props; - const yearRange = useReactiveVar(yearRangeVar); - const activeGoal = useReactiveVar(activeGoalVar); - const format = useFormatter(); - //const [sliceConfig, setSliceConfig] = useState(null); - - const hideForecast = activeGoal?.hideForecast; - const [emissions, setEmissions] = useState({ - total: { - latest: { - value: null, - label: null, - year: null, - change: null, - }, - reference: { value: null, label: null, year: null, change: null }, - }, - }); - - const [unit, setUnit] = useState(null); - - useEffect(() => { - const nodeMetric = new DimensionalMetric(node.metricDim!); - const sliceConfig: SliceConfig = - nodeMetric.getDefaultSliceConfig(activeGoal); - - const historicalYears = nodeMetric.getHistoricalYears(); - const lastHistoricalYear = historicalYears[historicalYears.length - 1]; - - setUnit(nodeMetric.getUnit()); - const latestData = nodeMetric.getSingleYear( - lastHistoricalYear, - sliceConfig.categories - ); - const referenceData = nodeMetric.getSingleYear( - yearRange[1], - sliceConfig.categories - ); - - // Let's assume the first key is the one we want to display - //const displayCategoryType = Object.keys(sliceConfig.categories)[0]; - const displayCategoryType = - sliceConfig.categories[Object.keys(sliceConfig.categories)[0]]; - - const displayCategory = - displayCategoryType && displayCategoryType.groups?.length - ? { id: displayCategoryType?.groups[0], type: 'group' } - : { id: displayCategoryType?.categories[0], type: 'category' }; - - if (displayCategory.id) { - const latestLabel = latestData.allLabels.find( - (label) => label.id === displayCategory.id - )?.label; - const referenceLabel = referenceData.allLabels.find( - (label) => label.id === displayCategory.id - )?.label; - - const latestValue = getTotalValues(latestData)[0]; - const referenceValue = hideForecast - ? null - : getTotalValues(referenceData)[0]; - - setEmissions({ - total: { - latest: { - value: latestValue, - label: latestLabel || null, - year: lastHistoricalYear, - change: - lastHistoricalYear > yearRange[1] && - referenceValue && - referenceValue !== latestValue - ? (latestValue - referenceValue) / Math.abs(referenceValue) - : null, - }, - reference: { - value: referenceValue, - label: referenceLabel || null, - year: yearRange[1], - change: - lastHistoricalYear < yearRange[1] && - latestValue && - latestValue !== referenceValue - ? (referenceValue - latestValue) / Math.abs(latestValue) - : null, - }, - }, - }); - setUnit(nodeMetric.getUnit()); - onLoaded(categoryId, referenceValue); - } - // using exhausive deps here causes an infinite loop - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [yearRange[1]]); - - return ( - - - {emissions.total.latest.value ? ( - - 0 ? '+' : '' - }${format.number(emissions.total.latest.change * 100, { - style: 'unit', - unit: 'percent', - maximumSignificantDigits: 2, - })}` - : undefined - } - /> - - ) : null} - {emissions.total.reference.value ? ( - - 0 ? '+' : '' - }${format.number(emissions.total.reference.change * 100, { - style: 'unit', - unit: 'percent', - maximumSignificantDigits: 2, - })}` - : undefined - } - /> - - ) : null} - - - ); -}; - -type PathsActionNodeContentProps = { - categoryId: string; - node: ActionNode; - refetching: boolean; - onLoaded: (id: string, impact: number) => void; -}; - -const PathsActionNodeContent = (props: PathsActionNodeContentProps) => { - const { categoryId, node, refetching = false, onLoaded } = props; - const t = useTranslations(); - const format = useFormatter(); - const yearRange = useReactiveVar(yearRangeVar); - const activeGoal = useReactiveVar(activeGoalVar); - const pathsAction = new PathsActionNode(node); - const impact = pathsAction.getYearlyImpact(yearRange[1]) || 0; - - const hideForecast = activeGoal?.hideForecast; - useEffect(() => { - onLoaded(categoryId, impact); - // Using exhaustive deps here causes an infinite loop - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [yearRange[1]]); - - if (hideForecast) return -; - return ( - - - - - - - - - - - ); -}; - type PathsNodeContentProps = { categoryId: string; node: string; - paths: string; + pathsInstance: InstanceType; onLoaded: (id: string, impact: number) => void; }; const PathsNodeContent = React.memo((props: PathsNodeContentProps) => { - const { categoryId, node, paths, onLoaded } = props; + const { categoryId, node, pathsInstance, onLoaded } = props; + const pathsInstanceId = pathsInstance.id; const activeGoal = useReactiveVar(activeGoalVar); + const displayAllGoals = true; + const displayGoals = displayAllGoals + ? pathsInstance.goals + : activeGoal + ? [activeGoal] + : undefined; const { data, loading, error, networkStatus } = useQuery(GET_NODE_CONTENT, { fetchPolicy: 'no-cache', @@ -400,7 +130,7 @@ const PathsNodeContent = React.memo((props: PathsNodeContentProps) => { notifyOnNetworkStatusChange: true, context: { uri: '/api/graphql-paths', - headers: getHttpHeaders({ instanceIdentifier: paths }), + headers: getHttpHeaders({ instanceIdentifier: pathsInstanceId }), }, }); @@ -415,19 +145,20 @@ const PathsNodeContent = React.memo((props: PathsNodeContentProps) => { if (data) { if (data.node.__typename === 'ActionNode') { return ( - ); - } else if (data.node.__typename) { + } else if (data.node.__typename && displayGoals) { return ( - ); } @@ -440,16 +171,16 @@ PathsNodeContent.displayName = 'PathsNodeContent'; type CategoryCardProps = { category: Category; group?: CategoryFragmentFragment; - pathsInstance?: string; + pathsInstance?: InstanceType; onLoaded: (id: string, impact: number) => void; }; const CategoryCard = (props: CategoryCardProps) => { const { category, group, pathsInstance, onLoaded } = props; - const mainGoalAttribute = category.attributes?.find( + const mainGoalAttribute: AttributeRichText = category.attributes?.find( (attr) => attr.key === 'Hauptziel' - ); + ) as AttributeRichText; const mainGoalLabel = mainGoalAttribute?.key || 'Main Goal'; const mainGoalValue = mainGoalAttribute?.value; @@ -485,11 +216,11 @@ const CategoryCard = (props: CategoryCardProps) => { )} - {category.kausalPathsNodeUuid && pathsInstance && ( + {category.kausalPathsNodeUuid && pathsInstance?.id && ( )} diff --git a/components/paths/InventoryNodeSummary.tsx b/components/paths/InventoryNodeSummary.tsx new file mode 100644 index 00000000..e79db4ee --- /dev/null +++ b/components/paths/InventoryNodeSummary.tsx @@ -0,0 +1,238 @@ +import React, { useEffect, useState } from 'react'; + +import { + CausalGridNodeFragment, + InstanceGoalEntry, +} from 'common/__generated__/paths/graphql'; + +import { useFormatter } from 'next-intl'; +import styled from 'styled-components'; + +import HighlightValue from '@/components/paths/HighlightValue'; +import { yearRangeVar } from '@/context/paths/cache'; +import { DimensionalMetric, type SliceConfig } from '@/utils/paths/metric'; +import { useReactiveVar } from '@apollo/client'; + +const CardContentBlock = styled.div<{ $disabled?: boolean }>` + margin: ${({ theme }) => `0 ${theme.spaces.s100} ${theme.spaces.s100}`}; + opacity: ${({ $disabled = false }) => ($disabled ? 0.5 : 1)}; +`; + +const Values = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px 10px; + align-items: stretch; + height: 100%; +`; + +const SubValue = styled.div` + flex: 45% 1 0; + + > div { + height: 100%; + } +`; + +const getTotalValues = (yearData) => { + const totals: number[] = []; + yearData.categoryTypes[1].options.forEach((colId, cIdx) => { + const pieSegmentValues: (number | null)[] = []; + yearData.categoryTypes[0].options.forEach((rowId, rIdx) => { + const datum = yearData.rows[rIdx][cIdx]; + if (datum != 0) { + pieSegmentValues.push(datum ? Math.abs(datum) : null); + } + }); + // Calculate total and percentages + const total = + pieSegmentValues.reduce((sum, value) => { + const numSum = sum === null ? 0 : sum; + const numValue = value === null ? 0 : value; + return numSum + numValue; + }, 0) || 0; + totals.push(total); + }); + return totals; +}; + +type PathsBasicNodeContentProps = { + categoryId: string; + node: CausalGridNodeFragment; + onLoaded: (id: string, impact: number) => void; + displayGoals: InstanceGoalEntry[]; +}; + +type EmissionDisplay = { + value: number | null; + label: string | null; + year: number | null; + change?: number | null; +}; +type Emissions = { latest: EmissionDisplay; reference: EmissionDisplay }; + +const InventoryNodeSummary = (props: PathsBasicNodeContentProps) => { + const { categoryId, node, onLoaded, displayGoals } = props; + const yearRange = useReactiveVar(yearRangeVar); + const format = useFormatter(); + + const [emissions, setEmissions] = useState([ + { + latest: { + value: null, + label: null, + year: null, + change: null, + }, + reference: { + value: null, + label: null, + year: null, + change: null, + }, + }, + ]); + + const [unit, setUnit] = useState(null); + + useEffect(() => { + const nodeMetric = new DimensionalMetric(node.metricDim!); + const historicalYears = nodeMetric.getHistoricalYears(); + const lastHistoricalYear = historicalYears[historicalYears.length - 1]; + setUnit(nodeMetric.getUnit()); + + const displayEmissions: Emissions[] = []; + + displayGoals.forEach((goal) => { + const sliceConfig: SliceConfig = nodeMetric.getDefaultSliceConfig(goal); + const latestData = nodeMetric.getSingleYear( + lastHistoricalYear, + sliceConfig.categories + ); + const referenceData = nodeMetric.getSingleYear( + yearRange[1], + sliceConfig.categories + ); + // Let's assume the first key is the one we want to display + //const displayCategoryType = Object.keys(sliceConfig.categories)[0]; + const displayCategoryType = + sliceConfig.categories[Object.keys(sliceConfig.categories)[0]]; + + const displayCategory = + displayCategoryType && displayCategoryType.groups?.length + ? { id: displayCategoryType?.groups[0], type: 'group' } + : { id: displayCategoryType?.categories[0], type: 'category' }; + + if (displayCategory.id) { + const latestLabel = latestData.allLabels.find( + (label) => label.id === displayCategory.id + )?.label; + const referenceLabel = referenceData.allLabels.find( + (label) => label.id === displayCategory.id + )?.label; + + const latestValue = getTotalValues(latestData)[0]; + const referenceValue = goal?.hideForecast + ? null + : getTotalValues(referenceData)[0]; + + displayEmissions.push({ + latest: { + value: latestValue, + label: latestLabel || null, + year: lastHistoricalYear, + change: + lastHistoricalYear > yearRange[1] && + referenceValue && + referenceValue !== latestValue + ? (latestValue - referenceValue) / Math.abs(referenceValue) + : null, + }, + reference: { + value: referenceValue, + label: referenceLabel || null, + year: yearRange[1], + change: + lastHistoricalYear < yearRange[1] && + latestValue && + latestValue !== referenceValue + ? (referenceValue - latestValue) / Math.abs(latestValue) + : null, + }, + }); + } + }); + + setEmissions(displayEmissions); + onLoaded(categoryId, 100); + // using exhausive deps here causes an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [yearRange[1]]); + + return ( + + {emissions.map((em) => ( + + {em.latest.value ? ( + + 0 ? '+' : ''}${format.number( + em.latest.change * 100, + { + style: 'unit', + unit: 'percent', + maximumSignificantDigits: 2, + } + )}` + : undefined + } + /> + + ) : null} + {em.reference.value ? ( + + 0 ? '+' : ''}${format.number( + em.reference.change * 100, + { + style: 'unit', + unit: 'percent', + maximumSignificantDigits: 2, + } + )}` + : undefined + } + /> + + ) : null} + + ))} + + ); +}; + +export default InventoryNodeSummary; diff --git a/components/paths/contentblocks/CategoryTypeListBlock.tsx b/components/paths/contentblocks/CategoryTypeListBlock.tsx index bba9b9d5..23fe2964 100644 --- a/components/paths/contentblocks/CategoryTypeListBlock.tsx +++ b/components/paths/contentblocks/CategoryTypeListBlock.tsx @@ -123,7 +123,7 @@ const CategoryTypeListBlock = (props: CategoryTypeListBlockProps) => { const paths = usePaths(); const t = useTranslations(); - const pathsInstance = paths?.instance.id; + const pathsInstance = paths?.instance; const sortOptions = getSortOptions(t); const groups = useMemo(