From 946758ab448a4019941d9470e3c4428544210493 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 3 Apr 2024 02:12:24 -0400 Subject: [PATCH 01/56] show metrics tab if phone_dashboard_ui is set in app config Up to now, the dashboard has only been shown for MULTILABEL configurations; on ENKETO configurations it was completely hidden. We are making the dashboard more configurable - https://github.com/e-mission/e-mission-docs/issues/1055. The presence of a new field `metrics`.`phone_dashboard_ui` being defined will cause the dashboard to be shown. If `phone_dashboard_ui` is not defined, it will fall back to the current behavior (which is to only show dashboard for MULTILABEL configurations). --- www/js/Main.tsx | 4 +++- www/js/types/appConfigTypes.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/www/js/Main.tsx b/www/js/Main.tsx index 6361e6828..6d0efcf0d 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -51,7 +51,9 @@ const Main = () => { const timelineContext = useTimelineContext(); const routes = useMemo(() => { - const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; + const showMetrics = + appConfig?.metrics?.phone_dashboard_ui || + appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); }, [appConfig, t]); diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 5bfedce03..f36f4b819 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -9,6 +9,21 @@ export type AppConfig = { surveys: EnketoSurveyConfig; buttons?: SurveyButtonsConfig; }; + metrics: { + include_test_users: boolean; + phone_dashboard_ui?: { + sections: ('footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys')[]; + footprint_options?: { + unlabeled_uncertainty: boolean; + }; + summary_options?: { + metrics_list: ('distance' | 'count' | 'duration')[]; + }; + engagement_options?: { + leaderboard_metric: [string, string]; + }; + }; + }; reminderSchemes?: ReminderSchemesConfig; [k: string]: any; // TODO fill in all the other fields }; From d3034ebaaad7c648d1e8715cc0f8332ac8ec8f67 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 3 Apr 2024 02:13:50 -0400 Subject: [PATCH 02/56] show/hide sections in MetricsTab based on config https://github.com/e-mission/e-mission-docs/issues/1055#issue-2182534334 *** sections A list of sections to show in the dashboard UI. They will appear in the specified order from top to bottom. Options are footprint, active_travel, summary, engagement, surveys. Whichever sections are omitted will not be shown in the UI. Default: ["footprint", "active_travel", "summary"] *** --- www/js/metrics/MetricsTab.tsx | 83 +++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 6d8744c85..56ce86a0d 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -23,6 +23,7 @@ import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; import { MetricsSummaries } from 'e-mission-common'; +const DEFAULT_SECTIONS_TO_SHOW = ['footprint', 'active_travel', 'summary'] as const; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; async function fetchMetricsFromServer( @@ -135,6 +136,8 @@ const MetricsTab = () => { } } + const sectionsToShow = + appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; @@ -154,43 +157,49 @@ const MetricsTab = () => { - - - - - - - - - - - - - - {/* */} - + {sectionsToShow.includes('footprint') && ( + + + + + )} + {sectionsToShow.includes('active_travel') && ( + + + + + + )} + {sectionsToShow.includes('summary') && ( + + + + + {/* */} + + )} ); From 5130653a8091c58ecf519187de1c3c6f211bcb5c Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 4 Apr 2024 15:19:36 -0700 Subject: [PATCH 03/56] show/hide unlabeled metrics based on config --- www/js/metrics/CarbonFootprintCard.tsx | 43 ++++++++++++++++---------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 68b68c5b1..1478e646b 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -26,13 +26,17 @@ 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(() => { @@ -72,11 +76,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { low: getFootprintForMetrics(userLastWeekSummaryMap, 0), high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPrevWeek.high - userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); + 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, @@ -89,11 +95,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { low: getFootprintForMetrics(userThisWeekSummaryMap, 0), high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPastWeek.high - userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); + 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, @@ -111,7 +119,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { x: worstCarbon, y: `${t('main-metrics.worst-case')}`, }); - return graphRecords; } }, [userMetrics?.distance]); @@ -145,11 +152,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`); - groupRecords.push({ - label: t('main-metrics.unlabeled'), - x: aggCarbon.high - aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); + 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, From 16edaf2217710f8b949d811f452cb25ce0efdd39 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 5 Apr 2024 13:45:56 -0700 Subject: [PATCH 04/56] set the active modes based on config (active_travel_options.modes_list) If there is no active_travel_options.modes_list, set the default active modes --- www/js/metrics/ActiveMinutesTableCard.tsx | 15 ++++++++++----- www/js/metrics/DailyActiveMinutesCard.tsx | 11 +++++++---- www/js/metrics/WeeklyActiveMinutesCard.tsx | 8 ++++++-- www/js/types/appConfigTypes.ts | 3 +++ 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index 57587e018..7d53ae766 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -13,17 +13,22 @@ 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) => { 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 []; const totals = {}; - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { const sum = userMetrics.duration.reduce( (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0, @@ -40,7 +45,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .reverse() .map((week) => { const totals = {}; - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { const sum = week.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0); totals[mode] = secondsToMinutes(sum); }); @@ -54,7 +59,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { return userMetrics.duration .map((day) => { const totals = {}; - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { const sum = valueForModeOnDay(day, mode) || 0; totals[mode] = secondsToMinutes(sum); }); @@ -85,7 +90,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { - {ACTIVE_MODES.map((mode, i) => ( + {activeModes.map((mode, i) => ( {labelKeyToRichMode(mode)} @@ -94,7 +99,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { {allTotals.slice(from, to).map((total, i) => ( {total['period']} - {ACTIVE_MODES.map((mode, j) => ( + {activeModes.map((mode, j) => ( {total[mode]} {t('metrics.minutes')} diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index b72c20ec6..7fe63d51d 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -8,20 +8,23 @@ import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHe import LineChart from '../components/LineChart'; import { getBaseModeByText } from '../diary/diaryHelper'; import { tsForDayOfMetricData, valueForModeOnDay } from './metricsHelper'; - -const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = (typeof ACTIVE_MODES)[number]; +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) => { - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { const activeSeconds = valueForModeOnDay(day, mode); records.push({ label: labelKeyToRichMode(mode), diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index 5078f2cfc..8331320e9 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -9,6 +9,7 @@ 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]; @@ -18,12 +19,15 @@ 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]); - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { if (prevWeek) { const prevSum = prevWeek?.reduce( (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index f36f4b819..dc5479eaa 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -22,6 +22,9 @@ export type AppConfig = { engagement_options?: { leaderboard_metric: [string, string]; }; + active_travel_options?: { + modes_list: string[]; + }; }; }; reminderSchemes?: ReminderSchemesConfig; From b17ffb7f1c95603f64d9ea38501cf19f38ef1063 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 5 Apr 2024 15:10:16 -0700 Subject: [PATCH 05/56] set summary list based on config (summary_options.metrics_list) --- www/js/components/Carousel.tsx | 15 ++++++---- www/js/metrics/MetricsTab.tsx | 51 ++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx index 92febb32b..8afe6624a 100644 --- a/www/js/components/Carousel.tsx +++ b/www/js/components/Carousel.tsx @@ -16,11 +16,16 @@ const Carousel = ({ children, cardWidth, cardMargin }: Props) => { snapToAlignment={'center'} style={s.carouselScroll(cardMargin)} contentContainerStyle={{ alignItems: 'flex-start' }}> - {React.Children.map(children, (child, i) => ( - - {child} - - ))} + {React.Children.map( + children, + (child, i) => + // If child is `null`, we need to skip it; otherwise, it takes up space + child && ( + + {child} + + ), + )} ); }; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 56ce86a0d..bd1883611 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -25,6 +25,7 @@ import { MetricsSummaries } from 'e-mission-common'; const DEFAULT_SECTIONS_TO_SHOW = ['footprint', 'active_travel', 'summary'] as const; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; +const DEFAULT_SUMMARY_LIST = ['distance', 'count', 'duration'] as const; async function fetchMetricsFromServer( type: 'user' | 'aggregate', @@ -138,6 +139,8 @@ const MetricsTab = () => { const sectionsToShow = appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; + const summaryList = + appConfig?.metrics?.phone_dashboard_ui?.summary_options?.metrics_list ?? DEFAULT_SUMMARY_LIST; const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; @@ -172,27 +175,33 @@ const MetricsTab = () => { )} {sectionsToShow.includes('summary') && ( - - - + {summaryList.includes('distance') && ( + + )} + {summaryList.includes('count') && ( + + )} + {summaryList.includes('duration') && ( + + )} {/* Date: Thu, 18 Apr 2024 18:03:35 -0700 Subject: [PATCH 06/56] show uncertain-footnote only when to show the uncertainty --- www/js/metrics/CarbonTextCard.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index dd9e25231..2707bf4f9 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -21,12 +21,17 @@ import { 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) { @@ -181,11 +186,13 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { {textEntries[i].value + ' ' + 'kg CO₂'} ))} - - {t('main-metrics.range-uncertain-footnote')} - + {showUnlabeledMetrics && ( + + {t('main-metrics.range-uncertain-footnote')} + + )} ); From 57d22989aa474712720ca0068343229ca6ef1b45 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 18 Apr 2024 19:13:57 -0700 Subject: [PATCH 07/56] rendering surveys Card components dynamically --- www/i18n/en.json | 14 ++++++- www/js/metrics/MetricsTab.tsx | 8 ++++ www/js/metrics/SurveyLeaderboardCard.tsx | 45 +++++++++++++++++++++ www/js/metrics/SurveyTripCategoriesCard.tsx | 25 ++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 www/js/metrics/SurveyLeaderboardCard.tsx create mode 100644 www/js/metrics/SurveyTripCategoriesCard.tsx diff --git a/www/i18n/en.json b/www/i18n/en.json index 9a8b6bb61..5d2889ba4 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -223,7 +223,19 @@ "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₂)" + "footprint-label": "Footprint (kg CO₂)", + "surveys": "Surveys", + "leaderboard": "Leaderboard", + "survey-response-rate": "Survey Response Rate", + "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" }, "details": { diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index dbd5c8a92..dfae05fa0 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -22,6 +22,8 @@ import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; import { metrics_summaries } from 'e-mission-common'; +import SurveyLeaderboardCard from './SurveyLeaderboardCard'; +import SurveyTripCategoriesCard from './SurveyTripCategoriesCard'; // 2 weeks of data is needed in order to compare "past week" vs "previous week" const N_DAYS_TO_LOAD = 14; // 2 weeks @@ -213,6 +215,12 @@ const MetricsTab = () => { unitFormatFn={getFormattedSpeed} /> */} )} + {!sectionsToShow.includes('engagement') && ( + + + + + )} ); diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx new file mode 100644 index 000000000..d849b6e7c --- /dev/null +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { Card, useTheme } from 'react-native-paper'; +import { cardStyles } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; +import ToggleSwitch from '../components/ToggleSwitch'; + +const SurveyLeaderboardCard = () => { + const { colors } = useTheme(); + const { t } = useTranslation(); + const [tab, setTab] = useState('leaderboard'); + + return ( + + ( + + setTab(v as any)} + buttons={[ + { icon: 'chart-bar', value: 'leaderboard' }, + { icon: 'arrow-collapse', value: 'comparison' }, + ]} + /> + + )} + /> + + {tab === 'leaderboard' ? t('main-metrics.leaderboard') : t('main-metrics.comparison')} + + + ); +}; + +export default SurveyLeaderboardCard; diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx new file mode 100644 index 000000000..f6c31c71e --- /dev/null +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Card, useTheme } from 'react-native-paper'; +import { cardStyles } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; + +const SurveyTripCategoriesCard = () => { + const { colors } = useTheme(); + const { t } = useTranslation(); + + return ( + + + Trip Categories + + ); +}; + +export default SurveyTripCategoriesCard; From 18e99bbac15513b336be0e365280c786df69fa64 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 19 Apr 2024 16:36:57 -0700 Subject: [PATCH 08/56] Surveys Cards components UI done --- www/i18n/en.json | 6 +- www/js/appTheme.ts | 5 +- www/js/components/Chart.tsx | 9 ++- www/js/metrics/SurveyDoughnutCharts.tsx | 85 +++++++++++++++++++++ www/js/metrics/SurveyLeaderboardCard.tsx | 67 +++++++++++++++- www/js/metrics/SurveyTripCategoriesCard.tsx | 25 +++++- 6 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 www/js/metrics/SurveyDoughnutCharts.tsx diff --git a/www/i18n/en.json b/www/i18n/en.json index 5d2889ba4..1f578dfa7 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -226,7 +226,7 @@ "footprint-label": "Footprint (kg CO₂)", "surveys": "Surveys", "leaderboard": "Leaderboard", - "survey-response-rate": "Survey Response Rate", + "survey-response-rate": "Survey Response Rate (%)", "comparison": "Comparison", "you": "You", "others": "Others in group", @@ -235,7 +235,9 @@ "ev-return-trip": "EV Return trip", "gas-car-trip": "Gas Car trip", "response": "Response", - "no-response": "No Response" + "no-response": "No Response", + "you-are-in": "You're in", + "place": " place!" }, "details": { diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index b66f493e6..f777167c6 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -32,7 +32,10 @@ const AppTheme = { }, success: '#00a665', // lch(60% 55 155) warn: '#f8cf53', //lch(85% 65 85) - danger: '#f23934', // lch(55% 85 35) + danger: '#f23934', // lch(55% 85 35), + silver: '#d9d9d9', + skyblue: '#7fcaea', + navy: '#0077aa', }, roundness: 5, }; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 2ff236b5b..b604eb254 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -31,6 +31,8 @@ export type Props = { isHorizontal?: boolean; timeAxis?: boolean; stacked?: boolean; + hideLegend?: boolean; + reverse?: boolean; }; const Chart = ({ records, @@ -43,6 +45,8 @@ const Chart = ({ isHorizontal, timeAxis, stacked, + hideLegend = false, + reverse = true, }: Props) => { const { colors } = useTheme(); const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); @@ -149,7 +153,7 @@ const Chart = ({ }, font: { size: 11 }, // default is 12, we want a tad smaller }, - reverse: true, + reverse: reverse, stacked, }, x: { @@ -196,6 +200,9 @@ const Chart = ({ }), }, plugins: { + legend: { + display: hideLegend, + }, ...(lineAnnotations?.length && { annotation: { clip: false, diff --git a/www/js/metrics/SurveyDoughnutCharts.tsx b/www/js/metrics/SurveyDoughnutCharts.tsx new file mode 100644 index 000000000..2eee4850e --- /dev/null +++ b/www/js/metrics/SurveyDoughnutCharts.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +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'; + +ChartJS.register(ArcElement, Tooltip, Legend); + +const SurveyDoughnutCharts = () => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + const myResonseRate = 68; + const othersResponseRate = 41; + + const renderDoughnutChart = (rate) => { + const data = { + datasets: [ + { + data: [rate, 100 - rate], + backgroundColor: [colors.navy, colors.silver], + borderColor: [colors.navy, colors.silver], + borderWidth: 1, + }, + ], + }; + return ( + + + {rate}% + + + + ); + }; + + return ( + + {t('main-metrics.survey-response-rate')} + + {renderDoughnutChart(myResonseRate)} + {renderDoughnutChart(othersResponseRate)} + + + ); +}; + +const styles: any = { + chartTitle: { + alignSelf: 'center', + fontWeight: 'bold', + fontSize: 14, + marginBottom: 20, + }, + chartWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + textWrapper: { + position: 'absolute', + width: 150, + height: 150, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, +}; + +export default SurveyDoughnutCharts; diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx index d849b6e7c..7b346a9c3 100644 --- a/www/js/metrics/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -1,14 +1,59 @@ import React, { useState } from 'react'; -import { View, ScrollView, useWindowDimensions } from 'react-native'; -import { Card, useTheme } from 'react-native-paper'; +import { View, Text } from 'react-native'; +import { Card } from 'react-native-paper'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; import ToggleSwitch from '../components/ToggleSwitch'; +import BarChart from '../components/BarChart'; +import { useAppTheme } from '../appTheme'; +import SurveyComparisonChart from './SurveyDoughnutCharts'; const SurveyLeaderboardCard = () => { - const { colors } = useTheme(); + const { colors } = useAppTheme(); const { t } = useTranslation(); const [tab, setTab] = useState('leaderboard'); + const myLabel = '#3'; + + const getLeaderboardLabelColor = (l) => { + if (l === myLabel) { + return colors.skyblue; + } else { + return colors.silver; + } + }; + + const renderBarChart = () => { + const records = [ + { label: '#1', x: 91, y: '#1: 🏆' }, + { label: '#2', x: 72, y: '#2: 🥈' }, + { label: '#3', x: 68, y: '#3: 🥉' }, + { label: '#4', x: 57, y: '#4:' }, + { label: '#5', x: 50, y: '#5:' }, + { label: '#6', x: 40, y: '#6:' }, + { label: '#7', x: 30, y: '#7:' }, + ]; + return ( + + {t('main-metrics.survey-response-rate')} + getLeaderboardLabelColor(l)} + getColorForChartEl={(l) => getLeaderboardLabelColor(l)} + hideLegend={true} + reverse={false} + /> + + {t('main-metrics.you-are-in')} + {myLabel} + {t('main-metrics.place')} + + + ); + }; return ( @@ -36,10 +81,24 @@ const SurveyLeaderboardCard = () => { )} /> - {tab === 'leaderboard' ? t('main-metrics.leaderboard') : t('main-metrics.comparison')} + {tab === 'leaderboard' ? renderBarChart() : } ); }; +const styles: any = { + chartTitle: { + alignSelf: 'center', + fontWeight: 'bold', + fontSize: 14, + }, + statusTextWrapper: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'row', + fontSize: 16, + }, +}; + export default SurveyLeaderboardCard; diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index f6c31c71e..836457e40 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -1,11 +1,18 @@ import React from 'react'; -import { Card, useTheme } from 'react-native-paper'; +import { Card } from 'react-native-paper'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; +import BarChart from '../components/BarChart'; +import { useAppTheme } from '../appTheme'; const SurveyTripCategoriesCard = () => { - const { colors } = useTheme(); + const { colors } = useAppTheme(); const { t } = useTranslation(); + const records = [ + { label: 'EV Roaming trip', x: 'EV Roaming trip', y: 91 }, + { label: 'EV Return trip', x: 'EV Return trip', y: 72 }, + { label: 'Gas Car trip', x: 'Gas Car trip', y: 68 }, + ]; return ( @@ -17,7 +24,19 @@ const SurveyTripCategoriesCard = () => { subtitleStyle={[cardStyles.titleText(colors), cardStyles.subtitleText]} style={cardStyles.title(colors)} /> - Trip Categories + + colors.navy} + getColorForChartEl={() => colors.navy} + hideLegend={true} + reverse={false} + /> + ); }; From 2281c7e4b95393746aefec185db4e8fd6fb363c8 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Wed, 24 Apr 2024 18:01:45 -0700 Subject: [PATCH 09/56] Chart UI fix --- www/js/appTheme.ts | 1 + www/js/components/Chart.tsx | 11 +++-- www/js/metrics/SurveyDoughnutCharts.tsx | 46 ++++++++++++++++++--- www/js/metrics/SurveyLeaderboardCard.tsx | 21 ++++------ www/js/metrics/SurveyTripCategoriesCard.tsx | 44 ++++++++++++++++---- 5 files changed, 94 insertions(+), 29 deletions(-) diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index f777167c6..d2f13c47e 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -36,6 +36,7 @@ const AppTheme = { silver: '#d9d9d9', skyblue: '#7fcaea', navy: '#0077aa', + orange: '#f6a063', }, roundness: 5, }; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index b604eb254..f86c352c0 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -31,8 +31,9 @@ export type Props = { isHorizontal?: boolean; timeAxis?: boolean; stacked?: boolean; - hideLegend?: boolean; + showLegend?: boolean; reverse?: boolean; + enableTooltip?: boolean; }; const Chart = ({ records, @@ -45,8 +46,9 @@ const Chart = ({ isHorizontal, timeAxis, stacked, - hideLegend = false, + showLegend = true, reverse = true, + enableTooltip = true, }: Props) => { const { colors } = useTheme(); const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); @@ -201,7 +203,10 @@ const Chart = ({ }, plugins: { legend: { - display: hideLegend, + display: showLegend, + }, + tooltip: { + enabled: enableTooltip, }, ...(lineAnnotations?.length && { annotation: { diff --git a/www/js/metrics/SurveyDoughnutCharts.tsx b/www/js/metrics/SurveyDoughnutCharts.tsx index 2eee4850e..fd67325e5 100644 --- a/www/js/metrics/SurveyDoughnutCharts.tsx +++ b/www/js/metrics/SurveyDoughnutCharts.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text } from 'react-native'; +import { Icon } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../appTheme'; import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; @@ -7,19 +8,36 @@ import { Doughnut } from 'react-chartjs-2'; ChartJS.register(ArcElement, Tooltip, Legend); +export const LabelPanel = ({ first, second }) => { + const { colors } = useAppTheme(); + + return ( + + + + {first} + + + + {second} + + + ); +}; + const SurveyDoughnutCharts = () => { const { colors } = useAppTheme(); const { t } = useTranslation(); const myResonseRate = 68; const othersResponseRate = 41; - const renderDoughnutChart = (rate) => { + const renderDoughnutChart = (rate, chartColor, myResponse) => { const data = { datasets: [ { data: [rate, 100 - rate], - backgroundColor: [colors.navy, colors.silver], - borderColor: [colors.navy, colors.silver], + backgroundColor: [chartColor, colors.silver], + borderColor: [chartColor, colors.silver], borderWidth: 1, }, ], @@ -27,6 +45,11 @@ const SurveyDoughnutCharts = () => { return ( + {myResponse ? ( + + ) : ( + + )} {rate}% { {t('main-metrics.survey-response-rate')} - {renderDoughnutChart(myResonseRate)} - {renderDoughnutChart(othersResponseRate)} + {renderDoughnutChart(myResonseRate, colors.navy, true)} + {renderDoughnutChart(othersResponseRate, colors.orange, false)} + ); }; @@ -71,6 +95,7 @@ const styles: any = { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', + marginBottom: 20, }, textWrapper: { position: 'absolute', @@ -80,6 +105,17 @@ const styles: any = { alignItems: 'center', justifyContent: 'center', }, + labelWrapper: { + alignSelf: 'center', + display: 'flex', + gap: 10, + }, + labelItem: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, }; export default SurveyDoughnutCharts; diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx index 7b346a9c3..08430c56d 100644 --- a/www/js/metrics/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -14,19 +14,11 @@ const SurveyLeaderboardCard = () => { const [tab, setTab] = useState('leaderboard'); const myLabel = '#3'; - const getLeaderboardLabelColor = (l) => { - if (l === myLabel) { - return colors.skyblue; - } else { - return colors.silver; - } - }; - const renderBarChart = () => { const records = [ - { label: '#1', x: 91, y: '#1: 🏆' }, - { label: '#2', x: 72, y: '#2: 🥈' }, - { label: '#3', x: 68, y: '#3: 🥉' }, + { label: '#1', x: 91, y: '🏆 #1:' }, + { label: '#2', x: 72, y: '🥈 #2:' }, + { label: '#3', x: 68, y: '🥉 #3:' }, { label: '#4', x: 57, y: '#4:' }, { label: '#5', x: 50, y: '#5:' }, { label: '#6', x: 40, y: '#6:' }, @@ -41,10 +33,11 @@ const SurveyLeaderboardCard = () => { isHorizontal={true} timeAxis={false} stacked={false} - getColorForLabel={(l) => getLeaderboardLabelColor(l)} - getColorForChartEl={(l) => getLeaderboardLabelColor(l)} - hideLegend={true} + getColorForLabel={(l) => (l === myLabel ? colors.skyblue : colors.silver)} + getColorForChartEl={(l) => (l === myLabel ? colors.skyblue : colors.silver)} + showLegend={false} reverse={false} + enableTooltip={false} /> {t('main-metrics.you-are-in')} diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index 836457e40..aee33b057 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -1,17 +1,46 @@ import React from 'react'; +import { View, Text } from 'react-native'; import { Card } from 'react-native-paper'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { useAppTheme } from '../appTheme'; +import { LabelPanel } from './SurveyDoughnutCharts'; const SurveyTripCategoriesCard = () => { const { colors } = useAppTheme(); const { t } = useTranslation(); const records = [ - { label: 'EV Roaming trip', x: 'EV Roaming trip', y: 91 }, - { label: 'EV Return trip', x: 'EV Return trip', y: 72 }, - { label: 'Gas Car trip', x: 'Gas Car trip', y: 68 }, + { + label: 'Response', + x: 'EV Roaming trip', + y: 20, + }, + { + label: 'No Response', + x: 'EV Roaming trip', + y: 20, + }, + { + label: 'Response', + x: 'EV Return trip', + y: 30, + }, + { + label: 'No Response', + x: 'EV Return trip', + y: 40, + }, + { + label: 'Response', + x: 'Gas Car trip', + y: 50, + }, + { + label: 'No Response', + x: 'Gas Car trip', + y: 10, + }, ]; return ( @@ -30,12 +59,13 @@ const SurveyTripCategoriesCard = () => { axisTitle="" isHorizontal={false} timeAxis={false} - stacked={false} - getColorForLabel={() => colors.navy} - getColorForChartEl={() => colors.navy} - hideLegend={true} + stacked={true} + getColorForLabel={(l) => (l === 'Response' ? colors.navy : colors.orange)} + getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)} + showLegend={false} reverse={false} /> + ); From c38c35362c40f0ac42c8bcb8aea6dfe36e0462b7 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 25 Apr 2024 17:15:05 -0700 Subject: [PATCH 10/56] update TimelineScrollList only when the active tab is 'label' Leaflet map encounters an error when prerendered, so we need to render the TimelineScrollList component when the active tab is 'label' 'shouldUpdateTimeline' state is used to determine whether to render the TimelineScrollList or not --- www/js/Main.tsx | 8 +++++++- www/js/TimelineContext.ts | 7 +++++++ www/js/diary/list/LabelListScreen.tsx | 11 ++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/www/js/Main.tsx b/www/js/Main.tsx index 6d0efcf0d..650ed4044 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -1,7 +1,7 @@ /* Once onboarding is done, this is the main app content. Includes the bottom navigation bar and each of the tabs. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useContext, useMemo, useState } from 'react'; import { BottomNavigation, useTheme } from 'react-native-paper'; import { AppContext } from './App'; @@ -57,6 +57,12 @@ const Main = () => { return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); }, [appConfig, t]); + useEffect(() => { + const { setShouldUpdateTimeline } = timelineContext; + // update TimelineScrollList component only when the active tab is 'label' to fix leaflet map issue + setShouldUpdateTimeline(!index); + }, [index]); + return ( void; loadSpecificWeek: (d: string) => void; refreshTimeline: () => void; + shouldUpdateTimeline: Boolean; + setShouldUpdateTimeline: React.Dispatch>; }; export const useTimelineContext = (): ContextProps => { @@ -73,6 +75,9 @@ export const useTimelineContext = (): ContextProps => { const [timelineNotesMap, setTimelineNotesMap] = useState(null); const [timelineBleMap, setTimelineBleMap] = useState(null); const [refreshTime, setRefreshTime] = useState(null); + // Leaflet map encounters an error when prerendered, so we need to render the TimelineScrollList component when the active tab is 'label' + // 'shouldUpdateTimeline' gets updated based on the current tab index, and we can use it to determine whether to render the timeline or not + const [shouldUpdateTimeline, setShouldUpdateTimeline] = useState(true); // initialization, once the appConfig is loaded useEffect(() => { @@ -365,6 +370,8 @@ export const useTimelineContext = (): ContextProps => { notesFor, confirmedModeFor, addUserInputToEntry, + shouldUpdateTimeline, + setShouldUpdateTimeline, }; }; diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 54df33500..af27bfe00 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -12,8 +12,13 @@ import { displayErrorMsg } from '../../plugin/logger'; const LabelListScreen = () => { const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); - const { timelineMap, loadSpecificWeek, timelineIsLoading, refreshTimeline } = - useContext(TimelineContext); + const { + timelineMap, + loadSpecificWeek, + timelineIsLoading, + refreshTimeline, + shouldUpdateTimeline, + } = useContext(TimelineContext); const { colors } = useTheme(); return ( @@ -42,7 +47,7 @@ const LabelListScreen = () => { /> - + {shouldUpdateTimeline && } ); From 1f7a69214f81f228b66a4a9e37e88ff940f8de03 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 4 May 2024 00:19:36 -0700 Subject: [PATCH 11/56] add getSurveyMetric API endpoint --- www/js/services/commHelper.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index 26dce8056..0025128da 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -135,6 +135,21 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } +export function getSurveyMetric() { + console.log('hahaha') + return new Promise((rs, rj) => { + console.log(rs); + window['cordova'].plugins.BEMServerComm.getUserPersonalData( + '/get/metrics/survey', + rs, + rj, + ); + }).catch((error) => { + error = `While getting survey metric, ${error}`; + throw error; + }); +} + export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { return new Promise((rs, rj) => { const fullUrl = `${serverConnConfig.connectUrl}/${path}`; From 58cfb5f884913ad92d447e9bedb0e2c30ebee34f Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 4 May 2024 14:09:57 -0700 Subject: [PATCH 12/56] get new survey data every 24 hours purpose : prevent too much API call --- www/js/Main.tsx | 11 ++++++++- www/js/metrics/MetricsTab.tsx | 42 +++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/www/js/Main.tsx b/www/js/Main.tsx index 650ed4044..828332b9e 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -58,9 +58,18 @@ const Main = () => { }, [appConfig, t]); useEffect(() => { - const { setShouldUpdateTimeline } = timelineContext; + const { setShouldUpdateTimeline, lastUpdateMetricDateTime, setLastUpdateMetricDateTime } = timelineContext; // update TimelineScrollList component only when the active tab is 'label' to fix leaflet map issue setShouldUpdateTimeline(!index); + + // update it when the last updated data is more than 24 hours ago to get new survey data from server + // purpose : to prevent too much API call + if(index === 1) { + var oneDayAgo = new Date().getTime() - (24 * 60 * 60 * 1000) + if (!lastUpdateMetricDateTime || lastUpdateMetricDateTime < oneDayAgo) { + setLastUpdateMetricDateTime(new Date().getTime()); + } + } }, [index]); return ( diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index dfae05fa0..e1f56ac7d 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -14,7 +14,7 @@ import Carousel from '../components/Carousel'; import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; -import { getAggregateData, getMetrics } from '../services/commHelper'; +import { getAggregateData, getMetrics, getSurveyMetric } from '../services/commHelper'; import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; import { ServerConnConfig } from '../types/appConfigTypes'; @@ -31,6 +31,26 @@ const DEFAULT_SECTIONS_TO_SHOW = ['footprint', 'active_travel', 'summary'] as co export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; const DEFAULT_SUMMARY_LIST = ['distance', 'count', 'duration'] as const; +export type SurveyObject = { + 'answered': number, + 'unanswered': number, + 'mismatched': number, +} + +export type SurveyMetric = { + 'me' : { + 'overview' : SurveyObject, + 'rank' : number, + 'details': { + [key: string]: SurveyObject, + } + }, + 'others' : { + 'overview' : SurveyObject, + 'leaderboard': SurveyObject[], + } +} + async function fetchMetricsFromServer( type: 'user' | 'aggregate', dateRange: [string, string], @@ -63,9 +83,11 @@ const MetricsTab = () => { timelineIsLoading, refreshTimeline, loadMoreDays, + lastUpdateMetricDateTime } = useContext(TimelineContext); const [aggMetrics, setAggMetrics] = useState(undefined); + const [surveyMetric, setSurveyMetric] = useState(null); // user metrics are computed on the phone from the timeline data const userMetrics = useMemo(() => { @@ -150,6 +172,18 @@ const MetricsTab = () => { const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; + useEffect(() => { + async function getSurveyMetricData() { + const res = await getSurveyMetric(); + setSurveyMetric(res as SurveyMetric); + } + + // 'lastUpdateMetricDate' is used to get new survey data when the last data was 24 hours ago + if(lastUpdateMetricDateTime && sectionsToShow.includes('engagement')) { + getSurveyMetricData(); + } + }, [lastUpdateMetricDateTime]) + return ( <> @@ -215,10 +249,10 @@ const MetricsTab = () => { unitFormatFn={getFormattedSpeed} /> */} )} - {!sectionsToShow.includes('engagement') && ( + {surveyMetric && ( - - + + )} From 8150e95e2145965267adf944fd27431ae7e7aeb0 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 4 May 2024 17:24:19 -0700 Subject: [PATCH 13/56] delete dummy data and process survey data from server --- www/js/TimelineContext.ts | 5 ++ www/js/metrics/SurveyDoughnutCharts.tsx | 14 ++-- www/js/metrics/SurveyLeaderboardCard.tsx | 93 +++++++++++++++++---- www/js/metrics/SurveyTripCategoriesCard.tsx | 60 ++++++------- 4 files changed, 113 insertions(+), 59 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 25e015a4d..525eec90b 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -55,6 +55,8 @@ type ContextProps = { refreshTimeline: () => void; shouldUpdateTimeline: Boolean; setShouldUpdateTimeline: React.Dispatch>; + lastUpdateMetricDateTime: number; + setLastUpdateMetricDateTime: React.Dispatch>; }; export const useTimelineContext = (): ContextProps => { @@ -78,6 +80,7 @@ export const useTimelineContext = (): ContextProps => { // Leaflet map encounters an error when prerendered, so we need to render the TimelineScrollList component when the active tab is 'label' // 'shouldUpdateTimeline' gets updated based on the current tab index, and we can use it to determine whether to render the timeline or not const [shouldUpdateTimeline, setShouldUpdateTimeline] = useState(true); + const [lastUpdateMetricDateTime, setLastUpdateMetricDateTime] = useState(0); // initialization, once the appConfig is loaded useEffect(() => { @@ -372,6 +375,8 @@ export const useTimelineContext = (): ContextProps => { addUserInputToEntry, shouldUpdateTimeline, setShouldUpdateTimeline, + lastUpdateMetricDateTime, + setLastUpdateMetricDateTime, }; }; diff --git a/www/js/metrics/SurveyDoughnutCharts.tsx b/www/js/metrics/SurveyDoughnutCharts.tsx index fd67325e5..aa6311886 100644 --- a/www/js/metrics/SurveyDoughnutCharts.tsx +++ b/www/js/metrics/SurveyDoughnutCharts.tsx @@ -5,6 +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 { SurveyComparison } from './SurveyLeaderboardCard'; ChartJS.register(ArcElement, Tooltip, Legend); @@ -25,12 +26,13 @@ export const LabelPanel = ({ first, second }) => { ); }; -const SurveyDoughnutCharts = () => { +type Props = { + surveyComparison : SurveyComparison +} + +const SurveyDoughnutCharts = ({surveyComparison} : Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); - const myResonseRate = 68; - const othersResponseRate = 41; - const renderDoughnutChart = (rate, chartColor, myResponse) => { const data = { datasets: [ @@ -76,8 +78,8 @@ const SurveyDoughnutCharts = () => { {t('main-metrics.survey-response-rate')} - {renderDoughnutChart(myResonseRate, colors.navy, true)} - {renderDoughnutChart(othersResponseRate, colors.orange, false)} + {renderDoughnutChart(surveyComparison.me, colors.navy, true)} + {renderDoughnutChart(surveyComparison.others, colors.orange, false)} diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx index 08430c56d..57b991df1 100644 --- a/www/js/metrics/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -1,47 +1,104 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { View, Text } from 'react-native'; import { Card } from 'react-native-paper'; -import { cardStyles } from './MetricsTab'; +import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab'; import { useTranslation } from 'react-i18next'; import ToggleSwitch from '../components/ToggleSwitch'; import BarChart from '../components/BarChart'; import { useAppTheme } from '../appTheme'; import SurveyComparisonChart from './SurveyDoughnutCharts'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import Annotation from 'chartjs-plugin-annotation'; -const SurveyLeaderboardCard = () => { +ChartJS.register(...registerables, Annotation); + +type Props = { + surveyMetric: SurveyMetric +} + +type LeaderboardRecord = { + label: string, + x: number, + y: string +} +export type SurveyComparison = { + 'me' : number, + 'others' : number, +} + +const SurveyLeaderboardCard = ( { surveyMetric }: Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); const [tab, setTab] = useState('leaderboard'); - const myLabel = '#3'; + + const myRank = surveyMetric.me.rank; + const mySurveyMetric = surveyMetric.me.overview; + const othersSurveyMetric = surveyMetric.others.overview; + const mySurveyRate = Math.round(mySurveyMetric.answered / (mySurveyMetric.answered + mySurveyMetric.unanswered) * 100); + + const surveyComparison: SurveyComparison = { + 'me' : mySurveyRate, + 'others' : Math.round(othersSurveyMetric.answered / (othersSurveyMetric.answered + othersSurveyMetric.unanswered) * 100) + } + + function getLabel(rank: number): string { + if(rank === 0) { + return '🏆 #1:'; + }else if(rank === 1) { + return '🥈 #2:'; + }else if(rank === 2) { + return '🥉 #3:'; + }else { + return `#${rank+1}:`; + } + } + + const leaderboardRecords: LeaderboardRecord[] = useMemo(() => { + const combinedLeaderboard:SurveyObject[] = [...surveyMetric.others.leaderboard]; + combinedLeaderboard.splice(myRank, 0, mySurveyMetric); + // This is to prevent the leaderboard from being too long for UX purposes. + // For a total of 20 members, we only show the top 5 members, myself, and the bottom 3 members. + const numberOfTopUsers = 5 + const numberOfBottomUsers = surveyMetric.others.leaderboard.length -3; + + return combinedLeaderboard.map((item, idx) => ( + { + 'isMe': idx === myRank, + 'rank': idx, + 'answered': item.answered, + 'unanswered': item.unanswered, + 'mismatched': item.mismatched, + } + )).filter((item) => ( item.isMe || item.rank < numberOfTopUsers || item.rank >= numberOfBottomUsers)) + .map((item) => ( + { + label: item.isMe ? `${item.rank}-me` : `${item.rank}-other`, + x: Math.round(item.answered / (item.answered + item.unanswered) * 100), + y: getLabel(item.rank) + } + )) + }, [surveyMetric]) + const renderBarChart = () => { - const records = [ - { label: '#1', x: 91, y: '🏆 #1:' }, - { label: '#2', x: 72, y: '🥈 #2:' }, - { label: '#3', x: 68, y: '🥉 #3:' }, - { label: '#4', x: 57, y: '#4:' }, - { label: '#5', x: 50, y: '#5:' }, - { label: '#6', x: 40, y: '#6:' }, - { label: '#7', x: 30, y: '#7:' }, - ]; return ( {t('main-metrics.survey-response-rate')} (l === myLabel ? colors.skyblue : colors.silver)} - getColorForChartEl={(l) => (l === myLabel ? colors.skyblue : colors.silver)} + getColorForLabel={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + getColorForChartEl={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)} showLegend={false} reverse={false} enableTooltip={false} /> {t('main-metrics.you-are-in')} - {myLabel} + #{myRank+1} {t('main-metrics.place')} @@ -74,7 +131,7 @@ const SurveyLeaderboardCard = () => { )} /> - {tab === 'leaderboard' ? renderBarChart() : } + {tab === 'leaderboard' ? renderBarChart() : } ); diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index aee33b057..4abac3cf4 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -1,47 +1,37 @@ import React from 'react'; -import { View, Text } from 'react-native'; import { Card } from 'react-native-paper'; -import { cardStyles } from './MetricsTab'; +import { cardStyles, SurveyObject } from './MetricsTab'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { useAppTheme } from '../appTheme'; import { LabelPanel } from './SurveyDoughnutCharts'; -const SurveyTripCategoriesCard = () => { +type SurveyTripRecord = { + label: string, + x: string, + y: number +} + +type Props = { + surveyTripCategoryMetric : {[key: string]: SurveyObject} +} +const SurveyTripCategoriesCard = ( {surveyTripCategoryMetric}: Props ) => { const { colors } = useAppTheme(); const { t } = useTranslation(); - const records = [ - { - label: 'Response', - x: 'EV Roaming trip', - y: 20, - }, - { - label: 'No Response', - x: 'EV Roaming trip', - y: 20, - }, - { - label: 'Response', - x: 'EV Return trip', - y: 30, - }, - { - label: 'No Response', - x: 'EV Return trip', - y: 40, - }, - { - label: 'Response', - x: 'Gas Car trip', - y: 50, - }, - { - label: 'No Response', - x: 'Gas Car trip', - y: 10, - }, - ]; + const records: SurveyTripRecord[] = []; + + for(const category in surveyTripCategoryMetric) { + const metricByCategory = surveyTripCategoryMetric[category]; + for(const key in metricByCategory) { + // we don't consider "mismatched" survey result for now + if(key === "mismatched") continue; + records.push({ + label: key === 'answered' ? 'Response' : 'No Response', + x: category, + y: metricByCategory[key] + }) + } + } return ( From d68354ecfd28758e0c5dca67c043f4bb807d6e2f Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 16 May 2024 13:54:53 -0700 Subject: [PATCH 14/56] Delete the getSurveyMetric API call from the server logic alteration When I first designed the survey dashboard, it showed the accumulated survey data regardless of the selected date range. However, the updated logic is that the survey metric depends on the date range selected, while the leaderboard metric remains accumulated. --- www/js/Main.tsx | 11 +-- www/js/TimelineContext.ts | 5 -- www/js/metrics/MetricsTab.tsx | 131 +++++++++++++++++++++++++--------- www/js/services/commHelper.ts | 15 ---- 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/www/js/Main.tsx b/www/js/Main.tsx index 828332b9e..650ed4044 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -58,18 +58,9 @@ const Main = () => { }, [appConfig, t]); useEffect(() => { - const { setShouldUpdateTimeline, lastUpdateMetricDateTime, setLastUpdateMetricDateTime } = timelineContext; + const { setShouldUpdateTimeline } = timelineContext; // update TimelineScrollList component only when the active tab is 'label' to fix leaflet map issue setShouldUpdateTimeline(!index); - - // update it when the last updated data is more than 24 hours ago to get new survey data from server - // purpose : to prevent too much API call - if(index === 1) { - var oneDayAgo = new Date().getTime() - (24 * 60 * 60 * 1000) - if (!lastUpdateMetricDateTime || lastUpdateMetricDateTime < oneDayAgo) { - setLastUpdateMetricDateTime(new Date().getTime()); - } - } }, [index]); return ( diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index bf0e60145..347403e7d 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -52,8 +52,6 @@ type ContextProps = { refreshTimeline: () => void; shouldUpdateTimeline: Boolean; setShouldUpdateTimeline: React.Dispatch>; - lastUpdateMetricDateTime: number; - setLastUpdateMetricDateTime: React.Dispatch>; }; export const useTimelineContext = (): ContextProps => { @@ -76,7 +74,6 @@ export const useTimelineContext = (): ContextProps => { // Leaflet map encounters an error when prerendered, so we need to render the TimelineScrollList component when the active tab is 'label' // 'shouldUpdateTimeline' gets updated based on the current tab index, and we can use it to determine whether to render the timeline or not const [shouldUpdateTimeline, setShouldUpdateTimeline] = useState(true); - const [lastUpdateMetricDateTime, setLastUpdateMetricDateTime] = useState(0); // initialization, once the appConfig is loaded useEffect(() => { @@ -366,8 +363,6 @@ export const useTimelineContext = (): ContextProps => { addUserInputToEntry, shouldUpdateTimeline, setShouldUpdateTimeline, - lastUpdateMetricDateTime, - setLastUpdateMetricDateTime, }; }; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index e1f56ac7d..b865bf2ff 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -14,7 +14,7 @@ import Carousel from '../components/Carousel'; import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; -import { getAggregateData, getMetrics, getSurveyMetric } from '../services/commHelper'; +import { getAggregateData, getMetrics } from '../services/commHelper'; import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; import { ServerConnConfig } from '../types/appConfigTypes'; @@ -32,24 +32,101 @@ export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as co const DEFAULT_SUMMARY_LIST = ['distance', 'count', 'duration'] as const; export type SurveyObject = { - 'answered': number, - 'unanswered': number, - 'mismatched': number, -} + answered: number; + unanswered: number; + mismatched: number; +}; export type SurveyMetric = { - 'me' : { - 'overview' : SurveyObject, - 'rank' : number, - 'details': { - [key: string]: SurveyObject, - } + me: { + overview: SurveyObject; + rank: number; + details: { + [key: string]: SurveyObject; + }; + }; + others: { + overview: SurveyObject; + leaderboard: SurveyObject[]; + }; +}; + +const DUMMY_SURVEY_METRIC: SurveyMetric = { + me: { + overview: { + answered: 5, + unanswered: 5, + mismatched: 0, + }, + rank: 5, + details: { + ev_roaming_trip: { + answered: 10, + unanswered: 5, + mismatched: 0, + }, + ev_return_trip: { + answered: 10, + unanswered: 10, + mismatched: 0, + }, + gas_car_trip: { + answered: 5, + unanswered: 10, + mismatched: 0, + }, + }, }, - 'others' : { - 'overview' : SurveyObject, - 'leaderboard': SurveyObject[], - } -} + others: { + overview: { + answered: 30, + unanswered: 60, + mismatched: 0, + }, + leaderboard: [ + { + answered: 10, + unanswered: 0, + mismatched: 0, + }, + { + answered: 9, + unanswered: 1, + mismatched: 0, + }, + { + answered: 8, + unanswered: 2, + mismatched: 0, + }, + { + answered: 7, + unanswered: 3, + mismatched: 0, + }, + { + answered: 6, + unanswered: 4, + mismatched: 0, + }, + { + answered: 4, + unanswered: 6, + mismatched: 0, + }, + { + answered: 2, + unanswered: 8, + mismatched: 0, + }, + { + answered: 1, + unanswered: 9, + mismatched: 0, + }, + ], + }, +}; async function fetchMetricsFromServer( type: 'user' | 'aggregate', @@ -83,12 +160,10 @@ const MetricsTab = () => { timelineIsLoading, refreshTimeline, loadMoreDays, - lastUpdateMetricDateTime + lastUpdateMetricDateTime, } = useContext(TimelineContext); const [aggMetrics, setAggMetrics] = useState(undefined); - const [surveyMetric, setSurveyMetric] = useState(null); - // user metrics are computed on the phone from the timeline data const userMetrics = useMemo(() => { console.time('MetricsTab: generate_summaries'); @@ -172,18 +247,6 @@ const MetricsTab = () => { const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; - useEffect(() => { - async function getSurveyMetricData() { - const res = await getSurveyMetric(); - setSurveyMetric(res as SurveyMetric); - } - - // 'lastUpdateMetricDate' is used to get new survey data when the last data was 24 hours ago - if(lastUpdateMetricDateTime && sectionsToShow.includes('engagement')) { - getSurveyMetricData(); - } - }, [lastUpdateMetricDateTime]) - return ( <> @@ -249,10 +312,10 @@ const MetricsTab = () => { unitFormatFn={getFormattedSpeed} /> */} )} - {surveyMetric && ( + {DUMMY_SURVEY_METRIC && ( - - + + )} diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index 4861c789e..ec2ee9d97 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -136,21 +136,6 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } -export function getSurveyMetric() { - console.log('hahaha') - return new Promise((rs, rj) => { - console.log(rs); - window['cordova'].plugins.BEMServerComm.getUserPersonalData( - '/get/metrics/survey', - rs, - rj, - ); - }).catch((error) => { - error = `While getting survey metric, ${error}`; - throw error; - }); -} - export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { return new Promise((rs, rj) => { const fullUrl = `${serverConnConfig.connectUrl}/${path}`; From d4b3f51c9c2b412e15d7aa2f175f05520cd5f969 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 16 May 2024 14:45:49 -0700 Subject: [PATCH 15/56] Separate 'Survey comparison Card' and 'Leaderboard Card' Survey Comparison Card: Data depends on date range. Survey Leaderboard Card: Data is accumulated. To prevent confusion for users, we separate the two cards and inform them that the leaderboard data is accumulated. --- www/i18n/en.json | 1 + www/js/metrics/MetricsTab.tsx | 15 +- ...nutCharts.tsx => SurveyComparisonCard.tsx} | 86 +++++++--- www/js/metrics/SurveyLeaderboardCard.tsx | 154 ++++++++---------- www/js/metrics/SurveyTripCategoriesCard.tsx | 26 +-- 5 files changed, 152 insertions(+), 130 deletions(-) rename www/js/metrics/{SurveyDoughnutCharts.tsx => SurveyComparisonCard.tsx} (54%) diff --git a/www/i18n/en.json b/www/i18n/en.json index 244fbb212..06797fe52 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -226,6 +226,7 @@ "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", diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index b865bf2ff..4e047604b 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -24,6 +24,7 @@ import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelp import { metrics_summaries } from 'e-mission-common'; import SurveyLeaderboardCard from './SurveyLeaderboardCard'; import SurveyTripCategoriesCard from './SurveyTripCategoriesCard'; +import SurveyComparisonCard from './SurveyComparisonCard'; // 2 weeks of data is needed in order to compare "past week" vs "previous week" const N_DAYS_TO_LOAD = 14; // 2 weeks @@ -160,7 +161,6 @@ const MetricsTab = () => { timelineIsLoading, refreshTimeline, loadMoreDays, - lastUpdateMetricDateTime, } = useContext(TimelineContext); const [aggMetrics, setAggMetrics] = useState(undefined); @@ -246,6 +246,7 @@ const MetricsTab = () => { appConfig?.metrics?.phone_dashboard_ui?.summary_options?.metrics_list ?? DEFAULT_SUMMARY_LIST; const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; + const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; return ( <> @@ -312,12 +313,20 @@ const MetricsTab = () => { unitFormatFn={getFormattedSpeed} /> */} )} - {DUMMY_SURVEY_METRIC && ( + {sectionsToShow.includes('surveys') && ( - + )} + {sectionsToShow.includes('engagement') && ( + + + + )} ); diff --git a/www/js/metrics/SurveyDoughnutCharts.tsx b/www/js/metrics/SurveyComparisonCard.tsx similarity index 54% rename from www/js/metrics/SurveyDoughnutCharts.tsx rename to www/js/metrics/SurveyComparisonCard.tsx index aa6311886..9fd6e2114 100644 --- a/www/js/metrics/SurveyDoughnutCharts.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -1,14 +1,22 @@ import React from 'react'; import { View, Text } from 'react-native'; -import { Icon } from 'react-native-paper'; +import { Icon, Card } from 'react-native-paper'; 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 { SurveyComparison } from './SurveyLeaderboardCard'; - +import { cardStyles, SurveyMetric } from './MetricsTab'; ChartJS.register(ArcElement, Tooltip, Legend); +type Props = { + surveyMetric: SurveyMetric; +}; + +export type SurveyComparison = { + me: number; + others: number; +}; + export const LabelPanel = ({ first, second }) => { const { colors } = useAppTheme(); @@ -26,13 +34,25 @@ export const LabelPanel = ({ first, second }) => { ); }; -type Props = { - surveyComparison : SurveyComparison -} - -const SurveyDoughnutCharts = ({surveyComparison} : Props) => { +const SurveyComparisonCard = ({ surveyMetric }: Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); + + const mySurveyMetric = surveyMetric.me.overview; + const othersSurveyMetric = surveyMetric.others.overview; + const mySurveyRate = Math.round( + (mySurveyMetric.answered / (mySurveyMetric.answered + mySurveyMetric.unanswered)) * 100, + ); + + const surveyComparison: SurveyComparison = { + me: mySurveyRate, + others: Math.round( + (othersSurveyMetric.answered / + (othersSurveyMetric.answered + othersSurveyMetric.unanswered)) * + 100, + ), + }; + const renderDoughnutChart = (rate, chartColor, myResponse) => { const data = { datasets: [ @@ -56,8 +76,8 @@ const SurveyDoughnutCharts = ({surveyComparison} : Props) => { { }; return ( - - {t('main-metrics.survey-response-rate')} - - {renderDoughnutChart(surveyComparison.me, colors.navy, true)} - {renderDoughnutChart(surveyComparison.others, colors.orange, false)} - - - + + + + + {t('main-metrics.survey-response-rate')} + + {renderDoughnutChart(surveyComparison.me, colors.navy, true)} + {renderDoughnutChart(surveyComparison.others, colors.orange, false)} + + + + + ); }; @@ -91,18 +123,23 @@ const styles: any = { alignSelf: 'center', fontWeight: 'bold', fontSize: 14, - marginBottom: 20, + marginBottom: 10, + }, + statusTextWrapper: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'row', + fontSize: 16, }, chartWrapper: { display: 'flex', flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 20, + justifyContent: 'space-around', }, textWrapper: { position: 'absolute', - width: 150, - height: 150, + width: 140, + height: 140, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -111,6 +148,7 @@ const styles: any = { alignSelf: 'center', display: 'flex', gap: 10, + marginTop: 10, }, labelItem: { display: 'flex', @@ -120,4 +158,4 @@ const styles: any = { }, }; -export default SurveyDoughnutCharts; +export default SurveyComparisonCard; diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx index 57b991df1..34341616d 100644 --- a/www/js/metrics/SurveyLeaderboardCard.tsx +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -1,109 +1,71 @@ -import React, { useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { View, Text } from 'react-native'; import { Card } from 'react-native-paper'; import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab'; import { useTranslation } from 'react-i18next'; -import ToggleSwitch from '../components/ToggleSwitch'; import BarChart from '../components/BarChart'; import { useAppTheme } from '../appTheme'; -import SurveyComparisonChart from './SurveyDoughnutCharts'; import { Chart as ChartJS, registerables } from 'chart.js'; import Annotation from 'chartjs-plugin-annotation'; ChartJS.register(...registerables, Annotation); type Props = { - surveyMetric: SurveyMetric -} + studyStartDate: string; + surveyMetric: SurveyMetric; +}; type LeaderboardRecord = { - label: string, - x: number, - y: string -} -export type SurveyComparison = { - 'me' : number, - 'others' : number, -} + label: string; + x: number; + y: string; +}; -const SurveyLeaderboardCard = ( { surveyMetric }: Props) => { +const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); - const [tab, setTab] = useState('leaderboard'); - + const myRank = surveyMetric.me.rank; const mySurveyMetric = surveyMetric.me.overview; - const othersSurveyMetric = surveyMetric.others.overview; - const mySurveyRate = Math.round(mySurveyMetric.answered / (mySurveyMetric.answered + mySurveyMetric.unanswered) * 100); - - const surveyComparison: SurveyComparison = { - 'me' : mySurveyRate, - 'others' : Math.round(othersSurveyMetric.answered / (othersSurveyMetric.answered + othersSurveyMetric.unanswered) * 100) - } function getLabel(rank: number): string { - if(rank === 0) { + if (rank === 0) { return '🏆 #1:'; - }else if(rank === 1) { + } else if (rank === 1) { return '🥈 #2:'; - }else if(rank === 2) { + } else if (rank === 2) { return '🥉 #3:'; - }else { - return `#${rank+1}:`; + } else { + return `#${rank + 1}:`; } } const leaderboardRecords: LeaderboardRecord[] = useMemo(() => { - const combinedLeaderboard:SurveyObject[] = [...surveyMetric.others.leaderboard]; + const combinedLeaderboard: SurveyObject[] = [...surveyMetric.others.leaderboard]; combinedLeaderboard.splice(myRank, 0, mySurveyMetric); // This is to prevent the leaderboard from being too long for UX purposes. // For a total of 20 members, we only show the top 5 members, myself, and the bottom 3 members. - const numberOfTopUsers = 5 - const numberOfBottomUsers = surveyMetric.others.leaderboard.length -3; - - return combinedLeaderboard.map((item, idx) => ( - { - 'isMe': idx === myRank, - 'rank': idx, - 'answered': item.answered, - 'unanswered': item.unanswered, - 'mismatched': item.mismatched, - } - )).filter((item) => ( item.isMe || item.rank < numberOfTopUsers || item.rank >= numberOfBottomUsers)) - .map((item) => ( - { + const numberOfTopUsers = 5; + const numberOfBottomUsers = surveyMetric.others.leaderboard.length - 3; + + return combinedLeaderboard + .map((item, idx) => ({ + isMe: idx === myRank, + rank: idx, + answered: item.answered, + unanswered: item.unanswered, + mismatched: item.mismatched, + })) + .filter( + (item) => item.isMe || item.rank < numberOfTopUsers || item.rank >= numberOfBottomUsers, + ) + .map((item) => ({ label: item.isMe ? `${item.rank}-me` : `${item.rank}-other`, - x: Math.round(item.answered / (item.answered + item.unanswered) * 100), - y: getLabel(item.rank) - } - )) - }, [surveyMetric]) - - const renderBarChart = () => { - return ( - - {t('main-metrics.survey-response-rate')} - (l === `${myRank}-me` ? colors.skyblue : colors.silver)} - getColorForChartEl={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)} - showLegend={false} - reverse={false} - enableTooltip={false} - /> - - {t('main-metrics.you-are-in')} - #{myRank+1} - {t('main-metrics.place')} - - - ); - }; + x: Math.round((item.answered / (item.answered + item.unanswered)) * 100), + y: getLabel(item.rank), + })); + }, [surveyMetric]); return ( @@ -111,27 +73,35 @@ const SurveyLeaderboardCard = ( { surveyMetric }: Props) => { title={t('main-metrics.surveys')} titleVariant="titleLarge" titleStyle={cardStyles.titleText(colors)} - subtitle={ - tab === 'leaderboard' ? t('main-metrics.leaderboard') : t('main-metrics.comparison') - } + subtitle={t('main-metrics.leaderboard')} subtitleStyle={[cardStyles.titleText(colors), cardStyles.subtitleText]} style={cardStyles.title(colors)} - right={() => ( - - setTab(v as any)} - buttons={[ - { icon: 'chart-bar', value: 'leaderboard' }, - { icon: 'arrow-collapse', value: 'comparison' }, - ]} - /> - - )} /> - {tab === 'leaderboard' ? renderBarChart() : } + + + * {t('main-metrics.survey-leaderboard-desc')} + {studyStartDate} + + {t('main-metrics.survey-response-rate')} + (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + getColorForChartEl={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + showLegend={false} + reverse={false} + enableTooltip={false} + /> + + {t('main-metrics.you-are-in')} + #{myRank + 1} + {t('main-metrics.place')} + + ); @@ -143,6 +113,10 @@ const styles: any = { fontWeight: 'bold', fontSize: 14, }, + chartDesc: { + fontSoze: 12, + marginBottom: 10, + }, statusTextWrapper: { alignSelf: 'center', display: 'flex', diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index 4abac3cf4..fee29a0f0 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -4,32 +4,32 @@ import { cardStyles, SurveyObject } from './MetricsTab'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { useAppTheme } from '../appTheme'; -import { LabelPanel } from './SurveyDoughnutCharts'; +import { LabelPanel } from './SurveyComparisonCard'; type SurveyTripRecord = { - label: string, - x: string, - y: number -} + label: string; + x: string; + y: number; +}; type Props = { - surveyTripCategoryMetric : {[key: string]: SurveyObject} -} -const SurveyTripCategoriesCard = ( {surveyTripCategoryMetric}: Props ) => { + surveyTripCategoryMetric: { [key: string]: SurveyObject }; +}; +const SurveyTripCategoriesCard = ({ surveyTripCategoryMetric }: Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); const records: SurveyTripRecord[] = []; - for(const category in surveyTripCategoryMetric) { + for (const category in surveyTripCategoryMetric) { const metricByCategory = surveyTripCategoryMetric[category]; - for(const key in metricByCategory) { + for (const key in metricByCategory) { // we don't consider "mismatched" survey result for now - if(key === "mismatched") continue; + if (key === 'mismatched') continue; records.push({ label: key === 'answered' ? 'Response' : 'No Response', x: category, - y: metricByCategory[key] - }) + y: metricByCategory[key], + }); } } From abec657db2b66b0427d01ba385bf23f82452dc50 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 12:28:56 -0400 Subject: [PATCH 16/56] use the new yyyy_mm_dd endpoint for agg metrics Instead of querying by timestamp we can now query by "YYYY-MM-DD" strings. The yyyy_mm_dd endpoint also has a different format for metric_list: instead of just an array of metrics, it is a mapping of "metric names" to "grouping fields" (https://github.com/e-mission/e-mission-server/pull/966#issuecomment-2119705314) We will specify this in the config as "metrics_list". The yyyy_mm_dd endpoint will also ask for app_config, so we will pass that through to `fetchMetricsFromServer` and include it in the `query` (note that `survey_info` is the only field that needs to be included) We will be supporting another metric called "response_count"; added a card to the 'summary' section (if it is configured to show) Updated types in appConfigTypes.ts --- www/js/metrics/MetricsTab.tsx | 90 +++++++++++++++++++++------------- www/js/metrics/metricsTypes.ts | 4 +- www/js/types/appConfigTypes.ts | 49 +++++++++++------- 3 files changed, 87 insertions(+), 56 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 4e047604b..9244a0887 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -17,7 +17,7 @@ import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../services/commHelper'; import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; -import { ServerConnConfig } from '../types/appConfigTypes'; +import { AppConfig, MetricsList, MetricsUiSection } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; @@ -28,9 +28,17 @@ import SurveyComparisonCard from './SurveyComparisonCard'; // 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 = ['footprint', 'active_travel', 'summary'] as const; -export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; -const DEFAULT_SUMMARY_LIST = ['distance', 'count', 'duration'] as const; +const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [ + 'footprint', + 'active_travel', + 'summary', +] as const; +export const DEFAULT_METRICS_LIST: MetricsList = { + distance: ['mode_confirm'], + duration: ['mode_confirm'], + count: ['mode_confirm'], + response_count: ['mode_confirm'], +}; export type SurveyObject = { answered: number; @@ -132,19 +140,21 @@ const DUMMY_SURVEY_METRIC: SurveyMetric = { async function fetchMetricsFromServer( type: 'user' | 'aggregate', dateRange: [string, string], - serverConnConfig: ServerConnConfig, + metricsList: MetricsList, + appConfig: AppConfig, ) { const [startTs, endTs] = isoDateRangeToTsRange(dateRange); logDebug('MetricsTab: fetching metrics from server for ts range ' + startTs + ' to ' + endTs); const query = { freq: 'D', - start_time: startTs, - end_time: endTs, - metric_list: METRIC_LIST, + start_time: dateRange[0], + end_time: dateRange[1], + metric_list: metricsList, is_return_aggregate: type == 'aggregate', + app_config: { survey_info: appConfig.survey_info }, }; if (type == 'user') return getMetrics('timestamp', query); - return getAggregateData('result/metrics/timestamp', query, serverConnConfig); + return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server); } const MetricsTab = () => { @@ -163,26 +173,25 @@ const MetricsTab = () => { loadMoreDays, } = useContext(TimelineContext); + const metricsList = appConfig?.metrics?.phone_dashboard_ui?.metrics_list ?? DEFAULT_METRICS_LIST; + const [aggMetrics, setAggMetrics] = useState(undefined); // user metrics are computed on the phone from the timeline data const userMetrics = useMemo(() => { - console.time('MetricsTab: generate_summaries'); if (!timelineMap) return; - console.time('MetricsTab: timelineMap.values()'); const timelineValues = [...timelineMap.values()]; - console.timeEnd('MetricsTab: timelineMap.values()'); const result = metrics_summaries.generate_summaries( - METRIC_LIST, + { ...metricsList }, timelineValues, timelineLabelMap, ) as MetricsData; - console.timeEnd('MetricsTab: generate_summaries'); + logDebug('MetricsTab: computed userMetrics' + JSON.stringify(result)); return result; }, [timelineMap]); // at least N_DAYS_TO_LOAD of timeline data should be loaded for the user metrics useEffect(() => { - if (!appConfig?.server) return; + if (!appConfig) return; const dateRangeDays = isoDatesDifference(...dateRange); // this tab uses the last N_DAYS_TO_LOAD of data; if we need more, we should fetch it @@ -196,10 +205,11 @@ const MetricsTab = () => { } else { logDebug(`MetricsTab: date range >= ${N_DAYS_TO_LOAD} days, not loading more days`); } - }, [dateRange, timelineIsLoading, appConfig?.server]); + }, [dateRange, timelineIsLoading, appConfig]); // aggregate metrics fetched from the server whenever the date range is set useEffect(() => { + if (!appConfig) return; logDebug('MetricsTab: dateRange updated to ' + JSON.stringify(dateRange)); const dateRangeDays = isoDatesDifference(...dateRange); if (dateRangeDays < N_DAYS_TO_LOAD) { @@ -207,13 +217,14 @@ const MetricsTab = () => { `MetricsTab: date range < ${N_DAYS_TO_LOAD} days, not loading aggregate metrics yet`, ); } else { - loadMetricsForPopulation('aggregate', dateRange); + loadMetricsForPopulation('aggregate', dateRange, appConfig); } - }, [dateRange]); + }, [dateRange, appConfig]); async function loadMetricsForPopulation( population: 'user' | 'aggregate', dateRange: [string, string], + appConfig: AppConfig, ) { try { logDebug(`MetricsTab: fetching metrics for population ${population}' @@ -221,20 +232,22 @@ const MetricsTab = () => { const serverResponse: any = await fetchMetricsFromServer( population, dateRange, - appConfig.server, + metricsList, + appConfig, ); logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); - const metrics = {}; - const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; - METRIC_LIST.forEach((metricName, i) => { - metrics[metricName] = serverResponse[dataKey][i]; - }); - logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics)); - if (population == 'user') { - // setUserMetrics(metrics as MetricsData); - } else { - setAggMetrics(metrics as MetricsData); - } + // const metrics = {}; + // const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; + // METRIC_LIST.forEach((metricName, i) => { + // metrics[metricName] = serverResponse[dataKey][i]; + // }); + // logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics)); + // if (population == 'user') { + // // setUserMetrics(metrics as MetricsData); + // } else { + console.debug('MetricsTab: aggMetrics', serverResponse); + setAggMetrics(serverResponse as MetricsData); + // } } catch (e) { logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr } @@ -242,8 +255,6 @@ const MetricsTab = () => { const sectionsToShow = appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; - const summaryList = - appConfig?.metrics?.phone_dashboard_ui?.summary_options?.metrics_list ?? DEFAULT_SUMMARY_LIST; const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; @@ -279,7 +290,7 @@ const MetricsTab = () => { )} {sectionsToShow.includes('summary') && ( - {summaryList.includes('distance') && ( + {(userMetrics?.distance || aggMetrics?.distance) && ( { unitFormatFn={getFormattedDistance} /> )} - {summaryList.includes('count') && ( + {(userMetrics?.count || aggMetrics?.count) && ( { unitFormatFn={formatForDisplay} /> )} - {summaryList.includes('duration') && ( + {(userMetrics?.duration || aggMetrics?.duration) && ( { unitFormatFn={secondsToHours} /> )} + {(userMetrics?.response_count || aggMetrics?.response_count) && ( + + )} {/* , where could be anything export type DayOfServerMetricData = LabelProps & { diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 0415e7cfd..09c12a451 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -14,24 +14,7 @@ export type AppConfig = { tracking?: { bluetooth_only: boolean; }; - metrics: { - include_test_users: boolean; - phone_dashboard_ui?: { - sections: ('footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys')[]; - footprint_options?: { - unlabeled_uncertainty: boolean; - }; - summary_options?: { - metrics_list: ('distance' | 'count' | 'duration')[]; - }; - engagement_options?: { - leaderboard_metric: [string, string]; - }; - active_travel_options?: { - modes_list: string[]; - }; - }; - }; + metrics: MetricsConfig; reminderSchemes?: ReminderSchemesConfig; [k: string]: any; // TODO fill in all the other fields }; @@ -110,3 +93,33 @@ export type ReminderSchemesConfig = { defaultTime?: string; // format is HH:MM in 24 hour time }; }; + +// the available metrics that can be displayed in the phone dashboard +export type MetricName = 'distance' | 'count' | 'duration' | 'response_count'; +// the available trip / userinput properties that can be used to group the metrics +export const groupingFields = [ + 'mode_confirm', + 'purpose_confirm', + 'replaced_mode_confirm', + 'primary_ble_sensed_mode', +] as const; +export type GroupingField = (typeof groupingFields)[number]; +export type MetricsList = { [k in MetricName]?: GroupingField[] }; +export type MetricsUiSection = 'footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys'; +export type MetricsConfig = { + include_test_users: boolean; + phone_dashboard_ui?: { + sections: MetricsUiSection[]; + metrics_list: MetricsList; + footprint_options?: { + unlabeled_uncertainty: boolean; + }; + summary_options?: {}; + engagement_options?: { + leaderboard_metric: [string, string]; + }; + active_travel_options?: { + modes_list: string[]; + }; + }; +}; From 5ab984cbb711bf5a715a25fcac2075bec0cb9693 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 12:30:13 -0400 Subject: [PATCH 17/56] refactor valueForModeOnDay -> valueForFieldOnDay Making this more generic because we will now support fields other than the labeled mode (such as labeled purpose, BLE sensed mode, and survey that was prompted) --- www/js/metrics/ActiveMinutesTableCard.tsx | 11 ++++-- www/js/metrics/DailyActiveMinutesCard.tsx | 4 +- www/js/metrics/MetricsCard.tsx | 6 +-- www/js/metrics/WeeklyActiveMinutesCard.tsx | 6 +-- www/js/metrics/metricsHelper.ts | 46 +++++++++++++--------- 5 files changed, 43 insertions(+), 30 deletions(-) diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index 7d53ae766..aa8bc389f 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -7,7 +7,7 @@ import { formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks, - valueForModeOnDay, + valueForFieldOnDay, } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; @@ -30,7 +30,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const totals = {}; activeModes.forEach((mode) => { const sum = userMetrics.duration.reduce( - (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), 0, ); totals[mode] = secondsToMinutes(sum); @@ -46,7 +46,10 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .map((week) => { const totals = {}; activeModes.forEach((mode) => { - const sum = week.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0); + const sum = week.reduce( + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), + 0, + ); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(week); @@ -60,7 +63,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .map((day) => { const totals = {}; activeModes.forEach((mode) => { - const sum = valueForModeOnDay(day, mode) || 0; + const sum = valueForFieldOnDay(day, 'mode_confirm', mode) || 0; totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDate(day); diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index 7fe63d51d..b4f409971 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import LineChart from '../components/LineChart'; import { getBaseModeByText } from '../diary/diaryHelper'; -import { tsForDayOfMetricData, valueForModeOnDay } from './metricsHelper'; +import { tsForDayOfMetricData, valueForFieldOnDay } from './metricsHelper'; import useAppConfig from '../useAppConfig'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; @@ -25,7 +25,7 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { const recentDays = userMetrics?.duration?.slice(-14); recentDays?.forEach((day) => { activeModes.forEach((mode) => { - const activeSeconds = valueForModeOnDay(day, mode); + const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode); records.push({ label: labelKeyToRichMode(mode), x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 9a13aacdc..8e10d3cb2 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -9,7 +9,7 @@ import { getLabelsForDay, tsForDayOfMetricData, getUniqueLabelsForDays, - valueForModeOnDay, + valueForFieldOnDay, } from './metricsHelper'; import ToggleSwitch from '../components/ToggleSwitch'; import { cardStyles } from './MetricsTab'; @@ -48,7 +48,7 @@ const MetricsCard = ({ metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); labels.forEach((label) => { - const rawVal = valueForModeOnDay(day, label); + const rawVal = valueForFieldOnDay(day, 'mode_confirm', label); if (rawVal) { records.push({ label: labelKeyToRichMode(label), @@ -83,7 +83,7 @@ const MetricsCard = ({ const vals = {}; uniqueLabels.forEach((label) => { const sum = metricDataDays.reduce( - (acc, day) => acc + (valueForModeOnDay(day, label) || 0), + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', label) || 0), 0, ); vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index 8331320e9..b22085907 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -3,7 +3,7 @@ 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, valueForModeOnDay } from './metricsHelper'; +import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; @@ -30,14 +30,14 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { activeModes.forEach((mode) => { if (prevWeek) { const prevSum = prevWeek?.reduce( - (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), + (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 + (valueForModeOnDay(day, mode) || 0), + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), 0, ); const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 8097e460f..b233fe1d1 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -3,27 +3,39 @@ import { formatForDisplay } from '../config/useImperialConfig'; import { DayOfMetricData } from './metricsTypes'; import { logDebug } from '../plugin/logger'; import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; +import { groupingFields } from '../types/appConfigTypes'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; metricDataDays.forEach((e) => { Object.keys(e).forEach((k) => { - if (k.startsWith('label_') || k.startsWith('mode_')) { - let i = k.indexOf('_'); - const label = k.substring(i + 1); // remove prefix leaving just the mode label - if (!uniqueLabels.includes(label)) uniqueLabels.push(label); + const trimmed = trimGroupingPrefix(k); + if (trimmed && !uniqueLabels.includes(trimmed)) { + uniqueLabels.push(trimmed); } }); }); return uniqueLabels; } +/** + * @description Trims the "grouping field" prefix from a metrics key. Grouping fields are defined in appConfigTypes.ts + * @example removeGroupingPrefix('mode_purpose_access_recreation') => 'access_recreation' + * @example removeGroupingPrefix('primary_ble_sensed_mode_CAR') => 'CAR' + * @returns The key without the prefix (or undefined if the key didn't start with a grouping field) + */ +export const trimGroupingPrefix = (label: string) => { + for (let field of groupingFields) { + if (label.startsWith(field)) { + return label.substring(field.length + 1); + } + } +}; + export const getLabelsForDay = (metricDataDay: DayOfMetricData) => Object.keys(metricDataDay).reduce((acc, k) => { - if (k.startsWith('label_') || k.startsWith('mode_')) { - let i = k.indexOf('_'); - acc.push(k.substring(i + 1)); // remove prefix leaving just the mode label - } + const trimmed = trimGroupingPrefix(k); + if (trimmed) acc.push(trimmed); return acc; }, [] as string[]); @@ -127,15 +139,13 @@ export function parseDataFromMetrics(metrics, population) { ]); } } - //this section handles user lables, assuming 'label_' prefix - if (field.startsWith('label_') || field.startsWith('mode_')) { - let i = field.indexOf('_'); - let actualMode = field.substring(i + 1); // remove prefix - logDebug('Mapped field ' + field + ' to mode ' + actualMode); - if (!(actualMode in mode_bins)) { - mode_bins[actualMode] = []; + const trimmedField = trimGroupingPrefix(field); + if (trimmedField) { + logDebug('Mapped field ' + field + ' to mode ' + trimmedField); + if (!(trimmedField in mode_bins)) { + mode_bins[trimmedField] = []; } - mode_bins[actualMode].push([ + mode_bins[trimmedField].push([ metric.ts, Math.round(metricToValue(population, metric, field)), DateTime.fromISO(metric.fmt_time).toISO() as string, @@ -157,8 +167,8 @@ export const dateForDayOfMetricData = (day: DayOfMetricData) => export const tsForDayOfMetricData = (day: DayOfMetricData) => DateTime.fromISO(dateForDayOfMetricData(day)).toSeconds(); -export const valueForModeOnDay = (day: DayOfMetricData, key: string) => - day[`mode_${key}`] || day[`label_${key}`]; +export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) => + day[`${field}_${key}`] || day[`${field}_${key}`]; export type MetricsSummary = { key: string; values: number }; export function generateSummaryFromData(modeMap, metric) { From 5916fab60e8068a90fc32093ecda2c6c7179af60 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 17:17:30 -0400 Subject: [PATCH 18/56] display response_count MetricsCard properly Instead of specifically listing a MetricsCard for each metric, we can read metricsList and map each key to a MetricsCard. But the structure for response_count is an object of {responded: number, not_responded: number}, while the other metrics are just numbers. So we need to adjust change the way days get summed up to account for both. --- www/js/metrics/MetricsCard.tsx | 25 ++++++++--- www/js/metrics/MetricsTab.tsx | 74 +++++++++++++++------------------ www/js/metrics/metricsHelper.ts | 2 +- www/js/types/appConfigTypes.ts | 1 + 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 8e10d3cb2..508f506ca 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -16,8 +16,11 @@ import { cardStyles } from './MetricsTab'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import { getBaseModeByKey, getBaseModeByText, modeColors } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; +import { GroupingField } from '../types/appConfigTypes'; type Props = { + metricName: string; + groupingFields: GroupingField[]; cardTitle: string; userMetricsDays?: DayOfMetricData[]; aggMetricsDays?: DayOfMetricData[]; @@ -25,6 +28,8 @@ type Props = { unitFormatFn?: (val: number) => string | number; }; const MetricsCard = ({ + metricName, + groupingFields, cardTitle, userMetricsDays, aggMetricsDays, @@ -48,7 +53,7 @@ const MetricsCard = ({ metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); labels.forEach((label) => { - const rawVal = valueForFieldOnDay(day, 'mode_confirm', label); + const rawVal = valueForFieldOnDay(day, groupingFields[0], label); if (rawVal) { records.push({ label: labelKeyToRichMode(label), @@ -82,10 +87,20 @@ const MetricsCard = ({ // for each label, sum up cumulative values across all days const vals = {}; uniqueLabels.forEach((label) => { - const sum = metricDataDays.reduce( - (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', label) || 0), - 0, - ); + const sum: any = metricDataDays.reduce((acc, day) => { + const val = valueForFieldOnDay(day, groupingFields[0], label); + // if val is object, add its values to the accumulator's values + if (isNaN(val)) { + const newAcc = {}; + for (let key in val) { + newAcc[key] = (acc[key] || 0) + val[key]; + } + return newAcc; + } else { + // if val is number, add it to the accumulator + if (typeof val == 'number') return acc + val; + } + }, 0); vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; }); return vals; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 9244a0887..dd5b99388 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -17,7 +17,13 @@ import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../services/commHelper'; import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; -import { AppConfig, MetricsList, MetricsUiSection } from '../types/appConfigTypes'; +import { + AppConfig, + GroupingField, + MetricName, + MetricsList, + MetricsUiSection, +} from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; @@ -183,8 +189,10 @@ const MetricsTab = () => { const result = metrics_summaries.generate_summaries( { ...metricsList }, timelineValues, + appConfig, timelineLabelMap, ) as MetricsData; + console.debug('MetricsTab: computed userMetrics', result); logDebug('MetricsTab: computed userMetrics' + JSON.stringify(result)); return result; }, [timelineMap]); @@ -290,47 +298,31 @@ const MetricsTab = () => { )} {sectionsToShow.includes('summary') && ( - {(userMetrics?.distance || aggMetrics?.distance) && ( - + {Object.entries(metricsList).map( + ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { + const units: { [k: string]: [string, (any) => string] } = { + distance: [distanceSuffix, getFormattedDistance], + duration: [t('metrics.hours'), secondsToHours], + count: [t('metrics.trips'), formatForDisplay], + response_count: [ + t('metrics.responses'), + (e) => `${e.responded}/${e.responded || 0 + e.not_responded || 0}`, + ], + }; + return ( + + ); + }, )} - {(userMetrics?.count || aggMetrics?.count) && ( - - )} - {(userMetrics?.duration || aggMetrics?.duration) && ( - - )} - {(userMetrics?.response_count || aggMetrics?.response_count) && ( - - )} - {/* */} )} {sectionsToShow.includes('surveys') && ( diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index b233fe1d1..d7c72c939 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -168,7 +168,7 @@ export const tsForDayOfMetricData = (day: DayOfMetricData) => DateTime.fromISO(dateForDayOfMetricData(day)).toSeconds(); export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) => - day[`${field}_${key}`] || day[`${field}_${key}`]; + day[`${field}_${key}`]; export type MetricsSummary = { key: string; values: number }; export function generateSummaryFromData(modeMap, metric) { diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 09c12a451..a55c3ae2b 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -102,6 +102,7 @@ export const groupingFields = [ 'purpose_confirm', 'replaced_mode_confirm', 'primary_ble_sensed_mode', + 'survey', ] as const; export type GroupingField = (typeof groupingFields)[number]; export type MetricsList = { [k in MetricName]?: GroupingField[] }; From ab6fece13dcb80df58a5ebd3c1afe2640122634f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 17:17:47 -0400 Subject: [PATCH 19/56] include primary_ble_sensed_mode in derived properties The 'showsIf' conditions in dfc-fermata currently reference 'confirmedMode'. We want to rename this to 'primary_ble_sensed_mode'. Also see https://github.com/JGreenlee/e-mission-common/commit/039633992a16c78a86713fc4a4a3735075c74527 --- www/js/diary/useDerivedProperties.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index 06a870fe8..f13c1862d 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -8,6 +8,7 @@ import { getLocalTimeString, getDetectedModes, isMultiDay, + primarySectionForTrip, } from './diaryHelper'; import TimelineContext from '../TimelineContext'; @@ -24,6 +25,7 @@ const useDerivedProperties = (tlEntry) => { return { confirmedMode: confirmedModeFor(tlEntry), + primary_ble_sensed_mode: primarySectionForTrip(tlEntry)?.ble_sensed_mode?.baseMode, displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), displayEndTime: getLocalTimeString(endDt), From c9628e0e8f00812ae272f1a035a2532547c2b50f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 17:17:58 -0400 Subject: [PATCH 20/56] support localhost metrics when appConfig does not have 'server' This makes it easier to test aggregate metrics locally --- www/js/services/commHelper.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index ec2ee9d97..75fdbf8de 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -136,8 +136,13 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } -export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { +export function getAggregateData(path: string, query, serverConnConfig?: ServerConnConfig) { return new Promise((rs, rj) => { + // when app config does not have "server", localhost is used and no user authentication is required + serverConnConfig ||= { + connectUrl: 'http://localhost:8080' as any, + aggregate_call_auth: 'no_auth', + }; const fullUrl = `${serverConnConfig.connectUrl}/${path}`; query['aggregate'] = true; From 32336ea43464e51b7cda2abc62bbe57ec8ce9e66 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 17:18:19 -0400 Subject: [PATCH 21/56] add 'metrics.responses' to en.json --- www/i18n/en.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 06797fe52..424d337e1 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -115,6 +115,7 @@ "trips": "trips", "hours": "hours", "minutes": "minutes", + "responses": "responses", "custom": "Custom" }, @@ -192,8 +193,9 @@ "chart": "Chart", "change-data": "Change dates:", "distance": "Distance", - "trips": "Trips", + "count": "Trip Count", "duration": "Duration", + "response_count": "Response Count", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", From 1d99f59fbe04d0b8f64f8cbc96bf39ecad9d3009 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 May 2024 23:45:40 -0400 Subject: [PATCH 22/56] use real data for SurveyComparisonCard Instead of dummy data, we can now use real values via the "response_count" metric. Since our data is chunked by day and this comparison is across all surveys for all days loaded, we need a function (getResponsePctForDays) to sum up the 'responded' and 'not_responded' and calculate a percentage. This is called on userMetrics for "you" and on aggMetrics for "others". It was easy to swap in the real percentages to the existing doughnut charts and they look great! --- www/js/metrics/MetricsTab.tsx | 2 +- www/js/metrics/SurveyComparisonCard.tsx | 48 +++++++++++++++---------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index dd5b99388..0aedb4695 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -327,7 +327,7 @@ const MetricsTab = () => { )} {sectionsToShow.includes('surveys') && ( - + )} diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx index 9fd6e2114..cd013b4be 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -1,15 +1,30 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, Text } from 'react-native'; import { Icon, Card } from 'react-native-paper'; 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, SurveyMetric } from './MetricsTab'; +import { cardStyles } from './MetricsTab'; +import { DayOfMetricData, MetricsData } from './metricsTypes'; +import { getUniqueLabelsForDays } from './metricsHelper'; ChartJS.register(ArcElement, Tooltip, Legend); +function getResponsePctForDays(days: DayOfMetricData[]) { + const surveys = getUniqueLabelsForDays(days); + let acc = { responded: 0, not_responded: 0 }; + days.forEach((day) => { + surveys.forEach((survey) => { + acc.responded += day[`survey_${survey}`]?.responded || 0; + acc.not_responded += day[`survey_${survey}`]?.not_responded || 0; + }); + }); + return Math.round((acc.responded / (acc.responded + acc.not_responded)) * 100); +} + type Props = { - surveyMetric: SurveyMetric; + userMetrics: MetricsData; + aggMetrics: MetricsData; }; export type SurveyComparison = { @@ -34,24 +49,19 @@ export const LabelPanel = ({ first, second }) => { ); }; -const SurveyComparisonCard = ({ surveyMetric }: Props) => { +const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); - const mySurveyMetric = surveyMetric.me.overview; - const othersSurveyMetric = surveyMetric.others.overview; - const mySurveyRate = Math.round( - (mySurveyMetric.answered / (mySurveyMetric.answered + mySurveyMetric.unanswered)) * 100, - ); + const myResponsePct = useMemo(() => { + if (!userMetrics?.response_count) return; + return getResponsePctForDays(userMetrics.response_count); + }, [userMetrics]); - const surveyComparison: SurveyComparison = { - me: mySurveyRate, - others: Math.round( - (othersSurveyMetric.answered / - (othersSurveyMetric.answered + othersSurveyMetric.unanswered)) * - 100, - ), - }; + const othersResponsePct = useMemo(() => { + if (!aggMetrics?.response_count) return; + return getResponsePctForDays(aggMetrics.response_count); + }, [aggMetrics]); const renderDoughnutChart = (rate, chartColor, myResponse) => { const data = { @@ -108,8 +118,8 @@ const SurveyComparisonCard = ({ surveyMetric }: Props) => { {t('main-metrics.survey-response-rate')} - {renderDoughnutChart(surveyComparison.me, colors.navy, true)} - {renderDoughnutChart(surveyComparison.others, colors.orange, false)} + {renderDoughnutChart(myResponsePct, colors.navy, true)} + {renderDoughnutChart(othersResponsePct, colors.orange, false)} From c7fbc254aa09a405b96de2a6027cce702f84bb34 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 May 2024 00:15:25 -0400 Subject: [PATCH 23/56] use real data for SurveyTripCategoriesCard Instead of dummy data, we can now use real values via the "response_count" metric. For each survey, we sum up the 'responded' and 'not responded' across all loaded days. Then include the totals in the chart records --- www/js/metrics/MetricsTab.tsx | 2 +- www/js/metrics/SurveyTripCategoriesCard.tsx | 48 +++++++++++++-------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 0aedb4695..8315efbc8 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -328,7 +328,7 @@ const MetricsTab = () => { {sectionsToShow.includes('surveys') && ( - + )} {sectionsToShow.includes('engagement') && ( diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index fee29a0f0..169bf1ab3 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -1,10 +1,22 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Card } from 'react-native-paper'; -import { cardStyles, SurveyObject } from './MetricsTab'; +import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; 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'; + +function sumResponseCountsForValue(days: DayOfMetricData[], value: `${GroupingField}_${string}`) { + const acc = { responded: 0, not_responded: 0 }; + days.forEach((day) => { + acc.responded += day[value]?.responded || 0; + acc.not_responded += day[value]?.not_responded || 0; + }); + return acc; +} type SurveyTripRecord = { label: string; @@ -13,25 +25,27 @@ type SurveyTripRecord = { }; type Props = { - surveyTripCategoryMetric: { [key: string]: SurveyObject }; + userMetrics: MetricsData; + aggMetrics: MetricsData; }; -const SurveyTripCategoriesCard = ({ surveyTripCategoryMetric }: Props) => { +const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useAppTheme(); const { t } = useTranslation(); - const records: SurveyTripRecord[] = []; - for (const category in surveyTripCategoryMetric) { - const metricByCategory = surveyTripCategoryMetric[category]; - for (const key in metricByCategory) { - // we don't consider "mismatched" survey result for now - if (key === 'mismatched') continue; - records.push({ - label: key === 'answered' ? 'Response' : 'No Response', - x: category, - y: metricByCategory[key], - }); - } - } + const records = useMemo(() => { + if (!userMetrics?.response_count) return []; + const surveys = getUniqueLabelsForDays(userMetrics.response_count); + const records: SurveyTripRecord[] = []; + surveys.forEach((survey) => { + const { responded, not_responded } = sumResponseCountsForValue( + userMetrics.response_count, + `survey_${survey}`, + ); + records.push({ label: 'Response', x: survey, y: responded || 0 }); + records.push({ label: 'No Response', x: survey, y: not_responded || 0 }); + }); + return records; + }, [userMetrics]); return ( From 5a8759e145de4234133a5515f4361b9c61c0e975 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 May 2024 00:21:19 -0400 Subject: [PATCH 24/56] comment out leaderboard; remove dummy data Since we have implemented the real metrics for the "surveys" section of the dashboard, we do not need the dummy metrics anymore. Commented out the "engagement" section (leaderboard); it will be done in the next step. --- www/js/metrics/MetricsTab.tsx | 102 +--------------------------------- 1 file changed, 3 insertions(+), 99 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 8315efbc8..d49ec50d6 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -46,103 +46,6 @@ export const DEFAULT_METRICS_LIST: MetricsList = { response_count: ['mode_confirm'], }; -export type SurveyObject = { - answered: number; - unanswered: number; - mismatched: number; -}; - -export type SurveyMetric = { - me: { - overview: SurveyObject; - rank: number; - details: { - [key: string]: SurveyObject; - }; - }; - others: { - overview: SurveyObject; - leaderboard: SurveyObject[]; - }; -}; - -const DUMMY_SURVEY_METRIC: SurveyMetric = { - me: { - overview: { - answered: 5, - unanswered: 5, - mismatched: 0, - }, - rank: 5, - details: { - ev_roaming_trip: { - answered: 10, - unanswered: 5, - mismatched: 0, - }, - ev_return_trip: { - answered: 10, - unanswered: 10, - mismatched: 0, - }, - gas_car_trip: { - answered: 5, - unanswered: 10, - mismatched: 0, - }, - }, - }, - others: { - overview: { - answered: 30, - unanswered: 60, - mismatched: 0, - }, - leaderboard: [ - { - answered: 10, - unanswered: 0, - mismatched: 0, - }, - { - answered: 9, - unanswered: 1, - mismatched: 0, - }, - { - answered: 8, - unanswered: 2, - mismatched: 0, - }, - { - answered: 7, - unanswered: 3, - mismatched: 0, - }, - { - answered: 6, - unanswered: 4, - mismatched: 0, - }, - { - answered: 4, - unanswered: 6, - mismatched: 0, - }, - { - answered: 2, - unanswered: 8, - mismatched: 0, - }, - { - answered: 1, - unanswered: 9, - mismatched: 0, - }, - ], - }, -}; - async function fetchMetricsFromServer( type: 'user' | 'aggregate', dateRange: [string, string], @@ -331,14 +234,15 @@ const MetricsTab = () => { )} - {sectionsToShow.includes('engagement') && ( + {/* we will implement leaderboard later */} + {/* {sectionsToShow.includes('engagement') && ( - )} + )} */} ); From 269faedd0396a0d6cf59ee3102fb8f08632b630f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 May 2024 00:40:05 -0400 Subject: [PATCH 25/56] "metrics list" -> "metric list" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I noticed a discrepancy – the server request has "metric_list" but the config and dashboard UI uses "metrics_list" / "metricsList". Unifying to "metric_list" for consistency throughout the project. --- www/js/metrics/MetricsTab.tsx | 16 ++++++++-------- www/js/types/appConfigTypes.ts | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index d49ec50d6..0ab3104c4 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,7 +21,7 @@ import { AppConfig, GroupingField, MetricName, - MetricsList, + MetricList, MetricsUiSection, } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; @@ -39,7 +39,7 @@ const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [ 'active_travel', 'summary', ] as const; -export const DEFAULT_METRICS_LIST: MetricsList = { +export const DEFAULT_METRIC_LIST: MetricList = { distance: ['mode_confirm'], duration: ['mode_confirm'], count: ['mode_confirm'], @@ -49,7 +49,7 @@ export const DEFAULT_METRICS_LIST: MetricsList = { async function fetchMetricsFromServer( type: 'user' | 'aggregate', dateRange: [string, string], - metricsList: MetricsList, + metricList: MetricList, appConfig: AppConfig, ) { const [startTs, endTs] = isoDateRangeToTsRange(dateRange); @@ -58,7 +58,7 @@ async function fetchMetricsFromServer( freq: 'D', start_time: dateRange[0], end_time: dateRange[1], - metric_list: metricsList, + metric_list: metricList, is_return_aggregate: type == 'aggregate', app_config: { survey_info: appConfig.survey_info }, }; @@ -82,7 +82,7 @@ const MetricsTab = () => { loadMoreDays, } = useContext(TimelineContext); - const metricsList = appConfig?.metrics?.phone_dashboard_ui?.metrics_list ?? DEFAULT_METRICS_LIST; + const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST; const [aggMetrics, setAggMetrics] = useState(undefined); // user metrics are computed on the phone from the timeline data @@ -90,7 +90,7 @@ const MetricsTab = () => { if (!timelineMap) return; const timelineValues = [...timelineMap.values()]; const result = metrics_summaries.generate_summaries( - { ...metricsList }, + { ...metricList }, timelineValues, appConfig, timelineLabelMap, @@ -143,7 +143,7 @@ const MetricsTab = () => { const serverResponse: any = await fetchMetricsFromServer( population, dateRange, - metricsList, + metricList, appConfig, ); logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); @@ -201,7 +201,7 @@ const MetricsTab = () => { )} {sectionsToShow.includes('summary') && ( - {Object.entries(metricsList).map( + {Object.entries(metricList).map( ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { const units: { [k: string]: [string, (any) => string] } = { distance: [distanceSuffix, getFormattedDistance], diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index a55c3ae2b..87a4b4e85 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -105,13 +105,13 @@ export const groupingFields = [ 'survey', ] as const; export type GroupingField = (typeof groupingFields)[number]; -export type MetricsList = { [k in MetricName]?: GroupingField[] }; +export type MetricList = { [k in MetricName]?: GroupingField[] }; export type MetricsUiSection = 'footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys'; export type MetricsConfig = { include_test_users: boolean; phone_dashboard_ui?: { sections: MetricsUiSection[]; - metrics_list: MetricsList; + metric_list: MetricList; footprint_options?: { unlabeled_uncertainty: boolean; }; From cc4d134770170c3b277c64835cd4835128d45892 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 May 2024 01:17:15 -0400 Subject: [PATCH 26/56] show "No data" better when there's nothing to show --- www/i18n/en.json | 3 +- www/js/metrics/CarbonFootprintCard.tsx | 8 +-- www/js/metrics/DailyActiveMinutesCard.tsx | 8 +-- www/js/metrics/MetricsCard.tsx | 80 ++++++++++++--------- www/js/metrics/SurveyComparisonCard.tsx | 10 ++- www/js/metrics/SurveyTripCategoriesCard.tsx | 34 +++++---- www/js/metrics/WeeklyActiveMinutesCard.tsx | 8 +-- 7 files changed, 85 insertions(+), 66 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 424d337e1..dfccf1f5e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -116,7 +116,8 @@ "hours": "hours", "minutes": "minutes", "responses": "responses", - "custom": "Custom" + "custom": "Custom", + "no-data": "No data" }, "diary": { diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 1478e646b..4c0c7b789 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -236,11 +236,9 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index b4f409971..f70b60587 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -56,11 +56,9 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { getColorForLabel={(l) => getBaseModeByText(l, labelOptions).color} /> ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 508f506ca..1d0652d9e 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -117,7 +117,7 @@ const MetricsCard = ({ }; return ( - + - {viewMode == 'details' && ( - - {Object.keys(metricSumValues).map((label, i) => ( - - {labelKeyToRichMode(label)} - {metricSumValues[label] + ' ' + axisUnits} - - ))} - - )} - {viewMode == 'graph' && ( - <> - - - Stack bars: - setGraphIsStacked(!graphIsStacked)} - /> + {viewMode == 'details' && + (Object.keys(metricSumValues).length ? ( + + {Object.keys(metricSumValues).map((label, i) => ( + + {labelKeyToRichMode(label)} + {metricSumValues[label] + ' ' + axisUnits} + + ))} - - )} + ) : ( + + {t('metrics.chart-no-data')} + + ))} + {viewMode == 'graph' && + (chartData.length ? ( + <> + + + Stack bars: + setGraphIsStacked(!graphIsStacked)} + /> + + + ) : ( + + {t('metrics.chart-no-data')} + + ))} ); diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx index cd013b4be..e7187e896 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -10,6 +10,10 @@ import { DayOfMetricData, MetricsData } from './metricsTypes'; import { getUniqueLabelsForDays } from './metricsHelper'; ChartJS.register(ArcElement, Tooltip, Legend); +/** + * @description Calculates the percentage of 'responded' values across days of 'response_count' data. + * @returns Percentage as a whole number (0-100), or null if no data. + */ function getResponsePctForDays(days: DayOfMetricData[]) { const surveys = getUniqueLabelsForDays(days); let acc = { responded: 0, not_responded: 0 }; @@ -19,7 +23,9 @@ function getResponsePctForDays(days: DayOfMetricData[]) { acc.not_responded += day[`survey_${survey}`]?.not_responded || 0; }); }); - return Math.round((acc.responded / (acc.responded + acc.not_responded)) * 100); + const total = acc.responded + acc.not_responded; + if (total === 0) return null; + return Math.round((acc.responded / total) * 100); } type Props = { @@ -82,7 +88,7 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { ) : ( )} - {rate}% + {rate === null ? t('metrics.no-data') : rate + '%'} { style={cardStyles.title(colors)} /> - (l === 'Response' ? colors.navy : colors.orange)} - getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)} - showLegend={false} - reverse={false} - /> - + {records.length ? ( + <> + (l === 'Response' ? colors.navy : colors.orange)} + getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)} + showLegend={false} + reverse={false} + /> + + + ) : ( + + {t('metrics.chart-no-data')} + + )} ); diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index b22085907..4201f993e 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -76,11 +76,9 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} From 7e1c53641533b4d41bf1f97a1d19642351969103 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 May 2024 01:59:01 -0400 Subject: [PATCH 27/56] use e-mission-common @ 0.5.0 This version has the updated metrics structure which all the recent commits use --- 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 c1317a782..389c9369a 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.4.4", + "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.5.0", "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 b610d6121..e6eee89d3 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": "git+https://github.com/JGreenlee/e-mission-common.git#0.4.4", + "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.5.0", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", From 7510bf11900266f2c21c6a2fc3ff3125e12f5139 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 23 May 2024 11:16:33 -0400 Subject: [PATCH 28/56] use semver for e-mission-common dependency By including "semver" (without ^ or ~), when the version is bumped, node will conisider the previous version invalid, and it will retrieve the new version. Without "semver", I found that node would not update the package unless I manually cleared out package-lock.json and node_modules/e-mission-common --- 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 389c9369a..1ffff8660 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.5.0", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.0", "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 e6eee89d3..abc9f4a85 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": "git+https://github.com/JGreenlee/e-mission-common.git#0.5.0", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.0", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", From f28bc236746df15bf9f8fb119ce4801fc77fc1ce Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 23 May 2024 13:11:22 -0400 Subject: [PATCH 29/56] fix filters issue on 'additions' configs We skip filters if places are shown in the timeline, so filterInputs stays as the initial value, which is []. So when we checked if filterInputs has length (line 64), this was always false on configs where places are shown. thus, displayedEntries never got set. We had no way to distinguish between "haven't done filters initialization yet" and "did filters initialization and there are none". So I changed the initial value to null. If filterInputs is null, it's awaiting init. If it's [], it's been initialized but there are no filters; we should proceed with displayedEntries. --- www/js/diary/LabelTab.tsx | 12 +++++++----- www/js/diary/list/FilterSelect.tsx | 7 +++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index ef8507559..b368adc0c 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -21,7 +21,7 @@ import { AppContext } from '../App'; type LabelContextProps = { displayedEntries: TimelineEntry[] | null; - filterInputs: LabelTabFilter[]; + filterInputs: LabelTabFilter[] | null; setFilterInputs: (filters: LabelTabFilter[]) => void; }; export const LabelTabContext = createContext({} as LabelContextProps); @@ -31,13 +31,15 @@ const LabelTab = () => { const { pipelineRange, timelineMap, timelineLabelMap } = useContext(TimelineContext); const [filterRefreshTs, setFilterRefreshTs] = useState(0); // used to force a refresh of the filters - const [filterInputs, setFilterInputs] = useState([]); + const [filterInputs, setFilterInputs] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); useEffect(() => { - // we will show filters if 'additions' are not configured + // if places are shown, we will skip filters and it will just be "show all" // https://github.com/e-mission/e-mission-docs/issues/894 - if (appConfig.survey_info?.buttons == undefined) { + if (appConfig.survey_info?.buttons?.['place-notes']) { + setFilterInputs([]); + } else { // initalize filters const tripFilters = appConfig.survey_info?.['trip-labels'] == 'ENKETO' @@ -61,7 +63,7 @@ const LabelTab = () => { }, [timelineMap]); useEffect(() => { - if (!timelineMap || !timelineLabelMap || !filterInputs.length) return; + if (!timelineMap || !timelineLabelMap || !filterInputs) return; logDebug('Applying filters'); const allEntries: TimelineEntry[] = Array.from(timelineMap.values()); const activeFilter = filterInputs?.find((f) => f.state == true); diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx index 2bad0c7cb..c9d23d602 100644 --- a/www/js/diary/list/FilterSelect.tsx +++ b/www/js/diary/list/FilterSelect.tsx @@ -15,7 +15,7 @@ import { NavBarButton } from '../../components/NavBar'; import { LabelTabFilter } from '../../TimelineContext'; type Props = { - filters: LabelTabFilter[]; + filters: LabelTabFilter[] | null; setFilters: (filters: LabelTabFilter[]) => void; numListDisplayed?: number; numListTotal?: number; @@ -32,6 +32,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P }, [filters, numListDisplayed, numListTotal]); function chooseFilter(filterKey) { + if (!filters) return; if (filterKey == 'show-all') { setFilters(filters.map((f) => ({ ...f, state: false }))); } else { @@ -62,9 +63,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P {/* {t('diary.filter-travel')} */} chooseFilter(k)} value={selectedFilter}> - {filters.map((f) => ( - - ))} + {filters?.map((f) => )} Date: Thu, 23 May 2024 13:16:47 -0400 Subject: [PATCH 30/56] on config refresh, wait for resources to finish caching When downloading a config, we look for resources referenced by URL and cache them. By default we don't 'await' this - we just let it happen. This works well for the onboarding flow where the config is loaded and we can fetch resouces to cache in the background while the user is proceeding through onboarding. But when refreshing a config, the app gets reloaded after the new config is loaded. So if we hadn't finished caching resources when the reload was triggered, those resources didn't get cached. It's not the end of the world, but the first time the user accesses that resource it will be slower. So now, before reloading we will wait for a promise which tracks whether all of the resources have been cached. The onboarding flow stays the same because it does doesn't wait for that promise. --- www/js/config/dynamicConfig.ts | 7 +++++-- www/js/control/ProfileSettings.tsx | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 5843af3d2..f92239170 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -81,6 +81,7 @@ function _fillSurveyInfo(config: Partial): AppConfig { const _backwardsCompatFill = (config: Partial): AppConfig => _fillSurveyInfo(_fillStudyName(config)); +export let _cacheResourcesFetchPromise: Promise<(string | undefined)[]> = Promise.resolve([]); /** * @description Fetch and cache any surveys resources that are referenced by URL in the config, * as well as the label_options config if it is present. @@ -89,15 +90,17 @@ const _backwardsCompatFill = (config: Partial): AppConfig => * @param config The app config */ function cacheResourcesFromConfig(config: AppConfig) { + const fetchPromises: Promise[] = []; if (config.survey_info?.surveys) { Object.values(config.survey_info.surveys).forEach((survey) => { if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); - fetchUrlCached(survey['formPath'], { cache: 'reload' }); + fetchPromises.push(fetchUrlCached(survey['formPath'], { cache: 'reload' })); }); } if (config.label_options) { - fetchUrlCached(config.label_options, { cache: 'reload' }); + fetchPromises.push(fetchUrlCached(config.label_options, { cache: 'reload' })); } + _cacheResourcesFetchPromise = Promise.all(fetchPromises); } /** diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index ab381e594..794a37bcb 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -26,7 +26,11 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; -import { loadNewConfig, resetDataAndRefresh } from '../config/dynamicConfig'; +import { + _cacheResourcesFetchPromise, + loadNewConfig, + resetDataAndRefresh, +} from '../config/dynamicConfig'; import { AppContext } from '../App'; import { shareQR } from '../components/QrCode'; import { storageClear } from '../plugin/storage'; @@ -311,7 +315,10 @@ const ProfileSettings = () => { AlertManager.addMessage({ text: t('control.refreshing-app-config') }); const updated = await loadNewConfig(authSettings.opcode, appConfig?.version); if (updated) { - window.location.reload(); + // wait for resources to finish downloading before reloading + _cacheResourcesFetchPromise + .then(() => window.location.reload()) + .catch((error) => displayError(error, 'Failed to download a resource')); } else { AlertManager.addMessage({ text: t('control.already-up-to-date') }); } From 0642f06ca03c21f3f80c047a4d4cd1018cf26112 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 23 May 2024 13:19:17 -0400 Subject: [PATCH 31/56] remove response_count from default metric_list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Showing response_count by mode_confirm doesn't really make sense – it's effectively the same as "trip count". So the default metric_list will just do distance, duration, and count; by mode_confirm. --- www/js/metrics/MetricsTab.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 0ab3104c4..a2606fd6f 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -43,7 +43,6 @@ export const DEFAULT_METRIC_LIST: MetricList = { distance: ['mode_confirm'], duration: ['mode_confirm'], count: ['mode_confirm'], - response_count: ['mode_confirm'], }; async function fetchMetricsFromServer( @@ -98,7 +97,7 @@ const MetricsTab = () => { console.debug('MetricsTab: computed userMetrics', result); logDebug('MetricsTab: computed userMetrics' + JSON.stringify(result)); return result; - }, [timelineMap]); + }, [appConfig, timelineMap, timelineLabelMap]); // at least N_DAYS_TO_LOAD of timeline data should be loaded for the user metrics useEffect(() => { From e4ba6d010433cc0284ff7995565531e6bff9b3f0 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 23 May 2024 13:23:27 -0400 Subject: [PATCH 32/56] fix metrics values summing The goal here is to sum values if they are numbers (as with distance, duration, count) and sum objects' properties if values are objects. But I forgot to consider the case where val is null or undefined, in which case isNaN(val) would be true. Better to check if value is a number by !isNaN(val), then check if it's an object. Otherwise do nothing because it's null or undefined. --- www/js/metrics/MetricsCard.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 1d0652d9e..32beab937 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -89,17 +89,18 @@ const MetricsCard = ({ uniqueLabels.forEach((label) => { const sum: any = metricDataDays.reduce((acc, day) => { const val = valueForFieldOnDay(day, groupingFields[0], label); - // if val is object, add its values to the accumulator's values - if (isNaN(val)) { + // if val is number, add it to the accumulator + if (!isNaN(val)) { + return acc + val; + } else if (val && typeof val == 'object') { + // if val is object, add its values to the accumulator's values const newAcc = {}; for (let key in val) { newAcc[key] = (acc[key] || 0) + val[key]; } return newAcc; - } else { - // if val is number, add it to the accumulator - if (typeof val == 'number') return acc + val; } + return acc; }, 0); vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; }); From 6ff253f93e3dd64511c55fd926dc6de3cb737bb9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 23 May 2024 23:11:17 -0400 Subject: [PATCH 33/56] refactor metrics "units" & "format" fns to support response_count charts MetricsCard had been receiving these 2 props: axisUnits and unitFormatFn. The value of the metric was always a number, so we could just use that for the chart, and for "display" we'd just append the axisUnits as a suffix to the number value. With response_count, the value of the metric is an object which has counts for "responded" and "not_responded". For "display", we want the # responded / total responses, e.g. "3/5 responses". But for the chart we need a single number so it will be the # responded (e.g. 3) So to deal with units we basically need 3 things: a "convert" fn, a "display" fn, and the units suffix (for the axis of the chart). I call these "unit utils" and I put them in metricsHelper to keep MetricsTab and MetricsCard from being too cluttered. --- www/js/config/useImperialConfig.ts | 4 +++ www/js/metrics/MetricsCard.tsx | 22 +++++++++------- www/js/metrics/MetricsTab.tsx | 13 --------- www/js/metrics/metricsHelper.ts | 42 ++++++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index aa87ed1c6..feb2bb114 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -5,6 +5,8 @@ import i18next from 'i18next'; export type ImperialConfig = { distanceSuffix: string; speedSuffix: string; + convertDistance: (d: number) => number; + convertSpeed: (s: number) => number; getFormattedDistance: (d: number) => string; getFormattedSpeed: (s: number) => string; }; @@ -50,6 +52,8 @@ export function useImperialConfig(): ImperialConfig { 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)), diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 32beab937..cbd2a86b3 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -10,22 +10,22 @@ import { tsForDayOfMetricData, getUniqueLabelsForDays, valueForFieldOnDay, + getUnitUtilsForMetric, } from './metricsHelper'; import ToggleSwitch from '../components/ToggleSwitch'; import { cardStyles } from './MetricsTab'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import { getBaseModeByKey, getBaseModeByText, modeColors } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; -import { GroupingField } from '../types/appConfigTypes'; +import { GroupingField, MetricName } from '../types/appConfigTypes'; +import { useImperialConfig } from '../config/useImperialConfig'; type Props = { - metricName: string; + metricName: MetricName; groupingFields: GroupingField[]; cardTitle: string; userMetricsDays?: DayOfMetricData[]; aggMetricsDays?: DayOfMetricData[]; - axisUnits: string; - unitFormatFn?: (val: number) => string | number; }; const MetricsCard = ({ metricName, @@ -33,11 +33,10 @@ const MetricsCard = ({ cardTitle, userMetricsDays, aggMetricsDays, - axisUnits, - unitFormatFn, }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); + const imperialConfig = useImperialConfig(); const [viewMode, setViewMode] = useState<'details' | 'graph'>('details'); const [populationMode, setPopulationMode] = useState<'user' | 'aggregate'>('user'); const [graphIsStacked, setGraphIsStacked] = useState(true); @@ -46,6 +45,11 @@ const MetricsCard = ({ [populationMode, userMetricsDays, aggMetricsDays], ); + const [axisUnits, unitConvertFn, unitDisplayFn] = useMemo( + () => getUnitUtilsForMetric(metricName, imperialConfig), + [metricName], + ); + // for each label on each day, create a record for the chart const chartData = useMemo(() => { if (!metricDataDays || viewMode != 'graph') return []; @@ -57,7 +61,7 @@ const MetricsCard = ({ if (rawVal) { records.push({ label: labelKeyToRichMode(label), - x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, + x: unitConvertFn(rawVal), y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart }); } @@ -102,7 +106,7 @@ const MetricsCard = ({ } return acc; }, 0); - vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; + vals[label] = unitDisplayFn(sum); }); return vals; }, [metricDataDays, viewMode]); @@ -156,7 +160,7 @@ const MetricsCard = ({ {Object.keys(metricSumValues).map((label, i) => ( {labelKeyToRichMode(label)} - {metricSumValues[label] + ' ' + axisUnits} + {metricSumValues[label]} ))} diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index a2606fd6f..82a130c99 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -69,8 +69,6 @@ const MetricsTab = () => { const appConfig = useAppConfig(); const { colors } = useTheme(); const { t } = useTranslation(); - const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = - useImperialConfig(); const { dateRange, setDateRange, @@ -202,15 +200,6 @@ const MetricsTab = () => { {Object.entries(metricList).map( ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { - const units: { [k: string]: [string, (any) => string] } = { - distance: [distanceSuffix, getFormattedDistance], - duration: [t('metrics.hours'), secondsToHours], - count: [t('metrics.trips'), formatForDisplay], - response_count: [ - t('metrics.responses'), - (e) => `${e.responded}/${e.responded || 0 + e.not_responded || 0}`, - ], - }; return ( { cardTitle={t(`main-metrics.${metricName}`)} userMetricsDays={userMetrics?.[metricName]} aggMetricsDays={aggMetrics?.[metricName]} - axisUnits={units[metricName][0]} - unitFormatFn={units[metricName][1]} /> ); }, diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index d7c72c939..01e46106a 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,9 +1,10 @@ import { DateTime } from 'luxon'; -import { formatForDisplay } from '../config/useImperialConfig'; import { DayOfMetricData } from './metricsTypes'; import { logDebug } from '../plugin/logger'; import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; -import { groupingFields } from '../types/appConfigTypes'; +import { MetricName, groupingFields } from '../types/appConfigTypes'; +import { ImperialConfig, formatForDisplay } from '../config/useImperialConfig'; +import i18next from 'i18next'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; @@ -39,9 +40,8 @@ export const getLabelsForDay = (metricDataDay: DayOfMetricData) => return acc; }, [] as string[]); -export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds / 60); - -export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600); +export const secondsToMinutes = (seconds: number) => seconds / 60; +export const secondsToHours = (seconds: number) => seconds / 3600; // segments metricsDays into weeks, with the most recent week first export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { @@ -234,3 +234,35 @@ function isAllCustom(isSensedKeys, isCustomKeys) { // "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]; +export function getUnitUtilsForMetric( + metricName: MetricName, + imperialConfig: ImperialConfig, +): UnitUtils { + const fns: { [k in MetricName]: UnitUtils } = { + distance: [ + imperialConfig.distanceSuffix, + (x) => imperialConfig.convertDistance(x), + (x) => imperialConfig.getFormattedDistance(x) + ' ' + imperialConfig.distanceSuffix, + ], + duration: [ + i18next.t('metrics.hours'), + (v) => secondsToHours(v), + (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.hours'), + ], + count: [i18next.t('metrics.trips'), (v) => v, (v) => v + ' ' + i18next.t('metrics.trips')], + response_count: [ + i18next.t('metrics.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 fns[metricName]; +} From 34a99da7479d841594987617d1c5ca5013b87340 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 23 May 2024 23:18:34 -0400 Subject: [PATCH 34/56] fix incorrect summing of response_count objs Given a batch of days where val is of the form `{responded: 2, not_responded: 3}`, we should expect the `sum` to be an object of the same form where responded and not_responded have been accumulated. This was not happening because the accumulator in the reduce was getting reset. --- www/js/metrics/MetricsCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index cbd2a86b3..287193711 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -91,18 +91,18 @@ const MetricsCard = ({ // for each label, sum up cumulative values across all days const vals = {}; uniqueLabels.forEach((label) => { - const sum: any = metricDataDays.reduce((acc, day) => { + const sum: any = metricDataDays.reduce((acc, day) => { const val = valueForFieldOnDay(day, groupingFields[0], label); // if val is number, add it to the accumulator if (!isNaN(val)) { return acc + val; } else if (val && typeof val == 'object') { // if val is object, add its values to the accumulator's values - const newAcc = {}; + acc = acc || {}; for (let key in val) { - newAcc[key] = (acc[key] || 0) + val[key]; + acc[key] = (acc[key] || 0) + val[key]; } - return newAcc; + return acc; } return acc; }, 0); From f71280f89eaf19aad6f224cf181dceb580940476 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 00:15:34 -0400 Subject: [PATCH 35/56] unify datepickers, loadSpecificWeek -> loadDateRange; simplify We are unifying the datepickers of the label tab and dashboard tab to both be "range" datepickers (https://github.com/e-mission/e-mission-phone/pull/1138/files#r1606146462) So there is no need for a function like "loadSpecificWeek" that takes one day as input. I refactored this into "loadDateRange" which accepts a start and end date, and clamps it within [pipeline start, today]. Then I realized loadMoreDays could be simplified and it can just call loadDateRange once it determines what the range should be. 'setDateRange' no longer needs to be exposed because 'loadDateRange' fulfills that purpose and also handles clamping --- www/js/TimelineContext.ts | 57 ++++++++++----------------- www/js/diary/list/LabelListScreen.tsx | 20 ++++------ www/js/metrics/MetricsTab.tsx | 4 +- 3 files changed, 30 insertions(+), 51 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index aa12dacac..2f18fa908 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -44,10 +44,9 @@ type ContextProps = { pipelineRange: TimestampRange | null; queriedDateRange: [string, string] | null; // YYYY-MM-DD format dateRange: [string, string]; // YYYY-MM-DD format - setDateRange: (d: [string, string]) => void; timelineIsLoading: string | false; loadMoreDays: (when: 'past' | 'future', nDays: number) => void; - loadSpecificWeek: (d: string) => void; + loadDateRange: (d: [string, string]) => void; refreshTimeline: () => void; shouldUpdateTimeline: Boolean; setShouldUpdateTimeline: React.Dispatch>; @@ -168,42 +167,27 @@ export const useTimelineContext = (): ContextProps => { function loadMoreDays(when: 'past' | 'future', nDays: number) { const existingRange = queriedDateRange || initialQueryRange; logDebug(`Timeline: loadMoreDays, ${nDays} days into the ${when}; - queriedDateRange = ${queriedDateRange}; - existingRange = ${existingRange}`); - let newDateRange: [string, string]; - if (when == 'past') { - newDateRange = [isoDateWithOffset(existingRange[0], -nDays), existingRange[1]]; - } else { - newDateRange = [existingRange[0], isoDateWithOffset(existingRange[1], nDays)]; - } - logDebug('Timeline: loadMoreDays setting new date range = ' + newDateRange); - setDateRange(newDateRange); + queriedDateRange = ${queriedDateRange}; existingRange = ${existingRange}`); + loadDateRange( + when == 'past' + ? [isoDateWithOffset(existingRange[0], -nDays), existingRange[1]] + : [existingRange[0], isoDateWithOffset(existingRange[1], nDays)], + ); } - function loadSpecificWeek(date: string) { - logDebug('Timeline: loadSpecificWeek for date ' + date); - if (!pipelineRange) return logWarn('No pipelineRange yet - early return from loadSpecificWeek'); - let newStartDate = isoDateWithOffset(date, -3); // three days before - let newEndDate = isoDateWithOffset(date, 3); // three days after - - const pipelineStart = DateTime.fromSeconds(pipelineRange.start_ts).toISODate(); + function loadDateRange(range: [string, string]) { + logDebug('Timeline: loadDateRange with newDateRange = ' + range); + if (!pipelineRange) return logWarn('No pipelineRange yet - early return from loadDateRange'); + const pipelineStartDate = DateTime.fromSeconds(pipelineRange.start_ts).toISODate(); const todayDate = DateTime.now().toISODate(); - - const wentBeforePipeline = newStartDate.replace(/-/g, '') < pipelineStart.replace(/-/g, ''); - const wentAfterToday = newEndDate.replace(/-/g, '') > todayDate.replace(/-/g, ''); - - if (wentBeforePipeline && wentAfterToday) { - newStartDate = pipelineStart; - newEndDate = todayDate; - } else if (wentBeforePipeline) { - newStartDate = pipelineStart; - newEndDate = isoDateWithOffset(pipelineStart, 6); - } else if (wentAfterToday) { - newStartDate = isoDateWithOffset(todayDate, -6); - newEndDate = todayDate; - } - logDebug('Timeline: loadSpecificWeek setting new date range = ' + [newStartDate, newEndDate]); - setDateRange([newStartDate, newEndDate]); + // clamp range to ensure it is within [pipelineStartDate, todayDate] + const clampedDateRange: [string, string] = [ + new Date(range[0]) < new Date(pipelineStartDate) ? pipelineStartDate : range[0], + new Date(range[1]) > new Date(todayDate) ? todayDate : range[1], + ]; + logDebug('Timeline: loadDateRange setting new date range = ' + clampedDateRange); + setTimelineIsLoading('queued'); + setDateRange(clampedDateRange); } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { @@ -335,13 +319,12 @@ export const useTimelineContext = (): ContextProps => { pipelineRange, queriedDateRange, dateRange, - setDateRange, timelineMap, timelineIsLoading, timelineLabelMap, labelOptions, loadMoreDays, - loadSpecificWeek, + loadDateRange, refreshTimeline, userInputFor, labelFor, diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index af27bfe00..6905d3fd5 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -12,13 +12,8 @@ import { displayErrorMsg } from '../../plugin/logger'; const LabelListScreen = () => { const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); - const { - timelineMap, - loadSpecificWeek, - timelineIsLoading, - refreshTimeline, - shouldUpdateTimeline, - } = useContext(TimelineContext); + const { timelineMap, loadDateRange, timelineIsLoading, refreshTimeline, shouldUpdateTimeline } = + useContext(TimelineContext); const { colors } = useTheme(); return ( @@ -31,11 +26,12 @@ const LabelListScreen = () => { numListTotal={timelineMap?.size} /> { - const d = DateTime.fromJSDate(date).toISODate(); - if (!d) return displayErrorMsg('Invalid date'); - loadSpecificWeek(d); + mode="range" + onChoose={({ startDate, endDate }) => { + const start = DateTime.fromJSDate(startDate).toISODate(); + const end = DateTime.fromJSDate(endDate).toISODate(); + if (!start || !end) return displayErrorMsg('Invalid date'); + loadDateRange([start, end]); }} /> { const { t } = useTranslation(); const { dateRange, - setDateRange, timelineMap, timelineLabelMap, timelineIsLoading, refreshTimeline, loadMoreDays, + loadDateRange, } = useContext(TimelineContext); const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST; @@ -177,7 +177,7 @@ const MetricsTab = () => { const start = DateTime.fromJSDate(startDate).toISODate(); const end = DateTime.fromJSDate(endDate).toISODate(); if (!start || !end) return displayErrorMsg('Invalid date'); - setDateRange([start, end]); + loadDateRange([start, end]); }} /> From 637d496fbcbdc9efeeea4bdfadb16a8fa8a2260d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 00:42:11 -0400 Subject: [PATCH 36/56] refactor & simplify MetricsTab Now that user metrics are computed on the phone, agg metrics are computed on the server, and they both use a common format, MetricsTab can be simplified a lot. At the top, outside of the component definition let's define 2 async functions, computeUserMetrics and fetchAggMetrics. Obviously they differ in implementation but we can treat them the same now. Now let's condense all the useEffects and useMemos into one useEffect - if the timeline is in a loading state, pass - elif the timeline date range is < 14 days, call loadMoreDays, then pass - else, call computeUserMetrics and fetchAggMetrics sequentially; and when they finish, setUserMetrics and setAggMetrics. We no longer need any of the other stuff about distinguishing 'user' or 'aggregate' population, or parsing metrics after they are received. One thing that is new is `aggMetricsIsLoading`. The NavBar has a progress bar to help demonstrate when data is being lazy-loaded. It will now be active when either timelineIsLoading or aggMetricsIsLoading is truthy. --- www/js/metrics/MetricsTab.tsx | 135 +++++++++++++--------------------- 1 file changed, 53 insertions(+), 82 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index c52b0feb1..1bc2e7986 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -15,7 +15,7 @@ import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../services/commHelper'; -import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; import { AppConfig, @@ -25,7 +25,7 @@ import { MetricsUiSection, } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; -import TimelineContext from '../TimelineContext'; +import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; import { metrics_summaries } from 'e-mission-common'; import SurveyLeaderboardCard from './SurveyLeaderboardCard'; @@ -45,24 +45,49 @@ export const DEFAULT_METRIC_LIST: MetricList = { count: ['mode_confirm'], }; -async function fetchMetricsFromServer( - type: 'user' | 'aggregate', - dateRange: [string, string], +async function computeUserMetrics( metricList: MetricList, + timelineMap: TimelineMap, + timelineLabelMap: TimelineLabelMap | null, appConfig: AppConfig, ) { - const [startTs, endTs] = isoDateRangeToTsRange(dateRange); - logDebug('MetricsTab: fetching metrics from server for ts range ' + startTs + ' to ' + endTs); + try { + const timelineValues = [...timelineMap.values()]; + const result = metrics_summaries.generate_summaries( + { ...metricList }, + timelineValues, + appConfig, + timelineLabelMap, + ); + logDebug('MetricsTab: computed userMetrics'); + console.debug('MetricsTab: computed userMetrics', result); + return result as MetricsData; + } catch (e) { + displayError(e, 'Error computing user metrics'); + } +} + +async function fetchAggMetrics( + metricList: MetricList, + dateRange: [string, string], + appConfig: AppConfig, +) { + logDebug('MetricsTab: fetching agg metrics from server for dateRange ' + dateRange); const query = { freq: 'D', start_time: dateRange[0], end_time: dateRange[1], metric_list: metricList, - is_return_aggregate: type == 'aggregate', + is_return_aggregate: true, app_config: { survey_info: appConfig.survey_info }, }; - if (type == 'user') return getMetrics('timestamp', query); - return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server); + return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server) + .then((response) => { + logDebug('MetricsTab: received aggMetrics'); + console.debug('MetricsTab: received aggMetrics', response); + return response as MetricsData; + }) + .catch((e) => displayError(e, 'Error fetching aggregate metrics')); } const MetricsTab = () => { @@ -81,85 +106,31 @@ const MetricsTab = () => { const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST; + const [userMetrics, setUserMetrics] = useState(undefined); const [aggMetrics, setAggMetrics] = useState(undefined); - // user metrics are computed on the phone from the timeline data - const userMetrics = useMemo(() => { - if (!timelineMap) return; - const timelineValues = [...timelineMap.values()]; - const result = metrics_summaries.generate_summaries( - { ...metricList }, - timelineValues, - appConfig, - timelineLabelMap, - ) as MetricsData; - console.debug('MetricsTab: computed userMetrics', result); - logDebug('MetricsTab: computed userMetrics' + JSON.stringify(result)); - return result; - }, [appConfig, timelineMap, timelineLabelMap]); - - // at least N_DAYS_TO_LOAD of timeline data should be loaded for the user metrics - useEffect(() => { - if (!appConfig) return; - const dateRangeDays = isoDatesDifference(...dateRange); - - // this tab uses the last N_DAYS_TO_LOAD of data; if we need more, we should fetch it - if (dateRangeDays < N_DAYS_TO_LOAD) { - if (timelineIsLoading) { - logDebug('MetricsTab: timeline is still loading, not loading more days yet'); - } else { - logDebug('MetricsTab: loading more days'); - loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); - } - } else { - logDebug(`MetricsTab: date range >= ${N_DAYS_TO_LOAD} days, not loading more days`); - } - }, [dateRange, timelineIsLoading, appConfig]); + const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false); - // aggregate metrics fetched from the server whenever the date range is set useEffect(() => { if (!appConfig) return; - logDebug('MetricsTab: dateRange updated to ' + JSON.stringify(dateRange)); const dateRangeDays = isoDatesDifference(...dateRange); - if (dateRangeDays < N_DAYS_TO_LOAD) { - logDebug( - `MetricsTab: date range < ${N_DAYS_TO_LOAD} days, not loading aggregate metrics yet`, - ); + if (timelineIsLoading) { + logDebug('MetricsTab: timeline is still loading, skipping'); + } else if (dateRangeDays < N_DAYS_TO_LOAD) { + logDebug('MetricsTab: loading more days'); + loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); } else { - loadMetricsForPopulation('aggregate', dateRange, appConfig); - } - }, [dateRange, appConfig]); - - async function loadMetricsForPopulation( - population: 'user' | 'aggregate', - dateRange: [string, string], - appConfig: AppConfig, - ) { - try { - logDebug(`MetricsTab: fetching metrics for population ${population}' - in date range ${JSON.stringify(dateRange)}`); - const serverResponse: any = await fetchMetricsFromServer( - population, - dateRange, - metricList, - appConfig, + if (!timelineMap) return; + logDebug(`MetricsTab: date range >= ${N_DAYS_TO_LOAD} days, computing metrics`); + computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) => + setUserMetrics(result), ); - logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); - // const metrics = {}; - // const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; - // METRIC_LIST.forEach((metricName, i) => { - // metrics[metricName] = serverResponse[dataKey][i]; - // }); - // logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics)); - // if (population == 'user') { - // // setUserMetrics(metrics as MetricsData); - // } else { - console.debug('MetricsTab: aggMetrics', serverResponse); - setAggMetrics(serverResponse as MetricsData); - // } - } catch (e) { - logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr + setAggMetricsIsLoading(true); + fetchAggMetrics(metricList, dateRange, appConfig).then((response) => { + setAggMetricsIsLoading(false); + setAggMetrics(response); + }); } - } + }, [appConfig, dateRange, timelineIsLoading, timelineMap, timelineLabelMap]); const sectionsToShow = appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; @@ -169,7 +140,7 @@ const MetricsTab = () => { return ( <> - + Date: Fri, 24 May 2024 00:49:11 -0400 Subject: [PATCH 37/56] update metricsTypes Using the '/metrics/yyyy_mm_dd' endpoint on the server, there is no longer a discrepancy between server metric data and client metric data. Also, we can support a variety of GroupingFields now, instead of just 'mode', and the metric values can be objects instead of just numbers. --- www/js/metrics/metricsTypes.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index eb70e7b22..e2fc4d5df 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,21 +1,18 @@ -import { LocalDt } from '../types/serverData'; -import { MetricName } from '../types/appConfigTypes'; +import { GroupingField, MetricName } from '../types/appConfigTypes'; -type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything -export type DayOfServerMetricData = LabelProps & { - ts: number; - fmt_time: string; - nUsers: number; - local_dt: LocalDt; -}; - -type ModeProps = { [k in `mode_${string}`]?: number }; // mode_, where could be anything -export type DayOfClientMetricData = ModeProps & { +// 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 = number | { responded?: number; not_responded?: number }; +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; }; -export type DayOfMetricData = DayOfClientMetricData | DayOfServerMetricData; - export type MetricsData = { [key in MetricName]: DayOfMetricData[]; }; From 6ef2adf30caecdc249187ac02d2ec358facd8486 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 01:29:36 -0400 Subject: [PATCH 38/56] remove dateForDayOfMetricData function No longer needed since server + client types are unified. 'date' will always be present in DayOfMetricData objects. --- www/js/metrics/CarbonFootprintCard.tsx | 6 +----- www/js/metrics/CarbonTextCard.tsx | 6 +----- www/js/metrics/metricsHelper.ts | 24 ++++++++++-------------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 4c0c7b789..9624e10df 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -16,7 +16,6 @@ import { segmentDaysByWeeks, isCustomLabels, MetricsSummary, - dateForDayOfMetricData, } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; @@ -50,10 +49,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //formatted data from last week, if exists (14 days ago -> 8 days ago) let userLastWeekModeMap = {}; let userLastWeekSummaryMap = {}; - if ( - lastWeekDistance && - isoDatesDifference(dateRange[0], dateForDayOfMetricData(lastWeekDistance[0])) >= 0 - ) { + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); } diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index 2707bf4f9..ca9f50fdc 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -16,7 +16,6 @@ import { calculatePercentChange, segmentDaysByWeeks, MetricsSummary, - dateForDayOfMetricData, } from './metricsHelper'; import { logDebug, logWarn } from '../plugin/logger'; import TimelineContext from '../TimelineContext'; @@ -44,10 +43,7 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { //formatted data from last week, if exists (14 days ago -> 8 days ago) let userLastWeekModeMap = {}; let userLastWeekSummaryMap = {}; - if ( - lastWeekDistance && - isoDatesDifference(dateRange[0], dateForDayOfMetricData(lastWeekDistance[0])) >= 0 - ) { + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); } diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 01e46106a..5d9a9fe96 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -48,9 +48,8 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { const weeks: DayOfMetricData[][] = [[]]; let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); for (let i = days.length - 1; i >= 0; i--) { - const date = dateForDayOfMetricData(days[i]); // if date is older than cutoff, start a new week - if (isoDatesDifference(date, cutoff) > 0) { + if (isoDatesDifference(days[i].date, cutoff) > 0) { weeks.push([]); cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); } @@ -60,18 +59,14 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { } export function formatDate(day: DayOfMetricData) { - const dt = DateTime.fromISO(dateForDayOfMetricData(day), { zone: 'utc' }); + const dt = DateTime.fromISO(day.date, { zone: 'utc' }); return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); } export function formatDateRangeOfDays(days: DayOfMetricData[]) { if (!days?.length) return ''; - const firstDayDt = DateTime.fromISO(dateForDayOfMetricData(days[0]), { - zone: 'utc', - }); - const lastDayDt = DateTime.fromISO(dateForDayOfMetricData(days[days.length - 1]), { - zone: 'utc', - }); + 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}`; @@ -161,11 +156,12 @@ export function parseDataFromMetrics(metrics, population) { return Object.entries(mode_bins).map(([key, values]) => ({ key, values })); } -export const dateForDayOfMetricData = (day: DayOfMetricData) => - 'date' in day ? day.date : day.fmt_time.substring(0, 10); - -export const tsForDayOfMetricData = (day: DayOfMetricData) => - DateTime.fromISO(dateForDayOfMetricData(day)).toSeconds(); +const _datesTsCache = {}; +export const tsForDayOfMetricData = (day: DayOfMetricData) => { + if (_datesTsCache[day.date] == undefined) + _datesTsCache[day.date] = DateTime.fromISO(day.date).toSeconds(); + return _datesTsCache[day.date]; +}; export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) => day[`${field}_${key}`]; From bbfbfa190be5705a3b0d8e682d624dd4718511e6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 01:32:00 -0400 Subject: [PATCH 39/56] have MetricValue support type generics to disambiguate number vs object --- www/js/metrics/SurveyComparisonCard.tsx | 2 +- www/js/metrics/SurveyTripCategoriesCard.tsx | 5 ++++- www/js/metrics/metricsTypes.ts | 11 +++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx index e7187e896..723d4e3dc 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -14,7 +14,7 @@ ChartJS.register(ArcElement, Tooltip, Legend); * @description Calculates the percentage of 'responded' values across days of 'response_count' data. * @returns Percentage as a whole number (0-100), or null if no data. */ -function getResponsePctForDays(days: DayOfMetricData[]) { +function getResponsePctForDays(days: DayOfMetricData<'response_count'>[]) { const surveys = getUniqueLabelsForDays(days); let acc = { responded: 0, not_responded: 0 }; days.forEach((day) => { diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index 3ecc13ca3..45e9f9286 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -9,7 +9,10 @@ import { DayOfMetricData, MetricsData } from './metricsTypes'; import { GroupingField } from '../types/appConfigTypes'; import { getUniqueLabelsForDays } from './metricsHelper'; -function sumResponseCountsForValue(days: DayOfMetricData[], value: `${GroupingField}_${string}`) { +function sumResponseCountsForValue( + days: DayOfMetricData<'response_count'>[], + value: `${GroupingField}_${string}`, +) { const acc = { responded: 0, not_responded: 0 }; days.forEach((day) => { acc.responded += day[value]?.responded || 0; diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index e2fc4d5df..d6105c30a 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -2,17 +2,20 @@ import { GroupingField, MetricName } from '../types/appConfigTypes'; // 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 = number | { responded?: number; not_responded?: number }; -export type DayOfMetricData = { +type MetricValue = T extends 'response_count' + ? { responded?: number; not_responded?: number } + : number; + +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; + [k in `${GroupingField}_${string}`]: MetricValue; }; export type MetricsData = { - [key in MetricName]: DayOfMetricData[]; + [key in MetricName]: DayOfMetricData[]; }; From 96549b3c30b4b0fd3662a906f0dec426ea27f871 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 01:32:30 -0400 Subject: [PATCH 40/56] use e-mission-common @ 0.5.1 --- 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 4c307961b..23d2d38ad 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.0", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.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 abc9f4a85..ff9bf5879 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.0", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", From 114d15ed5634d9395cb28eed0c7873a8ced27eba Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 10:24:20 -0400 Subject: [PATCH 41/56] improve MetricsTab loading Breaks the useEffect into 3 smaller chunks. `readyToLoad` handles the fetching of more days if < 14. Also added a return type to `loadMoreDays` to handle the case where we don't have 14 days, but we can't go back any further; `loadMoreDays` will return false and `readyToLoad` will become true. Then 2 separate useEffects for `userMetrics` and `aggMetrics`. `userMetrics` depend on readyToLoad, appConfig, timelineIsLoading, timelineMap, and timlineLabelMap, and compute if timelineIsLoading is falsy and all the rest are truthy. `aggMetrics` depend on readyToLoad, appConfig, and dateRange and get fetched if readyToLoad and appConfig are defined. The result of this is that `aggMetrics` do not have to wait for the timeline to finish loading; fetching aggMetrics begins once the dateRange is determined to be adequate. Also, `aggMetrics` will not be unnecessarily recomputed if `timelineLabelMap` changes (ie the user labels a trip) --- www/js/TimelineContext.ts | 23 +++++++++++----- www/js/metrics/MetricsTab.tsx | 52 +++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 2f18fa908..e263ca0bb 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -45,8 +45,8 @@ type ContextProps = { queriedDateRange: [string, string] | null; // YYYY-MM-DD format dateRange: [string, string]; // YYYY-MM-DD format timelineIsLoading: string | false; - loadMoreDays: (when: 'past' | 'future', nDays: number) => void; - loadDateRange: (d: [string, string]) => void; + loadMoreDays: (when: 'past' | 'future', nDays: number) => boolean | void; + loadDateRange: (d: [string, string]) => boolean | void; refreshTimeline: () => void; shouldUpdateTimeline: Boolean; setShouldUpdateTimeline: React.Dispatch>; @@ -168,7 +168,7 @@ export const useTimelineContext = (): ContextProps => { const existingRange = queriedDateRange || initialQueryRange; logDebug(`Timeline: loadMoreDays, ${nDays} days into the ${when}; queriedDateRange = ${queriedDateRange}; existingRange = ${existingRange}`); - loadDateRange( + return loadDateRange( when == 'past' ? [isoDateWithOffset(existingRange[0], -nDays), existingRange[1]] : [existingRange[0], isoDateWithOffset(existingRange[1], nDays)], @@ -177,7 +177,10 @@ export const useTimelineContext = (): ContextProps => { function loadDateRange(range: [string, string]) { logDebug('Timeline: loadDateRange with newDateRange = ' + range); - if (!pipelineRange) return logWarn('No pipelineRange yet - early return from loadDateRange'); + if (!pipelineRange) { + logWarn('No pipelineRange yet - early return from loadDateRange'); + return; + } const pipelineStartDate = DateTime.fromSeconds(pipelineRange.start_ts).toISODate(); const todayDate = DateTime.now().toISODate(); // clamp range to ensure it is within [pipelineStartDate, todayDate] @@ -185,9 +188,15 @@ export const useTimelineContext = (): ContextProps => { new Date(range[0]) < new Date(pipelineStartDate) ? pipelineStartDate : range[0], new Date(range[1]) > new Date(todayDate) ? todayDate : range[1], ]; - logDebug('Timeline: loadDateRange setting new date range = ' + clampedDateRange); - setTimelineIsLoading('queued'); - setDateRange(clampedDateRange); + if (clampedDateRange[0] != dateRange[0] || clampedDateRange[1] != dateRange[1]) { + logDebug('Timeline: loadDateRange setting new date range = ' + clampedDateRange); + setTimelineIsLoading('queued'); + setDateRange(clampedDateRange); + return true; + } else { + logDebug('Timeline: loadDateRange no change in date range'); + return false; + } } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 1bc2e7986..bf1c7ec40 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -87,7 +87,10 @@ async function fetchAggMetrics( console.debug('MetricsTab: received aggMetrics', response); return response as MetricsData; }) - .catch((e) => displayError(e, 'Error fetching aggregate metrics')); + .catch((e) => { + displayError(e, 'Error fetching aggregate metrics'); + return undefined; + }); } const MetricsTab = () => { @@ -110,27 +113,36 @@ const MetricsTab = () => { const [aggMetrics, setAggMetrics] = useState(undefined); const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false); - useEffect(() => { - if (!appConfig) return; + const readyToLoad = useMemo(() => { + if (!appConfig) return false; const dateRangeDays = isoDatesDifference(...dateRange); - if (timelineIsLoading) { - logDebug('MetricsTab: timeline is still loading, skipping'); - } else if (dateRangeDays < N_DAYS_TO_LOAD) { - logDebug('MetricsTab: loading more days'); - loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); - } else { - if (!timelineMap) return; - logDebug(`MetricsTab: date range >= ${N_DAYS_TO_LOAD} days, computing metrics`); - computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) => - setUserMetrics(result), - ); - setAggMetricsIsLoading(true); - fetchAggMetrics(metricList, dateRange, appConfig).then((response) => { - setAggMetricsIsLoading(false); - setAggMetrics(response); - }); + 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'); } - }, [appConfig, dateRange, timelineIsLoading, timelineMap, timelineLabelMap]); + return true; + }, [appConfig, dateRange]); + + useEffect(() => { + if (!readyToLoad || !appConfig || timelineIsLoading || !timelineMap || !timelineLabelMap) + return; + logDebug('MetricsTab: ready to compute userMetrics'); + computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) => + setUserMetrics(result), + ); + }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]); + + useEffect(() => { + if (!readyToLoad || !appConfig) return; + logDebug('MetricsTab: ready to fetch aggMetrics'); + setAggMetricsIsLoading(true); + fetchAggMetrics(metricList, dateRange, appConfig).then((response) => { + setAggMetricsIsLoading(false); + setAggMetrics(response); + }); + }, [readyToLoad, appConfig, dateRange]); const sectionsToShow = appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; From cbefbbdcc9b23263cd61759d4563ba316d80cf6a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 10:26:30 -0400 Subject: [PATCH 42/56] remove unused code from MetricsTab --- www/js/metrics/MetricsTab.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index bf1c7ec40..6393dc518 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,21 +1,19 @@ import React, { useEffect, useState, useMemo, useContext } from 'react'; -import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { ScrollView, useWindowDimensions } from 'react-native'; import { Appbar, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; import NavBar from '../components/NavBar'; import { MetricsData } from './metricsTypes'; import MetricsCard from './MetricsCard'; -import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig'; import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; -import { secondsToHours, secondsToMinutes } from './metricsHelper'; import CarbonFootprintCard from './CarbonFootprintCard'; import Carousel from '../components/Carousel'; import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; -import { getAggregateData, getMetrics } from '../services/commHelper'; -import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { getAggregateData } from '../services/commHelper'; +import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; import { AppConfig, @@ -26,7 +24,7 @@ import { } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext'; -import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; +import { isoDatesDifference } from '../diary/timelineHelper'; import { metrics_summaries } from 'e-mission-common'; import SurveyLeaderboardCard from './SurveyLeaderboardCard'; import SurveyTripCategoriesCard from './SurveyTripCategoriesCard'; @@ -95,7 +93,6 @@ async function fetchAggMetrics( const MetricsTab = () => { const appConfig = useAppConfig(); - const { colors } = useTheme(); const { t } = useTranslation(); const { dateRange, From c8039734f115dc9310d127e657e4b88c286dd3d9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 10:53:21 -0400 Subject: [PATCH 43/56] humanize all caps values for Metrics Tab Due to recent metrics restructuring, base modes can be passed through here --- www/js/survey/multilabel/confirmHelper.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index bc3a2b717..79695918a 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -111,6 +111,10 @@ export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails) as Mu /** @description replace all underscores with spaces, and capitalizes the first letter of each word */ export function labelKeyToReadable(otherValue: string) { + if (otherValue == otherValue.toUpperCase()) { + // if all caps, make lowercase + otherValue = otherValue.toLowerCase(); + } const words = otherValue.replace(/_/g, ' ').trim().split(' '); if (words.length == 0) return ''; return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(' '); From b3e80dfc5fb55773ffbeb37fb894a61fa341a79c Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 11:37:31 -0400 Subject: [PATCH 44/56] fix date range picker timezone issue `new Date(isoString)` assumes that `isoString` is in UTC. We wanted to parse it in the local timezone, which is what luxon does here. --- www/js/diary/list/DateSelect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index df3bbbd66..849f23188 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -39,7 +39,7 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { }, [pipelineRange]); const queriedRangeAsJsDates = useMemo( - () => queriedDateRange?.map((d) => new Date(d)), + () => queriedDateRange?.map((d) => DateTime.fromISO(d).toJSDate()), [queriedDateRange], ); From 663f86cbf586bcc8002d3badc3b62cf52bdc0e29 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 24 May 2024 11:47:21 -0400 Subject: [PATCH 45/56] update metricsHelper.test.ts to use the unified DayOfMetricData type There is no discrepancy between DayOfClientMetricData and DayOfServerMetricData anymore, so this test file gets stripped down a lot --- www/__tests__/metricsHelper.test.ts | 74 +++++------------------------ 1 file changed, 11 insertions(+), 63 deletions(-) diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index 075a9000f..07fe64736 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -6,41 +6,25 @@ import { getUniqueLabelsForDays, segmentDaysByWeeks, } from '../js/metrics/metricsHelper'; -import { - DayOfClientMetricData, - DayOfMetricData, - DayOfServerMetricData, -} from '../js/metrics/metricsTypes'; +import { DayOfMetricData } from '../js/metrics/metricsTypes'; describe('metricsHelper', () => { describe('getUniqueLabelsForDays', () => { const days1 = [ - { label_a: 1, label_b: 2 }, - { label_c: 1, label_d: 3 }, - ] as any as DayOfServerMetricData[]; - it("should return unique labels for days with 'label_*'", () => { + { mode_confirm_a: 1, mode_confirm_b: 2 }, + { mode_confirm_b: 1, mode_confirm_c: 3 }, + { mode_confirm_c: 1, mode_confirm_d: 3 }, + ] as any as DayOfMetricData[]; + it("should return unique labels for days with 'mode_confirm_*'", () => { expect(getUniqueLabelsForDays(days1)).toEqual(['a', 'b', 'c', 'd']); }); - - const days2 = [ - { mode_a: 1, mode_b: 2 }, - { mode_c: 1, mode_d: 3 }, - ] as any as DayOfClientMetricData[]; - it("should return unique labels for days with 'mode_*'", () => { - expect(getUniqueLabelsForDays(days2)).toEqual(['a', 'b', 'c', 'd']); - }); }); describe('getLabelsForDay', () => { - const day1 = { label_a: 1, label_b: 2 } as any as DayOfServerMetricData; - it("should return labels for a day with 'label_*'", () => { + const day1 = { mode_confirm_a: 1, mode_confirm_b: 2 } as any as DayOfMetricData; + it("should return labels for a day with 'mode_confirm_*'", () => { expect(getLabelsForDay(day1)).toEqual(['a', 'b']); }); - - const day2 = { mode_a: 1, mode_b: 2 } as any as DayOfClientMetricData; - it("should return labels for a day with 'mode_*'", () => { - expect(getLabelsForDay(day2)).toEqual(['a', 'b']); - }); }); // secondsToMinutes @@ -55,7 +39,7 @@ describe('metricsHelper', () => { { date: '2021-01-08' }, { date: '2021-01-09' }, { date: '2021-01-10' }, - ] as any as DayOfClientMetricData[]; + ] as any as DayOfMetricData[]; it("should segment days with 'date' into weeks", () => { expect(segmentDaysByWeeks(days1, '2021-01-10')).toEqual([ @@ -70,40 +54,13 @@ describe('metricsHelper', () => { [{ date: '2021-01-01' }, { date: '2021-01-02' }], ]); }); - - const days2 = [ - { fmt_time: '2021-01-01T00:00:00Z' }, - { fmt_time: '2021-01-02T00:00:00Z' }, - { fmt_time: '2021-01-04T00:00:00Z' }, - { fmt_time: '2021-01-08T00:00:00Z' }, - { fmt_time: '2021-01-09T00:00:00Z' }, - { fmt_time: '2021-01-10T00:00:00Z' }, - ] as any as DayOfServerMetricData[]; - it("should segment days with 'fmt_time' into weeks", () => { - expect(segmentDaysByWeeks(days2, '2021-01-10')).toEqual([ - // most recent week - [ - { fmt_time: '2021-01-04T00:00:00Z' }, - { fmt_time: '2021-01-08T00:00:00Z' }, - { fmt_time: '2021-01-09T00:00:00Z' }, - { fmt_time: '2021-01-10T00:00:00Z' }, - ], - // prior week - [{ fmt_time: '2021-01-01T00:00:00Z' }, { fmt_time: '2021-01-02T00:00:00Z' }], - ]); - }); }); describe('formatDate', () => { - const day1 = { date: '2021-01-01' } as any as DayOfClientMetricData; + const day1 = { date: '2021-01-01' } as any as DayOfMetricData; it('should format date', () => { expect(formatDate(day1)).toEqual('1/1'); }); - - const day2 = { fmt_time: '2021-01-01T00:00:00Z' } as any as DayOfServerMetricData; - it('should format date', () => { - expect(formatDate(day2)).toEqual('1/1'); - }); }); describe('formatDateRangeOfDays', () => { @@ -111,18 +68,9 @@ describe('metricsHelper', () => { { date: '2021-01-01' }, { date: '2021-01-02' }, { date: '2021-01-04' }, - ] as any as DayOfClientMetricData[]; + ] as any as DayOfMetricData[]; it('should format date range for days with date', () => { expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4'); }); - - const days2 = [ - { fmt_time: '2021-01-01T00:00:00Z' }, - { fmt_time: '2021-01-02T00:00:00Z' }, - { fmt_time: '2021-01-04T00:00:00Z' }, - ] as any as DayOfServerMetricData[]; - it('should format date range for days with fmt_time', () => { - expect(formatDateRangeOfDays(days2)).toEqual('1/1 - 1/4'); - }); }); }); From 90b631cf19c34ee79a966b56b6f82e7f9427b801 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 25 May 2024 00:06:17 -0700 Subject: [PATCH 46/56] Hide chart when there is no survey data --- www/js/metrics/SurveyComparisonCard.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx index 723d4e3dc..fcbce61ff 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; -import { View, Text } from 'react-native'; -import { Icon, Card } from 'react-native-paper'; +import { View } from 'react-native'; +import { Icon, Card, Text } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../appTheme'; import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; @@ -121,14 +121,20 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { style={cardStyles.title(colors)} /> - - {t('main-metrics.survey-response-rate')} - - {renderDoughnutChart(myResponsePct, colors.navy, true)} - {renderDoughnutChart(othersResponsePct, colors.orange, false)} + {myResponsePct ? ( + + {t('main-metrics.survey-response-rate')} + + {renderDoughnutChart(myResponsePct, colors.navy, true)} + {renderDoughnutChart(othersResponsePct, colors.orange, false)} + + - - + ) : ( + + {t('metrics.chart-no-data')} + + )} ); From a3570d62e5a2b47c152f3cf71e80b676f80cddb6 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 25 May 2024 00:19:37 -0700 Subject: [PATCH 47/56] type error handling --- www/js/metrics/SurveyComparisonCard.tsx | 4 ++-- www/js/metrics/SurveyTripCategoriesCard.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx index fcbce61ff..9b1f31816 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -29,8 +29,8 @@ function getResponsePctForDays(days: DayOfMetricData<'response_count'>[]) { } type Props = { - userMetrics: MetricsData; - aggMetrics: MetricsData; + userMetrics: MetricsData | undefined; + aggMetrics: MetricsData | undefined; }; export type SurveyComparison = { diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index 45e9f9286..7c6e6e464 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -28,8 +28,8 @@ type SurveyTripRecord = { }; type Props = { - userMetrics: MetricsData; - aggMetrics: MetricsData; + userMetrics: MetricsData | undefined; + aggMetrics: MetricsData | undefined; }; const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useAppTheme(); From 084224e1ce2afffd16e172fc627ea0f28e472279 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 25 May 2024 10:32:24 -0700 Subject: [PATCH 48/56] fix checking data logic for surveyComparisonCard --- www/js/metrics/SurveyComparisonCard.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx index 9b1f31816..a99a604eb 100644 --- a/www/js/metrics/SurveyComparisonCard.tsx +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -121,7 +121,11 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { style={cardStyles.title(colors)} /> - {myResponsePct ? ( + {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? ( + + {t('metrics.chart-no-data')} + + ) : ( {t('main-metrics.survey-response-rate')} @@ -130,10 +134,6 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { - ) : ( - - {t('metrics.chart-no-data')} - )} From 8cf5fb07a6173abc68295962b93aecc2456ee54f Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Sat, 25 May 2024 12:01:58 -0700 Subject: [PATCH 49/56] Add maxBarThickness for chart.js --- www/js/components/Chart.tsx | 3 +++ www/js/metrics/SurveyTripCategoriesCard.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index f86c352c0..e86ef794b 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -34,6 +34,7 @@ export type Props = { showLegend?: boolean; reverse?: boolean; enableTooltip?: boolean; + maxBarThickness?: number; }; const Chart = ({ records, @@ -49,6 +50,7 @@ const Chart = ({ showLegend = true, reverse = true, enableTooltip = true, + maxBarThickness = 100, }: Props) => { const { colors } = useTheme(); const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); @@ -74,6 +76,7 @@ const Chart = ({ getColorForChartEl?.(chartRef.current, e, barCtx, 'border'), borderWidth: borderWidth || 2, borderRadius: 3, + maxBarThickness: maxBarThickness, })), }; }, [chartDatasets, getColorForLabel]); diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx index 7c6e6e464..77df43abf 100644 --- a/www/js/metrics/SurveyTripCategoriesCard.tsx +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -73,6 +73,7 @@ const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)} showLegend={false} reverse={false} + maxBarThickness={60} /> From f55faf1fc460d2551d18ed6b751b1d6dd1a717d7 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 28 May 2024 15:15:15 -0700 Subject: [PATCH 50/56] add more unit tests --- www/__tests__/metricsHelper.test.ts | 167 +++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index 07fe64736..a6afc7d63 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -1,10 +1,20 @@ +import { DateTime } from 'luxon'; import { calculatePercentChange, formatDate, formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays, + secondsToHours, + secondsToMinutes, segmentDaysByWeeks, + metricToValue, + tsForDayOfMetricData, + valueForFieldOnDay, + generateSummaryFromData, + isCustomLabels, + isAllCustom, + isOnFoot, } from '../js/metrics/metricsHelper'; import { DayOfMetricData } from '../js/metrics/metricsTypes'; @@ -27,9 +37,17 @@ describe('metricsHelper', () => { }); }); - // secondsToMinutes + describe('secondsToMinutes', () => { + it("should convert from seconds to minutes properly", () => { + expect(secondsToMinutes(360)).toEqual(6); + }); + }); - // secondsToHours + describe('secondsToHours', () => { + it("should convert from seconds to hours properly", () => { + expect(secondsToHours(3600)).toEqual(1); + }); + }); describe('segmentDaysByWeeks', () => { const days1 = [ @@ -73,4 +91,149 @@ describe('metricsHelper', () => { expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4'); }); }); + + 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 }; + const previousWeekRange = { low: 5, high: 10 }; + const result = calculatePercentChange(pastWeekRange, previousWeekRange); + expect(result.low).toBe(100); + expect(result.high).toBe(200); + }); + }); + + describe('tsForDayOfMetricData', () => { + const mockDay = { + date: '2024-05-28T12:00:00Z', + nUsers: 10, + }; + let _datesTsCache; + beforeEach(() => { + _datesTsCache = {}; + }); + + it('calculates timestamp for a given day', () => { + const expectedTimestamp = DateTime.fromISO(mockDay.date).toSeconds(); + const result = tsForDayOfMetricData(mockDay); + expect(result).toBe(expectedTimestamp); + }); + + it('caches the timestamp for subsequent calls with the same day', () => { + const firstResult = tsForDayOfMetricData(mockDay); + const secondResult = tsForDayOfMetricData(mockDay); + expect(secondResult).toBe(firstResult); + }); + }); + + describe('valueForFieldOnDay', () => { + const mockDay = { + date: '2024-05-28T12:00:00Z', + nUsers: 10, + field_key: 'example_value' + }; + + it('returns the value for a specified field and key', () => { + const result = valueForFieldOnDay(mockDay, 'field', 'key'); + expect(result).toBe('example_value'); + }); + }); + + 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', () => { + const modeMap = [ + { key: 'label_mode1', values: [['value1', 10], ['value2', 20]] }, + { key: 'label_mode2', values: [['value3', 30], ['value4', 40]] }, + ]; + + it('returns true for all custom labels', () => { + const result = isCustomLabels(modeMap); + expect(result).toBe(true); + }); + + it('returns true for all sensed labels', () => { + const result = isCustomLabels(modeMap); + expect(result).toBe(true); + }); + + it('returns false for mixed custom and sensed labels', () => { + 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); + }); + }); }); From abf2f3f8347968ee9a489872acc2a613a5ab7a4d Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 28 May 2024 15:15:34 -0700 Subject: [PATCH 51/56] export functions for tests and fix syntax issue --- www/js/metrics/metricsHelper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 5d9a9fe96..65337690b 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -75,7 +75,7 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { /* formatting data form carbon footprint calculations */ //modes considered on foot for carbon calculation, expandable as needed -const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; +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 @@ -83,13 +83,13 @@ const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; * for regular data (user-specific), this will return the field value * for avg data (aggregate), this will return the field value/nUsers */ -const metricToValue = (population: 'user' | 'aggregate', metric, field) => +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 -function isOnFoot(mode: string) { - for (let ped_mode in ON_FOOT_MODES) { +export function isOnFoot(mode: string) { + for (let ped_mode of ON_FOOT_MODES) { if (mode === ped_mode) { return true; } @@ -215,7 +215,7 @@ export function isCustomLabels(modeMap) { return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); } -function isAllCustom(isSensedKeys, isCustomKeys) { +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); From 66464ca2121842faad52d79fac090cc800d61746 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Wed, 29 May 2024 03:05:42 -0700 Subject: [PATCH 52/56] done unit tests with metricsHelper --- www/__tests__/metricsHelper.test.ts | 163 ++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 32 deletions(-) diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index a6afc7d63..c914c5782 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -15,8 +15,11 @@ import { isCustomLabels, isAllCustom, isOnFoot, + getUnitUtilsForMetric, } from '../js/metrics/metricsHelper'; import { DayOfMetricData } from '../js/metrics/metricsTypes'; +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; describe('metricsHelper', () => { describe('getUniqueLabelsForDays', () => { @@ -38,13 +41,13 @@ describe('metricsHelper', () => { }); describe('secondsToMinutes', () => { - it("should convert from seconds to minutes properly", () => { + it('should convert from seconds to minutes properly', () => { expect(secondsToMinutes(360)).toEqual(6); }); }); describe('secondsToHours', () => { - it("should convert from seconds to hours properly", () => { + it('should convert from seconds to hours properly', () => { expect(secondsToHours(3600)).toEqual(1); }); }); @@ -99,12 +102,12 @@ describe('metricsHelper', () => { }; it('returns correct value for user population', () => { const result = metricToValue('user', metric, 'walking'); - expect(result).toBe(10); + expect(result).toBe(10); }); - + it('returns correct value for aggregate population', () => { const result = metricToValue('aggregate', metric, 'walking'); - expect(result).toBe(2); + expect(result).toBe(2); }); }); @@ -115,7 +118,7 @@ describe('metricsHelper', () => { }); it('returns false for non on foot mode', () => { - const result = isOnFoot('DRIVING'); + const result = isOnFoot('DRIVING'); expect(result).toBe(false); }); }); @@ -131,21 +134,21 @@ describe('metricsHelper', () => { }); describe('tsForDayOfMetricData', () => { - const mockDay = { + const mockDay = { date: '2024-05-28T12:00:00Z', - nUsers: 10, + nUsers: 10, }; let _datesTsCache; beforeEach(() => { _datesTsCache = {}; }); - + it('calculates timestamp for a given day', () => { const expectedTimestamp = DateTime.fromISO(mockDay.date).toSeconds(); const result = tsForDayOfMetricData(mockDay); expect(result).toBe(expectedTimestamp); }); - + it('caches the timestamp for subsequent calls with the same day', () => { const firstResult = tsForDayOfMetricData(mockDay); const secondResult = tsForDayOfMetricData(mockDay); @@ -157,9 +160,9 @@ describe('metricsHelper', () => { const mockDay = { date: '2024-05-28T12:00:00Z', nUsers: 10, - field_key: 'example_value' + field_key: 'example_value', }; - + it('returns the value for a specified field and key', () => { const result = valueForFieldOnDay(mockDay, 'field', 'key'); expect(result).toBe('example_value'); @@ -168,24 +171,36 @@ describe('metricsHelper', () => { describe('generateSummaryFromData', () => { const modeMap = [ - { key: 'mode1', values: [['value1', 10], ['value2', 20]] }, - { key: 'mode2', values: [['value3', 30], ['value4', 40]] }, - ]; + { + 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 }, + { 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 }, + { key: 'mode1', values: 15 }, + { key: 'mode2', values: 35 }, ]; const result = generateSummaryFromData(modeMap, metric); expect(result).toEqual(expectedResult); @@ -193,22 +208,65 @@ describe('metricsHelper', () => { }); describe('isCustomLabels', () => { - const modeMap = [ - { key: 'label_mode1', values: [['value1', 10], ['value2', 20]] }, - { key: 'label_mode2', values: [['value3', 30], ['value4', 40]] }, - ]; - 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); }); @@ -216,24 +274,65 @@ describe('metricsHelper', () => { describe('isAllCustom', () => { it('returns true when all keys are custom', () => { - const isSensedKeys = [false, false, false]; + 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 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 isSensedKeys = [true, false, true]; const isCustomKeys = [false, true, false]; const result = isAllCustom(isSensedKeys, isCustomKeys); expect(result).toBe(undefined); }); }); + + describe('getUnitUtilsForMetric', () => { + const imperialConfig = { + distanceSuffix: 'mi', + speedSuffix: 'mph', + convertDistance: jest.fn((d) => d), + convertSpeed: jest.fn((s) => s), + getFormattedDistance: jest.fn((d) => `${d} mi`), + getFormattedSpeed: jest.fn((s) => `${s} mph`), + }; + + it('checks for distance metric', () => { + const result = getUnitUtilsForMetric('distance', imperialConfig); + expect(result).toEqual(['mi', expect.any(Function), expect.any(Function)]); + expect(result[1](1)).toBe(1); + expect(result[2](1)).toBe('1 mi mi'); + }); + + it('checks for duration metric', () => { + const result = getUnitUtilsForMetric('duration', imperialConfig); + expect(result).toEqual(['hours', expect.any(Function), expect.any(Function)]); + expect(result[1](3600)).toBe(1); + expect(result[2](3600)).toBe('1 hours'); + }); + + it('checks for count metric', () => { + const result = getUnitUtilsForMetric('count', imperialConfig); + expect(result).toEqual(['trips', expect.any(Function), expect.any(Function)]); + const mockTrip = { responded: 4, not_responded: 3 }; + expect(result[1](mockTrip)).toBe(mockTrip); + expect(result[2](mockTrip)).toBe(mockTrip + ' trips'); + }); + + it('checks for response_count metric', () => { + const result = getUnitUtilsForMetric('response_count', imperialConfig); + expect(result).toEqual(['responses', expect.any(Function), expect.any(Function)]); + const mockResponse = { responded: 5, not_responded: 2 }; + expect(result[1](mockResponse)).toBe(5); + expect(result[2](mockResponse)).toBe('5/7 responses'); + }); + }); }); From a963381e37653f1e1fe729bcaffb63757aa56b13 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Wed, 29 May 2024 04:42:48 -0700 Subject: [PATCH 53/56] add useImperialConfig test --- www/__tests__/useImperialConfig.test.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index 593498aae..33c354271 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,14 +1,22 @@ -import { convertDistance, convertSpeed, formatForDisplay } from '../js/config/useImperialConfig'; +import React from 'react'; +import { + convertDistance, + convertSpeed, + formatForDisplay, + 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(() => ({ - appConfig: { + display_config: { use_imperial: false, }, loading: false, })); }); +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', () => { @@ -53,3 +61,15 @@ describe('convertSpeed', () => { expect(convertSpeed(6.7056, true)).toBeCloseTo(15); // Approximately 15 mph }); }); + +describe('useImperialConfig', () => { + it('returns ImperialConfig with imperial units', () => { + const imperialConfig = useImperialConfig(); + expect(imperialConfig.distanceSuffix).toBe('km'); + expect(imperialConfig.speedSuffix).toBe('kmph'); + expect(imperialConfig.convertDistance(10)).toBe(0.01); + expect(imperialConfig.convertSpeed(20)).toBe(72); + expect(imperialConfig.getFormattedDistance(10)).toBe('0.01'); + expect(imperialConfig.getFormattedSpeed(20)).toBe('72'); + }); +}); From 3f08b6c4994dd15cf1f62de96695466a2c0010bb Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Wed, 29 May 2024 05:06:00 -0700 Subject: [PATCH 54/56] add getTheme unit test --- www/__tests__/appTheme.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 www/__tests__/appTheme.test.ts diff --git a/www/__tests__/appTheme.test.ts b/www/__tests__/appTheme.test.ts new file mode 100644 index 000000000..9ec3e0fdf --- /dev/null +++ b/www/__tests__/appTheme.test.ts @@ -0,0 +1,22 @@ +import { getTheme } from '../js/appTheme'; + +describe('getTheme', () => { + it('should return the right theme with place', () => { + const theme = getTheme('place'); + expect(theme.colors.elevation.level1).toEqual('#cbe6ff'); + }); + + it('should return the right theme with untracked', () => { + const theme = getTheme('untracked'); + expect(theme.colors.primary).toEqual('#8c4a57'); + expect(theme.colors.primaryContainer).toEqual('#e3bdc2'); + expect(theme.colors.elevation.level1).toEqual('#f8ebec'); + }); + + it('should return the right theme with draft', () => { + const theme = getTheme('draft'); + expect(theme.colors.primary).toEqual('#616971'); + expect(theme.colors.primaryContainer).toEqual('#b6bcc2'); + expect(theme.colors.background).toEqual('#eef1f4'); + }); +}); From 1bd1f48fcf342e61fc3e756f180dac5fa0e543fd Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Wed, 29 May 2024 05:14:40 -0700 Subject: [PATCH 55/56] add unit test if Carousel renders children correctly --- www/__tests__/Carousel.test.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 www/__tests__/Carousel.test.tsx diff --git a/www/__tests__/Carousel.test.tsx b/www/__tests__/Carousel.test.tsx new file mode 100644 index 000000000..7b8601109 --- /dev/null +++ b/www/__tests__/Carousel.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { View } from 'react-native'; +import Carousel from '../js/components/Carousel'; + +describe('Carousel component', () => { + const child1 = Child 1; + const child2 = Child 2; + const cardWidth = 100; + const cardMargin = 10; + + it('renders children correctly', () => { + const { getByTestId } = render( + + {child1} + {child2} + , + ); + + const renderedChild1 = getByTestId('child1'); + const renderedChild2 = getByTestId('child2'); + + expect(renderedChild1).toBeTruthy(); + expect(renderedChild2).toBeTruthy(); + }); +}); From e55e5ae7c194f21b02cef130ad2a126a27b128b4 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Wed, 29 May 2024 20:13:30 -0700 Subject: [PATCH 56/56] add DateSelect rendering unit test --- www/__tests__/DateSelect.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 www/__tests__/DateSelect.test.tsx diff --git a/www/__tests__/DateSelect.test.tsx b/www/__tests__/DateSelect.test.tsx new file mode 100644 index 000000000..79fdc1997 --- /dev/null +++ b/www/__tests__/DateSelect.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import DateSelect from '../js/diary/list/DateSelect'; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 30, left: 0, right: 0, top: 30 }), +})); +jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); +jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); + +describe('DateSelect', () => { + it('renders correctly', () => { + const onChooseMock = jest.fn(); + const { getByText } = render(); + + expect(screen.getByTestId('button-container')).toBeTruthy(); + expect(screen.getByTestId('button')).toBeTruthy(); + }); +});