From 4b8264d04c956b013cf038a6c850d9f6fc65c807 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 29 Aug 2024 01:31:13 -0400 Subject: [PATCH 01/50] refactor MetricsTab into sections Move the components used by a 'row' of cards to a subdirectory of 'metrics' Create new components for each section which groups the cards into a Carousel --- www/__tests__/footprintHelper.test.ts | 2 +- www/__tests__/metHelper.test.ts | 2 +- www/js/components/Carousel.tsx | 15 +-- www/js/metrics/MetricsTab.tsx | 92 +++++-------------- .../ActiveMinutesTableCard.tsx | 12 +-- .../activetravel/ActiveTravelSection.tsx | 17 ++++ .../DailyActiveMinutesCard.tsx | 14 +-- .../WeeklyActiveMinutesCard.tsx | 16 ++-- .../metrics/{ => activetravel}/metDataset.ts | 0 .../metrics/{ => activetravel}/metHelper.ts | 4 +- .../{ => carbon}/CarbonFootprintCard.tsx | 18 ++-- www/js/metrics/carbon/CarbonSection.tsx | 15 +++ .../metrics/{ => carbon}/CarbonTextCard.tsx | 14 +-- .../metrics/{ => carbon}/ChangeIndicator.tsx | 2 +- .../metrics/{ => carbon}/footprintHelper.ts | 4 +- www/js/metrics/customMetricsHelper.ts | 2 +- www/js/metrics/energy/EnergySection.tsx | 12 +++ www/js/metrics/{ => summary}/MetricsCard.tsx | 18 ++-- www/js/metrics/summary/SummarySection.tsx | 28 ++++++ .../{ => surveys}/SurveyComparisonCard.tsx | 8 +- .../{ => surveys}/SurveyLeaderboardCard.tsx | 10 +- .../SurveyTripCategoriesCard.tsx | 12 +-- www/js/metrics/surveys/SurveysSection.tsx | 15 +++ www/js/types/appConfigTypes.ts | 8 +- 24 files changed, 193 insertions(+), 147 deletions(-) rename www/js/metrics/{ => activetravel}/ActiveMinutesTableCard.tsx (93%) create mode 100644 www/js/metrics/activetravel/ActiveTravelSection.tsx rename www/js/metrics/{ => activetravel}/DailyActiveMinutesCard.tsx (85%) rename www/js/metrics/{ => activetravel}/WeeklyActiveMinutesCard.tsx (88%) rename www/js/metrics/{ => activetravel}/metDataset.ts (100%) rename www/js/metrics/{ => activetravel}/metHelper.ts (93%) rename www/js/metrics/{ => carbon}/CarbonFootprintCard.tsx (95%) create mode 100644 www/js/metrics/carbon/CarbonSection.tsx rename www/js/metrics/{ => carbon}/CarbonTextCard.tsx (95%) rename www/js/metrics/{ => carbon}/ChangeIndicator.tsx (97%) rename www/js/metrics/{ => carbon}/footprintHelper.ts (97%) create mode 100644 www/js/metrics/energy/EnergySection.tsx rename www/js/metrics/{ => summary}/MetricsCard.tsx (93%) create mode 100644 www/js/metrics/summary/SummarySection.tsx rename www/js/metrics/{ => surveys}/SurveyComparisonCard.tsx (96%) rename www/js/metrics/{ => surveys}/SurveyLeaderboardCard.tsx (93%) rename www/js/metrics/{ => surveys}/SurveyTripCategoriesCard.tsx (89%) create mode 100644 www/js/metrics/surveys/SurveysSection.tsx diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index ba49e6645..f87aca118 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -7,7 +7,7 @@ import { getFootprintForMetrics, getHighestFootprint, getHighestFootprintForDistance, -} from '../js/metrics/footprintHelper'; +} from '../js/metrics/carbon/footprintHelper'; import { getConfig } from '../js/config/dynamicConfig'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts index 72aa09488..a65f00aa4 100644 --- a/www/__tests__/metHelper.test.ts +++ b/www/__tests__/metHelper.test.ts @@ -1,4 +1,4 @@ -import { getMet } from '../js/metrics/metHelper'; +import { getMet } from '../js/metrics/activetravel/metHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx index 8afe6624a..800b8b118 100644 --- a/www/js/components/Carousel.tsx +++ b/www/js/components/Carousel.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import { ScrollView, View } from 'react-native'; +import { ScrollView, View, useWindowDimensions } from 'react-native'; -type Props = { - children: React.ReactNode; - cardWidth: number; - cardMargin: number; -}; -const Carousel = ({ children, cardWidth, cardMargin }: Props) => { +const cardMargin = 10; + +type Props = { children: React.ReactNode }; +const Carousel = ({ children }: Props) => { const numCards = React.Children.count(children); + const { width: windowWidth } = useWindowDimensions(); + const cardWidth = windowWidth * 0.88; + return ( { const sectionsToShow = appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; - const { width: windowWidth } = useWindowDimensions(); - const cardWidth = windowWidth * 0.88; const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; return ( @@ -162,59 +150,23 @@ const MetricsTab = () => { - {sectionsToShow.includes('footprint') && ( - - - - - )} - {sectionsToShow.includes('active_travel') && ( - - - - - - )} - {sectionsToShow.includes('summary') && ( - - {Object.entries(metricList).map( - ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { - return ( - - ); - }, - )} - - )} - {sectionsToShow.includes('surveys') && ( - - - - - )} - {/* we will implement leaderboard later */} - {/* {sectionsToShow.includes('engagement') && ( - - - - )} */} + {[ + ['carbon', CarbonSection], + ['energy', EnergySection], + ['active_travel', ActiveTravelSection], + ['summary', SummarySection], + // ['engagement', EngagementSection], + ['surveys', SurveysSection], + ].map(([section, component]: any) => { + if (sectionsToShow.includes(section)) { + return React.createElement(component, { userMetrics, aggMetrics, metricList }); + } + })} ); }; -export const cardMargin = 10; - export const cardStyles: any = { card: { overflow: 'hidden', diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx similarity index 93% rename from www/js/metrics/ActiveMinutesTableCard.tsx rename to www/js/metrics/activetravel/ActiveMinutesTableCard.tsx index aa8bc389f..10feb1d11 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx @@ -1,19 +1,19 @@ import React, { useContext, useMemo, useState } from 'react'; import { Card, DataTable, useTheme } from 'react-native-paper'; -import { MetricsData } from './metricsTypes'; -import { cardStyles } from './MetricsTab'; +import { MetricsData } from '../metricsTypes'; +import { cardStyles } from '../MetricsTab'; import { formatDate, formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks, valueForFieldOnDay, -} from './metricsHelper'; +} from '../metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; -import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper'; -import TimelineContext from '../TimelineContext'; -import useAppConfig from '../useAppConfig'; +import { labelKeyToRichMode } from '../../survey/multilabel/confirmHelper'; +import TimelineContext from '../../TimelineContext'; +import useAppConfig from '../../useAppConfig'; type Props = { userMetrics?: MetricsData }; const ActiveMinutesTableCard = ({ userMetrics }: Props) => { diff --git a/www/js/metrics/activetravel/ActiveTravelSection.tsx b/www/js/metrics/activetravel/ActiveTravelSection.tsx new file mode 100644 index 000000000..eff892b40 --- /dev/null +++ b/www/js/metrics/activetravel/ActiveTravelSection.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import Carousel from '../../components/Carousel'; +import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; +import DailyActiveMinutesCard from './DailyActiveMinutesCard'; +import ActiveMinutesTableCard from './ActiveMinutesTableCard'; + +const ActiveTravelSection = ({ userMetrics }) => { + return ( + + + + + + ); +}; + +export default ActiveTravelSection; diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx similarity index 85% rename from www/js/metrics/DailyActiveMinutesCard.tsx rename to www/js/metrics/activetravel/DailyActiveMinutesCard.tsx index f70b60587..178b56b94 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx @@ -1,14 +1,14 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; -import { MetricsData } from './metricsTypes'; -import { cardStyles } from './MetricsTab'; +import { MetricsData } from '../metricsTypes'; +import { cardStyles } from '../MetricsTab'; import { useTranslation } from 'react-i18next'; -import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; -import LineChart from '../components/LineChart'; -import { getBaseModeByText } from '../diary/diaryHelper'; -import { tsForDayOfMetricData, valueForFieldOnDay } from './metricsHelper'; -import useAppConfig from '../useAppConfig'; +import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; +import LineChart from '../../components/LineChart'; +import { getBaseModeByText } from '../../diary/diaryHelper'; +import { tsForDayOfMetricData, valueForFieldOnDay } from '../metricsHelper'; +import useAppConfig from '../../useAppConfig'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; type Props = { userMetrics?: MetricsData }; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx similarity index 88% rename from www/js/metrics/WeeklyActiveMinutesCard.tsx rename to www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx index 4201f993e..226ea7c52 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx @@ -1,15 +1,15 @@ import React, { useContext, useMemo, useState } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; -import { MetricsData } from './metricsTypes'; -import { cardMargin, cardStyles } from './MetricsTab'; -import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from './metricsHelper'; +import { MetricsData } from '../metricsTypes'; +import { cardMargin, cardStyles } from '../MetricsTab'; +import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from '../metricsHelper'; import { useTranslation } from 'react-i18next'; -import BarChart from '../components/BarChart'; -import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; -import { getBaseModeByText } from '../diary/diaryHelper'; -import TimelineContext from '../TimelineContext'; -import useAppConfig from '../useAppConfig'; +import BarChart from '../../components/BarChart'; +import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; +import { getBaseModeByText } from '../../diary/diaryHelper'; +import TimelineContext from '../../TimelineContext'; +import useAppConfig from '../../useAppConfig'; export const ACTIVE_MODES = ['walk', 'bike'] as const; type ActiveMode = (typeof ACTIVE_MODES)[number]; diff --git a/www/js/metrics/metDataset.ts b/www/js/metrics/activetravel/metDataset.ts similarity index 100% rename from www/js/metrics/metDataset.ts rename to www/js/metrics/activetravel/metDataset.ts diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/activetravel/metHelper.ts similarity index 93% rename from www/js/metrics/metHelper.ts rename to www/js/metrics/activetravel/metHelper.ts index 25bcc2e7e..a059a784d 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/activetravel/metHelper.ts @@ -1,5 +1,5 @@ -import { logDebug, logWarn } from '../plugin/logger'; -import { getCustomMETs } from './customMetricsHelper'; +import { logDebug, logWarn } from '../../plugin/logger'; +import { getCustomMETs } from '../customMetricsHelper'; import { standardMETs } from './metDataset'; /** diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/carbon/CarbonFootprintCard.tsx similarity index 95% rename from www/js/metrics/CarbonFootprintCard.tsx rename to www/js/metrics/carbon/CarbonFootprintCard.tsx index c40254256..5eaa1820c 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/carbon/CarbonFootprintCard.tsx @@ -1,8 +1,8 @@ import React, { useState, useMemo, useContext } from 'react'; import { View } from 'react-native'; import { Card, Text } from 'react-native-paper'; -import { MetricsData } from './metricsTypes'; -import { cardStyles } from './MetricsTab'; +import { MetricsData } from '../metricsTypes'; +import { cardStyles } from '../MetricsTab'; import { getFootprintForMetrics, getHighestFootprint, @@ -16,16 +16,16 @@ import { segmentDaysByWeeks, isCustomLabels, MetricsSummary, -} from './metricsHelper'; +} from '../metricsHelper'; import { useTranslation } from 'react-i18next'; -import BarChart from '../components/BarChart'; +import BarChart from '../../components/BarChart'; import ChangeIndicator, { CarbonChange } from './ChangeIndicator'; import color from 'color'; -import { useAppTheme } from '../appTheme'; -import { logDebug, logWarn } from '../plugin/logger'; -import TimelineContext from '../TimelineContext'; -import { isoDatesDifference } from '../diary/timelineHelper'; -import useAppConfig from '../useAppConfig'; +import { useAppTheme } from '../../appTheme'; +import { logDebug, logWarn } from '../../plugin/logger'; +import TimelineContext from '../../TimelineContext'; +import { isoDatesDifference } from '../../diary/timelineHelper'; +import useAppConfig from '../../useAppConfig'; type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { diff --git a/www/js/metrics/carbon/CarbonSection.tsx b/www/js/metrics/carbon/CarbonSection.tsx new file mode 100644 index 000000000..ac9b2aecb --- /dev/null +++ b/www/js/metrics/carbon/CarbonSection.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import CarbonFootprintCard from './CarbonFootprintCard'; +import CarbonTextCard from './CarbonTextCard'; +import Carousel from '../../components/Carousel'; + +const CarbonSection = ({ userMetrics, aggMetrics }) => { + return ( + + + + + ); +}; + +export default CarbonSection; diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/carbon/CarbonTextCard.tsx similarity index 95% rename from www/js/metrics/CarbonTextCard.tsx rename to www/js/metrics/carbon/CarbonTextCard.tsx index 225942af1..c10b4b52c 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/carbon/CarbonTextCard.tsx @@ -1,8 +1,8 @@ import React, { useContext, useMemo } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; -import { MetricsData } from './metricsTypes'; -import { cardStyles } from './MetricsTab'; +import { MetricsData } from '../metricsTypes'; +import { cardStyles } from '../MetricsTab'; import { useTranslation } from 'react-i18next'; import { getFootprintForMetrics, @@ -16,11 +16,11 @@ import { calculatePercentChange, segmentDaysByWeeks, MetricsSummary, -} from './metricsHelper'; -import { logDebug, logWarn } from '../plugin/logger'; -import TimelineContext from '../TimelineContext'; -import { isoDatesDifference } from '../diary/timelineHelper'; -import useAppConfig from '../useAppConfig'; +} from '../metricsHelper'; +import { logDebug, logWarn } from '../../plugin/logger'; +import TimelineContext from '../../TimelineContext'; +import { isoDatesDifference } from '../../diary/timelineHelper'; +import useAppConfig from '../../useAppConfig'; type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/carbon/ChangeIndicator.tsx similarity index 97% rename from www/js/metrics/ChangeIndicator.tsx rename to www/js/metrics/carbon/ChangeIndicator.tsx index 8118d59ad..fca1ca146 100644 --- a/www/js/metrics/ChangeIndicator.tsx +++ b/www/js/metrics/carbon/ChangeIndicator.tsx @@ -3,7 +3,7 @@ import { View } from 'react-native'; import { Text } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import colorLib from 'color'; -import { useAppTheme } from '../appTheme'; +import { useAppTheme } from '../../appTheme'; export type CarbonChange = { low: number; high: number } | undefined; type Props = { change: CarbonChange }; diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/carbon/footprintHelper.ts similarity index 97% rename from www/js/metrics/footprintHelper.ts rename to www/js/metrics/carbon/footprintHelper.ts index 2a02ea133..0b3109273 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/carbon/footprintHelper.ts @@ -1,5 +1,5 @@ -import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; -import { getCustomFootprint } from './customMetricsHelper'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../../plugin/logger'; +import { getCustomFootprint } from '../customMetricsHelper'; //variables for the highest footprint in the set and if using custom let highestFootprint: number | undefined = 0; diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts index b5f099d06..5952c6e0c 100644 --- a/www/js/metrics/customMetricsHelper.ts +++ b/www/js/metrics/customMetricsHelper.ts @@ -1,6 +1,6 @@ import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { displayError, logDebug, logWarn } from '../plugin/logger'; -import { standardMETs } from './metDataset'; +import { standardMETs } from './activetravel/metDataset'; import { AppConfig } from '../types/appConfigTypes'; //variables to store values locally diff --git a/www/js/metrics/energy/EnergySection.tsx b/www/js/metrics/energy/EnergySection.tsx new file mode 100644 index 000000000..efdc66f93 --- /dev/null +++ b/www/js/metrics/energy/EnergySection.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Carousel from '../../components/Carousel'; + +const EnergySection = ({ userMetrics, aggMetrics }) => { + return ( + + <> + + ); +}; + +export default EnergySection; diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/summary/MetricsCard.tsx similarity index 93% rename from www/js/metrics/MetricsCard.tsx rename to www/js/metrics/summary/MetricsCard.tsx index 241ab8208..e550bf64d 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/summary/MetricsCard.tsx @@ -2,8 +2,8 @@ import React, { useMemo, useState } from 'react'; import { View } from 'react-native'; import { Card, Checkbox, Text, useTheme } from 'react-native-paper'; import colorLib from 'color'; -import BarChart from '../components/BarChart'; -import { DayOfMetricData } from './metricsTypes'; +import BarChart from '../../components/BarChart'; +import { DayOfMetricData } from '../metricsTypes'; import { formatDateRangeOfDays, getLabelsForDay, @@ -11,14 +11,14 @@ import { getUniqueLabelsForDays, valueForFieldOnDay, getUnitUtilsForMetric, -} from './metricsHelper'; -import ToggleSwitch from '../components/ToggleSwitch'; -import { cardStyles } from './MetricsTab'; -import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; -import { getBaseModeByText } from '../diary/diaryHelper'; +} from '../metricsHelper'; +import ToggleSwitch from '../../components/ToggleSwitch'; +import { cardStyles } from '../MetricsTab'; +import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; +import { getBaseModeByText } from '../../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; -import { GroupingField, MetricName } from '../types/appConfigTypes'; -import { useImperialConfig } from '../config/useImperialConfig'; +import { GroupingField, MetricName } from '../../types/appConfigTypes'; +import { useImperialConfig } from '../../config/useImperialConfig'; import { base_modes } from 'e-mission-common'; type Props = { diff --git a/www/js/metrics/summary/SummarySection.tsx b/www/js/metrics/summary/SummarySection.tsx new file mode 100644 index 000000000..4b9036037 --- /dev/null +++ b/www/js/metrics/summary/SummarySection.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Carousel from '../../components/Carousel'; +import { GroupingField, MetricName } from '../../types/appConfigTypes'; +import MetricsCard from './MetricsCard'; +import { t } from 'i18next'; + +const SummarySection = ({ userMetrics, aggMetrics, metricList }) => { + return ( + + {Object.entries(metricList).map( + ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { + return ( + + ); + }, + )} + + ); +}; + +export default SummarySection; diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/surveys/SurveyComparisonCard.tsx similarity index 96% rename from www/js/metrics/SurveyComparisonCard.tsx rename to www/js/metrics/surveys/SurveyComparisonCard.tsx index a99a604eb..91e39f8ff 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/surveys/SurveyComparisonCard.tsx @@ -2,12 +2,12 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; import { Icon, Card, Text } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; -import { useAppTheme } from '../appTheme'; +import { useAppTheme } from '../../appTheme'; import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; import { Doughnut } from 'react-chartjs-2'; -import { cardStyles } from './MetricsTab'; -import { DayOfMetricData, MetricsData } from './metricsTypes'; -import { getUniqueLabelsForDays } from './metricsHelper'; +import { cardStyles } from '../MetricsTab'; +import { DayOfMetricData, MetricsData } from '../metricsTypes'; +import { getUniqueLabelsForDays } from '../metricsHelper'; ChartJS.register(ArcElement, Tooltip, Legend); /** diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx similarity index 93% rename from www/js/metrics/SurveyLeaderboardCard.tsx rename to www/js/metrics/surveys/SurveyLeaderboardCard.tsx index 34341616d..d982bfeab 100644 --- a/www/js/metrics/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; import { View, Text } from 'react-native'; import { Card } from 'react-native-paper'; -import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab'; +import { cardStyles } from '../MetricsTab'; import { useTranslation } from 'react-i18next'; -import BarChart from '../components/BarChart'; -import { useAppTheme } from '../appTheme'; +import BarChart from '../../components/BarChart'; +import { useAppTheme } from '../../appTheme'; import { Chart as ChartJS, registerables } from 'chart.js'; import Annotation from 'chartjs-plugin-annotation'; @@ -12,7 +12,7 @@ ChartJS.register(...registerables, Annotation); type Props = { studyStartDate: string; - surveyMetric: SurveyMetric; + surveyMetric; }; type LeaderboardRecord = { @@ -41,7 +41,7 @@ const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { } const leaderboardRecords: LeaderboardRecord[] = useMemo(() => { - const combinedLeaderboard: SurveyObject[] = [...surveyMetric.others.leaderboard]; + const combinedLeaderboard = [...surveyMetric.others.leaderboard]; combinedLeaderboard.splice(myRank, 0, mySurveyMetric); // This is to prevent the leaderboard from being too long for UX purposes. diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx similarity index 89% rename from www/js/metrics/SurveyTripCategoriesCard.tsx rename to www/js/metrics/surveys/SurveyTripCategoriesCard.tsx index 77df43abf..f97dd4d37 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx @@ -1,13 +1,13 @@ import React, { useMemo } from 'react'; import { Text, Card } from 'react-native-paper'; -import { cardStyles } from './MetricsTab'; +import { cardStyles } from '../MetricsTab'; import { useTranslation } from 'react-i18next'; -import BarChart from '../components/BarChart'; -import { useAppTheme } from '../appTheme'; +import BarChart from '../../components/BarChart'; +import { useAppTheme } from '../../appTheme'; import { LabelPanel } from './SurveyComparisonCard'; -import { DayOfMetricData, MetricsData } from './metricsTypes'; -import { GroupingField } from '../types/appConfigTypes'; -import { getUniqueLabelsForDays } from './metricsHelper'; +import { DayOfMetricData, MetricsData } from '../metricsTypes'; +import { GroupingField } from '../../types/appConfigTypes'; +import { getUniqueLabelsForDays } from '../metricsHelper'; function sumResponseCountsForValue( days: DayOfMetricData<'response_count'>[], diff --git a/www/js/metrics/surveys/SurveysSection.tsx b/www/js/metrics/surveys/SurveysSection.tsx new file mode 100644 index 000000000..f4fad8046 --- /dev/null +++ b/www/js/metrics/surveys/SurveysSection.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import Carousel from '../../components/Carousel'; +import SurveyComparisonCard from './SurveyComparisonCard'; +import SurveyTripCategoriesCard from './SurveyTripCategoriesCard'; + +const SurveysSection = ({ userMetrics, aggMetrics }) => { + return ( + + + + + ); +}; + +export default SurveysSection; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 87a4b4e85..28492cbb3 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -106,7 +106,13 @@ export const groupingFields = [ ] as const; export type GroupingField = (typeof groupingFields)[number]; export type MetricList = { [k in MetricName]?: GroupingField[] }; -export type MetricsUiSection = 'footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys'; +export type MetricsUiSection = + | 'carbon' + | 'energy' + | 'active_travel' + | 'summary' + | 'engagement' + | 'surveys'; export type MetricsConfig = { include_test_users: boolean; phone_dashboard_ui?: { From 7e99a32df9a831abeb4ad91e4802c8f801d1be67 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 3 Sep 2024 13:47:00 -0400 Subject: [PATCH 02/50] split metrics tab into sections; add MetricsScreen component --- package.cordovabuild.json | 1 + package.serve.json | 1 + www/__tests__/footprintHelper.test.ts | 2 +- www/js/metrics/MetricsScreen.tsx | 86 +++++++ www/js/metrics/MetricsTab.tsx | 93 ++----- .../activetravel/ActiveMinutesTableCard.tsx | 12 +- .../activetravel/ActiveTravelSection.tsx | 5 +- .../activetravel/DailyActiveMinutesCard.tsx | 12 +- .../activetravel/WeeklyActiveMinutesCard.tsx | 12 +- www/js/metrics/energy/EnergySection.tsx | 7 +- .../CarbonFootprintCard.tsx | 12 +- .../{carbon => footprint}/CarbonTextCard.tsx | 12 +- .../{carbon => footprint}/ChangeIndicator.tsx | 0 .../metrics/footprint/EnergyFootprintCard.tsx | 242 ++++++++++++++++++ .../FootprintSection.tsx} | 11 +- .../{carbon => footprint}/footprintHelper.ts | 0 www/js/metrics/summary/MetricsCard.tsx | 12 +- www/js/metrics/summary/SummarySection.tsx | 5 +- .../metrics/surveys/SurveyComparisonCard.tsx | 12 +- .../metrics/surveys/SurveyLeaderboardCard.tsx | 12 +- .../surveys/SurveyTripCategoriesCard.tsx | 12 +- www/js/metrics/surveys/SurveysSection.tsx | 5 +- 22 files changed, 425 insertions(+), 141 deletions(-) create mode 100644 www/js/metrics/MetricsScreen.tsx rename www/js/metrics/{carbon => footprint}/CarbonFootprintCard.tsx (96%) rename www/js/metrics/{carbon => footprint}/CarbonTextCard.tsx (95%) rename www/js/metrics/{carbon => footprint}/ChangeIndicator.tsx (100%) create mode 100644 www/js/metrics/footprint/EnergyFootprintCard.tsx rename www/js/metrics/{carbon/CarbonSection.tsx => footprint/FootprintSection.tsx} (57%) rename www/js/metrics/{carbon => footprint}/footprintHelper.ts (100%) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index a2c36e855..113b28047 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -157,6 +157,7 @@ "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", + "react-native-paper-tabs": "^0.10.4", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", "react-native-vector-icons": "^9.2.0", diff --git a/package.serve.json b/package.serve.json index a96c29657..2ca38f2cd 100644 --- a/package.serve.json +++ b/package.serve.json @@ -84,6 +84,7 @@ "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", + "react-native-paper-tabs": "^0.10.4", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", "react-native-vector-icons": "^9.2.0", diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index f87aca118..a0abe6bbb 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -7,7 +7,7 @@ import { getFootprintForMetrics, getHighestFootprint, getHighestFootprintForDistance, -} from '../js/metrics/carbon/footprintHelper'; +} from '../js/metrics/footprint/footprintHelper'; import { getConfig } from '../js/config/dynamicConfig'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx new file mode 100644 index 000000000..bc57a98ce --- /dev/null +++ b/www/js/metrics/MetricsScreen.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { ScrollView, StyleSheet } from 'react-native'; +import { SegmentedButtons } from 'react-native-paper'; +import { TabsProvider, Tabs, TabScreen } from 'react-native-paper-tabs'; +import FootprintSection from './footprint/FootprintSection'; +import ActiveTravelSection from './activetravel/ActiveTravelSection'; +import SummarySection from './summary/SummarySection'; +import useAppConfig from '../useAppConfig'; +import { MetricsUiSection } from '../types/appConfigTypes'; +import SurveysSection from './surveys/SurveysSection'; +import { useAppTheme } from '../appTheme'; + +const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = ['footprint', 'movement', 'travel']; + +const SECTIONS: Record = { + footprint: [FootprintSection, 'shoe-print', 'Footprint'], + movement: [ActiveTravelSection, 'walk', 'Movement'], + travel: [SummarySection, 'chart-timeline', 'Travel'], + surveys: [SurveysSection, 'clipboard-list', 'Surveys'], +}; + +const MetricsScreen = ({ userMetrics, aggMetrics, metricList }) => { + const { colors } = useAppTheme(); + const appConfig = useAppConfig(); + const sectionsToShow: string[] = + appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; + const [selectedSection, setSelectedSection] = useState(sectionsToShow[0]); + + const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; + + return ( + + 2 ? 'scrollable' : 'fixed'} + style={{ backgroundColor: colors.elevation.level2 }}> + {Object.entries(SECTIONS).map(([section, [Component, icon, label]]) => + sectionsToShow.includes(section) ? ( + + + + + + ) : null, + )} + + + ); +}; + +export const metricsStyles = StyleSheet.create({ + card: { + overflow: 'hidden', + minHeight: 300, + }, + title: (colors) => ({ + backgroundColor: colors.primary, + paddingHorizontal: 8, + minHeight: 52, + }), + titleText: (colors) => ({ + color: colors.onPrimary, + fontWeight: '500', + textAlign: 'center', + }), + subtitleText: { + fontSize: 13, + lineHeight: 13, + fontWeight: '400', + fontStyle: 'italic', + }, + content: { + padding: 8, + paddingBottom: 12, + flex: 1, + }, +}); + +export default MetricsScreen; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 1cefde6d1..716103e35 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useMemo, useContext } from 'react'; -import { ScrollView } from 'react-native'; import { Appbar } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; @@ -8,26 +7,19 @@ import { MetricsData } from './metricsTypes'; import { getAggregateData } from '../services/commHelper'; import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; -import { AppConfig, MetricList, MetricsUiSection } from '../types/appConfigTypes'; +import { AppConfig, MetricList } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext'; import { isoDatesDifference } from '../diary/timelineHelper'; import { metrics_summaries } from 'e-mission-common'; -import CarbonSection from './carbon/CarbonSection'; -import EnergySection from './energy/EnergySection'; -import SummarySection from './summary/SummarySection'; -import ActiveTravelSection from './activetravel/ActiveTravelSection'; -import SurveysSection from './surveys/SurveysSection'; +import MetricsScreen from './MetricsScreen'; +import { LabelOptions } from '../types/labelTypes'; +import { useAppTheme } from '../appTheme'; // 2 weeks of data is needed in order to compare "past week" vs "previous week" const N_DAYS_TO_LOAD = 14; // 2 weeks -const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [ - 'carbon', - 'energy', - 'active_travel', - 'summary', -] as const; export const DEFAULT_METRIC_LIST: MetricList = { + footprint: ['mode_confirm'], distance: ['mode_confirm'], duration: ['mode_confirm'], count: ['mode_confirm'], @@ -36,16 +28,18 @@ export const DEFAULT_METRIC_LIST: MetricList = { async function computeUserMetrics( metricList: MetricList, timelineMap: TimelineMap, - timelineLabelMap: TimelineLabelMap | null, appConfig: AppConfig, + timelineLabelMap: TimelineLabelMap | null, + labelOptions: LabelOptions, ) { try { const timelineValues = [...timelineMap.values()]; - const result = metrics_summaries.generate_summaries( + const result = await metrics_summaries.generate_summaries( { ...metricList }, timelineValues, appConfig, timelineLabelMap, + labelOptions, ); logDebug('MetricsTab: computed userMetrics'); console.debug('MetricsTab: computed userMetrics', result); @@ -81,19 +75,21 @@ async function fetchAggMetrics( } const MetricsTab = () => { + const { colors } = useAppTheme(); const appConfig = useAppConfig(); const { t } = useTranslation(); const { dateRange, timelineMap, timelineLabelMap, + labelOptions, timelineIsLoading, refreshTimeline, loadMoreDays, loadDateRange, } = useContext(TimelineContext); - const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST; + const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list || DEFAULT_METRIC_LIST; const [userMetrics, setUserMetrics] = useState(undefined); const [aggMetrics, setAggMetrics] = useState(undefined); @@ -112,11 +108,18 @@ const MetricsTab = () => { }, [appConfig, dateRange]); useEffect(() => { - if (!readyToLoad || !appConfig || timelineIsLoading || !timelineMap || !timelineLabelMap) + if ( + !readyToLoad || + !appConfig || + timelineIsLoading || + !timelineMap || + !timelineLabelMap || + !labelOptions + ) return; logDebug('MetricsTab: ready to compute userMetrics'); - computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) => - setUserMetrics(result), + computeUserMetrics(metricList, timelineMap, appConfig, timelineLabelMap, labelOptions).then( + (result) => setUserMetrics(result), ); }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]); @@ -130,13 +133,12 @@ const MetricsTab = () => { }); }, [readyToLoad, appConfig, dateRange]); - const sectionsToShow = - appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; - const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; - return ( <> - + { /> - - {[ - ['carbon', CarbonSection], - ['energy', EnergySection], - ['active_travel', ActiveTravelSection], - ['summary', SummarySection], - // ['engagement', EngagementSection], - ['surveys', SurveysSection], - ].map(([section, component]: any) => { - if (sectionsToShow.includes(section)) { - return React.createElement(component, { userMetrics, aggMetrics, metricList }); - } - })} - + ); }; -export const cardStyles: any = { - card: { - overflow: 'hidden', - minHeight: 300, - }, - title: (colors) => ({ - backgroundColor: colors.primary, - paddingHorizontal: 8, - minHeight: 52, - }), - titleText: (colors) => ({ - color: colors.onPrimary, - fontWeight: '500', - textAlign: 'center', - }), - subtitleText: { - fontSize: 13, - lineHeight: 13, - fontWeight: '400', - fontStyle: 'italic', - }, - content: { - padding: 8, - paddingBottom: 12, - flex: 1, - }, -}; - export default MetricsTab; diff --git a/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx b/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx index 10feb1d11..f10ccf66e 100644 --- a/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx @@ -1,7 +1,7 @@ import React, { useContext, useMemo, useState } from 'react'; import { Card, DataTable, useTheme } from 'react-native-paper'; import { MetricsData } from '../metricsTypes'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { formatDate, formatDateRangeOfDays, @@ -80,16 +80,16 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const to = Math.min((page + 1) * itemsPerPage, allTotals.length); return ( - + - + diff --git a/www/js/metrics/activetravel/ActiveTravelSection.tsx b/www/js/metrics/activetravel/ActiveTravelSection.tsx index eff892b40..5c2fbea4f 100644 --- a/www/js/metrics/activetravel/ActiveTravelSection.tsx +++ b/www/js/metrics/activetravel/ActiveTravelSection.tsx @@ -1,16 +1,15 @@ import React from 'react'; -import Carousel from '../../components/Carousel'; import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; const ActiveTravelSection = ({ userMetrics }) => { return ( - + <> - + ); }; diff --git a/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx b/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx index 178b56b94..57a8341a9 100644 --- a/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from '../metricsTypes'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { useTranslation } from 'react-i18next'; import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; import LineChart from '../../components/LineChart'; @@ -37,16 +37,16 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { }, [userMetrics?.duration]); return ( - + - + {dailyActiveMinutesRecords.length ? ( { }, [userMetrics?.duration]); return ( - + - + {weeklyActiveMinutesRecords.length ? ( { - return ( - - <> - - ); + return <>; }; export default EnergySection; diff --git a/www/js/metrics/carbon/CarbonFootprintCard.tsx b/www/js/metrics/footprint/CarbonFootprintCard.tsx similarity index 96% rename from www/js/metrics/carbon/CarbonFootprintCard.tsx rename to www/js/metrics/footprint/CarbonFootprintCard.tsx index 5eaa1820c..4e2b0220a 100644 --- a/www/js/metrics/carbon/CarbonFootprintCard.tsx +++ b/www/js/metrics/footprint/CarbonFootprintCard.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo, useContext } from 'react'; import { View } from 'react-native'; import { Card, Text } from 'react-native-paper'; import { MetricsData } from '../metricsTypes'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { getFootprintForMetrics, getHighestFootprint, @@ -202,17 +202,17 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; return ( - + } - style={cardStyles.title(colors)} + style={metricsStyles.title(colors)} /> - + {chartData?.length > 0 ? ( { }, [aggMetrics?.distance]); return ( - + - + {textEntries?.length > 0 && Object.keys(textEntries).map((i) => ( { + const { colors } = useAppTheme(); + const { dateRange } = useContext(TimelineContext); + const appConfig = useAppConfig(); + const { t } = useTranslation(); + + const userCarbonRecords = useMemo(() => { + if (userMetrics?.distance?.length) { + //separate data into weeks + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks( + userMetrics?.distance, + dateRange[1], + ); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + console.debug({ lastWeekDistance, userLastWeekModeMap, userLastWeekSummaryMap }); + } + + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce( + (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, + 0, + ); + + //setting up data to be displayed + let graphRecords: { label: string; x: number | string; y: number | string }[] = []; + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + let userPrevWeek; + if (userLastWeekSummaryMap[0]) { + userPrevWeek = { + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), + }; + if (showUnlabeledMetrics) { + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPrevWeek.high - userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + } + graphRecords.push({ + label: t('main-metrics.labeled'), + x: userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), + }; + if (showUnlabeledMetrics) { + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPastWeek.high - userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + } + graphRecords.push({ + label: t('main-metrics.labeled'), + x: userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + // if (userPrevWeek) { + // let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); + // setEmissionsChange(pctChange); + // } + + //calculate worst-case carbon footprint + let worstCarbon = getHighestFootprintForDistance(worstDistance); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: worstCarbon, + y: `${t('main-metrics.worst-case')}`, + }); + return graphRecords; + } + }, [userMetrics?.distance]); + + const groupCarbonRecords = useMemo(() => { + if (aggMetrics?.distance?.length) { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0]; + logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)}; + thisWeekDistance = ${JSON.stringify(thisWeekDistance)}`); + + let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); + let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); + + // Issue 422: + // https://github.com/e-mission/e-mission-docs/issues/422 + let aggCarbonData: MetricsSummary[] = aggThisWeekSummary.map((summaryEntry) => { + if (isNaN(summaryEntry.values)) { + logWarn(`WARNING in calculating groupCarbonRecords: value is NaN for mode + ${summaryEntry.key}, changing to 0`); + summaryEntry.values = 0; + } + return summaryEntry; + }); + + let groupRecords: { label: string; x: number | string; y: number | string }[] = []; + + let aggCarbon = { + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), + }; + logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`); + if (showUnlabeledMetrics) { + groupRecords.push({ + label: t('main-metrics.unlabeled'), + x: aggCarbon.high - aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + } + groupRecords.push({ + label: t('main-metrics.labeled'), + x: aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + + return groupRecords; + } + }, [aggMetrics]); + + const chartData = useMemo(() => { + let tempChartData: { label: string; x: number | string; y: number | string }[] = []; + if (userCarbonRecords?.length) { + tempChartData = tempChartData.concat(userCarbonRecords); + } + if (groupCarbonRecords?.length) { + tempChartData = tempChartData.concat(groupCarbonRecords); + } + tempChartData = tempChartData.reverse(); + return tempChartData; + }, [userCarbonRecords, groupCarbonRecords]); + + const cardSubtitleText = useMemo(() => { + if (!aggMetrics?.distance?.length) return; + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1]) + .slice(0, 2) + .reverse() + .flat(); + const recentEntriesRange = formatDateRangeOfDays(recentEntries); + return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; + }, [aggMetrics?.distance]); + + //hardcoded here, could be read from config at later customization? + let carbonGoals = [ + { + label: t('main-metrics.us-2050-goal'), + value: 14, + color: color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(), + }, + { + label: t('main-metrics.us-2030-goal'), + value: 54, + color: color(colors.danger).saturate(0.5).rgb().toString(), + }, + ]; + let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; + + return ( + + } + style={metricsStyles.title(colors)} + /> + + {chartData?.length > 0 ? ( + + + + {t('main-metrics.us-goals-footnote')} + + + ) : ( + + {t('metrics.chart-no-data')} + + )} + + + ); +}; + +export default EnergyFootprintCard; diff --git a/www/js/metrics/carbon/CarbonSection.tsx b/www/js/metrics/footprint/FootprintSection.tsx similarity index 57% rename from www/js/metrics/carbon/CarbonSection.tsx rename to www/js/metrics/footprint/FootprintSection.tsx index ac9b2aecb..116fa910e 100644 --- a/www/js/metrics/carbon/CarbonSection.tsx +++ b/www/js/metrics/footprint/FootprintSection.tsx @@ -1,15 +1,16 @@ import React from 'react'; import CarbonFootprintCard from './CarbonFootprintCard'; import CarbonTextCard from './CarbonTextCard'; -import Carousel from '../../components/Carousel'; +import EnergyFootprintCard from './EnergyFootprintCard'; -const CarbonSection = ({ userMetrics, aggMetrics }) => { +const FootprintSection = ({ userMetrics, aggMetrics }) => { return ( - + <> - + + ); }; -export default CarbonSection; +export default FootprintSection; diff --git a/www/js/metrics/carbon/footprintHelper.ts b/www/js/metrics/footprint/footprintHelper.ts similarity index 100% rename from www/js/metrics/carbon/footprintHelper.ts rename to www/js/metrics/footprint/footprintHelper.ts diff --git a/www/js/metrics/summary/MetricsCard.tsx b/www/js/metrics/summary/MetricsCard.tsx index e550bf64d..d962d5f09 100644 --- a/www/js/metrics/summary/MetricsCard.tsx +++ b/www/js/metrics/summary/MetricsCard.tsx @@ -13,7 +13,7 @@ import { getUnitUtilsForMetric, } from '../metricsHelper'; import ToggleSwitch from '../../components/ToggleSwitch'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; import { getBaseModeByText } from '../../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; @@ -123,13 +123,13 @@ const MetricsCard = ({ }; return ( - + ( )} - style={cardStyles.title(colors)} + style={metricsStyles.title(colors)} /> - + {viewMode == 'details' && (Object.keys(metricSumValues).length ? ( diff --git a/www/js/metrics/summary/SummarySection.tsx b/www/js/metrics/summary/SummarySection.tsx index 4b9036037..e8b6a99f2 100644 --- a/www/js/metrics/summary/SummarySection.tsx +++ b/www/js/metrics/summary/SummarySection.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import Carousel from '../../components/Carousel'; import { GroupingField, MetricName } from '../../types/appConfigTypes'; import MetricsCard from './MetricsCard'; import { t } from 'i18next'; const SummarySection = ({ userMetrics, aggMetrics, metricList }) => { return ( - + <> {Object.entries(metricList).map( ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { return ( @@ -21,7 +20,7 @@ const SummarySection = ({ userMetrics, aggMetrics, metricList }) => { ); }, )} - + ); }; diff --git a/www/js/metrics/surveys/SurveyComparisonCard.tsx b/www/js/metrics/surveys/SurveyComparisonCard.tsx index 91e39f8ff..0b642831e 100644 --- a/www/js/metrics/surveys/SurveyComparisonCard.tsx +++ b/www/js/metrics/surveys/SurveyComparisonCard.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../../appTheme'; import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; import { Doughnut } from 'react-chartjs-2'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { DayOfMetricData, MetricsData } from '../metricsTypes'; import { getUniqueLabelsForDays } from '../metricsHelper'; ChartJS.register(ArcElement, Tooltip, Legend); @@ -111,16 +111,16 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { }; return ( - + - + {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? ( {t('metrics.chart-no-data')} diff --git a/www/js/metrics/surveys/SurveyLeaderboardCard.tsx b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx index d982bfeab..169060154 100644 --- a/www/js/metrics/surveys/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { View, Text } from 'react-native'; import { Card } from 'react-native-paper'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { useTranslation } from 'react-i18next'; import BarChart from '../../components/BarChart'; import { useAppTheme } from '../../appTheme'; @@ -68,16 +68,16 @@ const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { }, [surveyMetric]); return ( - + - + * {t('main-metrics.survey-leaderboard-desc')} diff --git a/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx index f97dd4d37..a894ed432 100644 --- a/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { Text, Card } from 'react-native-paper'; -import { cardStyles } from '../MetricsTab'; +import { metricsStyles } from '../MetricsScreen'; import { useTranslation } from 'react-i18next'; import BarChart from '../../components/BarChart'; import { useAppTheme } from '../../appTheme'; @@ -51,16 +51,16 @@ const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { }, [userMetrics]); return ( - + - + {records.length ? ( <> { return ( - + <> - + ); }; From 11bef71d7fcbf3348ada44e84eb214ae3724c6c9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 4 Sep 2024 22:10:39 -0400 Subject: [PATCH 03/50] revise dashboard tab sections; 'Travel' tab Renamed "Summary" section to "Travel" section; this will show 'distance', 'duration', and 'count' Update types to include 'footprint' as a metric, and revise the sections ('footprint', 'movement', 'surveys', 'travel') --- www/js/metrics/MetricsScreen.tsx | 4 ++-- www/js/metrics/{summary => travel}/MetricsCard.tsx | 0 .../SummarySection.tsx => travel/TravelSection.tsx} | 13 +++++++------ www/js/types/appConfigTypes.ts | 10 ++-------- 4 files changed, 11 insertions(+), 16 deletions(-) rename www/js/metrics/{summary => travel}/MetricsCard.tsx (100%) rename www/js/metrics/{summary/SummarySection.tsx => travel/TravelSection.tsx} (73%) diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx index bc57a98ce..90bab531a 100644 --- a/www/js/metrics/MetricsScreen.tsx +++ b/www/js/metrics/MetricsScreen.tsx @@ -4,7 +4,7 @@ import { SegmentedButtons } from 'react-native-paper'; import { TabsProvider, Tabs, TabScreen } from 'react-native-paper-tabs'; import FootprintSection from './footprint/FootprintSection'; import ActiveTravelSection from './activetravel/ActiveTravelSection'; -import SummarySection from './summary/SummarySection'; +import TravelSection from './travel/TravelSection'; import useAppConfig from '../useAppConfig'; import { MetricsUiSection } from '../types/appConfigTypes'; import SurveysSection from './surveys/SurveysSection'; @@ -15,7 +15,7 @@ const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = ['footprint', 'movement', ' const SECTIONS: Record = { footprint: [FootprintSection, 'shoe-print', 'Footprint'], movement: [ActiveTravelSection, 'walk', 'Movement'], - travel: [SummarySection, 'chart-timeline', 'Travel'], + travel: [TravelSection, 'chart-timeline', 'Travel'], surveys: [SurveysSection, 'clipboard-list', 'Surveys'], }; diff --git a/www/js/metrics/summary/MetricsCard.tsx b/www/js/metrics/travel/MetricsCard.tsx similarity index 100% rename from www/js/metrics/summary/MetricsCard.tsx rename to www/js/metrics/travel/MetricsCard.tsx diff --git a/www/js/metrics/summary/SummarySection.tsx b/www/js/metrics/travel/TravelSection.tsx similarity index 73% rename from www/js/metrics/summary/SummarySection.tsx rename to www/js/metrics/travel/TravelSection.tsx index e8b6a99f2..e9999d0e6 100644 --- a/www/js/metrics/summary/SummarySection.tsx +++ b/www/js/metrics/travel/TravelSection.tsx @@ -3,12 +3,14 @@ import { GroupingField, MetricName } from '../../types/appConfigTypes'; import MetricsCard from './MetricsCard'; import { t } from 'i18next'; -const SummarySection = ({ userMetrics, aggMetrics, metricList }) => { +const TRAVEL_METRICS = ['distance', 'duration', 'count']; + +const TravelSection = ({ userMetrics, aggMetrics, metricList }) => { return ( <> {Object.entries(metricList).map( - ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { - return ( + ([metricName, groupingFields]: [MetricName, GroupingField[]]) => + TRAVEL_METRICS.includes(metricName) ? ( { userMetricsDays={userMetrics?.[metricName]} aggMetricsDays={aggMetrics?.[metricName]} /> - ); - }, + ) : null, )} ); }; -export default SummarySection; +export default TravelSection; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 28492cbb3..4ce81944c 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -95,7 +95,7 @@ export type ReminderSchemesConfig = { }; // the available metrics that can be displayed in the phone dashboard -export type MetricName = 'distance' | 'count' | 'duration' | 'response_count'; +export type MetricName = 'distance' | 'count' | 'duration' | 'response_count' | 'footprint'; // the available trip / userinput properties that can be used to group the metrics export const groupingFields = [ 'mode_confirm', @@ -106,13 +106,7 @@ export const groupingFields = [ ] as const; export type GroupingField = (typeof groupingFields)[number]; export type MetricList = { [k in MetricName]?: GroupingField[] }; -export type MetricsUiSection = - | 'carbon' - | 'energy' - | 'active_travel' - | 'summary' - | 'engagement' - | 'surveys'; +export type MetricsUiSection = 'footprint' | 'movement' | 'surveys' | 'travel'; export type MetricsConfig = { include_test_users: boolean; phone_dashboard_ui?: { From afe5973281d8ed961b441083595795ef795e15f1 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 4 Sep 2024 23:23:08 -0400 Subject: [PATCH 04/50] refresh styles: palette, navbar, navigation tabs - update color palette to align with Material Design 3 (which is what React Native Paper is based on) - update NavBar component; pass props through to the underlying Appbar.Header, allowing flexibility of elevated = true or false --- www/js/Main.tsx | 3 +- www/js/appTheme.ts | 14 +++++----- www/js/components/NavBar.tsx | 31 +++++++++++---------- www/js/control/ProfileSettings.tsx | 2 +- www/js/control/SensedPage.tsx | 2 +- www/js/diary/details/LabelDetailsScreen.tsx | 2 +- www/js/diary/list/LabelListScreen.tsx | 2 +- www/js/metrics/MetricsScreen.tsx | 11 +++----- 8 files changed, 33 insertions(+), 34 deletions(-) diff --git a/www/js/Main.tsx b/www/js/Main.tsx index 650ed4044..1fea100bd 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -71,8 +71,7 @@ const Main = () => { renderScene={renderScene} // Place at bottom, color of 'surface' (white) by default, and 68px tall (default was 80) safeAreaInsets={{ bottom: 0 }} - style={{ backgroundColor: colors.surface }} - barStyle={{ height: 68, justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0)' }} + barStyle={{ height: 68, justifyContent: 'center' }} // BottomNavigation uses secondaryContainer color for the background, but we want primaryContainer // (light blue), so we override here. theme={{ colors: { secondaryContainer: colors.primaryContainer } }} diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index d2f13c47e..c6e561e1b 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -13,8 +13,8 @@ const AppTheme = { secondary: '#c08331', // lch(60% 55 70) secondaryContainer: '#fcefda', // lch(95% 12 80) onSecondaryContainer: '#45392e', // lch(25% 10 65) - background: '#edf1f6', // lch(95% 3 250) - background of label screen, other screens still have this as CSS .pane - surface: '#fafdff', // lch(99% 30 250) + background: '#f9fdff', // lch(99% 2 250) + surface: '#f9fdff', // lch(99% 2 250) surfaceVariant: '#e0f0ff', // lch(94% 50 250) - background of DataTable surfaceDisabled: '#c7e0f7', // lch(88% 15 250) onSurfaceDisabled: '#3a4955', // lch(30% 10 250) @@ -24,11 +24,11 @@ const AppTheme = { inverseOnSurface: '#edf1f6', // lch(95% 3 250) - SnackBar text elevation: { level0: 'transparent', - level1: '#fafdff', // lch(99% 30 250) - level2: '#f2f9ff', // lch(97.5% 50 250) - level3: '#ebf5ff', // lch(96% 50 250) - level4: '#e0f0ff', // lch(94% 50 250) - level5: '#d6ebff', // lch(92% 50 250) + level1: '#f4f7fa', // lch(97% 2 250) + level2: '#edf1f6', // lch(95% 3 250) + level3: '#e7eff7', // lch(94% 5 250) + level4: '#e4ecf4', // lch(93% 5 250) + level5: '#e1e9f1', // lch(92% 5 250) }, success: '#00a665', // lch(60% 55 155) warn: '#f8cf53', //lch(85% 65 85) diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx index cf2a19dff..a644aa0a4 100644 --- a/www/js/components/NavBar.tsx +++ b/www/js/components/NavBar.tsx @@ -1,13 +1,21 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; import color from 'color'; -import { Appbar, Button, ButtonProps, Icon, ProgressBar, useTheme } from 'react-native-paper'; +import { + Appbar, + AppbarHeaderProps, + Button, + ButtonProps, + Icon, + ProgressBar, + useTheme, +} from 'react-native-paper'; -type NavBarProps = { children: React.ReactNode; isLoading?: boolean }; -const NavBar = ({ children, isLoading }: NavBarProps) => { +type NavBarProps = AppbarHeaderProps & { isLoading?: boolean }; +const NavBar = ({ children, isLoading, ...rest }: NavBarProps) => { const { colors } = useTheme(); return ( - + {children} { const { colors } = useTheme(); const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string(); - const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string(); + const borderColor = color(colors.onBackground).alpha(0.1).rgb().string(); return ( <> @@ -38,7 +46,7 @@ export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButton buttonColor={buttonColor} textColor={colors.onBackground} contentStyle={[s.btnContent, rest.contentStyle]} - style={[s.btn(outlineColor), rest.style]} + style={[rest.style, { borderColor }]} labelStyle={[s.btnLabel, rest.labelStyle]} {...rest}> {children} @@ -53,16 +61,11 @@ export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButton }; const s = StyleSheet.create({ - navBar: (backgroundColor) => ({ - backgroundColor, - height: 56, + navBar: { + height: 60, paddingHorizontal: 8, gap: 5, - }), - btn: (borderColor) => ({ - borderColor, - borderRadius: 10, - }), + }, btnContent: { height: 44, flexDirection: 'row', diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index ef61e0a24..1b37a2c8a 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -411,7 +411,7 @@ const ProfileSettings = () => { return ( <> - + setLogoutVis(true)}> {t('control.log-out')} diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index 1ceaf1178..99cbe8d81 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -52,7 +52,7 @@ const SensedPage = ({ pageVis, setPageVis }) => { return ( setPageVis(false)}> - + setPageVis(false)} /> diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 8fee14d07..ffac02543 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -57,7 +57,7 @@ const LabelScreenDetails = ({ route, navigation }) => { const modal = ( - + { navigation.goBack(); diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 6905d3fd5..48db10492 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -18,7 +18,7 @@ const LabelListScreen = () => { return ( <> - + { const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; return ( - + 2 ? 'scrollable' : 'fixed'} - style={{ backgroundColor: colors.elevation.level2 }}> + style={{ backgroundColor: colors.elevation.level2, ...(shadow(2) as ViewStyle) }}> {Object.entries(SECTIONS).map(([section, [Component, icon, label]]) => sectionsToShow.includes(section) ? ( From 2eab6c54158f9b786d76f19e21555c0d92958483 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Sep 2024 12:01:15 -0400 Subject: [PATCH 05/50] update react-native-paper-dates includes a fix for 'validateDOMNesting' error message --- package.cordovabuild.json | 2 +- package.serve.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 113b28047..2bd4fd480 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -156,7 +156,7 @@ "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", - "react-native-paper-dates": "^0.18.12", + "react-native-paper-dates": "0.21.8", "react-native-paper-tabs": "^0.10.4", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", diff --git a/package.serve.json b/package.serve.json index 2ca38f2cd..34e928dff 100644 --- a/package.serve.json +++ b/package.serve.json @@ -83,7 +83,7 @@ "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", - "react-native-paper-dates": "^0.18.12", + "react-native-paper-dates": "0.21.8", "react-native-paper-tabs": "^0.10.4", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", From 6bad3dd2275933256a48d35145f9835c3348c0e2 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Sep 2024 12:04:29 -0400 Subject: [PATCH 06/50] move formatForDisplay to js/util.ts Moved to a more general location since this can be used in many parts of the UI --- www/__tests__/useImperialConfig.test.ts | 31 +------------------------ www/__tests__/util.ts | 27 +++++++++++++++++++++ www/js/config/useImperialConfig.ts | 18 +------------- www/js/metrics/metricsHelper.ts | 3 ++- www/js/util.ts | 13 +++++++++++ 5 files changed, 44 insertions(+), 48 deletions(-) create mode 100644 www/__tests__/util.ts create mode 100644 www/js/util.ts diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index 33c354271..8db08d0fc 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,10 +1,5 @@ import React from 'react'; -import { - convertDistance, - convertSpeed, - formatForDisplay, - useImperialConfig, -} from '../js/config/useImperialConfig'; +import { convertDistance, convertSpeed, useImperialConfig } from '../js/config/useImperialConfig'; // This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root jest.mock('../js/useAppConfig', () => { @@ -18,30 +13,6 @@ jest.mock('../js/useAppConfig', () => { jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); -describe('formatForDisplay', () => { - it('should round to the nearest integer when value is >= 100', () => { - expect(formatForDisplay(105)).toBe('105'); - expect(formatForDisplay(119.01)).toBe('119'); - expect(formatForDisplay(119.91)).toBe('120'); - }); - - it('should round to 3 significant digits when 1 <= value < 100', () => { - expect(formatForDisplay(7.02)).toBe('7.02'); - expect(formatForDisplay(9.6262)).toBe('9.63'); - expect(formatForDisplay(11.333)).toBe('11.3'); - expect(formatForDisplay(99.99)).toBe('100'); - }); - - it('should round to 2 decimal places when value < 1', () => { - expect(formatForDisplay(0.07178)).toBe('0.07'); - expect(formatForDisplay(0.08978)).toBe('0.09'); - expect(formatForDisplay(0.75)).toBe('0.75'); - expect(formatForDisplay(0.001)).toBe('0'); - expect(formatForDisplay(0.006)).toBe('0.01'); - expect(formatForDisplay(0.00001)).toBe('0'); - }); -}); - describe('convertDistance', () => { it('should convert meters to kilometers by default', () => { expect(convertDistance(1000, false)).toBe(1); diff --git a/www/__tests__/util.ts b/www/__tests__/util.ts new file mode 100644 index 000000000..0a8678a34 --- /dev/null +++ b/www/__tests__/util.ts @@ -0,0 +1,27 @@ +import { formatForDisplay } from '../js/util'; + +describe('util.ts', () => { + describe('formatForDisplay', () => { + it('should round to the nearest integer when value is >= 100', () => { + expect(formatForDisplay(105)).toBe('105'); + expect(formatForDisplay(119.01)).toBe('119'); + expect(formatForDisplay(119.91)).toBe('120'); + }); + + it('should round to 3 significant digits when 1 <= value < 100', () => { + expect(formatForDisplay(7.02)).toBe('7.02'); + expect(formatForDisplay(9.6262)).toBe('9.63'); + expect(formatForDisplay(11.333)).toBe('11.3'); + expect(formatForDisplay(99.99)).toBe('100'); + }); + + it('should round to 2 decimal places when value < 1', () => { + expect(formatForDisplay(0.07178)).toBe('0.07'); + expect(formatForDisplay(0.08978)).toBe('0.09'); + expect(formatForDisplay(0.75)).toBe('0.75'); + expect(formatForDisplay(0.001)).toBe('0'); + expect(formatForDisplay(0.006)).toBe('0.01'); + expect(formatForDisplay(0.00001)).toBe('0'); + }); + }); +}); diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index feb2bb114..223267f20 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import useAppConfig from '../useAppConfig'; -import i18next from 'i18next'; +import { formatForDisplay } from '../util'; export type ImperialConfig = { distanceSuffix: string; @@ -14,22 +14,6 @@ export type ImperialConfig = { const KM_TO_MILES = 0.621371; const MPS_TO_KMPH = 3.6; -// it might make sense to move this to a more general location in the codebase -/* formatting units for display: - - if value >= 100, round to the nearest integer - e.g. "105 mi", "119 kmph" - - if 1 <= value < 100, round to 3 significant digits - e.g. "7.02 km", "11.3 mph" - - if value < 1, round to 2 decimal places - e.g. "0.07 mi", "0.75 km" */ -export function formatForDisplay(value: number): string { - let opts: Intl.NumberFormatOptions = {}; - if (value >= 100) opts.maximumFractionDigits = 0; - else if (value >= 1) opts.maximumSignificantDigits = 3; - else opts.maximumFractionDigits = 2; - return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value); -} - export function convertDistance(distMeters: number, imperial: boolean): number { if (imperial) return (distMeters / 1000) * KM_TO_MILES; return distMeters / 1000; diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 65337690b..296721a7d 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -3,8 +3,9 @@ import { DayOfMetricData } from './metricsTypes'; import { logDebug } from '../plugin/logger'; import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; import { MetricName, groupingFields } from '../types/appConfigTypes'; -import { ImperialConfig, formatForDisplay } from '../config/useImperialConfig'; +import { ImperialConfig } from '../config/useImperialConfig'; import i18next from 'i18next'; +import { formatForDisplay } from '../util'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; diff --git a/www/js/util.ts b/www/js/util.ts new file mode 100644 index 000000000..d8340e04d --- /dev/null +++ b/www/js/util.ts @@ -0,0 +1,13 @@ +import i18next from 'i18next'; + +/* formatting units for display: + - if value >= 100, round to the nearest integer + e.g. "105 mi", "119 kmph" + - if 1 <= value < 100, round to 3 significant digits + e.g. "7.02 km", "11.3 mph" + - if value < 1, round to 2 decimal places + e.g. "0.07 mi", "0.75 km" */ +export function formatForDisplay(value: number, opts: Intl.NumberFormatOptions = {}): string { + opts.maximumFractionDigits ??= value >= 100 ? 0 : 1; + return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value); +} From 6e671b225d13dc341c8146d52678658f831cf90b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Sep 2024 12:15:37 -0400 Subject: [PATCH 07/50] DateSelect + NavBarButton style tweak height = 40 & adjust padding/margins to match standard Material UI button use lighter grey on DateSelect, show on one line and use month + day (e.g. "Sep 9") instead of MM/DD/YYYY --- www/js/components/NavBar.tsx | 8 ++++---- www/js/diary/list/DateSelect.tsx | 28 +++++++++++++--------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx index a644aa0a4..5d2e2498d 100644 --- a/www/js/components/NavBar.tsx +++ b/www/js/components/NavBar.tsx @@ -36,7 +36,7 @@ export default NavBar; type NavBarButtonProps = ButtonProps & { icon?: string; iconSize?: number }; export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButtonProps) => { const { colors } = useTheme(); - const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string(); + const buttonColor = color(colors.onBackground).alpha(0.05).rgb().string(); const borderColor = color(colors.onBackground).alpha(0.1).rgb().string(); return ( @@ -67,9 +67,9 @@ const s = StyleSheet.create({ gap: 5, }, btnContent: { - height: 44, + height: 40, flexDirection: 'row', - paddingHorizontal: 2, + paddingHorizontal: 8, }, btnLabel: { fontSize: 12.5, @@ -78,6 +78,7 @@ const s = StyleSheet.create({ marginHorizontal: 'auto', marginVertical: 'auto', display: 'flex', + gap: 5, }, icon: { margin: 'auto', @@ -86,7 +87,6 @@ const s = StyleSheet.create({ }, textWrapper: { lineHeight: '100%', - marginHorizontal: 5, justifyContent: 'space-evenly', alignItems: 'center', }, diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index d79568e91..012966a8a 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -15,12 +15,18 @@ import { DatePickerModalRangeProps, DatePickerModalSingleProps, } from 'react-native-paper-dates'; -import { Text, Divider, useTheme } from 'react-native-paper'; +import { Text, useTheme } from 'react-native-paper'; import i18next from 'i18next'; import { useTranslation } from 'react-i18next'; import { NavBarButton } from '../../components/NavBar'; import { isoDateRangeToTsRange } from '../timelineHelper'; +// formats as e.g. 'Aug 1' +const MONTH_DAY_SHORT: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', +}; + type Props = Partial & { mode: 'single' | 'range'; onChoose: (params) => void; @@ -47,12 +53,10 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { if (!pipelineRange || !queriedDateRange?.[0]) return null; const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange); const displayStartTs = Math.max(queriedStartTs, pipelineRange.start_ts); - const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString( - DateTime.DATE_SHORT, - ); + const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString(MONTH_DAY_SHORT); let displayEndDate; if (queriedEndTs < pipelineRange.end_ts) { - displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(DateTime.DATE_SHORT); + displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(MONTH_DAY_SHORT); } return [displayStartDate, displayEndDate]; }, [pipelineRange, queriedDateRange]); @@ -79,16 +83,10 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { displayDateRangeEnd } onPress={() => setOpen(true)}> - {displayDateRange?.[0] && ( - <> - {displayDateRange?.[0]} - - - )} - {displayDateRangeEnd} + + {displayDateRange?.[0] ? displayDateRange?.[0] + ' – ' : ''} + {displayDateRangeEnd} + Date: Wed, 11 Sep 2024 12:17:19 -0400 Subject: [PATCH 08/50] allow surveys to be skipped on DEV builds Speeds up development; does not affect production --- www/js/survey/enketo/EnketoModal.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index f8b503407..4291d297a 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -63,6 +63,13 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { useEffect(() => { if (!rest.visible || !appConfig) return; initSurvey(); + + // on dev builds, allow skipping survey with ESC + if (__DEV__) { + const handleKeyDown = (e) => e.key === 'Escape' && onResponseSaved(null); + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } }, [appConfig, rest.visible]); /* adapted from the template given by enketo-core: From 462ed94f72abb0fbd228e9f06e20d6075cf55821 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Sep 2024 12:20:55 -0400 Subject: [PATCH 09/50] use instead of for Alerts We had used here (while still in the process of migrating from Angular) to ensure the snackbar shows above the rest of the content. This prevented interacting with other content until the snackbar (and its modal) were dismissed. Per RN Paper docs, Snackbars are intended to work with Portals. https://callstack.github.io/react-native-paper/docs/components/Snackbar/ We can do this now that we have a PaperProvider at the App level (index.js) --- www/js/components/AlertBar.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/www/js/components/AlertBar.tsx b/www/js/components/AlertBar.tsx index 6bdd8d157..8b1b39fcf 100644 --- a/www/js/components/AlertBar.tsx +++ b/www/js/components/AlertBar.tsx @@ -2,8 +2,7 @@ Alerts can be added to the queue from anywhere by calling AlertManager.addMessage. */ import React, { useState, useEffect } from 'react'; -import { Snackbar } from 'react-native-paper'; -import { Modal } from 'react-native'; +import { Portal, Snackbar } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { ParseKeys } from 'i18next'; @@ -41,7 +40,7 @@ const AlertBar = () => { const { msgKey, text } = messages[0]; const alertText = [msgKey && t(msgKey), text].filter((x) => x).join(' '); return ( - + { }}> {alertText} - + ); }; From a3940ea74d1ec143634bf468791115d6183e772d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 13:27:43 -0400 Subject: [PATCH 10/50] rename dash_key to uncertainty_prefix Bars on a chart get shown with transparency if they begin with this. In a previous version, it was dashed lines, but it's no longer apt to call it dash_key. --- www/js/components/BarChart.tsx | 2 +- www/js/components/charting.ts | 6 ++- www/js/metrics/SumaryCard.tsx | 84 ++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 www/js/metrics/SumaryCard.tsx diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index ccf1a6f74..0c63c0fe4 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -4,7 +4,7 @@ import { useTheme } from 'react-native-paper'; import { getGradient } from './charting'; type Props = Omit & { - meter?: { high: number; middle: number; dash_key: string }; + meter?: { high: number; middle: number; uncertainty_prefix: string }; }; const BarChart = ({ meter, ...rest }: Props) => { const { colors } = useTheme(); diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts index 11ae43be7..657a3b8ab 100644 --- a/www/js/components/charting.ts +++ b/www/js/components/charting.ts @@ -2,6 +2,8 @@ import color from 'color'; import { readableLabelToKey } from '../survey/multilabel/confirmHelper'; import { logDebug } from '../plugin/logger'; +export const UNCERTAIN_OPACITY = 0.12; + export const defaultPalette = [ '#c95465', // red oklch(60% 0.15 14) '#4a71b1', // blue oklch(55% 0.11 260) @@ -89,7 +91,7 @@ export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, da return color(meteredColor).darken(darken).hex(); } //if "unlabeled", etc -> stripes - if (currDataset.label == meter.dash_key) { + if (currDataset.label == meter.uncertainty_prefix) { return createDiagonalPattern(meteredColor); } //if :labeled", etc -> solid @@ -115,7 +117,7 @@ export function getGradient( if (!chartArea) return null; let gradient: CanvasGradient; const total = getBarHeight(barCtx.parsed._stacks); - alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1); + alpha = alpha || (currDataset.label.startsWith(meter.uncertainty_prefix) ? UNCERTAIN_OPACITY : 1); if (total < meter.middle) { const adjColor = darken || alpha diff --git a/www/js/metrics/SumaryCard.tsx b/www/js/metrics/SumaryCard.tsx new file mode 100644 index 000000000..3be3442d9 --- /dev/null +++ b/www/js/metrics/SumaryCard.tsx @@ -0,0 +1,84 @@ +import React, { useEffect } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Card, Text } from 'react-native-paper'; +import color from 'color'; +import { formatForDisplay } from '../util'; +import { useAppTheme } from '../appTheme'; + +type Value = [number, number]; +type Props = { + title: string; + unit: string; + value: Value; + nDays: number; + guidelines: number[]; +}; +const SummaryCard = ({ title, unit, value, nDays, guidelines }: Props) => { + const { colors } = useAppTheme(); + const valueIsRange = value[0] != value[1]; + const perDayValue = value.map((v) => v / nDays) as Value; + + const formatVal = (v: Value) => { + const opts = { maximumFractionDigits: 1 }; + if (valueIsRange) return `${formatForDisplay(v[0], opts)} - ${formatForDisplay(v[1], opts)}`; + return `${formatForDisplay(v[0], opts)}`; + }; + + const colorFn = (v: Value) => { + const low = v[0]; + const high = v[1]; + if (high < guidelines[guidelines.length - 1]) return colors.success; + if (low > guidelines[0]) return colors.error; + return colors.onSurfaceVariant; + }; + + useEffect(() => { + console.debug('SummaryCard: value', value); + }, [value]); + + return ( + + {!isNaN(value[0]) ? ( + + {title} + + {formatVal(value)} {unit} + + + + + {formatVal(perDayValue)} {unit} + + per day + + + + ) : ( + + {title} + No data available + + )} + + ); +}; + +const s = StyleSheet.create({ + titleText: { + fontSize: 24, + fontWeight: 'bold', + margin: 'auto', + }, + perDay: { + borderLeftWidth: 5, + paddingLeft: 12, + marginLeft: 4, + }, +}); + +export default SummaryCard; From eae8ab0d49c84f48aa53effc617f8bf4fcbec234 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 13:34:17 -0400 Subject: [PATCH 11/50] TimelineContext: only load the last week even if pipeline is behind This mostly just matters for: 1) testing with historical data, or 2) people whose pipeline is stuck, which indicates a deeper problem. But it will prevent any scenarios where several weeks/months get loaded in all at once. To aid with scenario (1), we can add a button to the TimelineScrollList: if there is no travel in the past week and the pipeline end is not in the last week, this gives us a shortcut to the last processed week --- www/i18n/en.json | 3 ++- www/js/TimelineContext.ts | 10 +++------- www/js/diary/list/TimelineScrollList.tsx | 18 +++++++++++++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 738df38f5..fec243cb5 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -154,7 +154,8 @@ "show-more-travel": "Show More Travel", "show-older-travel": "Show Older Travel", "no-travel": "No travel to show", - "no-travel-hint": "To see more, change the filters above or go record some travel!" + "no-travel-hint": "To see more, change the filters above or go record some travel!", + "jump-to-last-processed-week": "Jump to the last processed week" }, "multilabel": { diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 203051094..7fc2f6c62 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -7,7 +7,6 @@ import { displayError, displayErrorMsg, logDebug, logWarn } from './plugin/logge import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; import { - isoDateWithOffset, compositeTrips2TimelineMap, readAllCompositeTrips, readUnprocessedTrips, @@ -17,13 +16,13 @@ import { unprocessedBleScans, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, - isoDateRangeToTsRange, } from './diary/timelineHelper'; import { getPipelineRangeTs } from './services/commHelper'; import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher'; import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; import { VehicleIdentity } from './types/appConfigTypes'; import { primarySectionForTrip } from './diary/diaryHelper'; +import { isoDateRangeToTsRange, isoDateWithOffset } from './util'; const TODAY_DATE = DateTime.now().toISODate(); @@ -152,11 +151,8 @@ export const useTimelineContext = (): ContextProps => { } setPipelineRange(pipelineRange); if (pipelineRange.end_ts) { - // set initial date range to [pipelineEndDate - 7 days, TODAY_DATE] - setDateRange([ - DateTime.fromSeconds(pipelineRange.end_ts).minus({ days: 7 }).toISODate(), - TODAY_DATE, - ]); + // set initial date range to past week: [TODAY - 6 days, TODAY] + setDateRange([isoDateWithOffset(TODAY_DATE, -6), TODAY_DATE]); } else { logWarn('Timeline: no pipeline end date. dateRange will stay null'); setTimelineIsLoading(false); diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index 57842bbcf..98f12d139 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -3,11 +3,12 @@ import TripCard from '../cards/TripCard'; import PlaceCard from '../cards/PlaceCard'; import UntrackedTimeCard from '../cards/UntrackedTimeCard'; import { View, FlatList } from 'react-native'; -import { ActivityIndicator, Banner, Icon, Text } from 'react-native-paper'; +import { ActivityIndicator, Banner, Button, Icon, Text } from 'react-native-paper'; import LoadMoreButton from './LoadMoreButton'; import { useTranslation } from 'react-i18next'; -import { isoDateRangeToTsRange } from '../timelineHelper'; import TimelineContext from '../../TimelineContext'; +import { isoDateRangeToTsRange, isoDateWithOffset } from '../../util'; +import { DateTime } from 'luxon'; function renderCard({ item: listEntry, index }) { if (listEntry.origin_key.includes('trip')) { @@ -30,7 +31,7 @@ type Props = { }; const TimelineScrollList = ({ listEntries }: Props) => { const { t } = useTranslation(); - const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays } = + const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays, loadDateRange } = useContext(TimelineContext); const listRef = React.useRef(null); @@ -56,11 +57,22 @@ const TimelineScrollList = ({ listEntries }: Props) => { ); + const pipelineEndDate = pipelineRange && DateTime.fromSeconds(pipelineRange.end_ts).toISODate(); const noTravelBanner = ( }> {t('diary.no-travel')} {t('diary.no-travel-hint')} + {queriedDateRange?.[0] && pipelineEndDate && queriedDateRange?.[0] > pipelineEndDate && ( + + )} ); From adf7b1e5bdf3fc374f9a7c924fc4a5a641652ca3 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 13:46:20 -0400 Subject: [PATCH 12/50] unify date & time formatting functions, move to common place both Label and Dashboard tabs use date & time formatting functions using ISO strings, including with/without weekday, and with/without year, and "humanized" duration strings Makes generic versions of these and puts them in js/util.ts DateSelect will use this as well; as a result it will shift from using a numerical, with year (e.g. "9/17/2024") representation, to an abbreviated string form (e.g. "Sep 17") This is 1) more friendly / less initimidating, 2) allows the datepicker text to be one line, which reduces clutter --- www/js/diary/diaryHelper.ts | 79 +------------------------ www/js/diary/list/DateSelect.tsx | 31 ++++------ www/js/diary/timelineHelper.ts | 23 ------- www/js/diary/useDerivedProperties.tsx | 15 ++--- www/js/metrics/metricsHelper.ts | 16 ++--- www/js/survey/enketo/AddedNotesList.tsx | 6 +- www/js/util.ts | 78 ++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 142 deletions(-) diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 4af19c2cf..7890151ac 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,15 +1,10 @@ -// here we have some helper functions used throughout the label tab -// these functions are being gradually migrated out of services.js - -import i18next from 'i18next'; import { DateTime } from 'luxon'; import { CompositeTrip } from '../types/diaryTypes'; import { LabelOptions } from '../types/labelTypes'; import { LocalDt } from '../types/serverData'; -import humanizeDuration from 'humanize-duration'; -import { AppConfig } from '../types/appConfigTypes'; import { ImperialConfig } from '../config/useImperialConfig'; import { base_modes } from 'e-mission-common'; +import { humanizeIsoRange } from '../util'; export type BaseModeKey = string; // TODO figure out how to get keyof typeof base_modes.BASE_MODES @@ -32,76 +27,6 @@ export function getBaseModeByText(text: string, labelOptions: LabelOptions) { return base_modes.get_base_mode_by_key(modeOption?.baseMode || 'OTHER'); } -/** - * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone) - * @param endTs An ISO 8601 formatted timestamp (with timezone) - * @returns true if the start and end timestamps fall on different days - * @example isMultiDay("2023-07-13T00:00:00-07:00", "2023-07-14T00:00:00-07:00") => true - */ -export function isMultiDay(beginFmtTime?: string, endFmtTime?: string) { - if (!beginFmtTime || !endFmtTime) return false; - return ( - DateTime.fromISO(beginFmtTime, { setZone: true }).toFormat('YYYYMMDD') != - DateTime.fromISO(endFmtTime, { setZone: true }).toFormat('YYYYMMDD') - ); -} - -/** - * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone) - * @param endTs An ISO 8601 formatted timestamp (with timezone) - * @returns A formatted range if both params are defined, one formatted date if only one is defined - * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Fri, Jul 14, 2023" - */ -export function getFormattedDate(beginFmtTime?: string, endFmtTime?: string) { - if (!beginFmtTime && !endFmtTime) return; - if (isMultiDay(beginFmtTime, endFmtTime)) { - return `${getFormattedDate(beginFmtTime)} - ${getFormattedDate(endFmtTime)}`; - } - // only one day given, or both are the same day - const t = DateTime.fromISO(beginFmtTime || endFmtTime || '', { setZone: true }); - // We use toLocale to get Wed May 3, 2023 or equivalent, - const tConversion = t.toLocaleString({ - weekday: 'short', - month: 'long', - day: '2-digit', - year: 'numeric', - }); - return tConversion; -} - -/** - * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone) - * @param endTs An ISO 8601 formatted timestamp (with timezone) - * @returns A formatted range if both params are defined, one formatted date if only one is defined - * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Fri, Jul 14" - */ -export function getFormattedDateAbbr(beginFmtTime?: string, endFmtTime?: string) { - if (!beginFmtTime && !endFmtTime) return; - if (isMultiDay(beginFmtTime, endFmtTime)) { - return `${getFormattedDateAbbr(beginFmtTime)} - ${getFormattedDateAbbr(endFmtTime)}`; - } - // only one day given, or both are the same day - const dt = DateTime.fromISO(beginFmtTime || endFmtTime || '', { setZone: true }); - return dt.toLocaleString({ weekday: 'short', month: 'short', day: 'numeric' }); -} - -/** - * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone) - * @param endFmtTime An ISO 8601 formatted timestamp (with timezone) - * @returns A human-readable, approximate time range, e.g. "2 hours" - */ -export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) { - if (!beginFmtTime || !endFmtTime) return; - const beginTime = DateTime.fromISO(beginFmtTime, { setZone: true }); - const endTime = DateTime.fromISO(endFmtTime, { setZone: true }); - const range = endTime.diff(beginTime, ['hours', 'minutes']); - return humanizeDuration(range.as('milliseconds'), { - language: i18next.resolvedLanguage, - largest: 1, - round: true, - }); -} - /** * @param trip A composite trip object * @returns An array of objects containing the mode key, icon, color, and percentage for each mode @@ -124,7 +49,7 @@ export function getDetectedModes(trip: CompositeTrip) { export function getFormattedSectionProperties(trip: CompositeTrip, imperialConfig: ImperialConfig) { return trip.sections?.map((s) => ({ startTime: getLocalTimeString(s.start_local_dt), - duration: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time), + duration: humanizeIsoRange(s.start_fmt_time, s.end_fmt_time), distance: imperialConfig.getFormattedDistance(s.distance), distanceSuffix: imperialConfig.distanceSuffix, icon: base_modes.get_base_mode_by_key(s.sensed_mode_str)?.icon, diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 012966a8a..2f629b3d1 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -19,7 +19,7 @@ import { Text, useTheme } from 'react-native-paper'; import i18next from 'i18next'; import { useTranslation } from 'react-i18next'; import { NavBarButton } from '../../components/NavBar'; -import { isoDateRangeToTsRange } from '../timelineHelper'; +import { formatIsoNoYear, isoDateRangeToTsRange } from '../../util'; // formats as e.g. 'Aug 1' const MONTH_DAY_SHORT: Intl.DateTimeFormatOptions = { @@ -49,16 +49,15 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { [queriedDateRange], ); - const displayDateRange = useMemo(() => { - if (!pipelineRange || !queriedDateRange?.[0]) return null; - const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange); - const displayStartTs = Math.max(queriedStartTs, pipelineRange.start_ts); - const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString(MONTH_DAY_SHORT); - let displayEndDate; - if (queriedEndTs < pipelineRange.end_ts) { - displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(MONTH_DAY_SHORT); + const displayDateText = useMemo(() => { + if (!pipelineRange || !queriedDateRange?.[0]) { + return ' – '; // en dash surrounded by em spaces + } + const displayDateRange = [...queriedDateRange]; + if (queriedDateRange[1] == DateTime.now().toISODate()) { + displayDateRange[1] = t('diary.today'); } - return [displayStartDate, displayEndDate]; + return formatIsoNoYear(...displayDateRange); }, [pipelineRange, queriedDateRange]); const midpointDate = useMemo(() => { @@ -72,21 +71,13 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { setOpen(false); }, [setOpen]); - const displayDateRangeEnd = displayDateRange?.[1] || t('diary.today'); return ( <> setOpen(true)}> - - {displayDateRange?.[0] ? displayDateRange?.[0] + ' – ' : ''} - {displayDateRangeEnd} - + {displayDateText} '2024-03-23' - * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26' - */ -export function isoDateWithOffset(date: string, offset: number) { - let d = new Date(date); - d.setUTCDate(d.getUTCDate() + offset); - return d.toISOString().substring(0, 10); -} - -export const isoDateRangeToTsRange = (dateRange: [string, string], zone?) => [ - DateTime.fromISO(dateRange[0], { zone: zone }).startOf('day').toSeconds(), - DateTime.fromISO(dateRange[1], { zone: zone }).endOf('day').toSeconds(), -]; - -/** - * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7 - * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000 - * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4 - */ -export const isoDatesDifference = (date1: string, date2: string) => - -DateTime.fromISO(date1).diff(DateTime.fromISO(date2), 'days').days; diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index f13c1862d..4071000f7 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,16 +1,13 @@ import { useContext, useMemo } from 'react'; import { useImperialConfig } from '../config/useImperialConfig'; import { - getFormattedDate, - getFormattedDateAbbr, getFormattedSectionProperties, - getFormattedTimeRange, getLocalTimeString, getDetectedModes, - isMultiDay, primarySectionForTrip, } from './diaryHelper'; import TimelineContext from '../TimelineContext'; +import { formatIsoNoYear, formatIsoWeekday, humanizeIsoRange, isoDatesDifference } from '../util'; const useDerivedProperties = (tlEntry) => { const imperialConfig = useImperialConfig(); @@ -21,17 +18,17 @@ const useDerivedProperties = (tlEntry) => { const endFmt = tlEntry.end_fmt_time || tlEntry.exit_fmt_time; const beginDt = tlEntry.start_local_dt || tlEntry.enter_local_dt; const endDt = tlEntry.end_local_dt || tlEntry.exit_local_dt; - const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); + const tlEntryIsMultiDay = isoDatesDifference(beginFmt, endFmt); return { confirmedMode: confirmedModeFor(tlEntry), primary_ble_sensed_mode: primarySectionForTrip(tlEntry)?.ble_sensed_mode?.baseMode, - displayDate: getFormattedDate(beginFmt, endFmt), + displayDate: formatIsoWeekday(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), displayEndTime: getLocalTimeString(endDt), - displayTime: getFormattedTimeRange(beginFmt, endFmt), - displayStartDateAbbr: tlEntryIsMultiDay ? getFormattedDateAbbr(beginFmt) : null, - displayEndDateAbbr: tlEntryIsMultiDay ? getFormattedDateAbbr(endFmt) : null, + displayTime: humanizeIsoRange(beginFmt, endFmt), + displayStartDateAbbr: tlEntryIsMultiDay ? formatIsoNoYear(beginFmt) : null, + displayEndDateAbbr: tlEntryIsMultiDay ? formatIsoNoYear(endFmt) : null, formattedDistance: imperialConfig.getFormattedDistance(tlEntry.distance), formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig), distanceSuffix: imperialConfig.distanceSuffix, diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 296721a7d..d3a061368 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -5,7 +5,7 @@ import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; import { MetricName, groupingFields } from '../types/appConfigTypes'; import { ImperialConfig } from '../config/useImperialConfig'; import i18next from 'i18next'; -import { formatForDisplay } from '../util'; +import { formatForDisplay, formatIsoNoYear, isoDatesDifference, isoDateWithOffset } from '../util'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; @@ -59,18 +59,14 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { return weeks.map((week) => week.reverse()); } -export function formatDate(day: DayOfMetricData) { - const dt = DateTime.fromISO(day.date, { zone: 'utc' }); - return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); -} +export const formatDate = (day: DayOfMetricData) => formatIsoNoYear(day.date); export function formatDateRangeOfDays(days: DayOfMetricData[]) { if (!days?.length) return ''; - const firstDayDt = DateTime.fromISO(days[0].date, { zone: 'utc' }); - const lastDayDt = DateTime.fromISO(days[days.length - 1].date, { zone: 'utc' }); - const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); - const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); - return `${firstDay} - ${lastDay}`; + const startIsoDate = days[0].date; + const endIsoDate = days[days.length - 1].date; + return formatIsoNoYear(startIsoDate, endIsoDate); +} } /* formatting data form carbon footprint calculations */ diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index 155dabace..caf53e89c 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -7,11 +7,11 @@ import { DateTime } from 'luxon'; import { Modal } from 'react-native'; import { Text, Button, DataTable, Dialog, Icon } from 'react-native-paper'; import TimelineContext from '../../TimelineContext'; -import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; import EnketoModal from './EnketoModal'; import { useTranslation } from 'react-i18next'; import { EnketoUserInputEntry } from './enketoHelper'; import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { formatIsoNoYear, isoDatesDifference } from '../../util'; type Props = { timelineEntry: any; @@ -43,8 +43,8 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { const beginIso = DateTime.fromSeconds(beginTs).setZone(timezone).toISO() || undefined; const stopIso = DateTime.fromSeconds(stopTs).setZone(timezone).toISO() || undefined; let d; - if (isMultiDay(beginIso, stopIso)) { - d = getFormattedDateAbbr(beginIso, stopIso); + if (beginIso && stopIso && isoDatesDifference(beginIso, stopIso)) { + d = formatIsoNoYear(beginIso, stopIso); } const begin = DateTime.fromSeconds(beginTs) .setZone(timezone) diff --git a/www/js/util.ts b/www/js/util.ts index d8340e04d..2e87ba1eb 100644 --- a/www/js/util.ts +++ b/www/js/util.ts @@ -1,4 +1,6 @@ import i18next from 'i18next'; +import { DateTime } from 'luxon'; +import humanizeDuration from 'humanize-duration'; /* formatting units for display: - if value >= 100, round to the nearest integer @@ -11,3 +13,79 @@ export function formatForDisplay(value: number, opts: Intl.NumberFormatOptions = opts.maximumFractionDigits ??= value >= 100 ? 0 : 1; return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value); } + +/** + * @param isoStr1 An ISO 8601 string (with/without timezone) + * @param isoStr2 An ISO 8601 string (with/without timezone) + * @param opts Intl.DateTimeFormatOptions for formatting (optional, defaults to { weekday: 'short', month: 'short', day: 'numeric' }) + * @returns A formatted range if both params are defined, one formatted date if only one is defined + * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Jul 14, 2023" + * @example getFormattedDate("2023-07-14", "2023-07-15") => "Jul 14, 2023 - Jul 15, 2023" + */ +export function formatIso(isoStr1?: string, isoStr2?: string, opts?: Intl.DateTimeFormatOptions) { + if (!isoStr1 && !isoStr2) return; + // both dates are given and are different, show a range + if (isoStr1 && isoStr2 && isoStr1.substring(0, 10) != isoStr2.substring(0, 10)) { + // separate the two dates with an en dash + return `${formatIso(isoStr1, undefined, opts)} – ${formatIso(isoStr2, undefined, opts)}`; + } + const isoStr = isoStr1 || isoStr2 || ''; + // only one day given, or both are the same day, show a single date + const dt = DateTime.fromISO(isoStr, { setZone: true }); + if (!dt.isValid) return isoStr; + return dt.toLocaleString(opts ?? { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export const formatIsoNoYear = (isoStr1?: string, isoStr2?: string) => + formatIso(isoStr1, isoStr2, { month: 'short', day: 'numeric' }); + +export const formatIsoWeekday = (isoStr1?: string, isoStr2?: string) => + formatIso(isoStr1, isoStr2, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + +export const formatIsoWeekdayNoYear = (isoStr1?: string, isoStr2?: string) => + formatIso(isoStr1, isoStr2, { weekday: 'short', month: 'short', day: 'numeric' }); + +/** + * @example IsoDateWithOffset('2024-03-22', 1) -> '2024-03-23' + * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26' + */ +export function isoDateWithOffset(date: string, offset: number) { + let d = new Date(date); + d.setUTCDate(d.getUTCDate() + offset); + return d.toISOString().substring(0, 10); +} + +export const isoDateRangeToTsRange = (isoDateRange: [string, string], zone?) => [ + DateTime.fromISO(isoDateRange[0], { zone: zone }).startOf('day').toSeconds(), + DateTime.fromISO(isoDateRange[1], { zone: zone }).endOf('day').toSeconds(), +]; + +/** + * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7 + * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000 + * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4 + */ +export const isoDatesDifference = (isoStr1: string, isoStr2: string) => + -DateTime.fromISO(isoStr1).diff(DateTime.fromISO(isoStr2), 'days').days; + +/** + * @param isoStr1 An ISO 8601 formatted timestamp (with timezone) + * @param isoStr2 An ISO 8601 formatted timestamp (with timezone) + * @returns A human-readable, approximate time range, e.g. "2 hours" + */ +export function humanizeIsoRange(isoStr1: string, isoStr2: string) { + if (!isoStr1 || !isoStr2) return; + const beginTime = DateTime.fromISO(isoStr1, { setZone: true }); + const endTime = DateTime.fromISO(isoStr2, { setZone: true }); + const range = endTime.diff(beginTime, ['hours', 'minutes']); + return humanizeDuration(range.as('milliseconds'), { + language: i18next.resolvedLanguage, + largest: 1, + round: true, + }); +} From db160bc6de56259003d7870c9205a78fa08e9733 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 13:48:36 -0400 Subject: [PATCH 13/50] fix segmentDaysByWeeks with blank weeks If there is an long stretch of time with no data, this implementation will not work; it should keep inserting empty weeks until there are no more days --- www/js/metrics/metricsHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index d3a061368..7a7766ad1 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -50,7 +50,7 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); for (let i = days.length - 1; i >= 0; i--) { // if date is older than cutoff, start a new week - if (isoDatesDifference(days[i].date, cutoff) > 0) { + while (isoDatesDifference(days[i].date, cutoff) >= 0) { weeks.push([]); cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); } From 5f131ae7ebe30dd4374c2f602a612a10374f1608 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 13:56:45 -0400 Subject: [PATCH 14/50] update "Movement" section Categorizing this as "Movement" instead of "Active Travel" Also updating to determine the list of "active modes" from the presence of "met" in the rich modes. Before, this was a config option or defaulted to only "walk" and "bike" (now it will include "ebike" if "ebike" has non-zero "mets", or any other modes with non-zero "mets") -update styles on the cards --- www/__tests__/metHelper.test.ts | 2 +- www/js/metrics/MetricsScreen.tsx | 10 +-- .../activetravel/ActiveTravelSection.tsx | 16 ---- .../activetravel/DailyActiveMinutesCard.tsx | 68 -------------- .../activetravel/WeeklyActiveMinutesCard.tsx | 88 ------------------- www/js/metrics/customMetricsHelper.ts | 2 +- www/js/metrics/metricsHelper.ts | 8 +- .../ActiveMinutesTableCard.tsx | 22 ++--- .../movement/DailyActiveMinutesCard.tsx | 55 ++++++++++++ www/js/metrics/movement/MovementSection.tsx | 21 +++++ .../movement/WeeklyActiveMinutesCard.tsx | 81 +++++++++++++++++ .../{activetravel => movement}/metDataset.ts | 0 .../{activetravel => movement}/metHelper.ts | 0 13 files changed, 176 insertions(+), 197 deletions(-) delete mode 100644 www/js/metrics/activetravel/ActiveTravelSection.tsx delete mode 100644 www/js/metrics/activetravel/DailyActiveMinutesCard.tsx delete mode 100644 www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx rename www/js/metrics/{activetravel => movement}/ActiveMinutesTableCard.tsx (80%) create mode 100644 www/js/metrics/movement/DailyActiveMinutesCard.tsx create mode 100644 www/js/metrics/movement/MovementSection.tsx create mode 100644 www/js/metrics/movement/WeeklyActiveMinutesCard.tsx rename www/js/metrics/{activetravel => movement}/metDataset.ts (100%) rename www/js/metrics/{activetravel => movement}/metHelper.ts (100%) diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts index a65f00aa4..c9d0b21df 100644 --- a/www/__tests__/metHelper.test.ts +++ b/www/__tests__/metHelper.test.ts @@ -1,4 +1,4 @@ -import { getMet } from '../js/metrics/activetravel/metHelper'; +import { getMet } from '../js/metrics/movement/metHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx index c5d416ccb..868ace6d8 100644 --- a/www/js/metrics/MetricsScreen.tsx +++ b/www/js/metrics/MetricsScreen.tsx @@ -3,7 +3,7 @@ import { ScrollView, StyleSheet, ViewStyle } from 'react-native'; import { shadow } from 'react-native-paper'; import { TabsProvider, Tabs, TabScreen } from 'react-native-paper-tabs'; import FootprintSection from './footprint/FootprintSection'; -import ActiveTravelSection from './activetravel/ActiveTravelSection'; +import MovementSection from './movement/MovementSection'; import TravelSection from './travel/TravelSection'; import useAppConfig from '../useAppConfig'; import { MetricsUiSection } from '../types/appConfigTypes'; @@ -13,10 +13,10 @@ import { useAppTheme } from '../appTheme'; const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = ['footprint', 'movement', 'travel']; const SECTIONS: Record = { - footprint: [FootprintSection, 'shoe-print', 'Footprint'], - movement: [ActiveTravelSection, 'walk', 'Movement'], - travel: [TravelSection, 'chart-timeline', 'Travel'], - surveys: [SurveysSection, 'clipboard-list', 'Surveys'], + footprint: [FootprintSection, 'shoe-print', i18next.t('metrics.footprint.footprint')], + movement: [MovementSection, 'run', i18next.t('metrics.movement.movement')], + travel: [TravelSection, 'chart-timeline', i18next.t('metrics.travel.travel')], + surveys: [SurveysSection, 'clipboard-list', i18next.t('metrics.surveys.surveys')], }; const MetricsScreen = ({ userMetrics, aggMetrics, metricList }) => { diff --git a/www/js/metrics/activetravel/ActiveTravelSection.tsx b/www/js/metrics/activetravel/ActiveTravelSection.tsx deleted file mode 100644 index 5c2fbea4f..000000000 --- a/www/js/metrics/activetravel/ActiveTravelSection.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; -import DailyActiveMinutesCard from './DailyActiveMinutesCard'; -import ActiveMinutesTableCard from './ActiveMinutesTableCard'; - -const ActiveTravelSection = ({ userMetrics }) => { - return ( - <> - - - - - ); -}; - -export default ActiveTravelSection; diff --git a/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx b/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx deleted file mode 100644 index 57a8341a9..000000000 --- a/www/js/metrics/activetravel/DailyActiveMinutesCard.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useMemo } from 'react'; -import { View } from 'react-native'; -import { Card, Text, useTheme } from 'react-native-paper'; -import { MetricsData } from '../metricsTypes'; -import { metricsStyles } from '../MetricsScreen'; -import { useTranslation } from 'react-i18next'; -import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; -import LineChart from '../../components/LineChart'; -import { getBaseModeByText } from '../../diary/diaryHelper'; -import { tsForDayOfMetricData, valueForFieldOnDay } from '../metricsHelper'; -import useAppConfig from '../../useAppConfig'; -import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; - -type Props = { userMetrics?: MetricsData }; -const DailyActiveMinutesCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); - const { t } = useTranslation(); - const appConfig = useAppConfig(); - // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] - const activeModes = - appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; - - const dailyActiveMinutesRecords = useMemo(() => { - const records: { label: string; x: number; y: number }[] = []; - const recentDays = userMetrics?.duration?.slice(-14); - recentDays?.forEach((day) => { - activeModes.forEach((mode) => { - const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode); - records.push({ - label: labelKeyToRichMode(mode), - x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis - y: activeSeconds ? activeSeconds / 60 : null, // minutes on Y axis - }); - }); - }); - return records as { label: ActiveMode; x: number; y: number }[]; - }, [userMetrics?.duration]); - - return ( - - - - {dailyActiveMinutesRecords.length ? ( - getBaseModeByText(l, labelOptions).color} - /> - ) : ( - - {t('metrics.chart-no-data')} - - )} - - - ); -}; - -export default DailyActiveMinutesCard; diff --git a/www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx b/www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx deleted file mode 100644 index 37842fd0a..000000000 --- a/www/js/metrics/activetravel/WeeklyActiveMinutesCard.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useContext, useMemo, useState } from 'react'; -import { View } from 'react-native'; -import { Card, Text, useTheme } from 'react-native-paper'; -import { MetricsData } from '../metricsTypes'; -import { cardMargin, metricsStyles } from '../MetricsScreen'; -import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from '../metricsHelper'; -import { useTranslation } from 'react-i18next'; -import BarChart from '../../components/BarChart'; -import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; -import { getBaseModeByText } from '../../diary/diaryHelper'; -import TimelineContext from '../../TimelineContext'; -import useAppConfig from '../../useAppConfig'; - -export const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = (typeof ACTIVE_MODES)[number]; - -type Props = { userMetrics?: MetricsData }; -const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); - const { dateRange } = useContext(TimelineContext); - const { t } = useTranslation(); - const appConfig = useAppConfig(); - // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] - const activeModes = - appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; - const weeklyActiveMinutesRecords = useMemo(() => { - if (!userMetrics?.duration) return []; - const records: { x: string; y: number; label: string }[] = []; - const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]); - activeModes.forEach((mode) => { - if (prevWeek) { - const prevSum = prevWeek?.reduce( - (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), - 0, - ); - const xLabel = `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(prevWeek)})`; - records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60 }); - } - const recentSum = recentWeek?.reduce( - (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), - 0, - ); - const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; - records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 }); - }); - return records as { label: ActiveMode; x: string; y: number }[]; - }, [userMetrics?.duration]); - - return ( - - - - {weeklyActiveMinutesRecords.length ? ( - - getBaseModeByText(l, labelOptions).color} - /> - - {t('main-metrics.weekly-goal-footnote')} - - - ) : ( - - {t('metrics.chart-no-data')} - - )} - - - ); -}; - -export default WeeklyActiveMinutesCard; diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts index 5952c6e0c..d468f2f35 100644 --- a/www/js/metrics/customMetricsHelper.ts +++ b/www/js/metrics/customMetricsHelper.ts @@ -1,6 +1,6 @@ import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { displayError, logDebug, logWarn } from '../plugin/logger'; -import { standardMETs } from './activetravel/metDataset'; +import { standardMETs } from './movement/metDataset'; import { AppConfig } from '../types/appConfigTypes'; //variables to store values locally diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 7a7766ad1..df388ecca 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,10 +1,10 @@ import { DateTime } from 'luxon'; import { DayOfMetricData } from './metricsTypes'; import { logDebug } from '../plugin/logger'; -import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; import { MetricName, groupingFields } from '../types/appConfigTypes'; import { ImperialConfig } from '../config/useImperialConfig'; import i18next from 'i18next'; +import { base_modes, metrics_summaries } from 'e-mission-common'; import { formatForDisplay, formatIsoNoYear, isoDatesDifference, isoDateWithOffset } from '../util'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { @@ -67,6 +67,12 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { const endIsoDate = days[days.length - 1].date; return formatIsoNoYear(startIsoDate, endIsoDate); } + +export function getActiveModes(labelOptions: LabelOptions) { + return labelOptions.MODE.filter((mode) => { + const richMode = base_modes.get_rich_mode(mode) as RichMode; + return richMode.met && Object.values(richMode.met).some((met) => met?.mets || -1 > 0); + }).map((mode) => mode.value); } /* formatting data form carbon footprint calculations */ diff --git a/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx b/www/js/metrics/movement/ActiveMinutesTableCard.tsx similarity index 80% rename from www/js/metrics/activetravel/ActiveMinutesTableCard.tsx rename to www/js/metrics/movement/ActiveMinutesTableCard.tsx index f10ccf66e..7daae283c 100644 --- a/www/js/metrics/activetravel/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/movement/ActiveMinutesTableCard.tsx @@ -1,5 +1,5 @@ import React, { useContext, useMemo, useState } from 'react'; -import { Card, DataTable, useTheme } from 'react-native-paper'; +import { Card, DataTable, Text, useTheme } from 'react-native-paper'; import { MetricsData } from '../metricsTypes'; import { metricsStyles } from '../MetricsScreen'; import { @@ -10,20 +10,14 @@ import { valueForFieldOnDay, } from '../metricsHelper'; import { useTranslation } from 'react-i18next'; -import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; import { labelKeyToRichMode } from '../../survey/multilabel/confirmHelper'; import TimelineContext from '../../TimelineContext'; -import useAppConfig from '../../useAppConfig'; -type Props = { userMetrics?: MetricsData }; -const ActiveMinutesTableCard = ({ userMetrics }: Props) => { +type Props = { userMetrics?: MetricsData; activeModes: string[] }; +const ActiveMinutesTableCard = ({ userMetrics, activeModes }: Props) => { const { colors } = useTheme(); const { dateRange } = useContext(TimelineContext); const { t } = useTranslation(); - const appConfig = useAppConfig(); - // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] - const activeModes = - appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; const cumulativeTotals = useMemo(() => { if (!userMetrics?.duration) return []; @@ -81,15 +75,9 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { return ( - + {t('metrics.movement.active-minutes')} + {t('metrics.movement.active-minutes-table')} diff --git a/www/js/metrics/movement/DailyActiveMinutesCard.tsx b/www/js/metrics/movement/DailyActiveMinutesCard.tsx new file mode 100644 index 000000000..fd33c85b1 --- /dev/null +++ b/www/js/metrics/movement/DailyActiveMinutesCard.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { Card, Text } from 'react-native-paper'; +import { MetricsData } from '../metricsTypes'; +import { metricsStyles } from '../MetricsScreen'; +import { useTranslation } from 'react-i18next'; +import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; +import LineChart from '../../components/LineChart'; +import { getBaseModeByText } from '../../diary/diaryHelper'; +import { tsForDayOfMetricData, valueForFieldOnDay } from '../metricsHelper'; + +type Props = { userMetrics?: MetricsData; activeModes: string[] }; +const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { + const { t } = useTranslation(); + + const dailyActiveMinutesRecords = useMemo(() => { + const records: { label: string; x: number; y: number }[] = []; + const recentDays = userMetrics?.duration?.slice(-14); + activeModes.forEach((mode) => { + if (recentDays?.some((d) => valueForFieldOnDay(d, 'mode_confirm', mode))) { + recentDays?.forEach((day) => { + const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode); + records.push({ + label: labelKeyToRichMode(mode), + x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis + y: (activeSeconds || 0) / 60, // minutes on Y axis + }); + }); + } + }); + return records as { label: string; x: number; y: number }[]; + }, [userMetrics?.duration]); + + return ( + + + {t('metrics.movement.daily-active-minutes')} + {dailyActiveMinutesRecords.length ? ( + getBaseModeByText(l, labelOptions).color} + /> + ) : ( + + {t('metrics.no-data-available')} + + )} + + + ); +}; + +export default DailyActiveMinutesCard; diff --git a/www/js/metrics/movement/MovementSection.tsx b/www/js/metrics/movement/MovementSection.tsx new file mode 100644 index 000000000..6870736ed --- /dev/null +++ b/www/js/metrics/movement/MovementSection.tsx @@ -0,0 +1,21 @@ +import React, { useContext } from 'react'; +import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; +import DailyActiveMinutesCard from './DailyActiveMinutesCard'; +import ActiveMinutesTableCard from './ActiveMinutesTableCard'; +import TimelineContext from '../../TimelineContext'; +import { getActiveModes } from '../metricsHelper'; + +const MovementSection = ({ userMetrics }) => { + const { labelOptions } = useContext(TimelineContext); + const activeModes = labelOptions ? getActiveModes(labelOptions) : []; + + return ( + <> + + + + + ); +}; + +export default MovementSection; diff --git a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx new file mode 100644 index 000000000..40684d0cc --- /dev/null +++ b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx @@ -0,0 +1,81 @@ +import React, { useContext, useMemo } from 'react'; +import { View } from 'react-native'; +import { Card, Text, useTheme } from 'react-native-paper'; +import { MetricsData } from '../metricsTypes'; +import { metricsStyles } from '../MetricsScreen'; +import { + aggMetricEntries, + formatDateRangeOfDays, + segmentDaysByWeeks, + valueForFieldOnDay, +} from '../metricsHelper'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../../components/BarChart'; +import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; +import { getBaseModeByText } from '../../diary/diaryHelper'; +import TimelineContext from '../../TimelineContext'; + +type Props = { userMetrics?: MetricsData; activeModes: string[] }; +const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { + const { colors } = useTheme(); + const { dateRange } = useContext(TimelineContext); + const { t } = useTranslation(); + + const weekDurations = useMemo(() => { + if (!userMetrics?.duration?.length) return []; + const weeks = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]); + return weeks.map((week, i) => ({ + ...aggMetricEntries(week, 'duration'), + })); + }, [userMetrics]); + + const weeklyActiveMinutesRecords = useMemo(() => { + let records: { label: string; x: string; y: number }[] = []; + activeModes.forEach((mode) => { + if (weekDurations.some((week) => valueForFieldOnDay(week, 'mode_confirm', mode))) { + weekDurations.forEach((week) => { + const val = valueForFieldOnDay(week, 'mode_confirm', mode); + records.push({ + label: labelKeyToRichMode(mode), + x: formatDateRangeOfDays(week), + y: val / 60, + }); + }); + } + }); + return records; + }, [weekDurations]); + + return ( + + + {t('metrics.movement.weekly-active-minutes')} + {weeklyActiveMinutesRecords.length ? ( + + getBaseModeByText(l, labelOptions).color} + /> + + {t('metrics.movement.weekly-goal-footnote')} + + + ) : ( + + {t('metrics.no-data-available')} + + )} + + + ); +}; + +export default WeeklyActiveMinutesCard; diff --git a/www/js/metrics/activetravel/metDataset.ts b/www/js/metrics/movement/metDataset.ts similarity index 100% rename from www/js/metrics/activetravel/metDataset.ts rename to www/js/metrics/movement/metDataset.ts diff --git a/www/js/metrics/activetravel/metHelper.ts b/www/js/metrics/movement/metHelper.ts similarity index 100% rename from www/js/metrics/activetravel/metHelper.ts rename to www/js/metrics/movement/metHelper.ts From e000b15529b7c5068ca2e68e1c9e4fa939c8c7d2 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 14:02:29 -0400 Subject: [PATCH 15/50] MetricsTab: only force >= 14 days on initialization When the user first goes to the Dashboard in an app session, we want to show them at least 2 weeks so they can compare the previous week to the past week. But after that we should allow them to select any range. So we can add an 'isInitialized' bool state to mark this, and only do this ">= 14 days" check on the initialization Also add a 'refresh' function which not only refreshes the timeline but also resets 'isInitialized' (along with 'aggMetricsIsLoading') --- www/js/metrics/MetricsTab.tsx | 51 +++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 716103e35..d76cfbde5 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -79,7 +79,7 @@ const MetricsTab = () => { const appConfig = useAppConfig(); const { t } = useTranslation(); const { - dateRange, + queriedDateRange, timelineMap, timelineLabelMap, labelOptions, @@ -94,22 +94,28 @@ const MetricsTab = () => { const [userMetrics, setUserMetrics] = useState(undefined); const [aggMetrics, setAggMetrics] = useState(undefined); const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); - const readyToLoad = useMemo(() => { - if (!appConfig || !dateRange) return false; - const dateRangeDays = isoDatesDifference(...dateRange); - if (dateRangeDays < N_DAYS_TO_LOAD) { - logDebug('MetricsTab: not enough days loaded, trying to load more'); - const loadingMore = loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); - if (loadingMore !== false) return false; - logDebug('MetricsTab: no more days can be loaded, continuing with what we have'); + useEffect(() => { + if (!isInitialized && appConfig && queriedDateRange) { + logDebug('MetricsTab: initializing'); + const queriedNumDays = isoDatesDifference(...queriedDateRange) + 1; + if (queriedNumDays < N_DAYS_TO_LOAD) { + logDebug('MetricsTab: not enough days loaded, trying to load more'); + const loadingMore = loadMoreDays('past', N_DAYS_TO_LOAD - queriedNumDays); + if (!loadingMore) { + logDebug('MetricsTab: no more days can be loaded, continuing with what we have'); + setIsInitialized(true); + } + } else { + setIsInitialized(true); + } } - return true; - }, [appConfig, dateRange]); + }, [appConfig, queriedDateRange]); useEffect(() => { if ( - !readyToLoad || + !isInitialized || !appConfig || timelineIsLoading || !timelineMap || @@ -121,17 +127,23 @@ const MetricsTab = () => { computeUserMetrics(metricList, timelineMap, appConfig, timelineLabelMap, labelOptions).then( (result) => setUserMetrics(result), ); - }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]); + }, [isInitialized, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]); useEffect(() => { - if (!readyToLoad || !appConfig || !dateRange) return; + if (!isInitialized || !appConfig || !queriedDateRange || !labelOptions) return; logDebug('MetricsTab: ready to fetch aggMetrics'); setAggMetricsIsLoading(true); - fetchAggMetrics(metricList, dateRange, appConfig).then((response) => { + fetchAggMetrics(metricList, queriedDateRange, appConfig, labelOptions).then((response) => { setAggMetricsIsLoading(false); setAggMetrics(response); }); - }, [readyToLoad, appConfig, dateRange]); + }, [isInitialized, appConfig, queriedDateRange]); + + function refresh() { + refreshTimeline(); + setIsInitialized(false); + setAggMetricsIsLoading(true); + } return ( <> @@ -149,7 +161,12 @@ const MetricsTab = () => { loadDateRange([start, end]); }} /> - + From 612e674dc270f953ab325d40d7255521eb310c10 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 14:07:22 -0400 Subject: [PATCH 16/50] adjust metrics cards styles --- www/js/metrics/MetricsScreen.tsx | 9 +------- www/js/metrics/SumaryCard.tsx | 22 +++++++++---------- .../metrics/surveys/SurveyComparisonCard.tsx | 13 +++++------ .../metrics/surveys/SurveyLeaderboardCard.tsx | 16 +++++--------- .../surveys/SurveyTripCategoriesCard.tsx | 3 +-- 5 files changed, 24 insertions(+), 39 deletions(-) diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx index 868ace6d8..79b635978 100644 --- a/www/js/metrics/MetricsScreen.tsx +++ b/www/js/metrics/MetricsScreen.tsx @@ -58,15 +58,9 @@ export const metricsStyles = StyleSheet.create({ minHeight: 300, }, title: (colors) => ({ - backgroundColor: colors.primary, paddingHorizontal: 8, minHeight: 52, }), - titleText: (colors) => ({ - color: colors.onPrimary, - fontWeight: '500', - textAlign: 'center', - }), subtitleText: { fontSize: 13, lineHeight: 13, @@ -74,8 +68,7 @@ export const metricsStyles = StyleSheet.create({ fontStyle: 'italic', }, content: { - padding: 8, - paddingBottom: 12, + gap: 12, flex: 1, }, }); diff --git a/www/js/metrics/SumaryCard.tsx b/www/js/metrics/SumaryCard.tsx index 3be3442d9..dc7bac3ac 100644 --- a/www/js/metrics/SumaryCard.tsx +++ b/www/js/metrics/SumaryCard.tsx @@ -1,9 +1,11 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { View, StyleSheet } from 'react-native'; import { Card, Text } from 'react-native-paper'; -import color from 'color'; import { formatForDisplay } from '../util'; -import { useAppTheme } from '../appTheme'; +import { colors } from '../appTheme'; +import { t } from 'i18next'; +import { FootprintGoal } from '../types/appConfigTypes'; +import { metricsStyles } from './MetricsScreen'; type Value = [number, number]; type Props = { @@ -32,14 +34,10 @@ const SummaryCard = ({ title, unit, value, nDays, guidelines }: Props) => { return colors.onSurfaceVariant; }; - useEffect(() => { - console.debug('SummaryCard: value', value); - }, [value]); - return ( - + {!isNaN(value[0]) ? ( - + {title} {formatVal(value)} {unit} @@ -59,9 +57,9 @@ const SummaryCard = ({ title, unit, value, nDays, guidelines }: Props) => { ) : ( - - {title} - No data available + + {title} + {t('metrics.chart-no-data')} )} diff --git a/www/js/metrics/surveys/SurveyComparisonCard.tsx b/www/js/metrics/surveys/SurveyComparisonCard.tsx index 0b642831e..c1acde79f 100644 --- a/www/js/metrics/surveys/SurveyComparisonCard.tsx +++ b/www/js/metrics/surveys/SurveyComparisonCard.tsx @@ -113,26 +113,25 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { return ( {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? ( - {t('metrics.chart-no-data')} + {t('metrics.no-data-available')} ) : ( - {t('main-metrics.survey-response-rate')} + {t('metrics.surveys.survey-response-rate')} {renderDoughnutChart(myResponsePct, colors.navy, true)} {renderDoughnutChart(othersResponsePct, colors.orange, false)} - + )} diff --git a/www/js/metrics/surveys/SurveyLeaderboardCard.tsx b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx index 169060154..95a9c44a4 100644 --- a/www/js/metrics/surveys/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx @@ -70,20 +70,18 @@ const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { return ( - * {t('main-metrics.survey-leaderboard-desc')} - {studyStartDate} + * {t('metrics.leaderboard.data-accumulated-since-date', { date: studyStartDate })} - {t('main-metrics.survey-response-rate')} + {t('metrics.surveys.survey-response-rate')} { enableTooltip={false} /> - {t('main-metrics.you-are-in')} - #{myRank + 1} - {t('main-metrics.place')} + {t('metrics.leaderboard.you-are-in-x-place', { x: myRank + 1 })} diff --git a/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx index a894ed432..95da890bf 100644 --- a/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx @@ -55,9 +55,8 @@ const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { From de524ca6557a313898f5cf212acfdbd798a864c1 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 14:09:14 -0400 Subject: [PATCH 17/50] change SummaryCard, 'guidelines' -> 'goals' and fix them since they were flipped ('low' should come first) --- www/js/metrics/SumaryCard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/SumaryCard.tsx b/www/js/metrics/SumaryCard.tsx index dc7bac3ac..c36b48a9d 100644 --- a/www/js/metrics/SumaryCard.tsx +++ b/www/js/metrics/SumaryCard.tsx @@ -13,10 +13,9 @@ type Props = { unit: string; value: Value; nDays: number; - guidelines: number[]; + goals: FootprintGoal[]; }; -const SummaryCard = ({ title, unit, value, nDays, guidelines }: Props) => { - const { colors } = useAppTheme(); +const SummaryCard = ({ title, unit, value, nDays, goals }: Props) => { const valueIsRange = value[0] != value[1]; const perDayValue = value.map((v) => v / nDays) as Value; @@ -29,8 +28,8 @@ const SummaryCard = ({ title, unit, value, nDays, guidelines }: Props) => { const colorFn = (v: Value) => { const low = v[0]; const high = v[1]; - if (high < guidelines[guidelines.length - 1]) return colors.success; - if (low > guidelines[0]) return colors.error; + if (high < goals[0]?.value) return colors.success; + if (low > goals[goals.length - 1]?.value) return colors.error; return colors.onSurfaceVariant; }; From c9831b39299fc52766096685034b2b9d042427d0 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 14:16:02 -0400 Subject: [PATCH 18/50] use e-mission-common 0.6.1, update types e-mission-common 0.6.1 comes with expanded metrics (support for 'footprint', which needs label_options passed from MetricsTab.tsx). Update types: new LabelOptions spec, explicit type for RichMode, add 'footprint' to metricsTypes, add "MetricEntry" type which is like DayOfMetricData but not necessarily for 1 day (it could be aggregated across an entire week, for example) Add "goals" to appconfig.metrics.phone_dashboard_ui.footprint_options --- package.cordovabuild.json | 6 ++++-- package.serve.json | 2 +- www/js/metrics/MetricsTab.tsx | 16 +++++++++++----- www/js/metrics/metricsHelper.ts | 3 ++- www/js/metrics/metricsTypes.ts | 24 +++++++++++++++--------- www/js/types/appConfigTypes.ts | 23 ++++++++++++++++------- www/js/types/labelTypes.ts | 32 +++++++++++++++++++++++++------- 7 files changed, 74 insertions(+), 32 deletions(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 2bd4fd480..e68b0ccfb 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -98,7 +98,8 @@ }, "cordova-plugin-bluetooth-classic-serial-port": {}, "cordova-custom-config": {}, - "cordova-plugin-ibeacon": {} + "cordova-plugin-ibeacon": {}, + "cordova-plugin-statusbar": {} } }, "dependencies": { @@ -136,8 +137,9 @@ "cordova-plugin-bluetooth-classic-serial-port": "git+https://github.com/louisg1337/cordova-plugin-bluetooth-classic-serial-port.git", "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", + "cordova-plugin-statusbar": "^4.0.0", "core-js": "^2.5.7", - "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.4", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.6.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/package.serve.json b/package.serve.json index 34e928dff..a0d86cafc 100644 --- a/package.serve.json +++ b/package.serve.json @@ -65,7 +65,7 @@ "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", - "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.4", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.6.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index d76cfbde5..d4fdddc35 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -10,13 +10,12 @@ import useAppConfig from '../useAppConfig'; import { AppConfig, MetricList } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext'; -import { isoDatesDifference } from '../diary/timelineHelper'; import { metrics_summaries } from 'e-mission-common'; import MetricsScreen from './MetricsScreen'; import { LabelOptions } from '../types/labelTypes'; import { useAppTheme } from '../appTheme'; +import { isoDatesDifference } from '../util'; -// 2 weeks of data is needed in order to compare "past week" vs "previous week" const N_DAYS_TO_LOAD = 14; // 2 weeks export const DEFAULT_METRIC_LIST: MetricList = { footprint: ['mode_confirm'], @@ -34,12 +33,15 @@ async function computeUserMetrics( ) { try { const timelineValues = [...timelineMap.values()]; + const app_config = { + ...appConfig, + ...(metricList.footprint ? { label_options: labelOptions } : {}), + }; const result = await metrics_summaries.generate_summaries( { ...metricList }, timelineValues, - appConfig, + app_config, timelineLabelMap, - labelOptions, ); logDebug('MetricsTab: computed userMetrics'); console.debug('MetricsTab: computed userMetrics', result); @@ -53,6 +55,7 @@ async function fetchAggMetrics( metricList: MetricList, dateRange: [string, string], appConfig: AppConfig, + labelOptions: LabelOptions, ) { logDebug('MetricsTab: fetching agg metrics from server for dateRange ' + dateRange); const query = { @@ -61,7 +64,10 @@ async function fetchAggMetrics( end_time: dateRange[1], metric_list: metricList, is_return_aggregate: true, - app_config: { survey_info: appConfig.survey_info }, + app_config: { + ...(metricList.response_count ? { survey_info: appConfig.survey_info } : {}), + ...(metricList.footprint ? { label_options: labelOptions } : {}), + }, }; return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server) .then((response) => { diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index df388ecca..1c1ecb34a 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,11 +1,12 @@ import { DateTime } from 'luxon'; -import { DayOfMetricData } from './metricsTypes'; +import { DayOfMetricData, MetricEntry, MetricValue } from './metricsTypes'; import { logDebug } from '../plugin/logger'; import { MetricName, groupingFields } from '../types/appConfigTypes'; import { ImperialConfig } from '../config/useImperialConfig'; import i18next from 'i18next'; import { base_modes, metrics_summaries } from 'e-mission-common'; import { formatForDisplay, formatIsoNoYear, isoDatesDifference, isoDateWithOffset } from '../util'; +import { LabelOptions, RichMode } from '../types/labelTypes'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index d6105c30a..8815f1f56 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,20 +1,26 @@ import { GroupingField, MetricName } from '../types/appConfigTypes'; +type TravelMetricName = 'distance' | 'duration' | 'count'; + // distance, duration, and count use number values in meters, seconds, and count respectively // response_count uses object values containing responded and not_responded counts -type MetricValue = T extends 'response_count' - ? { responded?: number; not_responded?: number } - : number; +// footprint uses object values containing kg_co2 and kwh values with optional _uncertain values +export type MetricValue = T extends TravelMetricName + ? number + : T extends 'response_count' + ? { responded?: number; not_responded?: number } + : T extends 'footprint' + ? { kg_co2: number; kg_co2_uncertain?: number; kwh: number; kwh_uncertain?: number } + : never; + +export type MetricEntry = { + [k in `${GroupingField}_${string}`]?: MetricValue; +}; export type DayOfMetricData = { date: string; // yyyy-mm-dd nUsers: number; -} & { - // each key is a value for a specific grouping field - // and the value is the respective metric value - // e.g. { mode_confirm_bikeshare: 123, survey_TripConfirmSurvey: { responded: 4, not_responded: 5 } - [k in `${GroupingField}_${string}`]: MetricValue; -}; +} & MetricEntry; export type MetricsData = { [key in MetricName]: DayOfMetricData[]; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 4ce81944c..882f09e9a 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -107,6 +107,16 @@ export const groupingFields = [ export type GroupingField = (typeof groupingFields)[number]; export type MetricList = { [k in MetricName]?: GroupingField[] }; export type MetricsUiSection = 'footprint' | 'movement' | 'surveys' | 'travel'; +export type FootprintGoal = { + label: { [lang: string]: string }; + value: number; + color?: string; +}; +export type FootprintGoals = { + carbon: FootprintGoal[]; + energy: FootprintGoal[]; + goals_footnote?: { [lang: string]: string }; +}; export type MetricsConfig = { include_test_users: boolean; phone_dashboard_ui?: { @@ -114,13 +124,12 @@ export type MetricsConfig = { metric_list: MetricList; footprint_options?: { unlabeled_uncertainty: boolean; + goals?: FootprintGoals; }; - summary_options?: {}; - engagement_options?: { - leaderboard_metric: [string, string]; - }; - active_travel_options?: { - modes_list: string[]; - }; + movement_options?: {}; + surveys_options?: {}; + travel_options?: {}; }; }; + +export default AppConfig; diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts index 8ac720adc..f1340fdf7 100644 --- a/www/js/types/labelTypes.ts +++ b/www/js/types/labelTypes.ts @@ -6,17 +6,35 @@ export type InputDetails = { key: string; }; }; -export type LabelOption = { + +type FootprintFuelType = 'gasoline' | 'diesel' | 'electric' | 'cng' | 'lpg' | 'hydrogen'; + +export type RichMode = { value: string; - baseMode: string; - met?: { range: any[]; mets: number }; - met_equivalent?: string; - kgCo2PerKm: number; - text?: string; + base_mode: string; + icon: string; + color: string; + met?: { [k in string]?: { range: [number, number]; mets: number } }; + footprint?: { + [f in FootprintFuelType]?: { + wh_per_km?: number; + wh_per_trip?: number; + }; + }; }; + +export type LabelOption = T extends 'MODE' + ? { + value: string; + base_mode: string; + } & Partial + : { + value: string; + }; + export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; export type LabelOptions = { - [k in T]: LabelOption[]; + [k in T]: LabelOption[]; } & { translations: { [lang: string]: { [translationKey: string]: string }; From 7e109e25ff3ba1bc5047a28e32bde9529d7b2ec4 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 14:22:55 -0400 Subject: [PATCH 19/50] extract getColorForModeLabel to metricsHelper Also fixes translation issue: instead of checking for hardcoded value "Unlabeled" (which probably did not work in other languages) check if the label starts with whatever the "unlabeled" string is for the current language --- www/js/metrics/metricsHelper.ts | 13 +++++++++++++ www/js/metrics/travel/MetricsCard.tsx | 18 ++++-------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 1c1ecb34a..40b94d96c 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import color from 'color'; import { DayOfMetricData, MetricEntry, MetricValue } from './metricsTypes'; import { logDebug } from '../plugin/logger'; import { MetricName, groupingFields } from '../types/appConfigTypes'; @@ -7,6 +8,9 @@ import i18next from 'i18next'; import { base_modes, metrics_summaries } from 'e-mission-common'; import { formatForDisplay, formatIsoNoYear, isoDatesDifference, isoDateWithOffset } from '../util'; import { LabelOptions, RichMode } from '../types/labelTypes'; +import { getBaseModeByText } from '../diary/diaryHelper'; +import { labelOptions } from '../survey/multilabel/confirmHelper'; +import { UNCERTAIN_OPACITY } from '../components/charting'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; @@ -266,3 +270,12 @@ export function getUnitUtilsForMetric( }; return fns[metricName]; } +// Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent +// All other modes are colored according to their base mode +export function getColorForModeLabel(label: string) { + if (label.startsWith(i18next.t('metrics.footprint.unlabeled'))) { + const unknownModeColor = base_modes.get_base_mode_by_key('UNKNOWN').color; + return color(unknownModeColor).alpha(UNCERTAIN_OPACITY).rgb().string(); + } + return getBaseModeByText(label, labelOptions).color; +} diff --git a/www/js/metrics/travel/MetricsCard.tsx b/www/js/metrics/travel/MetricsCard.tsx index d962d5f09..cb53b7dbe 100644 --- a/www/js/metrics/travel/MetricsCard.tsx +++ b/www/js/metrics/travel/MetricsCard.tsx @@ -11,6 +11,7 @@ import { getUniqueLabelsForDays, valueForFieldOnDay, getUnitUtilsForMetric, + getColorForModeLabel, } from '../metricsHelper'; import ToggleSwitch from '../../components/ToggleSwitch'; import { metricsStyles } from '../MetricsScreen'; @@ -80,7 +81,7 @@ const MetricsCard = ({ const cardSubtitleText = useMemo(() => { if (!metricDataDays) return; const groupText = - populationMode == 'user' ? t('main-metrics.user-totals') : t('main-metrics.group-totals'); + populationMode == 'user' ? t('metrics.travel.user-totals') : t('metrics.travel.group-totals'); return `${groupText} (${formatDateRangeOfDays(metricDataDays)})`; }, [metricDataDays, populationMode]); @@ -112,24 +113,13 @@ const MetricsCard = ({ return vals; }, [metricDataDays, viewMode]); - // Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent - // All other modes are colored according to their base mode - const getColorForLabel = (label: string) => { - if (label == 'Unlabeled') { - const unknownModeColor = base_modes.get_base_mode_by_key('UNKNOWN').color; - return colorLib(unknownModeColor).alpha(0.15).rgb().string(); - } - return getBaseModeByText(label, labelOptions).color; - }; - return ( ( Date: Wed, 18 Sep 2024 15:33:13 -0400 Subject: [PATCH 20/50] add WeeklyFootprintCard Shows barchart of average daily footprint for the user, grouped by weeks, colored based on goals ("meter" of green -> red) Includes checkboxes to break down by a grouping field (mode_confirm, purpose_confirm, etc) If mode_confirm, then base mode colors are used instead of the 'meter' Works for either 'carbon' (with kg_co2) or 'energy' (with kwh) new functions in metricsHelper - aggMetricEntries combines/ merges array of MetricEntry (used by WeeklyFootprintCard to aggregate multiple days into a week) - sumMetricEntry adds up all values within a day - sumMetricEntries does both The type definitions may look complex here, but all they really do is allow the return type to depend on the 'metricName'. For example, if metricName is 'duration', then the return type is just a number. But if metricName is 'footprint', it is an object containing 'kwh' and 'kg_co2' This way we don't have to write separate functions for every metric --- .../metrics/footprint/WeeklyFootprintCard.tsx | 146 ++++++++++++++++++ www/js/metrics/metricsHelper.ts | 37 +++++ 2 files changed, 183 insertions(+) create mode 100644 www/js/metrics/footprint/WeeklyFootprintCard.tsx diff --git a/www/js/metrics/footprint/WeeklyFootprintCard.tsx b/www/js/metrics/footprint/WeeklyFootprintCard.tsx new file mode 100644 index 000000000..68177aaf4 --- /dev/null +++ b/www/js/metrics/footprint/WeeklyFootprintCard.tsx @@ -0,0 +1,146 @@ +import React, { useContext, useMemo, useState } from 'react'; +import { View } from 'react-native'; +import { Card, Checkbox, Text } from 'react-native-paper'; +import { metricsStyles } from '../MetricsScreen'; +import TimelineContext from '../../TimelineContext'; +import { + aggMetricEntries, + getColorForModeLabel, + segmentDaysByWeeks, + sumMetricEntries, + trimGroupingPrefix, +} from '../metricsHelper'; +import { formatIsoNoYear, isoDateWithOffset } from '../../util'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../../components/BarChart'; +import { ChartRecord } from '../../components/Chart'; +import i18next from 'i18next'; +import { MetricsData } from '../metricsTypes'; +import { GroupingField, MetricList } from '../../types/appConfigTypes'; +import { labelKeyToRichMode } from '../../survey/multilabel/confirmHelper'; + +type Props = { + type: 'carbon' | 'energy'; + unit: 'kg_co2' | 'kwh'; + title: string; + axisTitle: string; + goals; + addFootnote: (string) => string; + showUncertainty: boolean; + userMetrics: MetricsData; + metricList: MetricList; +}; +const WeeklyFootprintCard = ({ + type, + unit, + title, + axisTitle, + goals, + showUncertainty, + addFootnote, + userMetrics, + metricList, +}: Props) => { + const { t } = useTranslation(); + const { dateRange } = useContext(TimelineContext); + const [groupingField, setGroupingField] = useState(null); + + const weekFootprints = useMemo(() => { + if (!userMetrics?.footprint?.length) return []; + const weeks = segmentDaysByWeeks(userMetrics?.footprint, dateRange[1]); + return weeks.map( + (week) => [sumMetricEntries(week, 'footprint'), aggMetricEntries(week, 'footprint')] as const, + ); + }, [userMetrics]); + + const chartRecords = useMemo(() => { + let records: ChartRecord[] = []; + weekFootprints.forEach(([weekSum, weekAgg], i) => { + const startDate = isoDateWithOffset(dateRange[1], -7 * (i + 1) + 1); + if (startDate < dateRange[0]) return; // partial week at beginning of queried range; skip + const endDate = isoDateWithOffset(dateRange[1], -7 * i); + const displayDateRange = formatIsoNoYear(startDate, endDate); + if (groupingField) { + Object.keys(weekAgg) + .filter((key) => key.startsWith(groupingField) && !key.endsWith('UNLABELED')) + .forEach((key) => { + if (weekAgg[key][unit]) { + records.push({ + label: labelKeyToRichMode(trimGroupingPrefix(key)), + x: weekAgg[key][unit] / 7, + y: displayDateRange, + }); + } + }); + } else { + records.push({ + label: t('metrics.footprint.labeled'), + x: weekSum[unit] / 7, + y: displayDateRange, + }); + } + if (showUncertainty && weekSum[`${unit}_uncertain`]) { + records.push({ + label: + t('metrics.footprint.unlabeled') + + addFootnote(t('metrics.footprint.uncertainty-footnote')), + x: weekSum[`${unit}_uncertain`] / 7, + y: displayDateRange, + }); + } + }); + return records; + }, [weekFootprints, groupingField]); + + let meter = goals[type]?.length + ? { + uncertainty_prefix: t('metrics.footprint.unlabeled'), + middle: goals[type][0].value, + high: goals[type][goals[type].length - 1].value, + } + : undefined; + + return ( + + + {title} + {chartRecords?.length > 0 ? ( + + + {metricList.footprint!.map((gf: GroupingField) => ( + + + {t('metrics.split-by', { field: t(`metrics.grouping-fields.${gf}`) })} + + + groupingField == gf ? setGroupingField(null) : setGroupingField(gf) + } + /> + + ))} + + ) : ( + + {t('metrics.no-data')} + + )} + + + ); +}; + +export default WeeklyFootprintCard; diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 40b94d96c..dd97317f2 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -37,6 +37,7 @@ export const trimGroupingPrefix = (label: string) => { return label.substring(field.length + 1); } } + return ''; }; export const getLabelsForDay = (metricDataDay: DayOfMetricData) => @@ -270,6 +271,42 @@ export function getUnitUtilsForMetric( }; return fns[metricName]; } + +/** + * @param entries an array of metric entries + * @param metricName the metric that the values are for + * @returns a metric entry with fields of the same name summed across all entries + */ +export function aggMetricEntries(entries: MetricEntry[], metricName: T) { + let acc = {}; + entries?.forEach((e) => { + for (let field in e) { + if (groupingFields.some((f) => field.startsWith(f))) { + acc[field] = metrics_summaries.acc_value_of_metric(metricName, acc?.[field], e[field]); + } + } + }); + return acc as MetricEntry; +} + +/** + * @param a metric entry + * @param metricName the metric that the values are for + * @returns the result of summing the values across all fields in the entry + */ +export function sumMetricEntry(entry: MetricEntry, metricName: T) { + let acc; + for (let field in entry) { + if (groupingFields.some((f) => field.startsWith(f))) { + acc = metrics_summaries.acc_value_of_metric(metricName, acc, entry[field]); + } + } + return (acc || {}) as MetricValue; +} + +export const sumMetricEntries = (days: DayOfMetricData[], metricName: T) => + sumMetricEntry(aggMetricEntries(days, metricName) as any, metricName); + // Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent // All other modes are colored according to their base mode export function getColorForModeLabel(label: string) { From 24c0eb4398350e1d771c7f8335482588369da951 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 15:54:35 -0400 Subject: [PATCH 21/50] use SummaryCard and WeeklyFootprintCard; remove old cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit use the new components, SummaryCard and WeeklyFootprintCard, on the Footprint section. Each of these work for both carbon and energy, so we don't need the 'energy' components, nor the old CarbonFootprintCard and CarbonTextCard A few things of note in FootprintSection: cumulativeFootprintSum sums the footprint metric (which includes both carbon and energy) across all days. This gets fed into SummaryCard Goals are read from the appconfig. Because they are internationalized, though, the labels have to looked up by lang. If a footnote is configured, it is added and a reference to it is appended to the label. The goals are passed to SummaryCard and WeeklyFootprintCard A dynamic footnotes mechanism via `addFootnote`. Instead of including the footnote numbers, (e.g. ¹ ²) directly in the strings, we can round them up for the whole section, keeping track of their numbers, and show them all at the bottom. This allows footnotes to be dynamically shown. If one configuration doesn't use "goals", then that footnote won't show. The "unlabeled" footnote will then become ¹, avoiding any weird scenarios where ¹ is not present and it skips straight to ² We can expand on this later if we want to be more detailed about footnotes, references, data sources --- www/js/metrics/energy/EnergySection.tsx | 7 - .../metrics/footprint/CarbonFootprintCard.tsx | 243 ------------------ www/js/metrics/footprint/CarbonTextCard.tsx | 196 -------------- .../metrics/footprint/EnergyFootprintCard.tsx | 242 ----------------- www/js/metrics/footprint/FootprintSection.tsx | 111 +++++++- www/js/metrics/footprint/footprintHelper.ts | 140 ++++------ 6 files changed, 151 insertions(+), 788 deletions(-) delete mode 100644 www/js/metrics/energy/EnergySection.tsx delete mode 100644 www/js/metrics/footprint/CarbonFootprintCard.tsx delete mode 100644 www/js/metrics/footprint/CarbonTextCard.tsx delete mode 100644 www/js/metrics/footprint/EnergyFootprintCard.tsx diff --git a/www/js/metrics/energy/EnergySection.tsx b/www/js/metrics/energy/EnergySection.tsx deleted file mode 100644 index 006bce4a9..000000000 --- a/www/js/metrics/energy/EnergySection.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const EnergySection = ({ userMetrics, aggMetrics }) => { - return <>; -}; - -export default EnergySection; diff --git a/www/js/metrics/footprint/CarbonFootprintCard.tsx b/www/js/metrics/footprint/CarbonFootprintCard.tsx deleted file mode 100644 index 4e2b0220a..000000000 --- a/www/js/metrics/footprint/CarbonFootprintCard.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useState, useMemo, useContext } from 'react'; -import { View } from 'react-native'; -import { Card, Text } from 'react-native-paper'; -import { MetricsData } from '../metricsTypes'; -import { metricsStyles } from '../MetricsScreen'; -import { - getFootprintForMetrics, - getHighestFootprint, - getHighestFootprintForDistance, -} from './footprintHelper'; -import { - formatDateRangeOfDays, - parseDataFromMetrics, - generateSummaryFromData, - calculatePercentChange, - segmentDaysByWeeks, - isCustomLabels, - MetricsSummary, -} from '../metricsHelper'; -import { useTranslation } from 'react-i18next'; -import BarChart from '../../components/BarChart'; -import ChangeIndicator, { CarbonChange } from './ChangeIndicator'; -import color from 'color'; -import { useAppTheme } from '../../appTheme'; -import { logDebug, logWarn } from '../../plugin/logger'; -import TimelineContext from '../../TimelineContext'; -import { isoDatesDifference } from '../../diary/timelineHelper'; -import useAppConfig from '../../useAppConfig'; - -type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; -const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { - const { colors } = useAppTheme(); - const { dateRange } = useContext(TimelineContext); - const appConfig = useAppConfig(); - const { t } = useTranslation(); - // Whether to show the uncertainty on the carbon footprint charts, default: true - const showUnlabeledMetrics = - appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true; - const [emissionsChange, setEmissionsChange] = useState(undefined); - - const userCarbonRecords = useMemo(() => { - if (userMetrics?.distance?.length) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks( - userMetrics?.distance, - dateRange[1], - ); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce( - (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, - 0, - ); - - //setting up data to be displayed - let graphRecords: { label: string; x: number | string; y: number | string }[] = []; - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - let userPrevWeek; - if (userLastWeekSummaryMap[0]) { - userPrevWeek = { - low: getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), - }; - if (showUnlabeledMetrics) { - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPrevWeek.high - userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); - } - graphRecords.push({ - label: t('main-metrics.labeled'), - x: userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); - } - - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), - }; - if (showUnlabeledMetrics) { - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPastWeek.high - userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - } - graphRecords.push({ - label: t('main-metrics.labeled'), - x: userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - if (userPrevWeek) { - let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); - setEmissionsChange(pctChange); - } - - //calculate worst-case carbon footprint - let worstCarbon = getHighestFootprintForDistance(worstDistance); - graphRecords.push({ - label: t('main-metrics.labeled'), - x: worstCarbon, - y: `${t('main-metrics.worst-case')}`, - }); - return graphRecords; - } - }, [userMetrics?.distance]); - - const groupCarbonRecords = useMemo(() => { - if (aggMetrics?.distance?.length) { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0]; - logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)}; - thisWeekDistance = ${JSON.stringify(thisWeekDistance)}`); - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData: MetricsSummary[] = aggThisWeekSummary.map((summaryEntry) => { - if (isNaN(summaryEntry.values)) { - logWarn(`WARNING in calculating groupCarbonRecords: value is NaN for mode - ${summaryEntry.key}, changing to 0`); - summaryEntry.values = 0; - } - return summaryEntry; - }); - - let groupRecords: { label: string; x: number | string; y: number | string }[] = []; - - let aggCarbon = { - low: getFootprintForMetrics(aggCarbonData, 0), - high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), - }; - logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`); - if (showUnlabeledMetrics) { - groupRecords.push({ - label: t('main-metrics.unlabeled'), - x: aggCarbon.high - aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - } - groupRecords.push({ - label: t('main-metrics.labeled'), - x: aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - - return groupRecords; - } - }, [aggMetrics]); - - const chartData = useMemo(() => { - let tempChartData: { label: string; x: number | string; y: number | string }[] = []; - if (userCarbonRecords?.length) { - tempChartData = tempChartData.concat(userCarbonRecords); - } - if (groupCarbonRecords?.length) { - tempChartData = tempChartData.concat(groupCarbonRecords); - } - tempChartData = tempChartData.reverse(); - return tempChartData; - }, [userCarbonRecords, groupCarbonRecords]); - - const cardSubtitleText = useMemo(() => { - if (!aggMetrics?.distance?.length) return; - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1]) - .slice(0, 2) - .reverse() - .flat(); - const recentEntriesRange = formatDateRangeOfDays(recentEntries); - return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; - }, [aggMetrics?.distance]); - - //hardcoded here, could be read from config at later customization? - let carbonGoals = [ - { - label: t('main-metrics.us-2050-goal'), - value: 14, - color: color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(), - }, - { - label: t('main-metrics.us-2030-goal'), - value: 54, - color: color(colors.danger).saturate(0.5).rgb().toString(), - }, - ]; - let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; - - return ( - - } - style={metricsStyles.title(colors)} - /> - - {chartData?.length > 0 ? ( - - - - {t('main-metrics.us-goals-footnote')} - - - ) : ( - - {t('metrics.chart-no-data')} - - )} - - - ); -}; - -export default CarbonFootprintCard; diff --git a/www/js/metrics/footprint/CarbonTextCard.tsx b/www/js/metrics/footprint/CarbonTextCard.tsx deleted file mode 100644 index 2c888f5e5..000000000 --- a/www/js/metrics/footprint/CarbonTextCard.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import React, { useContext, useMemo } from 'react'; -import { View } from 'react-native'; -import { Card, Text, useTheme } from 'react-native-paper'; -import { MetricsData } from '../metricsTypes'; -import { metricsStyles } from '../MetricsScreen'; -import { useTranslation } from 'react-i18next'; -import { - getFootprintForMetrics, - getHighestFootprint, - getHighestFootprintForDistance, -} from './footprintHelper'; -import { - formatDateRangeOfDays, - parseDataFromMetrics, - generateSummaryFromData, - calculatePercentChange, - segmentDaysByWeeks, - MetricsSummary, -} from '../metricsHelper'; -import { logDebug, logWarn } from '../../plugin/logger'; -import TimelineContext from '../../TimelineContext'; -import { isoDatesDifference } from '../../diary/timelineHelper'; -import useAppConfig from '../../useAppConfig'; - -type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; -const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { - const { colors } = useTheme(); - const { dateRange } = useContext(TimelineContext); - const { t } = useTranslation(); - const appConfig = useAppConfig(); - // Whether to show the uncertainty on the carbon footprint charts, default: true - const showUnlabeledMetrics = - appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true; - - const userText = useMemo(() => { - if (userMetrics?.distance?.length) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks( - userMetrics?.distance, - dateRange[1], - ); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce( - (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, - 0, - ); - - //setting up data to be displayed - let textList: { label: string; value: string }[] = []; - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - if (userLastWeekSummaryMap[0]) { - let userPrevWeek = { - low: getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), - }; - const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; - if (userPrevWeek.low == userPrevWeek.high) - textList.push({ label: label, value: `${Math.round(userPrevWeek.low)}` }); - else - textList.push({ - label: label + '²', - value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`, - }); - } - - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), - }; - const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; - if (userPastWeek.low == userPastWeek.high) - textList.push({ label: label, value: `${Math.round(userPastWeek.low)}` }); - else - textList.push({ - label: label + '²', - value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.high)}`, - }); - - //calculate worst-case carbon footprint - let worstCarbon = getHighestFootprintForDistance(worstDistance); - textList.push({ label: t('main-metrics.worst-case'), value: `${Math.round(worstCarbon)}` }); - - return textList; - } - }, [userMetrics]); - - const groupText = useMemo(() => { - if (aggMetrics?.distance?.length) { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0]; - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData: MetricsSummary[] = aggThisWeekSummary.map((summaryEntry) => { - if (isNaN(summaryEntry.values)) { - logWarn(`WARNING in calculating groupCarbonRecords: value is NaN for mode - ${summaryEntry.key}, changing to 0`); - summaryEntry.values = 0; - } - return summaryEntry; - }); - - let groupText: { label: string; value: string }[] = []; - - let aggCarbon = { - low: getFootprintForMetrics(aggCarbonData, 0), - high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), - }; - logDebug(`groupText: aggCarbon = ${JSON.stringify(aggCarbon)}`); - const label = t('main-metrics.average'); - if (aggCarbon.low == aggCarbon.high) - groupText.push({ label: label, value: `${Math.round(aggCarbon.low)}` }); - else - groupText.push({ - label: label + '²', - value: `${Math.round(aggCarbon.low)} - ${Math.round(aggCarbon.high)}`, - }); - - return groupText; - } - }, [aggMetrics]); - - const textEntries = useMemo(() => { - let tempText: { label: string; value: string }[] = []; - if (userText?.length) { - tempText = tempText.concat(userText); - } - if (groupText?.length) { - tempText = tempText.concat(groupText); - } - return tempText; - }, [userText, groupText]); - - const cardSubtitleText = useMemo(() => { - if (!aggMetrics?.distance?.length) return; - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1]) - .slice(0, 2) - .reverse() - .flat(); - const recentEntriesRange = formatDateRangeOfDays(recentEntries); - return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; - }, [aggMetrics?.distance]); - - return ( - - - - {textEntries?.length > 0 && - Object.keys(textEntries).map((i) => ( - - {textEntries[i].label} - {textEntries[i].value + ' ' + 'kg CO₂'} - - ))} - {showUnlabeledMetrics && ( - - {t('main-metrics.range-uncertain-footnote')} - - )} - - - ); -}; - -export default CarbonTextCard; diff --git a/www/js/metrics/footprint/EnergyFootprintCard.tsx b/www/js/metrics/footprint/EnergyFootprintCard.tsx deleted file mode 100644 index ab78f7b37..000000000 --- a/www/js/metrics/footprint/EnergyFootprintCard.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import React, { useState, useMemo, useContext } from 'react'; -import { View } from 'react-native'; -import { Card, Text } from 'react-native-paper'; -import { MetricsData } from '../metricsTypes'; -import { metricsStyles } from '../MetricsScreen'; -import { - getFootprintForMetrics, - getHighestFootprint, - getHighestFootprintForDistance, -} from './footprintHelper'; -import { - formatDateRangeOfDays, - parseDataFromMetrics, - generateSummaryFromData, - calculatePercentChange, - segmentDaysByWeeks, - isCustomLabels, - MetricsSummary, -} from '../metricsHelper'; -import { useTranslation } from 'react-i18next'; -import BarChart from '../../components/BarChart'; -import ChangeIndicator, { CarbonChange } from './ChangeIndicator'; -import color from 'color'; -import { useAppTheme } from '../../appTheme'; -import { logDebug, logWarn } from '../../plugin/logger'; -import TimelineContext from '../../TimelineContext'; -import { isoDatesDifference } from '../../diary/timelineHelper'; -import useAppConfig from '../../useAppConfig'; - -let showUnlabeledMetrics = true; - -type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; -const EnergyFootprintCard = ({ userMetrics, aggMetrics }: Props) => { - const { colors } = useAppTheme(); - const { dateRange } = useContext(TimelineContext); - const appConfig = useAppConfig(); - const { t } = useTranslation(); - - const userCarbonRecords = useMemo(() => { - if (userMetrics?.distance?.length) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks( - userMetrics?.distance, - dateRange[1], - ); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - console.debug({ lastWeekDistance, userLastWeekModeMap, userLastWeekSummaryMap }); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce( - (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, - 0, - ); - - //setting up data to be displayed - let graphRecords: { label: string; x: number | string; y: number | string }[] = []; - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - let userPrevWeek; - if (userLastWeekSummaryMap[0]) { - userPrevWeek = { - low: getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), - }; - if (showUnlabeledMetrics) { - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPrevWeek.high - userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); - } - graphRecords.push({ - label: t('main-metrics.labeled'), - x: userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); - } - - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), - }; - if (showUnlabeledMetrics) { - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPastWeek.high - userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - } - graphRecords.push({ - label: t('main-metrics.labeled'), - x: userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - // if (userPrevWeek) { - // let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); - // setEmissionsChange(pctChange); - // } - - //calculate worst-case carbon footprint - let worstCarbon = getHighestFootprintForDistance(worstDistance); - graphRecords.push({ - label: t('main-metrics.labeled'), - x: worstCarbon, - y: `${t('main-metrics.worst-case')}`, - }); - return graphRecords; - } - }, [userMetrics?.distance]); - - const groupCarbonRecords = useMemo(() => { - if (aggMetrics?.distance?.length) { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0]; - logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)}; - thisWeekDistance = ${JSON.stringify(thisWeekDistance)}`); - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData: MetricsSummary[] = aggThisWeekSummary.map((summaryEntry) => { - if (isNaN(summaryEntry.values)) { - logWarn(`WARNING in calculating groupCarbonRecords: value is NaN for mode - ${summaryEntry.key}, changing to 0`); - summaryEntry.values = 0; - } - return summaryEntry; - }); - - let groupRecords: { label: string; x: number | string; y: number | string }[] = []; - - let aggCarbon = { - low: getFootprintForMetrics(aggCarbonData, 0), - high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), - }; - logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`); - if (showUnlabeledMetrics) { - groupRecords.push({ - label: t('main-metrics.unlabeled'), - x: aggCarbon.high - aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - } - groupRecords.push({ - label: t('main-metrics.labeled'), - x: aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); - - return groupRecords; - } - }, [aggMetrics]); - - const chartData = useMemo(() => { - let tempChartData: { label: string; x: number | string; y: number | string }[] = []; - if (userCarbonRecords?.length) { - tempChartData = tempChartData.concat(userCarbonRecords); - } - if (groupCarbonRecords?.length) { - tempChartData = tempChartData.concat(groupCarbonRecords); - } - tempChartData = tempChartData.reverse(); - return tempChartData; - }, [userCarbonRecords, groupCarbonRecords]); - - const cardSubtitleText = useMemo(() => { - if (!aggMetrics?.distance?.length) return; - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1]) - .slice(0, 2) - .reverse() - .flat(); - const recentEntriesRange = formatDateRangeOfDays(recentEntries); - return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; - }, [aggMetrics?.distance]); - - //hardcoded here, could be read from config at later customization? - let carbonGoals = [ - { - label: t('main-metrics.us-2050-goal'), - value: 14, - color: color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(), - }, - { - label: t('main-metrics.us-2030-goal'), - value: 54, - color: color(colors.danger).saturate(0.5).rgb().toString(), - }, - ]; - let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; - - return ( - - } - style={metricsStyles.title(colors)} - /> - - {chartData?.length > 0 ? ( - - - - {t('main-metrics.us-goals-footnote')} - - - ) : ( - - {t('metrics.chart-no-data')} - - )} - - - ); -}; - -export default EnergyFootprintCard; diff --git a/www/js/metrics/footprint/FootprintSection.tsx b/www/js/metrics/footprint/FootprintSection.tsx index 116fa910e..c1864974e 100644 --- a/www/js/metrics/footprint/FootprintSection.tsx +++ b/www/js/metrics/footprint/FootprintSection.tsx @@ -1,14 +1,109 @@ -import React from 'react'; -import CarbonFootprintCard from './CarbonFootprintCard'; -import CarbonTextCard from './CarbonTextCard'; -import EnergyFootprintCard from './EnergyFootprintCard'; +import React, { useContext, useMemo, useState } from 'react'; +import { View } from 'react-native'; +import { Text } from 'react-native-paper'; +import color from 'color'; +import SummaryCard from '../SumaryCard'; +import { useTranslation } from 'react-i18next'; +import { sumMetricEntries } from '../metricsHelper'; +import TimelineContext from '../../TimelineContext'; +import { formatIso, isoDatesDifference } from '../../util'; +import WeeklyFootprintCard from './WeeklyFootprintCard'; +import useAppConfig from '../../useAppConfig'; +import { getFootprintGoals } from './footprintHelper'; + +const FootprintSection = ({ userMetrics, aggMetrics, metricList }) => { + const { t } = useTranslation(); + const appConfig = useAppConfig(); + const { queriedDateRange } = useContext(TimelineContext); + + const [footnotes, setFootnotes] = useState([]); + + function addFootnote(note: string) { + const i = footnotes.findIndex((n) => n == note); + let footnoteNumber: number; + const superscriptDigits = '⁰¹²³⁴⁵⁶⁷⁸⁹'; + if (i >= 0) { + footnoteNumber = i + 1; + } else { + setFootnotes([...footnotes, note]); + footnoteNumber = footnotes.length + 1; + } + return footnoteNumber + .toString() + .split('') + .map((d) => superscriptDigits[parseInt(d)]) + .join(''); + } + + const cumulativeFootprintSum = useMemo( + () => + userMetrics?.footprint?.length ? sumMetricEntries(userMetrics?.footprint, 'footprint') : null, + [userMetrics?.footprint], + ); + + const goals = getFootprintGoals(appConfig, addFootnote); + + // defaults to true if not defined in config + const showUncertainty = + appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty !== false; + + if (!queriedDateRange) return null; + const nDays = isoDatesDifference(...queriedDateRange) + 1; -const FootprintSection = ({ userMetrics, aggMetrics }) => { return ( <> - - - + + {t('metrics.footprint.estimated-footprint')} + {`${formatIso(...queriedDateRange)} (${nDays} days)`} + + {cumulativeFootprintSum && ( + + + + + )} + + + {footnotes.length && ( + + {footnotes.map((note, i) => ( + + {addFootnote(note)} + {note} + + ))} + + )} ); }; diff --git a/www/js/metrics/footprint/footprintHelper.ts b/www/js/metrics/footprint/footprintHelper.ts index 0b3109273..99dfa738b 100644 --- a/www/js/metrics/footprint/footprintHelper.ts +++ b/www/js/metrics/footprint/footprintHelper.ts @@ -1,95 +1,51 @@ -import { displayError, displayErrorMsg, logDebug, logWarn } from '../../plugin/logger'; -import { getCustomFootprint } from '../customMetricsHelper'; - -//variables for the highest footprint in the set and if using custom -let highestFootprint: number | undefined = 0; - -/** - * @function converts meters to kilometers - * @param {number} v value in meters to be converted - * @returns {number} converted value in km - */ -const mtokm = (v) => v / 1000; - -/** - * @function clears the stored highest footprint - */ -export function clearHighestFootprint() { - //need to clear for testing - highestFootprint = undefined; -} - -/** - * @function gets the footprint - * currently will only be custom, as all labels are "custom" - * @returns the footprint or undefined - */ -function getFootprint() { - let footprint = getCustomFootprint(); - if (footprint) { - return footprint; - } else { - throw new Error('In Footprint Calculatins, failed to use custom labels'); - } -} - -/** - * @function calculates footprint for given metrics - * @param {Array} userMetrics string mode + number distance in meters pairs - * ex: const custom_metrics = [ { key: 'walk', values: 3000 }, { key: 'bike', values: 6500 }, ]; - * @param {number} defaultIfMissing optional, carbon intensity if mode not in footprint - * @returns {number} the sum of carbon emissions for userMetrics given - */ -export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) { - const footprint = getFootprint(); - logDebug('getting footprint for ' + userMetrics + ' with ' + footprint); - let result = 0; - userMetrics.forEach((userMetric) => { - let mode = userMetric.key; - - //either the mode is in our custom footprint or it is not - if (mode in footprint) { - result += footprint[mode] * mtokm(userMetric.values); - } else if (mode == 'IN_VEHICLE') { - const sum = - footprint['CAR'] + - footprint['BUS'] + - footprint['LIGHT_RAIL'] + - footprint['TRAIN'] + - footprint['TRAM'] + - footprint['SUBWAY']; - result += (sum / 6) * mtokm(userMetric.values); - } else { - logWarn( - `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify( - userMetrics, - )}`, - ); - result += defaultIfMissing * mtokm(userMetric.values); - } - }); - return result; -} - -/** - * @function gets highest co2 intensity in the footprint - * @returns {number} the highest co2 intensity in the footprint - */ -export function getHighestFootprint() { - if (!highestFootprint) { - const footprint = getFootprint(); - let footprintList: number[] = []; - for (let mode in footprint) { - footprintList.push(footprint[mode]); +import i18next from 'i18next'; +import color from 'color'; +import { colors } from '../../appTheme'; +import AppConfig from '../../types/appConfigTypes'; + +const lang = i18next.resolvedLanguage || 'en'; +const darkWarn = color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(); +const darkDanger = color(colors.danger).darken(0.65).saturate(0.5).rgb().toString(); +const DEFAULT_FOOTPRINT_GOALS = { + carbon: [ + { + label: { [lang]: i18next.t('metrics.footprint.us-2050-goal') }, + value: 2, + color: darkDanger, + }, + { + label: { [lang]: i18next.t('metrics.footprint.us-2030-goal') }, + value: 7.7, + color: darkWarn, + }, + ], + energy: [ + { + label: { [lang]: i18next.t('metrics.footprint.us-2050-goal') }, + value: 5.7, + color: darkDanger, + }, + { + label: { [lang]: i18next.t('metrics.footprint.us-2030-goal') }, + value: 22, + color: darkWarn, + }, + ], + goals_footnote: { [lang]: i18next.t('metrics.footprint.us-goals-footnote') }, +}; + +export function getFootprintGoals(appConfig: AppConfig, addFootnote: (footnote: string) => any) { + const goals = { + ...(appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.goals ?? + DEFAULT_FOOTPRINT_GOALS), + }; + const footnoteNumber = goals.goals_footnote ? addFootnote(goals.goals_footnote[lang]) : ''; + for (const goalType of ['carbon', 'energy']) { + for (const goal of goals[goalType] || []) { + if (typeof goal.label == 'object') { + goal.label = goal.label[lang] + footnoteNumber; + } } - highestFootprint = Math.max(...footprintList); } - return highestFootprint; + return goals; } - -/** - * @function gets highest theoretical footprint for given distance - * @param {number} distance in meters to calculate max footprint - * @returns max footprint for given distance - */ -export const getHighestFootprintForDistance = (distance) => getHighestFootprint() * mtokm(distance); From b3c132e5ab9f90780e8f0025bf67ea593d0165a8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 15:55:03 -0400 Subject: [PATCH 22/50] add ChartRecord type used by WeeklyFootprintCard --- www/js/components/Chart.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 3a97ab697..8d7406b6c 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -14,9 +14,10 @@ type ChartDataset = { label: string; data: XYPair[]; }; +export type ChartRecord = { label: string; x: number | string; y: number | string }; export type Props = { - records: { label: string; x: number | string; y: number | string }[]; + records: ChartRecord[]; axisTitle: string; type: 'bar' | 'line'; getColorForLabel?: (label: string) => string; From f481608e5cbcd1ce3412f17358442f44de3ded29 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 15:55:49 -0400 Subject: [PATCH 23/50] add missing import in MetricsScreen --- www/js/metrics/MetricsScreen.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx index 79b635978..b3e442c76 100644 --- a/www/js/metrics/MetricsScreen.tsx +++ b/www/js/metrics/MetricsScreen.tsx @@ -9,6 +9,7 @@ import useAppConfig from '../useAppConfig'; import { MetricsUiSection } from '../types/appConfigTypes'; import SurveysSection from './surveys/SurveysSection'; import { useAppTheme } from '../appTheme'; +import i18next from 'i18next'; const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = ['footprint', 'movement', 'travel']; From 19881d8058a182d03d75bcc1fbc36e4a49253ddf Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 17:39:53 -0400 Subject: [PATCH 24/50] TimelineContext: set initial value for dateRange instead of setting dateRange only once pipelineRange is set, we can set dateRange upfront and just not do anything with it until pipelineRange has been set This saves us from constantly checking dateRange != null anywhere it is used downstream --- www/js/TimelineContext.ts | 22 ++++++++++------------ www/js/components/ToggleSwitch.tsx | 2 -- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 7fc2f6c62..e26016daa 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -25,6 +25,8 @@ import { primarySectionForTrip } from './diary/diaryHelper'; import { isoDateRangeToTsRange, isoDateWithOffset } from './util'; const TODAY_DATE = DateTime.now().toISODate(); +// initial date range is the past week: [TODAY - 6 days, TODAY] +const INITIAL_DATE_RANGE: [string, string] = [isoDateWithOffset(TODAY_DATE, -6), TODAY_DATE]; type ContextProps = { labelOptions: LabelOptions | null; @@ -40,7 +42,7 @@ type ContextProps = { addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; pipelineRange: TimestampRange | null; queriedDateRange: [string, string] | null; // YYYY-MM-DD format - dateRange: [string, string] | null; // YYYY-MM-DD format + dateRange: [string, string]; // YYYY-MM-DD format timelineIsLoading: string | false; loadMoreDays: (when: 'past' | 'future', nDays: number) => boolean | void; loadDateRange: (d: [string, string]) => boolean | void; @@ -59,7 +61,7 @@ export const useTimelineContext = (): ContextProps => { // date range (inclusive) that has been loaded into the UI [YYYY-MM-DD, YYYY-MM-DD] const [queriedDateRange, setQueriedDateRange] = useState<[string, string] | null>(null); // date range (inclusive) chosen by datepicker [YYYY-MM-DD, YYYY-MM-DD] - const [dateRange, setDateRange] = useState<[string, string] | null>(null); + const [dateRange, setDateRange] = useState<[string, string]>(INITIAL_DATE_RANGE); // map of timeline entries (trips, places, untracked time), ids to objects const [timelineMap, setTimelineMap] = useState(null); const [timelineIsLoading, setTimelineIsLoading] = useState('replace'); @@ -83,8 +85,11 @@ export const useTimelineContext = (): ContextProps => { // when a new date range is chosen, load more data, then update the queriedDateRange useEffect(() => { + if (!pipelineRange) { + logDebug('No pipelineRange yet - skipping dateRange useEffect'); + return; + } const onDateRangeChange = async () => { - if (!dateRange) return logDebug('No dateRange chosen, skipping onDateRangeChange'); logDebug('Timeline: onDateRangeChange with dateRange = ' + dateRange?.join(' to ')); // determine if this will be a new range or an expansion of the existing range @@ -119,7 +124,7 @@ export const useTimelineContext = (): ContextProps => { setTimelineIsLoading(false); displayError(e, 'While loading date range ' + dateRange?.join(' to ')); } - }, [dateRange]); + }, [dateRange, pipelineRange]); useEffect(() => { if (!timelineMap) return; @@ -150,13 +155,6 @@ export const useTimelineContext = (): ContextProps => { `); } setPipelineRange(pipelineRange); - if (pipelineRange.end_ts) { - // set initial date range to past week: [TODAY - 6 days, TODAY] - setDateRange([isoDateWithOffset(TODAY_DATE, -6), TODAY_DATE]); - } else { - logWarn('Timeline: no pipeline end date. dateRange will stay null'); - setTimelineIsLoading(false); - } } catch (e) { displayError(e, t('errors.while-loading-pipeline-range')); setTimelineIsLoading(false); @@ -260,7 +258,7 @@ export const useTimelineContext = (): ContextProps => { try { logDebug('timelineContext: refreshTimeline'); setTimelineIsLoading('replace'); - setDateRange(null); + setDateRange(INITIAL_DATE_RANGE); setQueriedDateRange(null); setTimelineMap(null); setRefreshTime(new Date()); diff --git a/www/js/components/ToggleSwitch.tsx b/www/js/components/ToggleSwitch.tsx index 671228b36..a4e20d69b 100644 --- a/www/js/components/ToggleSwitch.tsx +++ b/www/js/components/ToggleSwitch.tsx @@ -14,8 +14,6 @@ const ToggleSwitch = ({ value, buttons, ...rest }: SegmentedButtonsProps) => { showSelectedCheck: true, style: { minWidth: 0, - borderTopWidth: rest.density == 'high' ? 0 : 1, - borderBottomWidth: rest.density == 'high' ? 0 : 1, backgroundColor: value == o.value ? colors.elevation.level1 : colors.surfaceDisabled, }, ...o, From acd40b1e2bd6063cf97f70ae58a303ca690dc877 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 17:42:05 -0400 Subject: [PATCH 25/50] consolidate "metrics" translations --- www/i18n/en.json | 151 +++++++----------- www/js/metrics/SumaryCard.tsx | 2 +- www/js/metrics/metricsHelper.ts | 14 +- .../metrics/surveys/SurveyComparisonCard.tsx | 2 +- .../metrics/surveys/SurveyLeaderboardCard.tsx | 2 +- .../surveys/SurveyTripCategoriesCard.tsx | 11 +- www/js/metrics/travel/MetricsCard.tsx | 4 +- www/js/metrics/travel/TravelSection.tsx | 2 +- 8 files changed, 81 insertions(+), 107 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index fec243cb5..f92feee3d 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -84,44 +84,65 @@ "metrics": { "dashboard-tab": "Dashboard", - "cancel": "Cancel", - "confirm": "Confirm", - "get": "Get", - "range": "Range", - "filter": "Filter", - "from": "From:", - "to": "To:", - "last-week": "last week", - "frequency": "Frequency:", - "pandafreqoptions-daily": "DAILY", - "pandafreqoptions-weekly": "WEEKLY", - "pandafreqoptions-biweekly": "BIWEEKLY", - "pandafreqoptions-monthly": "MONTHLY", - "pandafreqoptions-yearly": "YEARLY", - "freqoptions-daily": "DAILY", - "freqoptions-monthly": "MONTHLY", - "freqoptions-yearly": "YEARLY", - "select-pandafrequency": "Select summary freqency", - "select-frequency": "Select summary freqency", - "chart-xaxis-date": "Date", - "chart-no-data": "No Data Available", - "trips-yaxis-number": "Number", - "calorie-data-change": " change", - "calorie-data-unknown": "Unknown...", - "greater-than": " greater than ", - "greater": " greater ", - "or": "or", - "less-than": " less than ", - "less": " less ", - "week-before": "vs. week before", - "this-week": "this week", - "pick-a-date": "Pick a date", - "trips": "trips", - "hours": "hours", - "minutes": "minutes", - "responses": "responses", - "custom": "Custom", - "no-data": "No data" + "no-data": "No data", + "no-data-available": "No data available", + "footprint": { + "footprint": "Footprint", + "estimated-footprint": "Estimated Footprint", + "ghg-emissions": "GHG Emissions", + "energy-usage": "Energy Usage", + "us-2030-goal": "2030 Guideline", + "us-2050-goal": "2050 Guideline", + "us-goals-footnote": "Guidelines are based on US decarbonization goals, scaled to per-capita travel-related footprint.", + "labeled": "Labeled", + "unlabeled": "Unlabeled", + "uncertainty-footnote": "Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", + "daily-emissions-by-week": "Average Daily Emissions by Week", + "kg-co2e-per-day": "kg CO₂e / day", + "daily-energy-by-week": "Average Daily Energy Usage by Week", + "kwh-per-day": "kWh / day" + }, + "movement": { + "movement": "Movement", + "active-minutes": "Active Minutes", + "daily-active-minutes": "Daily Active Minutes", + "weekly-active-minutes": "Weekly Active Minutes" + }, + "travel": { + "travel": "Travel", + "count": "Trip Count", + "distance": "Distance", + "duration": "Duration", + "trips": "trips", + "hours": "hours", + "user-totals": "My Totals", + "group-totals": "Group Totals" + }, + "surveys": { + "surveys": "Surveys", + "survey-response-rate": "Survey Response Rate (%)", + "survey-leaderboard-desc": "This data has been accumulated since ", + "trip-categories": "Trip Categories", + "response": "Response", + "no-response": "No Response", + "responses": "Responses", + "comparison": "Comparison", + "you": "You", + "others": "Others in group" + }, + "leaderboard": { + "leaderboard": "Leaderboard", + "you-are-in-x-place": "You are in #{{x}} place", + "data-accumulated-since-date": "This data has been accumulated since {{date}}" + }, + "split-by": "Split by {{field}}:", + "grouping-fields": { + "mode_confirm": "Mode", + "purpose_confirm": "Purpose", + "replaced_mode_confirm": "Replaced Mode", + "primary_ble_sensed_mode": "Detected Mode", + "survey": "Survey" + } }, "diary": { @@ -194,60 +215,6 @@ "other": "Other" }, - "main-metrics": { - "summary": "My Summary", - "chart": "Chart", - "change-data": "Change dates:", - "distance": "Distance", - "count": "Trip Count", - "duration": "Duration", - "response_count": "Response Count", - "fav-mode": "My Favorite Mode", - "speed": "My Speed", - "footprint": "My Footprint", - "estimated-emissions": "Estimated CO₂ emissions", - "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips)", - "average": "Group Avg.", - "worst-case": "Worse Case", - "label-to-squish": "Label trips to collapse the range into a single number", - "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", - "lastweek": "My last week value:", - "us-2030-goal": "2030 Guideline¹", - "us-2050-goal": "2050 Guideline¹", - "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", - "past-week": "Past Week", - "prev-week": "Prev. Week", - "no-summary-data": "No summary data", - "mean-speed": "My Average Speed", - "user-totals": "My Totals", - "group-totals": "Group Totals", - "active-minutes": "Active Minutes", - "weekly-active-minutes": "Weekly minutes of active travel", - "daily-active-minutes": "Daily minutes of active travel", - "active-minutes-table": "Table of active minutes metrics", - "weekly-goal": "Weekly Goal³", - "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", - "labeled": "Labeled", - "unlabeled": "Unlabeled²", - "footprint-label": "Footprint (kg CO₂)", - "surveys": "Surveys", - "leaderboard": "Leaderboard", - "survey-response-rate": "Survey Response Rate (%)", - "survey-leaderboard-desc": "This data has been accumulated since ", - "comparison": "Comparison", - "you": "You", - "others": "Others in group", - "trip-categories": "Trip Categories", - "ev-roading-trip": "EV Roaming trip", - "ev-return-trip": "EV Return trip", - "gas-car-trip": "Gas Car trip", - "response": "Response", - "no-response": "No Response", - "you-are-in": "You're in", - "place": " place!" - }, - "details": { "speed": "Speed", "time": "Time" diff --git a/www/js/metrics/SumaryCard.tsx b/www/js/metrics/SumaryCard.tsx index c36b48a9d..ea43d25a3 100644 --- a/www/js/metrics/SumaryCard.tsx +++ b/www/js/metrics/SumaryCard.tsx @@ -58,7 +58,7 @@ const SummaryCard = ({ title, unit, value, nDays, goals }: Props) => { ) : ( {title} - {t('metrics.chart-no-data')} + {t('metrics.no-data')} )} diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index dd97317f2..93748ed99 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -254,18 +254,22 @@ export function getUnitUtilsForMetric( (x) => imperialConfig.getFormattedDistance(x) + ' ' + imperialConfig.distanceSuffix, ], duration: [ - i18next.t('metrics.hours'), + i18next.t('metrics.travel.hours'), (v) => secondsToHours(v), - (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.hours'), + (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.travel.hours'), + ], + count: [ + i18next.t('metrics.travel.trips'), + (v) => v, + (v) => v + ' ' + i18next.t('metrics.travel.trips'), ], - count: [i18next.t('metrics.trips'), (v) => v, (v) => v + ' ' + i18next.t('metrics.trips')], response_count: [ - i18next.t('metrics.responses'), + i18next.t('metrics.surveys.responses'), (v) => v.responded || 0, (v) => { const responded = v.responded || 0; const total = responded + (v.not_responded || 0); - return `${responded}/${total} ${i18next.t('metrics.responses')}`; + return `${responded}/${total} ${i18next.t('metrics.surveys.responses')}`; }, ], }; diff --git a/www/js/metrics/surveys/SurveyComparisonCard.tsx b/www/js/metrics/surveys/SurveyComparisonCard.tsx index c1acde79f..f58e71dae 100644 --- a/www/js/metrics/surveys/SurveyComparisonCard.tsx +++ b/www/js/metrics/surveys/SurveyComparisonCard.tsx @@ -113,7 +113,7 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { return ( { return ( { return ( @@ -74,11 +74,14 @@ const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { reverse={false} maxBarThickness={60} /> - + ) : ( - {t('metrics.chart-no-data')} + {t('metrics.no-data-available')} )} diff --git a/www/js/metrics/travel/MetricsCard.tsx b/www/js/metrics/travel/MetricsCard.tsx index cb53b7dbe..3b4b24a91 100644 --- a/www/js/metrics/travel/MetricsCard.tsx +++ b/www/js/metrics/travel/MetricsCard.tsx @@ -157,7 +157,7 @@ const MetricsCard = ({ ) : ( - {t('metrics.chart-no-data')} + {t('metrics.no-data-available')} ))} {viewMode == 'graph' && @@ -187,7 +187,7 @@ const MetricsCard = ({ ) : ( - {t('metrics.chart-no-data')} + {t('metrics.no-data-available')} ))} diff --git a/www/js/metrics/travel/TravelSection.tsx b/www/js/metrics/travel/TravelSection.tsx index e9999d0e6..8585cc404 100644 --- a/www/js/metrics/travel/TravelSection.tsx +++ b/www/js/metrics/travel/TravelSection.tsx @@ -15,7 +15,7 @@ const TravelSection = ({ userMetrics, aggMetrics, metricList }) => { key={metricName} metricName={metricName} groupingFields={groupingFields} - cardTitle={t(`main-metrics.${metricName}`)} + cardTitle={t(`metrics.travel.${metricName}`)} userMetricsDays={userMetrics?.[metricName]} aggMetricsDays={aggMetrics?.[metricName]} /> From 7fc842ce906a5d12f06c7478f08440fb00dd7706 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 17:42:53 -0400 Subject: [PATCH 26/50] fix LabelOption type It needs to be MultilabelKey by default, otherwise we have to specify this everywhere --- www/js/types/labelTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts index f1340fdf7..771eade23 100644 --- a/www/js/types/labelTypes.ts +++ b/www/js/types/labelTypes.ts @@ -23,7 +23,7 @@ export type RichMode = { }; }; -export type LabelOption = T extends 'MODE' +export type LabelOption = T extends 'MODE' ? { value: string; base_mode: string; From aa01f1f99b33607042ec8c7ed3898908de534063 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 23:06:09 -0400 Subject: [PATCH 27/50] update styles of metrics cards, navbar Unifies the styling of all metrics cards Adjusts navbar and refresh button on label screen so the label screen and metrics screen navbars match each other --- www/js/components/NavBar.tsx | 2 +- www/js/diary/cards/TripCard.tsx | 2 +- www/js/diary/list/LabelListScreen.tsx | 2 +- www/js/metrics/MetricsScreen.tsx | 4 ---- www/js/metrics/SumaryCard.tsx | 3 +-- www/js/metrics/footprint/WeeklyFootprintCard.tsx | 15 ++++++++++----- .../metrics/movement/ActiveMinutesTableCard.tsx | 12 +++++++----- .../metrics/movement/DailyActiveMinutesCard.tsx | 4 ++-- .../metrics/movement/WeeklyActiveMinutesCard.tsx | 2 +- www/js/metrics/surveys/SurveyComparisonCard.tsx | 2 -- www/js/metrics/surveys/SurveyLeaderboardCard.tsx | 2 -- .../metrics/surveys/SurveyTripCategoriesCard.tsx | 2 -- www/js/metrics/travel/MetricsCard.tsx | 14 ++++++-------- 13 files changed, 30 insertions(+), 36 deletions(-) diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx index 5d2e2498d..a785c2f44 100644 --- a/www/js/components/NavBar.tsx +++ b/www/js/components/NavBar.tsx @@ -15,7 +15,7 @@ type NavBarProps = AppbarHeaderProps & { isLoading?: boolean }; const NavBar = ({ children, isLoading, ...rest }: NavBarProps) => { const { colors } = useTheme(); return ( - + {children} { const navigation = useNavigation(); const { labelOptions, confirmedModeFor, notesFor } = useContext(TimelineContext); const tripGeojson = - trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode); + trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.base_mode); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 48db10492..f4573acc6 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -39,7 +39,7 @@ const LabelListScreen = () => { size={32} onPress={() => refreshTimeline()} accessibilityLabel="Refresh" - style={{ marginLeft: 'auto' }} + style={{ margin: 0, marginLeft: 'auto' }} /> diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx index b3e442c76..18b8c3744 100644 --- a/www/js/metrics/MetricsScreen.tsx +++ b/www/js/metrics/MetricsScreen.tsx @@ -58,10 +58,6 @@ export const metricsStyles = StyleSheet.create({ overflow: 'hidden', minHeight: 300, }, - title: (colors) => ({ - paddingHorizontal: 8, - minHeight: 52, - }), subtitleText: { fontSize: 13, lineHeight: 13, diff --git a/www/js/metrics/SumaryCard.tsx b/www/js/metrics/SumaryCard.tsx index ea43d25a3..7f3170c5d 100644 --- a/www/js/metrics/SumaryCard.tsx +++ b/www/js/metrics/SumaryCard.tsx @@ -35,9 +35,9 @@ const SummaryCard = ({ title, unit, value, nDays, goals }: Props) => { return ( + {!isNaN(value[0]) ? ( - {title} {formatVal(value)} {unit} @@ -57,7 +57,6 @@ const SummaryCard = ({ title, unit, value, nDays, goals }: Props) => { ) : ( - {title} {t('metrics.no-data')} )} diff --git a/www/js/metrics/footprint/WeeklyFootprintCard.tsx b/www/js/metrics/footprint/WeeklyFootprintCard.tsx index 68177aaf4..a2c386c2a 100644 --- a/www/js/metrics/footprint/WeeklyFootprintCard.tsx +++ b/www/js/metrics/footprint/WeeklyFootprintCard.tsx @@ -102,23 +102,28 @@ const WeeklyFootprintCard = ({ return ( + - {title} {chartRecords?.length > 0 ? ( - + <> ({ ...g, label: g.label[i18next.language] }))} meter={!groupingField ? meter : undefined} getColorForLabel={groupingField == 'mode_confirm' ? getColorForModeLabel : undefined} /> {metricList.footprint!.map((gf: GroupingField) => ( {t('metrics.split-by', { field: t(`metrics.grouping-fields.${gf}`) })} @@ -132,7 +137,7 @@ const WeeklyFootprintCard = ({ /> ))} - + ) : ( {t('metrics.no-data')} diff --git a/www/js/metrics/movement/ActiveMinutesTableCard.tsx b/www/js/metrics/movement/ActiveMinutesTableCard.tsx index 7daae283c..847894b1d 100644 --- a/www/js/metrics/movement/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/movement/ActiveMinutesTableCard.tsx @@ -10,7 +10,7 @@ import { valueForFieldOnDay, } from '../metricsHelper'; import { useTranslation } from 'react-i18next'; -import { labelKeyToRichMode } from '../../survey/multilabel/confirmHelper'; +import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; import TimelineContext from '../../TimelineContext'; type Props = { userMetrics?: MetricsData; activeModes: string[] }; @@ -75,15 +75,17 @@ const ActiveMinutesTableCard = ({ userMetrics, activeModes }: Props) => { return ( + - {t('metrics.movement.active-minutes')} - {t('metrics.movement.active-minutes-table')} {activeModes.map((mode, i) => ( - {labelKeyToRichMode(mode)} + {labelKeyToText(mode)} ))} @@ -92,7 +94,7 @@ const ActiveMinutesTableCard = ({ userMetrics, activeModes }: Props) => { {total['period']} {activeModes.map((mode, j) => ( - {total[mode]} {t('metrics.minutes')} + {total[mode]} {t('metrics.movement.minutes')} ))} diff --git a/www/js/metrics/movement/DailyActiveMinutesCard.tsx b/www/js/metrics/movement/DailyActiveMinutesCard.tsx index fd33c85b1..48f2e139d 100644 --- a/www/js/metrics/movement/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/DailyActiveMinutesCard.tsx @@ -32,15 +32,15 @@ const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { return ( + - {t('metrics.movement.daily-active-minutes')} {dailyActiveMinutesRecords.length ? ( getBaseModeByText(l, labelOptions).color} + getColorForLabel={getColorForModeLabel} /> ) : ( diff --git a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx index 40684d0cc..f1bf88e64 100644 --- a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx @@ -48,8 +48,8 @@ const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { return ( + - {t('metrics.movement.weekly-active-minutes')} {weeklyActiveMinutesRecords.length ? ( { {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? ( diff --git a/www/js/metrics/surveys/SurveyLeaderboardCard.tsx b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx index ac54e51c0..faed8da48 100644 --- a/www/js/metrics/surveys/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx @@ -71,10 +71,8 @@ const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { diff --git a/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx index 95bb4ae6d..e68005605 100644 --- a/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx @@ -54,10 +54,8 @@ const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { {records.length ? ( diff --git a/www/js/metrics/travel/MetricsCard.tsx b/www/js/metrics/travel/MetricsCard.tsx index 3b4b24a91..fd66149c6 100644 --- a/www/js/metrics/travel/MetricsCard.tsx +++ b/www/js/metrics/travel/MetricsCard.tsx @@ -117,13 +117,12 @@ const MetricsCard = ({ ( - + setViewMode(v as any)} buttons={[ @@ -132,7 +131,7 @@ const MetricsCard = ({ ]} /> setPopulationMode(p as any)} buttons={[ @@ -142,15 +141,14 @@ const MetricsCard = ({ /> )} - style={metricsStyles.title(colors)} /> {viewMode == 'details' && (Object.keys(metricSumValues).length ? ( - + {Object.keys(metricSumValues).map((label, i) => ( - {labelKeyToRichMode(label)} + {labelKeyToText(label)} {metricSumValues[label]} ))} @@ -178,7 +176,7 @@ const MetricsCard = ({ alignItems: 'center', justifyContent: 'flex-end', }}> - Stack bars: + {t('metrics.stack-bars')} setGraphIsStacked(!graphIsStacked)} From 54f73d1d0fe5134e9fd94898f58107d39c85d5be Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Sep 2024 23:06:55 -0400 Subject: [PATCH 28/50] add more metrics translations --- www/i18n/en.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index f92feee3d..dded8dc75 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -105,8 +105,12 @@ "movement": { "movement": "Movement", "active-minutes": "Active Minutes", - "daily-active-minutes": "Daily Active Minutes", - "weekly-active-minutes": "Weekly Active Minutes" + "daily-active-minutes": "Daily Minutes of Active Travel", + "weekly-active-minutes": "Weekly Minutes of Active Travel", + "active-minutes-table": "Table of Active Minutes", + "weekly-goal": "Weekly Goal", + "weekly-goal-footnote": "*Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "minutes": "minutes" }, "travel": { "travel": "Travel", @@ -135,6 +139,7 @@ "you-are-in-x-place": "You are in #{{x}} place", "data-accumulated-since-date": "This data has been accumulated since {{date}}" }, + "stack-bars": "Stack bars:", "split-by": "Split by {{field}}:", "grouping-fields": { "mode_confirm": "Mode", From 616b6b01b6fbb569b72246da7887c8f4295cf81a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 01:19:09 -0400 Subject: [PATCH 29/50] use the new label options and rich modes pattern throughout the UI Where base modes were previously a property of the label options, and had a separate set of properties, we now have rich modes that inherit from base modes and may include other properties. This means for the numerous places in the UI where we use mode colors, we should always check the rich mode via get_rich_mode / get_rich_mode_for_value. Also we can tighten down typings a bit more. "text" does not exist in label options or in rich modes. It had been added in while label options are being read and the translations are parsed Instead of that let's just have a function that does this (labelKeyToText). We need to check for translations in 1) the label options being used; and 2) the default label options. if nothing, then convert it to readable (convert underscores to spaces & capitalize) We'll also have a function that does the opposite. Updated some typings: LabelOptions type should not accept a parameter. Also should mark REPLACED_MODE as optional UserInputData should not have "name". This only exists on EnketoUserInputData; it represents the name of the survey --- www/__tests__/confirmHelper.test.ts | 9 ++-- www/js/TimelineContext.ts | 17 ++++--- www/js/diary/cards/ModesIndicator.tsx | 18 ++++---- www/js/diary/cards/TripCard.tsx | 5 +- www/js/diary/details/LabelDetailsScreen.tsx | 5 +- .../details/TripSectionsDescriptives.tsx | 19 +++----- www/js/diary/diaryHelper.ts | 5 -- www/js/diary/timelineHelper.ts | 11 ++--- .../metrics/footprint/WeeklyFootprintCard.tsx | 6 +-- www/js/metrics/metricsHelper.ts | 7 ++- .../movement/DailyActiveMinutesCard.tsx | 7 ++- .../movement/WeeklyActiveMinutesCard.tsx | 9 ++-- www/js/metrics/travel/MetricsCard.tsx | 6 +-- www/js/survey/enketo/enketoHelper.ts | 1 + .../multilabel/MultiLabelButtonGroup.tsx | 24 +++++----- www/js/survey/multilabel/confirmHelper.ts | 46 ++++++++----------- www/js/types/diaryTypes.ts | 1 - www/js/types/labelTypes.ts | 6 ++- 18 files changed, 86 insertions(+), 116 deletions(-) diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts index 6642b6ed4..8d7a7c074 100644 --- a/www/__tests__/confirmHelper.test.ts +++ b/www/__tests__/confirmHelper.test.ts @@ -7,8 +7,7 @@ import { inferFinalLabels, labelInputDetailsForTrip, labelKeyToReadable, - labelKeyToRichMode, - labelOptionByValue, + labelKeyToText, readableLabelToKey, verifiabilityForTrip, } from '../js/survey/multilabel/confirmHelper'; @@ -67,7 +66,7 @@ describe('confirmHelper', () => { it('returns labelOptions given an appConfig', async () => { const labelOptions = await getLabelOptions(fakeAppConfig); expect(labelOptions).toBeTruthy(); - expect(labelOptions.MODE[0].text).toEqual('Walk'); // translation is filled in + expect(labelOptions.MODE[0].value).toEqual('walk'); }); it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => { @@ -115,10 +114,10 @@ describe('confirmHelper', () => { it('looks up a rich mode from a label key, or humanizes the label key if there is no rich mode', () => { const key = 'walk'; - const richMode = labelKeyToRichMode(key); + const richMode = labelKeyToText(key); expect(richMode).toEqual('Walk'); const key2 = 'scooby_doo_mystery_machine'; - const readableMode = labelKeyToRichMode(key2); + const readableMode = labelKeyToText(key2); expect(readableMode).toEqual('Scooby Doo Mystery Machine'); }); diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index e26016daa..58e9229ab 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -1,7 +1,7 @@ import { createContext, useEffect, useState } from 'react'; import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from './types/diaryTypes'; import useAppConfig from './useAppConfig'; -import { LabelOption, LabelOptions, MultilabelKey } from './types/labelTypes'; +import { LabelOption, LabelOptions, MultilabelKey, RichMode } from './types/labelTypes'; import { getLabelOptions, labelOptionByValue } from './survey/multilabel/confirmHelper'; import { displayError, displayErrorMsg, logDebug, logWarn } from './plugin/logger'; import { useTranslation } from 'react-i18next'; @@ -20,9 +20,9 @@ import { import { getPipelineRangeTs } from './services/commHelper'; import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher'; import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; -import { VehicleIdentity } from './types/appConfigTypes'; import { primarySectionForTrip } from './diary/diaryHelper'; import { isoDateRangeToTsRange, isoDateWithOffset } from './util'; +import { base_modes } from 'e-mission-common'; const TODAY_DATE = DateTime.now().toISODate(); // initial date range is the past week: [TODAY - 6 days, TODAY] @@ -34,11 +34,8 @@ type ContextProps = { timelineLabelMap: TimelineLabelMap | null; userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined; notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; - labelFor: ( - tlEntry: TimelineEntry, - labelType: MultilabelKey, - ) => VehicleIdentity | LabelOption | undefined; - confirmedModeFor: (tlEntry: TimelineEntry) => LabelOption | undefined; + labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined; + confirmedModeFor: (tlEntry: TimelineEntry) => RichMode | undefined; addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; pipelineRange: TimestampRange | null; queriedDateRange: [string, string] | null; // YYYY-MM-DD format @@ -283,11 +280,13 @@ export const useTimelineContext = (): ContextProps => { /** * @param tlEntry The trip or place object to get the confirmed mode for - * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, + * @returns Rich confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, * or the label option from a user-given 'MODE' label, or undefined if neither exists. */ const confirmedModeFor = (tlEntry: CompositeTrip) => - primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE'); + base_modes.get_rich_mode( + primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE'), + ) as RichMode; function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { const tlEntry = timelineMap?.get(oid); diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 8a4cf1689..eba2a5683 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -6,6 +6,7 @@ import { logDebug } from '../../plugin/logger'; import { Text, Icon, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { base_modes } from 'e-mission-common'; +import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; const ModesIndicator = ({ trip, detectedModes }) => { const { t } = useTranslation(); @@ -16,23 +17,22 @@ const ModesIndicator = ({ trip, detectedModes }) => { let indicatorBorderColor = color('black').alpha(0.5).rgb().string(); let modeViews; - const confirmedModeForTrip = confirmedModeFor(trip); - if (labelOptions && confirmedModeForTrip?.value) { - const baseMode = base_modes.get_base_mode_by_key(confirmedModeForTrip.baseMode); - indicatorBorderColor = baseMode.color; - logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); + const confirmedMode = confirmedModeFor(trip); + if (labelOptions && confirmedMode?.value) { + indicatorBorderColor = confirmedMode.color; + logDebug(`TripCard: got confirmedMode = ${JSON.stringify(confirmedMode)}`); modeViews = ( - + - {confirmedModeForTrip.text} + {labelKeyToText(confirmedMode.value)} ); diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 2814aaef3..192215320 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -42,9 +42,8 @@ const TripCard = ({ trip, isFirstInList }: Props) => { } = useDerivedProperties(trip); let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); - const { labelOptions, confirmedModeFor, notesFor } = useContext(TimelineContext); - const tripGeojson = - trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.base_mode); + const { confirmedModeFor, notesFor } = useContext(TimelineContext); + const tripGeojson = trip && useGeojsonForTrip(trip, confirmedModeFor(trip)); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ffac02543..8e2cdf33d 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -48,10 +48,7 @@ const LabelScreenDetails = ({ route, navigation }) => { const tripGeojson = trip && labelOptions && - useGeojsonForTrip( - trip, - modesShown == 'confirmed' ? confirmedModeFor(trip)?.baseMode : undefined, - ); + useGeojsonForTrip(trip, modesShown == 'confirmed' ? confirmedModeFor(trip) : undefined); const mapOpts = { minZoom: 3, maxZoom: 17 }; const modal = ( diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 4592c838f..c678048e0 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -4,6 +4,7 @@ import { Icon, Text, useTheme } from 'react-native-paper'; import useDerivedProperties from '../useDerivedProperties'; import TimelineContext from '../../TimelineContext'; import { base_modes } from 'e-mission-common'; +import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext); @@ -17,25 +18,19 @@ const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { const { colors } = useTheme(); - const confirmedModeForTrip = confirmedModeFor(trip); + const confirmedModeForTrip = showConfirmedMode && confirmedModeFor(trip); let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if ((showConfirmedMode && confirmedModeForTrip) || !trip.sections?.length) { - let baseMode; - if (showConfirmedMode && labelOptions && confirmedModeForTrip) { - baseMode = base_modes.get_base_mode_by_key(confirmedModeForTrip.baseMode); - } else { - baseMode = base_modes.get_base_mode_by_key('UNPROCESSED'); - } + if (confirmedModeForTrip || !trip.sections?.length) { sections = [ { startTime: displayStartTime, duration: displayTime, distance: formattedDistance, distanceSuffix, - color: baseMode.color, - icon: baseMode.icon, + color: (confirmedModeForTrip || base_modes.BASE_MODES['UNPROCESSED']).color, + icon: (confirmedModeForTrip || base_modes.BASE_MODES['UNPROCESSED']).icon, }, ]; } @@ -62,9 +57,9 @@ const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { - {showConfirmedMode && confirmedModeForTrip && ( + {confirmedModeForTrip && ( - {confirmedModeForTrip.text} + {labelKeyToText(confirmedModeForTrip.value)} )} diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 7890151ac..e182835c4 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -22,11 +22,6 @@ export type MotionTypeKey = | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; -export function getBaseModeByText(text: string, labelOptions: LabelOptions) { - const modeOption = labelOptions?.MODE?.find((opt) => opt.text == text); - return base_modes.get_base_mode_by_key(modeOption?.baseMode || 'OTHER'); -} - /** * @param trip A composite trip object * @returns An array of objects containing the mode key, icon, color, and percentage for each mode diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 071505d5a..c64ecb8e6 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -19,7 +19,7 @@ import { SectionSummary, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; -import { LabelOptions } from '../types/labelTypes'; +import { RichMode } from '../types/labelTypes'; import { EnketoUserInputEntry, filterByNameAndVersion, @@ -34,21 +34,18 @@ const cachedGeojsons: Map = new Map(); /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. */ -export function useGeojsonForTrip(trip: CompositeTrip, baseMode?: string) { +export function useGeojsonForTrip(trip: CompositeTrip, richMode?: RichMode) { if (!trip?._id?.$oid) return; - const gjKey = `trip-${trip._id.$oid}-${baseMode || 'detected'}`; + const gjKey = `trip-${trip._id.$oid}-${richMode?.value || 'detected'}`; if (cachedGeojsons.has(gjKey)) { return cachedGeojsons.get(gjKey); } - const trajectoryColor = - (baseMode && base_modes.get_base_mode_by_key(baseMode)?.color) || undefined; - logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); const features = [ location2GeojsonPoint(trip.start_loc, 'start_place'), location2GeojsonPoint(trip.end_loc, 'end_place'), - ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor), + ...locations2GeojsonTrajectory(trip, trip.locations, richMode?.color), ]; const gj: GeoJSONData = { diff --git a/www/js/metrics/footprint/WeeklyFootprintCard.tsx b/www/js/metrics/footprint/WeeklyFootprintCard.tsx index a2c386c2a..766aa894f 100644 --- a/www/js/metrics/footprint/WeeklyFootprintCard.tsx +++ b/www/js/metrics/footprint/WeeklyFootprintCard.tsx @@ -17,7 +17,7 @@ import { ChartRecord } from '../../components/Chart'; import i18next from 'i18next'; import { MetricsData } from '../metricsTypes'; import { GroupingField, MetricList } from '../../types/appConfigTypes'; -import { labelKeyToRichMode } from '../../survey/multilabel/confirmHelper'; +import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; type Props = { type: 'carbon' | 'energy'; @@ -66,7 +66,7 @@ const WeeklyFootprintCard = ({ .forEach((key) => { if (weekAgg[key][unit]) { records.push({ - label: labelKeyToRichMode(trimGroupingPrefix(key)), + label: labelKeyToText(trimGroupingPrefix(key)), x: weekAgg[key][unit] / 7, y: displayDateRange, }); @@ -84,7 +84,7 @@ const WeeklyFootprintCard = ({ label: t('metrics.footprint.unlabeled') + addFootnote(t('metrics.footprint.uncertainty-footnote')), - x: weekSum[`${unit}_uncertain`] / 7, + x: weekSum[`${unit}_uncertain`]! / 7, y: displayDateRange, }); } diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 93748ed99..6edf661e6 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -8,8 +8,7 @@ import i18next from 'i18next'; import { base_modes, metrics_summaries } from 'e-mission-common'; import { formatForDisplay, formatIsoNoYear, isoDatesDifference, isoDateWithOffset } from '../util'; import { LabelOptions, RichMode } from '../types/labelTypes'; -import { getBaseModeByText } from '../diary/diaryHelper'; -import { labelOptions } from '../survey/multilabel/confirmHelper'; +import { labelOptions, textToLabelKey } from '../survey/multilabel/confirmHelper'; import { UNCERTAIN_OPACITY } from '../components/charting'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { @@ -172,7 +171,7 @@ export const tsForDayOfMetricData = (day: DayOfMetricData) => { return _datesTsCache[day.date]; }; -export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) => +export const valueForFieldOnDay = (day: MetricEntry, field: string, key: string) => day[`${field}_${key}`]; export type MetricsSummary = { key: string; values: number }; @@ -318,5 +317,5 @@ export function getColorForModeLabel(label: string) { const unknownModeColor = base_modes.get_base_mode_by_key('UNKNOWN').color; return color(unknownModeColor).alpha(UNCERTAIN_OPACITY).rgb().string(); } - return getBaseModeByText(label, labelOptions).color; + base_modes.get_rich_mode_for_value(textToLabelKey(label), labelOptions).color; } diff --git a/www/js/metrics/movement/DailyActiveMinutesCard.tsx b/www/js/metrics/movement/DailyActiveMinutesCard.tsx index 48f2e139d..995d359b3 100644 --- a/www/js/metrics/movement/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/DailyActiveMinutesCard.tsx @@ -3,10 +3,9 @@ import { Card, Text } from 'react-native-paper'; import { MetricsData } from '../metricsTypes'; import { metricsStyles } from '../MetricsScreen'; import { useTranslation } from 'react-i18next'; -import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; +import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; import LineChart from '../../components/LineChart'; -import { getBaseModeByText } from '../../diary/diaryHelper'; -import { tsForDayOfMetricData, valueForFieldOnDay } from '../metricsHelper'; +import { getColorForModeLabel, tsForDayOfMetricData, valueForFieldOnDay } from '../metricsHelper'; type Props = { userMetrics?: MetricsData; activeModes: string[] }; const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { @@ -20,7 +19,7 @@ const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { recentDays?.forEach((day) => { const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode); records.push({ - label: labelKeyToRichMode(mode), + label: labelKeyToText(mode), x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis y: (activeSeconds || 0) / 60, // minutes on Y axis }); diff --git a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx index f1bf88e64..c5751860a 100644 --- a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx @@ -5,14 +5,13 @@ import { MetricsData } from '../metricsTypes'; import { metricsStyles } from '../MetricsScreen'; import { aggMetricEntries, - formatDateRangeOfDays, + getColorForModeLabel, segmentDaysByWeeks, valueForFieldOnDay, } from '../metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../../components/BarChart'; -import { labelKeyToRichMode, labelOptions } from '../../survey/multilabel/confirmHelper'; -import { getBaseModeByText } from '../../diary/diaryHelper'; +import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; import TimelineContext from '../../TimelineContext'; type Props = { userMetrics?: MetricsData; activeModes: string[] }; @@ -36,7 +35,7 @@ const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { weekDurations.forEach((week) => { const val = valueForFieldOnDay(week, 'mode_confirm', mode); records.push({ - label: labelKeyToRichMode(mode), + label: labelKeyToText(modeKey), x: formatDateRangeOfDays(week), y: val / 60, }); @@ -60,7 +59,7 @@ const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { lineAnnotations={[ { value: 150, label: t('metrics.movement.weekly-goal'), position: 'center' }, ]} - getColorForLabel={(l) => getBaseModeByText(l, labelOptions).color} + getColorForLabel={getColorForModeLabel} /> { const { colors } = useTheme(); @@ -81,7 +82,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { and inform LabelTab of new inputs */ function store(inputs: { [k in MultilabelKey]?: string }, isOther?) { if (!Object.keys(inputs).length) return displayErrorMsg('No inputs to store'); - const inputsToStore: UserInputMap = {}; + const inputsToStore: { [k in MultilabelKey]?: UserInputData } = {}; const storePromises: any[] = []; for (let [inputType, newLabel] of Object.entries(inputs)) { @@ -137,17 +138,18 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { {Object.keys(tripInputDetails).map((key, i) => { const input = tripInputDetails[key]; - const inputIsConfirmed = labelFor(trip, input.name); - const inputIsInferred = inferFinalLabels(trip, userInputFor(trip))[input.name]; + const confirmedInput = labelFor(trip, input.name); + const inferredInput = inferFinalLabels(trip, userInputFor(trip))[input.name]; let fillColor, textColor, borderColor; - if (inputIsConfirmed) { + if (confirmedInput) { fillColor = colors.primary; - } else if (inputIsInferred) { + } else if (inferredInput) { fillColor = colors.secondaryContainer; borderColor = colors.secondary; textColor = colors.onSecondaryContainer; } - const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; + const labelOption = confirmedInput || inferredInput; + const btnText = labelOption ? labelKeyToText(labelOption.value) : t(input.choosetext); return ( @@ -157,7 +159,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { borderColor={borderColor} textColor={textColor} onPress={(e) => setModalVisibleFor(input.name)}> - {t(btnText)} + {btnText} ); @@ -195,7 +197,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const radioItemForOption = ( @@ -204,7 +206,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { show the custom labels section before 'other' */ if (o.value == 'other' && customLabelMap[customLabelKeyInDatabase]?.length) { return ( - <> + @@ -223,7 +225,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { ))} {radioItemForOption} - + ); } // otherwise, just show the radio item as normal diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index c3a5417dd..4e4c4a6c3 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,13 +1,13 @@ import { fetchUrlCached } from '../../services/commHelper'; import i18next from 'i18next'; -import enJson from '../../../i18n/en.json'; import { logDebug } from '../../plugin/logger'; import { LabelOption, LabelOptions, MultilabelKey, InputDetails } from '../../types/labelTypes'; import { CompositeTrip, InferredLabels, TimelineEntry } from '../../types/diaryTypes'; import { UserInputMap } from '../../TimelineContext'; +import DEFAULT_LABEL_OPTIONS from 'e-mission-common/src/emcommon/resources/label-options.default.json'; let appConfig; -export let labelOptions: LabelOptions; +export let labelOptions: LabelOptions; export let inputDetails: InputDetails; export async function getLabelOptions(appConfigParam?) { @@ -19,31 +19,11 @@ export async function getLabelOptions(appConfigParam?) { logDebug(`label_options found in config, using dynamic label options at ${appConfig.label_options}`); labelOptions = JSON.parse(labelOptionsJson) as LabelOptions; + } else { + throw new Error('Label options were falsy from ' + appConfig.label_options); } } else { - const defaultLabelOptionsURL = 'json/label-options.json.sample'; - logDebug(`No label_options found in config, using default label options - at ${defaultLabelOptionsURL}`); - const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); - if (defaultLabelOptionsJson) { - labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; - } - } - /* fill in the translations to the 'text' fields of the labelOptions, - according to the current language */ - const lang = i18next.resolvedLanguage || 'en'; - for (const opt in labelOptions) { - labelOptions[opt]?.forEach?.((o, i) => { - const translationKey = o.value; - /* If translation exists in labelOptions, use that. Otherwise, try i18next translations. */ - const translationFromLabelOptions = labelOptions.translations?.[lang]?.[translationKey]; - if (translationFromLabelOptions) { - labelOptions[opt][i].text = translationFromLabelOptions; - } else { - const i18nextKey = translationKey as keyof typeof enJson.multilabel; // cast for type safety - labelOptions[opt][i].text = i18next.t(`multilabel.${i18nextKey}`); - } - }); + labelOptions = DEFAULT_LABEL_OPTIONS; } return labelOptions; } @@ -124,13 +104,23 @@ export const readableLabelToKey = (otherText: string) => export function getFakeEntry(otherValue): Partial | undefined { if (!otherValue) return undefined; return { - text: labelKeyToReadable(otherValue), value: otherValue, }; } -export const labelKeyToRichMode = (labelKey: string) => - labelOptionByValue(labelKey, 'MODE')?.text || labelKeyToReadable(labelKey); +export let labelTextToKeyMap: { [key: string]: string } = {}; + +export const labelKeyToText = (labelKey: string) => { + const lang = i18next.resolvedLanguage || 'en'; + const text = + labelOptions?.translations?.[lang]?.[labelKey] || + labelOptions?.translations?.[lang]?.[labelKey] || + labelKeyToReadable(labelKey); + labelTextToKeyMap[text] = labelKey; + return text; +}; + +export const textToLabelKey = (text: string) => labelTextToKeyMap[text] || readableLabelToKey(text); /** @description e.g. manual/mode_confirm becomes mode_confirm */ export const removeManualPrefix = (key: string) => key.split('/')[1]; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index c959dc944..35d44b108 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -162,7 +162,6 @@ export type UserInputData = { end_local_dt?: LocalDt; status?: string; match_id?: string; - name: string; }; export type UserInputEntry = { data: T; diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts index 771eade23..681462ba9 100644 --- a/www/js/types/labelTypes.ts +++ b/www/js/types/labelTypes.ts @@ -33,8 +33,10 @@ export type LabelOption = T extends 'MODE' }; export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; -export type LabelOptions = { - [k in T]: LabelOption[]; +export type LabelOptions = { + MODE: LabelOption<'MODE'>[]; + PURPOSE: LabelOption<'PURPOSE'>[]; + REPLACED_MODE?: LabelOption<'REPLACED_MODE'>[]; } & { translations: { [lang: string]: { [translationKey: string]: string }; From bacc745bbf84d7a72ba0e867fbeff143137e8c8c Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 01:19:16 -0400 Subject: [PATCH 30/50] use all days for DailyActiveMinutesCard not just the last 14 days --- www/js/metrics/movement/DailyActiveMinutesCard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/movement/DailyActiveMinutesCard.tsx b/www/js/metrics/movement/DailyActiveMinutesCard.tsx index 995d359b3..c6e1c0e07 100644 --- a/www/js/metrics/movement/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/DailyActiveMinutesCard.tsx @@ -13,11 +13,10 @@ const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { const dailyActiveMinutesRecords = useMemo(() => { const records: { label: string; x: number; y: number }[] = []; - const recentDays = userMetrics?.duration?.slice(-14); - activeModes.forEach((mode) => { - if (recentDays?.some((d) => valueForFieldOnDay(d, 'mode_confirm', mode))) { - recentDays?.forEach((day) => { - const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode); + activeModes.forEach((modeKey) => { + if (userMetrics?.duration?.some((d) => valueForFieldOnDay(d, 'mode_confirm', modeKey))) { + userMetrics?.duration?.forEach((day) => { + const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', modeKey); records.push({ label: labelKeyToText(mode), x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis From 5cfad8d94b99242561c2fc2d1089420bc9cdef73 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 01:20:04 -0400 Subject: [PATCH 31/50] fix display of week date ranges on WeeklyActiveMinutesCard --- .../metrics/movement/WeeklyActiveMinutesCard.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx index c5751860a..3fcdade74 100644 --- a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import BarChart from '../../components/BarChart'; import { labelKeyToText } from '../../survey/multilabel/confirmHelper'; import TimelineContext from '../../TimelineContext'; +import { formatIsoNoYear, isoDateWithOffset } from '../../util'; type Props = { userMetrics?: MetricsData; activeModes: string[] }; const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { @@ -30,13 +31,16 @@ const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { const weeklyActiveMinutesRecords = useMemo(() => { let records: { label: string; x: string; y: number }[] = []; - activeModes.forEach((mode) => { - if (weekDurations.some((week) => valueForFieldOnDay(week, 'mode_confirm', mode))) { - weekDurations.forEach((week) => { - const val = valueForFieldOnDay(week, 'mode_confirm', mode); + activeModes.forEach((modeKey) => { + if (weekDurations.some((week) => valueForFieldOnDay(week, 'mode_confirm', modeKey))) { + weekDurations.forEach((week, i) => { + const startDate = isoDateWithOffset(dateRange[1], -7 * (i + 1) + 1); + if (startDate < dateRange[0]) return; // partial week at beginning of queried range; skip + const endDate = isoDateWithOffset(dateRange[1], -7 * i); + const val = valueForFieldOnDay(week, 'mode_confirm', modeKey); records.push({ label: labelKeyToText(modeKey), - x: formatDateRangeOfDays(week), + x: formatIsoNoYear(startDate, endDate), y: val / 60, }); }); From 8d1965c6a2d92a77d979662d19f6dfbd3e42c5f5 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 01:20:27 -0400 Subject: [PATCH 32/50] fix missing return of getColorForModeLabel --- www/js/metrics/metricsHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 6edf661e6..7b68962ea 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -317,5 +317,5 @@ export function getColorForModeLabel(label: string) { const unknownModeColor = base_modes.get_base_mode_by_key('UNKNOWN').color; return color(unknownModeColor).alpha(UNCERTAIN_OPACITY).rgb().string(); } - base_modes.get_rich_mode_for_value(textToLabelKey(label), labelOptions).color; + return base_modes.get_rich_mode_for_value(textToLabelKey(label), labelOptions).color; } From e49bf0c0d1fbd56ec439eed9a70ae0e46187b527 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 01:23:19 -0400 Subject: [PATCH 33/50] fix types in metrics give default argument to MetricEntry and DayOfMetricData so we don't have to specify every time Only ['count', 'distance', 'duration'] are used on the TravelSection; add type TravelMetricName to reflect this I have not added unit utils for 'footprint' yet; but currently it is only used for ['count', 'distance', 'duration'] anyway --- www/js/metrics/metricsHelper.ts | 1 + www/js/metrics/metricsTypes.ts | 4 ++-- www/js/metrics/travel/TravelSection.tsx | 7 ++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 7b68962ea..737b3c0cf 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -271,6 +271,7 @@ export function getUnitUtilsForMetric( return `${responded}/${total} ${i18next.t('metrics.surveys.responses')}`; }, ], + footprint: [] as any, // TODO }; return fns[metricName]; } diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index 8815f1f56..366f7ada6 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -13,11 +13,11 @@ export type MetricValue = T extends TravelMetricName ? { kg_co2: number; kg_co2_uncertain?: number; kwh: number; kwh_uncertain?: number } : never; -export type MetricEntry = { +export type MetricEntry = { [k in `${GroupingField}_${string}`]?: MetricValue; }; -export type DayOfMetricData = { +export type DayOfMetricData = { date: string; // yyyy-mm-dd nUsers: number; } & MetricEntry; diff --git a/www/js/metrics/travel/TravelSection.tsx b/www/js/metrics/travel/TravelSection.tsx index 8585cc404..f862cb00a 100644 --- a/www/js/metrics/travel/TravelSection.tsx +++ b/www/js/metrics/travel/TravelSection.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import { GroupingField, MetricName } from '../../types/appConfigTypes'; +import { GroupingField } from '../../types/appConfigTypes'; import MetricsCard from './MetricsCard'; import { t } from 'i18next'; -const TRAVEL_METRICS = ['distance', 'duration', 'count']; +const TRAVEL_METRICS = ['count', 'distance', 'duration'] as const; +export type TravelMetricName = (typeof TRAVEL_METRICS)[number]; const TravelSection = ({ userMetrics, aggMetrics, metricList }) => { return ( <> {Object.entries(metricList).map( - ([metricName, groupingFields]: [MetricName, GroupingField[]]) => + ([metricName, groupingFields]: [TravelMetricName, GroupingField[]]) => TRAVEL_METRICS.includes(metricName) ? ( Date: Thu, 19 Sep 2024 01:25:14 -0400 Subject: [PATCH 34/50] export colors from appTheme There are some places where we want an instance of colors accessible from outside a component, ie. not via useAppTheme --- www/js/appTheme.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index c6e561e1b..430848907 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -103,3 +103,5 @@ export function getTheme(flavor?: keyof typeof flavorOverrides) { }; return { ...AppTheme, colors: scopedColors }; } + +export const colors = AppTheme.colors; From 6e1d719477a96a857a4dc3ca4b59b445d2bbf652 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 10:07:24 -0400 Subject: [PATCH 35/50] adjust formatForDisplay Makes this implementation match what the existing tests are expecting Updates comment since it was out of date --- www/js/util.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/www/js/util.ts b/www/js/util.ts index 2e87ba1eb..e0b8fa2f5 100644 --- a/www/js/util.ts +++ b/www/js/util.ts @@ -5,12 +5,14 @@ import humanizeDuration from 'humanize-duration'; /* formatting units for display: - if value >= 100, round to the nearest integer e.g. "105 mi", "119 kmph" - - if 1 <= value < 100, round to 3 significant digits - e.g. "7.02 km", "11.3 mph" - - if value < 1, round to 2 decimal places - e.g. "0.07 mi", "0.75 km" */ + - if 10 <= value < 100, round to 1 decimal place + e.g. "77.2 km", "11.3 mph" + - if value < 10, round to 2 decimal places + e.g. "7.27 mi", "0.75 km" */ export function formatForDisplay(value: number, opts: Intl.NumberFormatOptions = {}): string { - opts.maximumFractionDigits ??= value >= 100 ? 0 : 1; + if (value >= 100) opts.maximumFractionDigits ??= 0; + else if (value >= 10) opts.maximumFractionDigits ??= 1; + else opts.maximumFractionDigits ??= 2; return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value); } From 6743b8c4cae806172b748aa410459df8840e648f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:17:58 -0400 Subject: [PATCH 36/50] add FootprintComparisonCard gives a "You vs. Group" comparison for average daily carbon and energy --- .../footprint/FootprintComparisonCard.tsx | 100 ++++++++++++++++++ www/js/metrics/footprint/FootprintSection.tsx | 47 ++++++-- 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 www/js/metrics/footprint/FootprintComparisonCard.tsx diff --git a/www/js/metrics/footprint/FootprintComparisonCard.tsx b/www/js/metrics/footprint/FootprintComparisonCard.tsx new file mode 100644 index 000000000..344a94164 --- /dev/null +++ b/www/js/metrics/footprint/FootprintComparisonCard.tsx @@ -0,0 +1,100 @@ +import React, { useContext, useMemo } from 'react'; +import { Card, Text } from 'react-native-paper'; +import { metricsStyles } from '../MetricsScreen'; +import BarChart from '../../components/BarChart'; +import { useTranslation } from 'react-i18next'; +import { ChartRecord } from '../../components/Chart'; +import TimelineContext from '../../TimelineContext'; +import { formatIsoNoYear } from '../../util'; + +const FootprintComparisonCard = ({ + type, + unit, + userCumulativeFootprint, + groupCumulativeFootprint, + title, + addFootnote, + axisTitle, + goals, + showUncertainty, + nDays, +}) => { + const { t } = useTranslation(); + const { queriedDateRange } = useContext(TimelineContext); + + const chartRecords = useMemo(() => { + let records: ChartRecord[] = []; + if (!queriedDateRange || !userCumulativeFootprint || !groupCumulativeFootprint) return records; + + const nAggUserDays = groupCumulativeFootprint['nUsers']; + const yAxisLabelGroup = + t('metrics.footprint.group-average') + '\n' + formatIsoNoYear(...queriedDateRange); + records.push({ + label: t('metrics.footprint.labeled'), + x: groupCumulativeFootprint[unit] / nAggUserDays, + y: yAxisLabelGroup, + }); + if (showUncertainty && groupCumulativeFootprint[`${unit}_uncertain`]) { + records.push({ + label: + t('metrics.footprint.unlabeled') + + addFootnote(t('metrics.footprint.uncertainty-footnote')), + x: groupCumulativeFootprint[`${unit}_uncertain`]! / nAggUserDays, + y: yAxisLabelGroup, + }); + } + + const yAxisLabelUser = t('metrics.footprint.you') + '\n' + formatIsoNoYear(...queriedDateRange); + records.push({ + label: t('metrics.footprint.labeled'), + x: userCumulativeFootprint[unit] / nDays, + y: yAxisLabelUser, + }); + if (showUncertainty && userCumulativeFootprint[`${unit}_uncertain`]) { + records.push({ + label: + t('metrics.footprint.unlabeled') + + addFootnote(t('metrics.footprint.uncertainty-footnote')), + x: userCumulativeFootprint[`${unit}_uncertain`]! / nDays, + y: yAxisLabelUser, + }); + } + + return records; + }, [userCumulativeFootprint, groupCumulativeFootprint]); + + let meter = goals[type]?.length + ? { + uncertainty_prefix: t('metrics.footprint.unlabeled'), + middle: goals[type][0].value, + high: goals[type][goals[type].length - 1].value, + } + : undefined; + + return ( + + + + {chartRecords?.length > 0 ? ( + <> + + + ) : ( + + {t('metrics.no-data')} + + )} + + + ); +}; + +export default FootprintComparisonCard; diff --git a/www/js/metrics/footprint/FootprintSection.tsx b/www/js/metrics/footprint/FootprintSection.tsx index c1864974e..c871f9b07 100644 --- a/www/js/metrics/footprint/FootprintSection.tsx +++ b/www/js/metrics/footprint/FootprintSection.tsx @@ -10,6 +10,7 @@ import { formatIso, isoDatesDifference } from '../../util'; import WeeklyFootprintCard from './WeeklyFootprintCard'; import useAppConfig from '../../useAppConfig'; import { getFootprintGoals } from './footprintHelper'; +import FootprintComparisonCard from './FootprintComparisonCard'; const FootprintSection = ({ userMetrics, aggMetrics, metricList }) => { const { t } = useTranslation(); @@ -35,12 +36,18 @@ const FootprintSection = ({ userMetrics, aggMetrics, metricList }) => { .join(''); } - const cumulativeFootprintSum = useMemo( + const userCumulativeFootprint = useMemo( () => userMetrics?.footprint?.length ? sumMetricEntries(userMetrics?.footprint, 'footprint') : null, [userMetrics?.footprint], ); + const groupCumulativeFootprint = useMemo( + () => + aggMetrics?.footprint?.length ? sumMetricEntries(aggMetrics?.footprint, 'footprint') : null, + [aggMetrics?.footprint], + ); + const goals = getFootprintGoals(appConfig, addFootnote); // defaults to true if not defined in config @@ -56,14 +63,14 @@ const FootprintSection = ({ userMetrics, aggMetrics, metricList }) => { {t('metrics.footprint.estimated-footprint')} {`${formatIso(...queriedDateRange)} (${nDays} days)`} - {cumulativeFootprintSum && ( + {userCumulativeFootprint && ( { title={t('metrics.footprint.energy-usage')} unit="kWh" value={[ - cumulativeFootprintSum?.kwh, - cumulativeFootprintSum?.kwh + (cumulativeFootprintSum?.kwh_uncertain || 0), + userCumulativeFootprint?.kwh, + userCumulativeFootprint?.kwh + (userCumulativeFootprint?.kwh_uncertain || 0), ]} nDays={nDays} goals={goals['energy'] || []} @@ -94,6 +101,34 @@ const FootprintSection = ({ userMetrics, aggMetrics, metricList }) => { axisTitle={t('metrics.footprint.kwh-per-day')} {...{ goals, addFootnote, showUncertainty, userMetrics, metricList }} /> + + {footnotes.length && ( {footnotes.map((note, i) => ( From fd5010a72f6885a3848fc1fa0b0728681f8f9048 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:20:52 -0400 Subject: [PATCH 37/50] fix lineAnnotations on WeeklyFootprintCard we do not need to lookup the label by language beacuse getFootprintGoals already handles this --- www/js/metrics/footprint/WeeklyFootprintCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/metrics/footprint/WeeklyFootprintCard.tsx b/www/js/metrics/footprint/WeeklyFootprintCard.tsx index 766aa894f..5f4968f1a 100644 --- a/www/js/metrics/footprint/WeeklyFootprintCard.tsx +++ b/www/js/metrics/footprint/WeeklyFootprintCard.tsx @@ -112,7 +112,7 @@ const WeeklyFootprintCard = ({ isHorizontal={true} timeAxis={false} stacked={true} - lineAnnotations={goals[type].map((g) => ({ ...g, label: g.label[i18next.language] }))} + lineAnnotations={goals[type]} meter={!groupingField ? meter : undefined} getColorForLabel={groupingField == 'mode_confirm' ? getColorForModeLabel : undefined} /> From 6e7153f755fdcaff30d2075a426df2f333f86bc6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:22:10 -0400 Subject: [PATCH 38/50] keep total of nUsers when aggregating MetricEntries This is necessary to be able to appropriately divide aggregate metrics back to per-user scale for "You vs. Group" comparison --- www/js/metrics/metricsHelper.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 737b3c0cf..fb6be93e3 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -287,6 +287,8 @@ export function aggMetricEntries(entries: MetricEntry[] for (let field in e) { if (groupingFields.some((f) => field.startsWith(f))) { acc[field] = metrics_summaries.acc_value_of_metric(metricName, acc?.[field], e[field]); + } else if (field == 'nUsers') { + acc[field] = (acc[field] || 0) + e[field]; } } }); @@ -305,6 +307,7 @@ export function sumMetricEntry(entry: MetricEntry, metr acc = metrics_summaries.acc_value_of_metric(metricName, acc, entry[field]); } } + acc['nUsers'] = entry['nUsers'] || 1; return (acc || {}) as MetricValue; } From a7fcd0d00233a3342b76132c0cdd16979132578e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:26:53 -0400 Subject: [PATCH 39/50] remove old footprint metrics files customFootprint not needed anymore; replaced by emcommon footprint calculations aka CHEER 'mets' are not currently in use, but the data in 'metDataset.ts' is included in the base modes in emcommon, so we do have this available to add back in the future --- www/__tests__/customMetricsHelper.test.ts | 64 ----------- www/__tests__/metHelper.test.ts | 44 -------- www/js/App.tsx | 2 - www/js/metrics/customMetricsHelper.ts | 108 ------------------ www/js/metrics/movement/metDataset.ts | 128 ---------------------- www/js/metrics/movement/metHelper.ts | 59 ---------- 6 files changed, 405 deletions(-) delete mode 100644 www/__tests__/customMetricsHelper.test.ts delete mode 100644 www/__tests__/metHelper.test.ts delete mode 100644 www/js/metrics/customMetricsHelper.ts delete mode 100644 www/js/metrics/movement/metDataset.ts delete mode 100644 www/js/metrics/movement/metHelper.ts diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts deleted file mode 100644 index ef840283f..000000000 --- a/www/__tests__/customMetricsHelper.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getConfig } from '../js/config/dynamicConfig'; -import { - _test_clearCustomMetrics, - getCustomFootprint, - getCustomMETs, - initCustomDatasetHelper, -} from '../js/metrics/customMetricsHelper'; -import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; -import { mockLogger } from '../__mocks__/globalMocks'; -import fakeLabels from '../__mocks__/fakeLabels.json'; -import fakeConfig from '../__mocks__/fakeConfig.json'; - -mockBEMUserCache(fakeConfig); -mockLogger(); - -beforeEach(() => { - _test_clearCustomMetrics(); -}); - -global.fetch = (url: string) => - new Promise((rs, rj) => { - setTimeout(() => - rs({ - text: () => - new Promise((rs, rj) => { - let myJSON = JSON.stringify(fakeLabels); - setTimeout(() => rs(myJSON), 100); - }), - }), - ); - }) as any; - -it('has no footprint or mets before initialized', () => { - expect(getCustomFootprint()).toBeUndefined(); - expect(getCustomMETs()).toBeUndefined(); -}); - -it('gets the custom mets', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - //expecting the keys from fakeLabels.json NOT metrics/metDataset.ts - expect(getCustomMETs()).toMatchObject({ - walk: expect.any(Object), - bike: expect.any(Object), - bikeshare: expect.any(Object), - 'e-bike': expect.any(Object), - scootershare: expect.any(Object), - drove_alone: expect.any(Object), - }); -}); - -it('gets the custom footprint', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - //numbers from fakeLabels.json - expect(getCustomFootprint()).toMatchObject({ - walk: 0, - bike: 0, - bikeshare: 0, - 'e-bike': 0.00728, - scootershare: 0.00894, - drove_alone: 0.22031, - }); -}); diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts deleted file mode 100644 index c9d0b21df..000000000 --- a/www/__tests__/metHelper.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getMet } from '../js/metrics/movement/metHelper'; -import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; -import { mockLogger } from '../__mocks__/globalMocks'; -import fakeLabels from '../__mocks__/fakeLabels.json'; -import { getConfig } from '../js/config/dynamicConfig'; -import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; -import fakeConfig from '../__mocks__/fakeConfig.json'; - -mockBEMUserCache(fakeConfig); -mockLogger(); - -global.fetch = (url: string) => - new Promise((rs, rj) => { - setTimeout(() => - rs({ - text: () => - new Promise((rs, rj) => { - let myJSON = JSON.stringify(fakeLabels); - setTimeout(() => rs(myJSON), 100); - }), - }), - ); - }) as any; - -it('gets met for mode and speed', () => { - expect(getMet('WALKING', 1.47523, 0)).toBe(4.3); //1.47523 mps = 3.299 mph -> 4.3 METs - expect(getMet('BICYCLING', 4.5, 0)).toBe(6.8); //4.5 mps = 10.07 mph = 6.8 METs - expect(getMet('UNICYCLE', 100, 0)).toBe(0); //unkown mode, 0 METs - expect(getMet('CAR', 25, 1)).toBe(0); //0 METs in CAR - expect(getMet('ON_FOOT', 1.47523, 0)).toBe(4.3); //same as walking! - expect(getMet('WALKING', -2, 0)).toBe(0); //negative speed -> 0 -}); - -it('gets custom met for mode and speed', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - expect(getMet('walk', 1.47523, 0)).toBe(4.3); //1.47523 mps = 3.299 mph -> 4.3 METs - expect(getMet('bike', 4.5, 0)).toBe(6.8); //4.5 mps = 10.07 mph = 6.8 METs - expect(getMet('unicycle', 100, 0)).toBe(0); //unkown mode, 0 METs - expect(getMet('drove_alone', 25, 1)).toBe(0); //0 METs IN_VEHICLE - expect(getMet('e-bike', 6, 1)).toBe(4.9); //e-bike is 4.9 for all speeds - expect(getMet('e-bike', 12, 1)).toBe(4.9); //e-bike is 4.9 for all speeds - expect(getMet('walk', -2, 1)).toBe(0); //negative speed -> 0 -}); diff --git a/www/js/App.tsx b/www/js/App.tsx index 0d45ebeca..336c806b1 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -14,7 +14,6 @@ import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; import { getUserCustomLabels } from './services/commHelper'; -import { initCustomDatasetHelper } from './metrics/customMetricsHelper'; import AlertBar from './components/AlertBar'; import Main from './Main'; @@ -46,7 +45,6 @@ const App = () => { initStoreDeviceSettings(); initRemoteNotifyHandler(); getUserCustomLabels(CUSTOM_LABEL_KEYS_IN_DATABASE).then((res) => setCustomLabelMap(res)); - initCustomDatasetHelper(appConfig); }, [appConfig]); const appContextValue = { diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts deleted file mode 100644 index d468f2f35..000000000 --- a/www/js/metrics/customMetricsHelper.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getLabelOptions } from '../survey/multilabel/confirmHelper'; -import { displayError, logDebug, logWarn } from '../plugin/logger'; -import { standardMETs } from './movement/metDataset'; -import { AppConfig } from '../types/appConfigTypes'; - -//variables to store values locally -let _customMETs: { [key: string]: { [key: string]: { range: number[]; met: number } } }; -let _customPerKmFootprint: { [key: string]: number }; -let _labelOptions; - -/** - * ONLY USED IN TESTING - * @function clears the locally stored variables - */ -export function _test_clearCustomMetrics() { - _customMETs = undefined; - _customPerKmFootprint = undefined; - _labelOptions = undefined; -} - -/** - * @function gets custom mets, must be initialized - * @returns the custom mets stored locally - */ -export function getCustomMETs() { - logDebug('Getting custom METs ' + JSON.stringify(_customMETs)); - return _customMETs; -} - -/** - * @function gets the custom footprint, must be initialized - * @returns custom footprint - */ -export function getCustomFootprint() { - logDebug('Getting custom footprint ' + JSON.stringify(_customPerKmFootprint)); - return _customPerKmFootprint; -} - -/** - * @function stores custom mets in local var - * needs _labelOptions, stored after gotten from config - */ -function populateCustomMETs() { - let modeOptions = _labelOptions['MODE']; - let modeMETEntries = modeOptions.map((opt) => { - if (opt.met_equivalent) { - let currMET = standardMETs[opt.met_equivalent]; - return [opt.value, currMET]; - } else { - if (opt.met) { - let currMET = opt.met; - // if the user specifies a custom MET, they can't specify - // Number.MAX_VALUE since it is not valid JSON - // we assume that they specify -1 instead, and we will - // map -1 to Number.MAX_VALUE here by iterating over all the ranges - for (const rangeName in currMET) { - currMET[rangeName].range = currMET[rangeName].range.map((i) => - i == -1 ? Number.MAX_VALUE : i, - ); - } - return [opt.value, currMET]; - } else { - logWarn(`Did not find either met_equivalent or met for ${opt.value} ignoring entry`); - return undefined; - } - } - }); - _customMETs = Object.fromEntries(modeMETEntries.filter((e) => typeof e !== 'undefined')); - logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); -} - -/** - * @function stores custom footprint in local var - * needs _inputParams which is stored after gotten from config - */ -function populateCustomFootprints() { - let modeOptions = _labelOptions['MODE']; - let modeCO2PerKm = modeOptions - .map((opt) => { - if (typeof opt.kgCo2PerKm !== 'undefined') { - return [opt.value, opt.kgCo2PerKm]; - } else { - return undefined; - } - }) - .filter((modeCO2) => typeof modeCO2 !== 'undefined'); - _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); - logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); -} - -/** - * @function initializes the datasets based on configured label options - * calls popuplateCustomMETs and populateCustomFootprint - * @param newConfig the app config file - */ -export async function initCustomDatasetHelper(newConfig: AppConfig) { - try { - logDebug('initializing custom datasets'); - const labelOptions = await getLabelOptions(newConfig); - _labelOptions = labelOptions; - populateCustomMETs(); - populateCustomFootprints(); - } catch (e) { - setTimeout(() => { - displayError(e, 'Error while initializing custom dataset helper'); - }, 1000); - } -} diff --git a/www/js/metrics/movement/metDataset.ts b/www/js/metrics/movement/metDataset.ts deleted file mode 100644 index 901c17ae6..000000000 --- a/www/js/metrics/movement/metDataset.ts +++ /dev/null @@ -1,128 +0,0 @@ -export const standardMETs = { - WALKING: { - VERY_SLOW: { - range: [0, 2.0], - mets: 2.0, - }, - SLOW: { - range: [2.0, 2.5], - mets: 2.8, - }, - MODERATE_0: { - range: [2.5, 2.8], - mets: 3.0, - }, - MODERATE_1: { - range: [2.8, 3.2], - mets: 3.5, - }, - FAST: { - range: [3.2, 3.5], - mets: 4.3, - }, - VERY_FAST_0: { - range: [3.5, 4.0], - mets: 5.0, - }, - 'VERY_FAST_!': { - range: [4.0, 4.5], - mets: 6.0, - }, - VERY_VERY_FAST: { - range: [4.5, 5], - mets: 7.0, - }, - SUPER_FAST: { - range: [5, 6], - mets: 8.3, - }, - RUNNING: { - range: [6, Number.MAX_VALUE], - mets: 9.8, - }, - }, - BICYCLING: { - VERY_VERY_SLOW: { - range: [0, 5.5], - mets: 3.5, - }, - VERY_SLOW: { - range: [5.5, 10], - mets: 5.8, - }, - SLOW: { - range: [10, 12], - mets: 6.8, - }, - MODERATE: { - range: [12, 14], - mets: 8.0, - }, - FAST: { - range: [14, 16], - mets: 10.0, - }, - VERT_FAST: { - range: [16, 19], - mets: 12.0, - }, - RACING: { - range: [20, Number.MAX_VALUE], - mets: 15.8, - }, - }, - UNKNOWN: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - IN_VEHICLE: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - CAR: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - BUS: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - LIGHT_RAIL: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - TRAIN: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - TRAM: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - SUBWAY: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - AIR_OR_HSR: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, -}; diff --git a/www/js/metrics/movement/metHelper.ts b/www/js/metrics/movement/metHelper.ts deleted file mode 100644 index a059a784d..000000000 --- a/www/js/metrics/movement/metHelper.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { logDebug, logWarn } from '../../plugin/logger'; -import { getCustomMETs } from '../customMetricsHelper'; -import { standardMETs } from './metDataset'; - -/** - * @function gets the METs object - * @returns {object} mets either custom or standard - */ -function getMETs() { - let custom_mets = getCustomMETs(); - if (custom_mets) { - return custom_mets; - } else { - return standardMETs; - } -} - -/** - * @function checks number agains bounds - * @param num the number to check - * @param min lower bound - * @param max upper bound - * @returns {boolean} if number is within given bounds - */ -const between = (num, min, max) => num >= min && num <= max; - -/** - * @function converts meters per second to miles per hour - * @param mps meters per second speed - * @returns speed in miles per hour - */ -const mpstomph = (mps) => 2.23694 * mps; - -/** - * @function gets met for a given mode and speed - * @param {string} mode of travel - * @param {number} speed of travel in meters per second - * @param {number} defaultIfMissing default MET if mode not in METs - * @returns - */ -export function getMet(mode, speed, defaultIfMissing) { - if (mode == 'ON_FOOT') { - logDebug("getMet() converted 'ON_FOOT' to 'WALKING'"); - mode = 'WALKING'; - } - let currentMETs = getMETs(); - if (!currentMETs[mode]) { - logWarn('getMet() Illegal mode: ' + mode); - return defaultIfMissing; //So the calorie sum does not break with wrong return type - } - for (let i in currentMETs[mode]) { - if (between(mpstomph(speed), currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { - return currentMETs[mode][i].mets; - } else if (mpstomph(speed) < 0) { - logWarn('getMet() Negative speed: ' + mpstomph(speed)); - return 0; - } - } -} From a6234053a40784fec2a2147342b84e3a2b059146 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:27:34 -0400 Subject: [PATCH 40/50] update translations --- www/i18n/en.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index dded8dc75..b9318b16b 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -97,10 +97,14 @@ "labeled": "Labeled", "unlabeled": "Unlabeled", "uncertainty-footnote": "Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", - "daily-emissions-by-week": "Average Daily Emissions by Week", - "kg-co2e-per-day": "kg CO₂e / day", - "daily-energy-by-week": "Average Daily Energy Usage by Week", - "kwh-per-day": "kWh / day" + "daily-emissions-by-week": "Daily Emissions by Week", + "kg-co2e-per-day": "Average kg CO₂e / day", + "daily-energy-by-week": "Daily Energy Usage by Week", + "kwh-per-day": "Average kWh / day", + "daily-emissions-comparison": "Daily Emissions vs. Group", + "daily-energy-comparison": "Daily Energy Usage vs. Group", + "you": "You", + "group-average": "Group Average" }, "movement": { "movement": "Movement", @@ -129,7 +133,7 @@ "trip-categories": "Trip Categories", "response": "Response", "no-response": "No Response", - "responses": "Responses", + "responses": "responses", "comparison": "Comparison", "you": "You", "others": "Others in group" From b13524f03964a5704e03838bd24e66d0e8795ebd Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:34:29 -0400 Subject: [PATCH 41/50] update tests to reflect changes confirmHelper 'json/label-options.json.sample' does not exist anymore so let's just test the default, if we passed a blank config diaryHelper Most of the functions that were tested here don't exist anymore and the tests can be removed. In fact, getDetectedModes is the only one left. However there are functions that didn't have tests, add those footprintHelper All of these functions were removed and there is only one new one, getFootprintGoals metricsHelper The date format changed from numerical month/day to abbreviated month/day imperialConfig Extract the imperialConfig generation to a separate function so it can be accessed outside the hook. Simpler and makes it easier to test. Also let's test both metric and imperial, not just metric --- www/__tests__/confirmHelper.test.ts | 8 +- www/__tests__/diaryHelper.test.ts | 195 ++++++++++++++---------- www/__tests__/footprintHelper.test.ts | 147 ++++++++---------- www/__tests__/metricsHelper.test.ts | 4 +- www/__tests__/useImperialConfig.test.ts | 31 ++-- www/js/config/useImperialConfig.ts | 36 ++--- 6 files changed, 212 insertions(+), 209 deletions(-) diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts index 8d7a7c074..a0421a306 100644 --- a/www/__tests__/confirmHelper.test.ts +++ b/www/__tests__/confirmHelper.test.ts @@ -18,11 +18,7 @@ import { UserInputMap } from '../js/TimelineContext'; window['i18next'] = initializedI18next; mockLogger(); -const fakeAppConfig = { - label_options: 'json/label-options.json.sample', -}; const fakeAppConfigWithModeOfStudy = { - ...fakeAppConfig, intro: { mode_studied: 'walk', }, @@ -63,8 +59,8 @@ jest.mock('../js/services/commHelper', () => ({ })); describe('confirmHelper', () => { - it('returns labelOptions given an appConfig', async () => { - const labelOptions = await getLabelOptions(fakeAppConfig); + it('returns default labelOptions given a blank appConfig', async () => { + const labelOptions = await getLabelOptions({}); expect(labelOptions).toBeTruthy(); expect(labelOptions.MODE[0].value).toEqual('walk'); }); diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 58cd20246..416e0979f 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -1,99 +1,136 @@ import { - getFormattedDate, - isMultiDay, - getFormattedDateAbbr, - getFormattedTimeRange, getDetectedModes, + getFormattedSectionProperties, + primarySectionForTrip, + getLocalTimeString, } from '../js/diary/diaryHelper'; import { base_modes } from 'e-mission-common'; import initializedI18next from '../js/i18nextInit'; +import { getImperialConfig } from '../js/config/useImperialConfig'; +import { LocalDt } from '../js/types/serverData'; window['i18next'] = initializedI18next; -it('returns a formatted date', () => { - expect(getFormattedDate('2023-09-18T00:00:00-07:00')).toBe('Mon, September 18, 2023'); - expect(getFormattedDate('')).toBeUndefined(); - expect(getFormattedDate('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( - 'Mon, September 18, 2023 - Thu, September 21, 2023', - ); -}); +describe('diaryHelper', () => { + /* fake trips with 'distance' in their section summaries + ('count' and 'duration' are not used bygetDetectedModes) */ + let myFakeTrip = { + distance: 6729.0444371031606, + cleaned_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6013.73657416706, + WALKING: 715.3078629361006, + }, + }, + } as any; -it('returns an abbreviated formatted date', () => { - expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00')).toBe('Mon, Sep 18'); - expect(getFormattedDateAbbr('')).toBeUndefined(); - expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( - 'Mon, Sep 18 - Thu, Sep 21', - ); -}); + let myFakeTrip2 = { + ...myFakeTrip, + inferred_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6729.0444371031606, + }, + }, + }; -it('returns a human readable time range', () => { - expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:20')).toBe( - '2 hours', - ); - expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:30')).toBe( - '3 hours', - ); - expect(getFormattedTimeRange('', '2023-09-18T00:00:00-09:30')).toBeFalsy(); -}); + let myFakeDetectedModes = [ + { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 89 }, + { mode: 'WALKING', icon: 'walk', color: base_modes.mode_colors['blue'], pct: 11 }, + ]; -it('returns a Base Mode for a given key', () => { - expect(base_modes.get_base_mode_by_key('WALKING')).toMatchObject({ - icon: 'walk', - color: base_modes.mode_colors['blue'], - }); - expect(base_modes.get_base_mode_by_key('MotionTypes.WALKING')).toMatchObject({ - icon: 'walk', - color: base_modes.mode_colors['blue'], - }); - expect(base_modes.get_base_mode_by_key('I made this type up')).toMatchObject({ - icon: 'help', - color: base_modes.mode_colors['grey'], + let myFakeDetectedModes2 = [ + { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 100 }, + ]; + + describe('getDetectedModes', () => { + it('returns the detected modes, with percentages, for a trip', () => { + expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); + expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); + expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes + }); }); -}); -it('returns true/false is multi day', () => { - expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-19T00:00:00-07:00')).toBeTruthy(); - expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:00')).toBeFalsy(); - expect(isMultiDay('', '2023-09-18T00:00:00-09:00')).toBeFalsy(); -}); + const myFakeTripWithSections = { + sections: [ + { + start_fmt_time: '2024-09-18T08:30:00', + start_local_dt: { year: 2024, month: 9, day: 18, hour: 8, minute: 30, second: 0 }, + end_fmt_time: '2024-09-18T08:45:00', + end_local_dt: { year: 2024, month: 9, day: 18, hour: 8, minute: 45, second: 0 }, + distance: 1000, + sensed_mode_str: 'WALKING', + }, + { + start_fmt_time: '2024-09-18T08:45:00', + start_local_dt: { year: 2024, month: 9, day: 18, hour: 8, minute: 45, second: 0 }, + end_fmt_time: '2024-09-18T09:00:00', + end_local_dt: { year: 2024, month: 9, day: 18, hour: 9, minute: 0, second: 0 }, + distance: 2000, + sensed_mode_str: 'BICYCLING', + }, + ], + } as any; -/* fake trips with 'distance' in their section summaries - ('count' and 'duration' are not used bygetDetectedModes) */ -let myFakeTrip = { - distance: 6729.0444371031606, - cleaned_section_summary: { - // count: {...} - // duration: {...} - distance: { - BICYCLING: 6013.73657416706, - WALKING: 715.3078629361006, - }, - }, -} as any; + const imperialConfg = getImperialConfig(true); -let myFakeTrip2 = { - ...myFakeTrip, - inferred_section_summary: { - // count: {...} - // duration: {...} - distance: { - BICYCLING: 6729.0444371031606, - }, - }, -}; + describe('getFormattedSectionProperties', () => { + it('returns the formatted section properties for a trip', () => { + expect(getFormattedSectionProperties(myFakeTripWithSections, imperialConfg)).toEqual([ + { + startTime: '8:30 AM', + duration: '15 minutes', + distance: '0.62', + distanceSuffix: 'mi', + icon: 'walk', + color: base_modes.mode_colors['blue'], + }, + { + startTime: '8:45 AM', + duration: '15 minutes', + distance: '1.24', + distanceSuffix: 'mi', + icon: 'bike', + color: base_modes.mode_colors['green'], + }, + ]); + }); + }); + + describe('primarySectionForTrip', () => { + it('returns the section with the greatest distance for a trip', () => { + expect(primarySectionForTrip(myFakeTripWithSections)).toEqual( + myFakeTripWithSections.sections[1], + ); + }); + }); -let myFakeDetectedModes = [ - { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 89 }, - { mode: 'WALKING', icon: 'walk', color: base_modes.mode_colors['blue'], pct: 11 }, -]; + describe('getLocalTimeString', () => { + it('returns the formatted time string for a full LocalDt object', () => { + expect( + getLocalTimeString({ + year: 2024, + month: 9, + day: 18, + hour: 15, + minute: 30, + second: 8, + weekday: 3, + timezone: 'America/Los_Angeles', + }), + ).toEqual('3:30 PM'); + }); -let myFakeDetectedModes2 = [ - { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 100 }, -]; + it('returns the formatted time string for a LocalDt object with only hour and minute', () => { + expect(getLocalTimeString({ hour: 8, minute: 30 } as LocalDt)).toEqual('8:30 AM'); + }); -it('returns the detected modes, with percentages, for a trip', () => { - expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); - expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); - expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes + it('returns undefined for an undefined LocalDt object', () => { + expect(getLocalTimeString()).toBeUndefined(); + }); + }); }); diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index a0abe6bbb..e197e87d3 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -1,89 +1,66 @@ -import { - _test_clearCustomMetrics, - initCustomDatasetHelper, -} from '../js/metrics/customMetricsHelper'; -import { - clearHighestFootprint, - getFootprintForMetrics, - getHighestFootprint, - getHighestFootprintForDistance, -} from '../js/metrics/footprint/footprintHelper'; -import { getConfig } from '../js/config/dynamicConfig'; -import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; -import { mockLogger } from '../__mocks__/globalMocks'; -import fakeLabels from '../__mocks__/fakeLabels.json'; -import fakeConfig from '../__mocks__/fakeConfig.json'; +import i18next from 'i18next'; +import { getFootprintGoals } from '../js/metrics/footprint/footprintHelper'; +import AppConfig from '../js/types/appConfigTypes'; -mockBEMUserCache(fakeConfig); -mockLogger(); - -global.fetch = (url: string) => - new Promise((rs, rj) => { - setTimeout(() => - rs({ - text: () => - new Promise((rs, rj) => { - let myJSON = JSON.stringify(fakeLabels); - setTimeout(() => rs(myJSON), 100); - }), - }), - ); - }) as any; - -beforeEach(() => { - clearHighestFootprint(); - _test_clearCustomMetrics(); -}); - -const custom_metrics = [ - { key: 'ON_FOOT', values: 3000 }, //hits fallback under custom paradigm - { key: 'bike', values: 6500 }, - { key: 'drove_alone', values: 10000 }, - { key: 'scootershare', values: 25000 }, - { key: 'unicycle', values: 5000 }, -]; - -/* - 3*0 + 6.5*0 + 10*0.22031 + 25*0.00894 + 5*0 - 0 + 0 + 2.2031 + 0.2235 + 0 - 2.4266 -*/ -it('gets footprint for metrics (custom, fallback 0)', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); -}); - -/* - 3*0.1 + 6.5*0 + 10*0.22031 + 25*0.00894 + 5*0.1 - 0.3 + 0 + 2.2031 + 0.2235 + 0.5 - 0.3 2.4266 + 0.5 -*/ -it('gets footprint for metrics (custom, fallback 0.1)', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(3.2266); -}); - -//expects TAXI from the fake labels -it('gets the highest footprint from the dataset, custom', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - expect(getHighestFootprint()).toBe(0.30741); -}); - -/* - TAXI co2/km * meters/1000 -*/ -it('gets the highest footprint for distance, custom', async () => { - const appConfig = await getConfig(); - await initCustomDatasetHelper(appConfig!); - expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000)); -}); +describe('footprintHelper', () => { + const fakeAppConfig1 = { + metrics: { + phone_dashboard_ui: { + footprint_options: { + goals: { + carbon: [ + { + label: { en: 'Foo goal' }, + value: 1.1, + color: 'rgb(255, 0, 0)', + }, + { + label: { en: 'Bar goal' }, + value: 5.5, + color: 'rgb(0, 255, 0)', + }, + ], + energy: [ + { + label: { en: 'Baz goal' }, + value: 4.4, + color: 'rgb(0, 0, 255)', + }, + { + label: { en: 'Zab goal' }, + value: 9.9, + color: 'rgb(255, 255, 0)', + }, + ], + goals_footnote: { en: 'Foobar footnote' }, + }, + }, + }, + }, + }; -it('errors out if not initialized', () => { - const t = () => { - getFootprintForMetrics(custom_metrics, 0); + const myFakeFootnotes: string[] = []; + const addFakeFootnote = (footnote: string) => { + myFakeFootnotes.push(footnote); + return myFakeFootnotes.length.toString(); }; - expect(t).toThrow(Error); + describe('getFootprintGoals', () => { + it('should use default goals if appConfig is blank / does not have goals, extract the label, and add footnote', () => { + myFakeFootnotes.length = 0; + const goals = getFootprintGoals({} as any as AppConfig, addFakeFootnote); + expect(goals.carbon[0].label).toEqual(i18next.t('metrics.footprint.us-2050-goal') + '1'); + expect(goals.carbon[1].label).toEqual(i18next.t('metrics.footprint.us-2030-goal') + '1'); + expect(goals.energy[0].label).toEqual(i18next.t('metrics.footprint.us-2050-goal') + '1'); + expect(goals.energy[1].label).toEqual(i18next.t('metrics.footprint.us-2030-goal') + '1'); + }); + + it('should use goals from appConfig when provided, extract the label, and add footnote', () => { + myFakeFootnotes.length = 0; + const goals = getFootprintGoals(fakeAppConfig1 as any as AppConfig, addFakeFootnote); + expect(goals.carbon[0].label).toEqual('Foo goal1'); + expect(goals.carbon[1].label).toEqual('Bar goal1'); + expect(goals.energy[0].label).toEqual('Baz goal1'); + expect(goals.energy[1].label).toEqual('Zab goal1'); + }); + }); }); diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index c914c5782..07f1495b1 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -80,7 +80,7 @@ describe('metricsHelper', () => { describe('formatDate', () => { const day1 = { date: '2021-01-01' } as any as DayOfMetricData; it('should format date', () => { - expect(formatDate(day1)).toEqual('1/1'); + expect(formatDate(day1)).toEqual('Jan 1'); }); }); @@ -91,7 +91,7 @@ describe('metricsHelper', () => { { date: '2021-01-04' }, ] as any as DayOfMetricData[]; it('should format date range for days with date', () => { - expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4'); + expect(formatDateRangeOfDays(days1)).toEqual('Jan 1 – Jan 4'); // note: en dash }); }); diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index 8db08d0fc..2bebfb589 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,17 +1,4 @@ -import React from 'react'; -import { convertDistance, convertSpeed, useImperialConfig } from '../js/config/useImperialConfig'; - -// This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root -jest.mock('../js/useAppConfig', () => { - return jest.fn(() => ({ - display_config: { - use_imperial: false, - }, - loading: false, - })); -}); -jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); -jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); +import { convertDistance, convertSpeed, getImperialConfig } from '../js/config/useImperialConfig'; describe('convertDistance', () => { it('should convert meters to kilometers by default', () => { @@ -33,9 +20,9 @@ describe('convertSpeed', () => { }); }); -describe('useImperialConfig', () => { - it('returns ImperialConfig with imperial units', () => { - const imperialConfig = useImperialConfig(); +describe('getImperialConfig', () => { + it('gives an ImperialConfig that works in metric units', () => { + const imperialConfig = getImperialConfig(false); expect(imperialConfig.distanceSuffix).toBe('km'); expect(imperialConfig.speedSuffix).toBe('kmph'); expect(imperialConfig.convertDistance(10)).toBe(0.01); @@ -43,4 +30,14 @@ describe('useImperialConfig', () => { expect(imperialConfig.getFormattedDistance(10)).toBe('0.01'); expect(imperialConfig.getFormattedSpeed(20)).toBe('72'); }); + + it('gives an ImperialConfig that works in imperial units', () => { + const imperialConfig = getImperialConfig(true); + expect(imperialConfig.distanceSuffix).toBe('mi'); + expect(imperialConfig.speedSuffix).toBe('mph'); + expect(imperialConfig.convertDistance(10)).toBeCloseTo(0.01); + expect(imperialConfig.convertSpeed(20)).toBeCloseTo(44.74); + expect(imperialConfig.getFormattedDistance(10)).toBe('0.01'); + expect(imperialConfig.getFormattedSpeed(20)).toBe('44.7'); + }); }); diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index 223267f20..900c9854e 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useMemo } from 'react'; import useAppConfig from '../useAppConfig'; import { formatForDisplay } from '../util'; @@ -24,25 +24,21 @@ export function convertSpeed(speedMetersPerSec: number, imperial: boolean): numb return speedMetersPerSec * MPS_TO_KMPH; } +export const getImperialConfig = (useImperial: boolean): ImperialConfig => ({ + distanceSuffix: useImperial ? 'mi' : 'km', + speedSuffix: useImperial ? 'mph' : 'kmph', + convertDistance: (d) => convertDistance(d, useImperial), + convertSpeed: (s) => convertSpeed(s, useImperial), + getFormattedDistance: useImperial + ? (d) => formatForDisplay(convertDistance(d, true)) + : (d) => formatForDisplay(convertDistance(d, false)), + getFormattedSpeed: useImperial + ? (s) => formatForDisplay(convertSpeed(s, true)) + : (s) => formatForDisplay(convertSpeed(s, false)), +}); + export function useImperialConfig(): ImperialConfig { const appConfig = useAppConfig(); - const [useImperial, setUseImperial] = useState(false); - - useEffect(() => { - if (!appConfig) return; - setUseImperial(appConfig.display_config.use_imperial); - }, [appConfig]); - - return { - distanceSuffix: useImperial ? 'mi' : 'km', - speedSuffix: useImperial ? 'mph' : 'kmph', - convertDistance: (d) => convertDistance(d, useImperial), - convertSpeed: (s) => convertSpeed(s, useImperial), - getFormattedDistance: useImperial - ? (d) => formatForDisplay(convertDistance(d, true)) - : (d) => formatForDisplay(convertDistance(d, false)), - getFormattedSpeed: useImperial - ? (s) => formatForDisplay(convertSpeed(s, true)) - : (s) => formatForDisplay(convertSpeed(s, false)), - }; + const useImperial = useMemo(() => appConfig?.display_config.use_imperial, [appConfig]); + return getImperialConfig(useImperial); } From 59dbd72e2cfa86fe7e90b60972ca1abd8754b2dc Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:35:48 -0400 Subject: [PATCH 42/50] make the StatusBar color match the appbar background color --- www/index.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/www/index.js b/www/index.js index cd4757f3f..879d8887b 100644 --- a/www/index.js +++ b/www/index.js @@ -23,14 +23,17 @@ window.skipLocalNotificationReady = true; deviceReady.then(() => { logDebug('deviceReady'); - /* give status bar dark text because we have a light background - https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarstyledefault */ - if (window['StatusBar']) window['StatusBar'].styleDefault(); cordova.plugin.http.setDataSerializer('json'); const rootEl = document.getElementById('appRoot'); const reactRoot = createRoot(rootEl); const theme = getTheme(); + /* Set Cordova StatusBar color to match the "elevated" AppBar + https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarbackgroundcolorbyhexstring + https://callstack.github.io/react-native-paper/docs/components/Appbar/#theme-colors */ + if (window['StatusBar']) { + window['StatusBar'].backgroundColorByHexString(theme.colors.elevation.level2); + } reactRoot.render( From 314af14fa179ae0af3b2d645be833bf6282365bb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 14:49:00 -0400 Subject: [PATCH 43/50] fix error with adding nUsers if acc is not defined or is a number (as with distance,duration,count), do not add nUsers --- www/js/metrics/metricsHelper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index fb6be93e3..c3edf8524 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -307,7 +307,9 @@ export function sumMetricEntry(entry: MetricEntry, metr acc = metrics_summaries.acc_value_of_metric(metricName, acc, entry[field]); } } - acc['nUsers'] = entry['nUsers'] || 1; + if (acc && typeof acc == 'object') { + acc['nUsers'] = entry['nUsers'] || 1; + } return (acc || {}) as MetricValue; } From 61f873e824315a2039468f62c439f1607c703796 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 15:16:36 -0400 Subject: [PATCH 44/50] remove LoadMoreButton test We created this test a while ago intended as an example. We now have other component tests and don't need an example lying around --- www/__tests__/LoadMoreButton.test.tsx | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 www/__tests__/LoadMoreButton.test.tsx diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx deleted file mode 100644 index b3c9cc956..000000000 --- a/www/__tests__/LoadMoreButton.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @jest-environment jsdom - */ -import React from 'react'; -import { render, fireEvent, waitFor, screen } from '@testing-library/react-native'; -import LoadMoreButton from '../js/diary/list/LoadMoreButton'; - -describe('LoadMoreButton', () => { - it('renders correctly', async () => { - render( {}}>{}); - await waitFor(() => { - expect(screen.getByTestId('load-button')).toBeTruthy(); - }); - }, 15000); - - it('calls onPressFn when clicked', async () => { - const mockFn = jest.fn(); - const { getByTestId } = render({}); - const loadButton = getByTestId('load-button'); - fireEvent.press(loadButton); - await waitFor(() => { - expect(mockFn).toHaveBeenCalled(); - }); - }, 15000); -}); From 8740062ac76c86a7f70be5b57aadb7d12294da22 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 16:06:48 -0400 Subject: [PATCH 45/50] updated EXPECTED_COUNT of plugins I added the StatusBar plugin so we can change the color to match the appbar color --- setup/setup_native.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/setup_native.sh b/setup/setup_native.sh index 05624a693..a7c396ab2 100644 --- a/setup/setup_native.sh +++ b/setup/setup_native.sh @@ -121,7 +121,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|" npx cordova prepare$PLATFORMS -EXPECTED_COUNT=25 +EXPECTED_COUNT=26 INSTALLED_COUNT=`npx cordova plugin list | wc -l` echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT" if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ]; From f671e04018668229ae3b0bf2c84956e0d214dd58 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 16:13:15 -0400 Subject: [PATCH 46/50] fix prettier --- www/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/index.js b/www/index.js index 879d8887b..297430039 100644 --- a/www/index.js +++ b/www/index.js @@ -28,12 +28,12 @@ deviceReady.then(() => { const reactRoot = createRoot(rootEl); const theme = getTheme(); - /* Set Cordova StatusBar color to match the "elevated" AppBar + /* Set Cordova StatusBar color to match the "elevated" AppBar https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarbackgroundcolorbyhexstring https://callstack.github.io/react-native-paper/docs/components/Appbar/#theme-colors */ - if (window['StatusBar']) { + if (window['StatusBar']) { window['StatusBar'].backgroundColorByHexString(theme.colors.elevation.level2); - } + } reactRoot.render( From 33a38c93755ad905c4c369af51e3fc034ff1bc32 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 20:51:46 -0400 Subject: [PATCH 47/50] handle statusbar style on welcome page vs rest of app --- www/index.js | 12 +++++------- www/js/onboarding/OnboardingStack.tsx | 7 ++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/www/index.js b/www/index.js index 297430039..4ea9e93ac 100644 --- a/www/index.js +++ b/www/index.js @@ -23,17 +23,13 @@ window.skipLocalNotificationReady = true; deviceReady.then(() => { logDebug('deviceReady'); + // On init, use 'default' status bar (black text) + window['StatusBar']?.styleDefault(); cordova.plugin.http.setDataSerializer('json'); const rootEl = document.getElementById('appRoot'); const reactRoot = createRoot(rootEl); const theme = getTheme(); - /* Set Cordova StatusBar color to match the "elevated" AppBar - https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarbackgroundcolorbyhexstring - https://callstack.github.io/react-native-paper/docs/components/Appbar/#theme-colors */ - if (window['StatusBar']) { - window['StatusBar'].backgroundColorByHexString(theme.colors.elevation.level2); - } reactRoot.render( @@ -45,7 +41,9 @@ deviceReady.then(() => { } `} - + {/* The background color of this SafeAreaView effectively controls the status bar background color. + Set to theme.colors.elevation.level2 to match the background of the elevated AppBars present on each tab. */} + , diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx index 9682156ae..38def9cc3 100644 --- a/www/js/onboarding/OnboardingStack.tsx +++ b/www/js/onboarding/OnboardingStack.tsx @@ -13,8 +13,13 @@ const OnboardingStack = () => { const { onboardingState } = useContext(AppContext); if (onboardingState.route == OnboardingRoute.WELCOME) { + // This page needs 'light content' status bar (white text) due to blue header at the top + window['StatusBar']?.styleLightContent(); return ; - } else if (onboardingState.route == OnboardingRoute.SUMMARY) { + } + // All other pages go back to 'default' (black text) + window['StatusBar']?.styleDefault(); + if (onboardingState.route == OnboardingRoute.SUMMARY) { return ; } else if (onboardingState.route == OnboardingRoute.PROTOCOL) { return ; From dabeb7c828f6476ca0a7a9902ed0e55823e66135 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 19 Sep 2024 21:18:43 -0400 Subject: [PATCH 48/50] remove more unused functions from metricsHelper --- www/__tests__/metricsHelper.test.ts | 159 ---------------------------- www/js/metrics/metricsHelper.ts | 140 ------------------------ 2 files changed, 299 deletions(-) diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index 07f1495b1..96f192fa4 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -8,13 +8,8 @@ import { secondsToHours, secondsToMinutes, segmentDaysByWeeks, - metricToValue, tsForDayOfMetricData, valueForFieldOnDay, - generateSummaryFromData, - isCustomLabels, - isAllCustom, - isOnFoot, getUnitUtilsForMetric, } from '../js/metrics/metricsHelper'; import { DayOfMetricData } from '../js/metrics/metricsTypes'; @@ -95,34 +90,6 @@ describe('metricsHelper', () => { }); }); - describe('metricToValue', () => { - const metric = { - walking: 10, - nUsers: 5, - }; - it('returns correct value for user population', () => { - const result = metricToValue('user', metric, 'walking'); - expect(result).toBe(10); - }); - - it('returns correct value for aggregate population', () => { - const result = metricToValue('aggregate', metric, 'walking'); - expect(result).toBe(2); - }); - }); - - describe('isOnFoot', () => { - it('returns true for on foot mode', () => { - const result = isOnFoot('WALKING'); - expect(result).toBe(true); - }); - - it('returns false for non on foot mode', () => { - const result = isOnFoot('DRIVING'); - expect(result).toBe(false); - }); - }); - describe('calculatePercentChange', () => { it('calculates percent change correctly for low and high values', () => { const pastWeekRange = { low: 10, high: 30 }; @@ -169,132 +136,6 @@ describe('metricsHelper', () => { }); }); - describe('generateSummaryFromData', () => { - const modeMap = [ - { - key: 'mode1', - values: [ - ['value1', 10], - ['value2', 20], - ], - }, - { - key: 'mode2', - values: [ - ['value3', 30], - ['value4', 40], - ], - }, - ]; - it('returns summary with sum for non-speed metric', () => { - const metric = 'some_metric'; - const expectedResult = [ - { key: 'mode1', values: 30 }, - { key: 'mode2', values: 70 }, - ]; - const result = generateSummaryFromData(modeMap, metric); - expect(result).toEqual(expectedResult); - }); - - it('returns summary with average for speed metric', () => { - const metric = 'mean_speed'; - const expectedResult = [ - { key: 'mode1', values: 15 }, - { key: 'mode2', values: 35 }, - ]; - const result = generateSummaryFromData(modeMap, metric); - expect(result).toEqual(expectedResult); - }); - }); - - describe('isCustomLabels', () => { - it('returns true for all custom labels', () => { - const modeMap = [ - { - key: 'label_mode1', - values: [ - ['value1', 10], - ['value2', 20], - ], - }, - { - key: 'label_mode2', - values: [ - ['value3', 30], - ['value4', 40], - ], - }, - ]; - const result = isCustomLabels(modeMap); - expect(result).toBe(true); - }); - - it('returns true for all sensed labels', () => { - const modeMap = [ - { - key: 'label_mode1', - values: [ - ['value1', 10], - ['value2', 20], - ], - }, - { - key: 'label_mode2', - values: [ - ['value3', 30], - ['value4', 40], - ], - }, - ]; - const result = isCustomLabels(modeMap); - expect(result).toBe(true); - }); - - it('returns false for mixed custom and sensed labels', () => { - const modeMap = [ - { - key: 'label_mode1', - values: [ - ['value1', 10], - ['value2', 20], - ], - }, - { - key: 'MODE2', - values: [ - ['value3', 30], - ['value4', 40], - ], - }, - ]; - const result = isCustomLabels(modeMap); - expect(result).toBe(false); - }); - }); - - describe('isAllCustom', () => { - it('returns true when all keys are custom', () => { - const isSensedKeys = [false, false, false]; - const isCustomKeys = [true, true, true]; - const result = isAllCustom(isSensedKeys, isCustomKeys); - expect(result).toBe(true); - }); - - it('returns false when all keys are sensed', () => { - const isSensedKeys = [true, true, true]; - const isCustomKeys = [false, false, false]; - const result = isAllCustom(isSensedKeys, isCustomKeys); - expect(result).toBe(false); - }); - - it('returns undefined for mixed custom and sensed keys', () => { - const isSensedKeys = [true, false, true]; - const isCustomKeys = [false, true, false]; - const result = isAllCustom(isSensedKeys, isCustomKeys); - expect(result).toBe(undefined); - }); - }); - describe('getUnitUtilsForMetric', () => { const imperialConfig = { distanceSuffix: 'mi', diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index c3edf8524..f0cc458c5 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -80,31 +80,6 @@ export function getActiveModes(labelOptions: LabelOptions) { }).map((mode) => mode.value); } -/* formatting data form carbon footprint calculations */ - -//modes considered on foot for carbon calculation, expandable as needed -export const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; - -/* - * metric2val is a function that takes a metric entry and a field and returns - * the appropriate value. - * for regular data (user-specific), this will return the field value - * for avg data (aggregate), this will return the field value/nUsers - */ -export const metricToValue = (population: 'user' | 'aggregate', metric, field) => - population == 'user' ? metric[field] : metric[field] / metric.nUsers; - -//testing agains global list of what is "on foot" -//returns true | false -export function isOnFoot(mode: string) { - for (let ped_mode of ON_FOOT_MODES) { - if (mode === ped_mode) { - return true; - } - } - return false; -} - //from two weeks fo low and high values, calculates low and high change export function calculatePercentChange(pastWeekRange, previousWeekRange) { let greaterLesserPct = { @@ -114,56 +89,6 @@ export function calculatePercentChange(pastWeekRange, previousWeekRange) { return greaterLesserPct; } -export function parseDataFromMetrics(metrics, population) { - logDebug(`parseDataFromMetrics: metrics = ${JSON.stringify(metrics)}; - population = ${population}`); - let mode_bins: { [k: string]: [number, number, string][] } = {}; - metrics?.forEach((metric) => { - let onFootVal = 0; - - for (let field in metric) { - /*For modes inferred from sensor data, we check if the string is all upper case - by converting it to upper case and seeing if it is changed*/ - if (field == field.toUpperCase()) { - /*sum all possible on foot modes: see https://github.com/e-mission/e-mission-docs/issues/422 */ - if (isOnFoot(field)) { - onFootVal += metricToValue(population, metric, field); - field = 'ON_FOOT'; - } - if (!(field in mode_bins)) { - mode_bins[field] = []; - } - //for all except onFoot, add to bin - could discover mult onFoot modes - if (field != 'ON_FOOT') { - mode_bins[field].push([ - metric.ts, - metricToValue(population, metric, field), - metric.fmt_time, - ]); - } - } - const trimmedField = trimGroupingPrefix(field); - if (trimmedField) { - logDebug('Mapped field ' + field + ' to mode ' + trimmedField); - if (!(trimmedField in mode_bins)) { - mode_bins[trimmedField] = []; - } - mode_bins[trimmedField].push([ - metric.ts, - Math.round(metricToValue(population, metric, field)), - DateTime.fromISO(metric.fmt_time).toISO() as string, - ]); - } - } - //handle the ON_FOOT modes once all have been summed - if ('ON_FOOT' in mode_bins) { - mode_bins['ON_FOOT'].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); - } - }); - - return Object.entries(mode_bins).map(([key, values]) => ({ key, values })); -} - const _datesTsCache = {}; export const tsForDayOfMetricData = (day: DayOfMetricData) => { if (_datesTsCache[day.date] == undefined) @@ -174,71 +99,6 @@ export const tsForDayOfMetricData = (day: DayOfMetricData) => { export const valueForFieldOnDay = (day: MetricEntry, field: string, key: string) => day[`${field}_${key}`]; -export type MetricsSummary = { key: string; values: number }; -export function generateSummaryFromData(modeMap, metric) { - logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`); - - let summaryMap: MetricsSummary[] = []; - - for (let i = 0; i < modeMap.length; i++) { - let vals = 0; - for (let j = 0; j < modeMap[i].values.length; j++) { - vals += modeMap[i].values[j][1]; //2nd item of array is value - } - if (metric === 'mean_speed') { - // For speed, we take the avg. For other metrics we keep the sum - vals = vals / modeMap[i].values.length; - } - summaryMap.push({ - key: modeMap[i].key, - values: Math.round(vals), - }); - } - - return summaryMap; -} - -/* - * We use the results to determine whether these results are from custom - * labels or from the automatically sensed labels. Automatically sensedV - * labels are in all caps, custom labels are prefixed by label, but have had - * the label_prefix stripped out before this. Results should have either all - * sensed labels or all custom labels. - */ -export function isCustomLabels(modeMap) { - const isSensed = (mode) => mode == mode.toUpperCase(); - const isCustom = (mode) => mode == mode.toLowerCase(); - const metricSummaryChecksCustom: boolean[] = []; - const metricSummaryChecksSensed: boolean[] = []; - - const distanceKeys = modeMap.map((e) => e.key); - const isSensedKeys = distanceKeys.map(isSensed); - const isCustomKeys = distanceKeys.map(isCustom); - logDebug(`Checking metric keys ${distanceKeys}; sensed ${isSensedKeys}; custom ${isCustomKeys}`); - const isAllCustomForMetric = isAllCustom(isSensedKeys, isCustomKeys); - metricSummaryChecksSensed.push(!isAllCustomForMetric); - metricSummaryChecksCustom.push(Boolean(isAllCustomForMetric)); - logDebug(`overall custom/not results for each metric - is ${JSON.stringify(metricSummaryChecksCustom)}`); - return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); -} - -export function isAllCustom(isSensedKeys, isCustomKeys) { - const allSensed = isSensedKeys.reduce((a, b) => a && b, true); - const anySensed = isSensedKeys.reduce((a, b) => a || b, false); - const allCustom = isCustomKeys.reduce((a, b) => a && b, true); - const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); - if (allSensed && !anyCustom) { - return false; // sensed, not custom - } - if (!anySensed && allCustom) { - return true; // custom, not sensed; false implies that the other option is true - } - // Logger.displayError("Mixed entries that combine sensed and custom labels", - // "Please report to your program admin"); - return undefined; -} - // [unit suffix, unit conversion function, unit display function] // e.g. ['hours', (seconds) => seconds/3600, (seconds) => seconds/3600 + ' hours'] type UnitUtils = [string, (v) => number, (v) => string]; From cd0b48805fe80dce8a4ef63cef94d503bdf0bf2d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 20 Sep 2024 11:53:25 -0400 Subject: [PATCH 49/50] add more tests to metricsHelper --- www/__tests__/metricsHelper.test.ts | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index 96f192fa4..b8ab4dae0 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -3,7 +3,9 @@ import { calculatePercentChange, formatDate, formatDateRangeOfDays, + getActiveModes, getLabelsForDay, + trimGroupingPrefix, getUniqueLabelsForDays, secondsToHours, secondsToMinutes, @@ -11,9 +13,20 @@ import { tsForDayOfMetricData, valueForFieldOnDay, getUnitUtilsForMetric, + aggMetricEntries, + sumMetricEntry, + sumMetricEntries, + getColorForModeLabel, } from '../js/metrics/metricsHelper'; import { DayOfMetricData } from '../js/metrics/metricsTypes'; import initializedI18next from '../js/i18nextInit'; +import { LabelOptions } from '../js/types/labelTypes'; +import { + getLabelOptions, + labelKeyToText, + labelOptions, +} from '../js/survey/multilabel/confirmHelper'; +import { base_modes } from 'e-mission-common'; window['i18next'] = initializedI18next; describe('metricsHelper', () => { @@ -28,6 +41,17 @@ describe('metricsHelper', () => { }); }); + describe('trimGroupingPrefix', () => { + it('should trim the grouping field prefix from a metrics key', () => { + expect(trimGroupingPrefix('mode_confirm_access_recreation')).toEqual('access_recreation'); + expect(trimGroupingPrefix('primary_ble_sensed_mode_CAR')).toEqual('CAR'); + }); + + it('should return "" if the key did not start with a grouping field', () => { + expect(trimGroupingPrefix('invalid_foo')).toEqual(''); + }); + }); + describe('getLabelsForDay', () => { const day1 = { mode_confirm_a: 1, mode_confirm_b: 2 } as any as DayOfMetricData; it("should return labels for a day with 'mode_confirm_*'", () => { @@ -90,6 +114,22 @@ describe('metricsHelper', () => { }); }); + describe('getActiveModes', () => { + const fakeLabelOptions = { + MODE: [ + { value: 'walk', base_mode: 'WALKING' }, + { value: 'bike', base_mode: 'BICYCLING' }, + { value: 'ebike', base_mode: 'E_BIKE' }, + { value: 'car', base_mode: 'CAR' }, + { value: 'bus', base_mode: 'BUS' }, + { value: 'myskateboard', met: { ZOOMING: { mets: 5 } } }, + ], + } as LabelOptions; + it('should return active modes', () => { + expect(getActiveModes(fakeLabelOptions)).toEqual(['walk', 'bike', 'ebike', 'myskateboard']); + }); + }); + describe('calculatePercentChange', () => { it('calculates percent change correctly for low and high values', () => { const pastWeekRange = { low: 10, high: 30 }; @@ -176,4 +216,81 @@ describe('metricsHelper', () => { expect(result[2](mockResponse)).toBe('5/7 responses'); }); }); + + const fakeFootprintEntries = [ + { + date: '2024-05-28', + nUsers: 10, + mode_confirm_a: { kwh: 1, kg_co2: 2 }, + }, + { + date: '2024-05-29', + nUsers: 20, + mode_confirm_a: { kwh: 5, kg_co2: 8 }, + mode_confirm_b: { kwh: 2, kg_co2: 4, kwh_uncertain: 1, kg_co2_uncertain: 2 }, + }, + ]; + + describe('aggMetricEntries', () => { + it('aggregates footprint metric entries', () => { + const result = aggMetricEntries(fakeFootprintEntries, 'footprint'); + expect(result).toEqual({ + nUsers: 30, + mode_confirm_a: expect.objectContaining({ + kwh: 6, + kg_co2: 10, + }), + mode_confirm_b: expect.objectContaining({ + kwh: 2, + kg_co2: 4, + kwh_uncertain: 1, + kg_co2_uncertain: 2, + }), + }); + }); + }); + + describe('sumMetricEntry', () => { + it('sums a single footprint metric entry', () => { + expect(sumMetricEntry(fakeFootprintEntries[0], 'footprint')).toEqual( + expect.objectContaining({ + nUsers: 10, + kwh: 1, + kg_co2: 2, + }), + ); + }); + }); + + describe('sumMetricEntries', () => { + it('aggregates and sums footprint metric entries', () => { + expect(sumMetricEntries(fakeFootprintEntries, 'footprint')).toEqual( + expect.objectContaining({ + nUsers: 30, + kwh: 8, + kg_co2: 14, + kwh_uncertain: 1, + kg_co2_uncertain: 2, + }), + ); + }); + }); + + describe('getColorForModeLabel', () => { + // initialize label options (blank appconfig so the default label options will be used) + getLabelOptions({}); + // access the text for each mode option to initialize the color map + labelOptions.MODE.forEach((mode) => labelKeyToText(mode.value)); + + it('returns semi-transparent grey if the label starts with "Unlabeled"', () => { + expect(getColorForModeLabel('Unlabeledzzzzz')).toBe('rgba(85, 85, 85, 0.12)'); + }); + + it('returns color for modes that exist in the label options', () => { + expect(getColorForModeLabel('walk')).toBe(base_modes.BASE_MODES['WALKING'].color); + expect(getColorForModeLabel('bike')).toBe(base_modes.BASE_MODES['BICYCLING'].color); + expect(getColorForModeLabel('e-bike')).toBe(base_modes.BASE_MODES['E_BIKE'].color); + expect(getColorForModeLabel('bus')).toBe(base_modes.BASE_MODES['BUS'].color); + }); + }); }); From 9c2186b002ee815da1ea92baa12424cf1c411796 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 20 Sep 2024 12:50:04 -0400 Subject: [PATCH 50/50] fix typo DailyActiveMinutesCard --- www/js/metrics/movement/DailyActiveMinutesCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/metrics/movement/DailyActiveMinutesCard.tsx b/www/js/metrics/movement/DailyActiveMinutesCard.tsx index c6e1c0e07..cf6ff5f35 100644 --- a/www/js/metrics/movement/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/movement/DailyActiveMinutesCard.tsx @@ -18,7 +18,7 @@ const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => { userMetrics?.duration?.forEach((day) => { const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', modeKey); records.push({ - label: labelKeyToText(mode), + label: labelKeyToText(modeKey), x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis y: (activeSeconds || 0) / 60, // minutes on Y axis });