From f165a3fb7a6c5e5dbefb33129246b840bd450b2a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 25 Mar 2024 11:37:35 -0400 Subject: [PATCH] compute user metrics on phone using e-mission-common In MetricsTab, userMetrics will be populated not by a call to 'fetchMetricsFromServer', but by MetricsSummaries.generate_summaries from e-mission-common, which will compute user metrics from timeline data (ie timelineMap and timelineLabelMap) aggMetrics will still be the same as before, fetched from the server. There are a couple differences between metrics computed on e-misison-server and metrics computed on e-mission-common. (see metricsTypes.ts where DayOfMetricData can now be either DayOfClientMetricData or DayOfServerMetricData) To reconcile these differences, added helper functions `dateForDayOfMetricData`, `tsForDayOfMetricData`, and `valueForModeOnDay` --- www/js/metrics/ActiveMinutesTableCard.tsx | 10 +++++-- www/js/metrics/DailyActiveMinutesCard.tsx | 3 +- www/js/metrics/MetricsCard.tsx | 19 ++++++++---- www/js/metrics/MetricsTab.tsx | 17 +++++++++-- www/js/metrics/WeeklyActiveMinutesCard.tsx | 9 ++++-- www/js/metrics/metricsHelper.ts | 34 ++++++++++++++++------ www/js/metrics/metricsTypes.ts | 10 ++++++- 7 files changed, 78 insertions(+), 24 deletions(-) diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index 92a6ac768..51bdfb47f 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -7,6 +7,7 @@ import { formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks, + valueForModeOnDay, } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; @@ -21,7 +22,10 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { if (!userMetrics?.duration) return []; const totals = {}; ACTIVE_MODES.forEach((mode) => { - const sum = userMetrics.duration.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + const sum = userMetrics.duration.reduce( + (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), + 0, + ); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(userMetrics.duration); @@ -35,7 +39,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .map((week) => { const totals = {}; ACTIVE_MODES.forEach((mode) => { - const sum = week.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + const sum = week.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(week); @@ -49,7 +53,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .map((day) => { const totals = {}; ACTIVE_MODES.forEach((mode) => { - const sum = day[`label_${mode}`] || 0; + const sum = valueForModeOnDay(day, 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 c6ba7cbf0..558c3862c 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -7,6 +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 { valueForModeOnDay } from './metricsHelper'; const ACTIVE_MODES = ['walk', 'bike'] as const; type ActiveMode = (typeof ACTIVE_MODES)[number]; @@ -21,7 +22,7 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { const recentDays = userMetrics?.duration?.slice(-14); recentDays?.forEach((day) => { ACTIVE_MODES.forEach((mode) => { - const activeSeconds = day[`label_${mode}`]; + const activeSeconds = valueForModeOnDay(day, mode); if (activeSeconds) { records.push({ label: labelKeyToRichMode(mode), diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 6662762c2..9a13aacdc 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -4,11 +4,17 @@ import { Card, Checkbox, Text, useTheme } from 'react-native-paper'; import colorLib from 'color'; import BarChart from '../components/BarChart'; import { DayOfMetricData } from './metricsTypes'; -import { formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays } from './metricsHelper'; +import { + formatDateRangeOfDays, + getLabelsForDay, + tsForDayOfMetricData, + getUniqueLabelsForDays, + valueForModeOnDay, +} from './metricsHelper'; import ToggleSwitch from '../components/ToggleSwitch'; import { cardStyles } from './MetricsTab'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; -import { getBaseModeByKey, getBaseModeByText } from '../diary/diaryHelper'; +import { getBaseModeByKey, getBaseModeByText, modeColors } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; type Props = { @@ -42,12 +48,12 @@ const MetricsCard = ({ metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); labels.forEach((label) => { - const rawVal = day[`label_${label}`]; + const rawVal = valueForModeOnDay(day, label); if (rawVal) { records.push({ label: labelKeyToRichMode(label), x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, - y: day.ts * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart + y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart }); } }); @@ -76,7 +82,10 @@ const MetricsCard = ({ // for each label, sum up cumulative values across all days const vals = {}; uniqueLabels.forEach((label) => { - const sum = metricDataDays.reduce((acc, day) => acc + (day[`label_${label}`] || 0), 0); + const sum = metricDataDays.reduce( + (acc, day) => acc + (valueForModeOnDay(day, label) || 0), + 0, + ); vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; }); return vals; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 73d35c3ce..1e21145df 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,6 +21,7 @@ import { ServerConnConfig } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange } from '../diary/timelineHelper'; +import { MetricsSummaries } from '../../../../e-mission-common/js'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -54,17 +55,29 @@ const MetricsTab = () => { const { t } = useTranslation(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); - const { dateRange, setDateRange, refreshTimeline } = useContext(TimelineContext); + const { dateRange, setDateRange, timelineMap, timelineLabelMap, refreshTimeline } = + useContext(TimelineContext); const [aggMetrics, setAggMetrics] = useState(undefined); const [userMetrics, setUserMetrics] = useState(undefined); + // aggregate metrics are fetched from the server useEffect(() => { if (!appConfig?.server) return; - loadMetricsForPopulation('user', dateRange); loadMetricsForPopulation('aggregate', dateRange); }, [dateRange, appConfig?.server]); + // user metrics are computed on the phone from the timeline data + useEffect(() => { + if (!timelineMap) return; + const userMetrics = MetricsSummaries.generate_summaries( + METRIC_LIST, + [...timelineMap.values()], + timelineLabelMap, + ) as MetricsData; + setUserMetrics(userMetrics); + }, [timelineMap]); + async function loadMetricsForPopulation( population: 'user' | 'aggregate', dateRange: [string, string], diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index eb1a29939..cf105e019 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 } from './metricsHelper'; +import { formatDateRangeOfDays, segmentDaysByWeeks, valueForModeOnDay } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; @@ -22,13 +22,16 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { const records: { x: string; y: number; label: string }[] = []; const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, 2); ACTIVE_MODES.forEach((mode) => { - const prevSum = prevWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + const prevSum = prevWeek?.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0); if (prevSum) { // `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})` 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 + (day[`label_${mode}`] || 0), 0); + const recentSum = recentWeek?.reduce( + (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), + 0, + ); if (recentSum) { const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 }); diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index ca3846806..fbf989687 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -7,8 +7,9 @@ export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; metricDataDays.forEach((e) => { Object.keys(e).forEach((k) => { - if (k.startsWith('label_')) { - const label = k.substring(6); // remove 'label_' prefix leaving just the mode label + 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); } }); @@ -18,8 +19,9 @@ export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { export const getLabelsForDay = (metricDataDay: DayOfMetricData) => Object.keys(metricDataDay).reduce((acc, k) => { - if (k.startsWith('label_')) { - acc.push(k.substring(6)); // remove 'label_' prefix leaving just the mode label + if (k.startsWith('label_') || k.startsWith('mode_')) { + let i = k.indexOf('_'); + acc.push(k.substring(i + 1)); // remove prefix leaving just the mode label } return acc; }, [] as string[]); @@ -39,14 +41,18 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], nWeeks?: number) { } export function formatDate(day: DayOfMetricData) { - const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' }); + const dt = DateTime.fromISO(dateForDayOfMetricData(day), { zone: 'utc' }); return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); } export function formatDateRangeOfDays(days: DayOfMetricData[]) { if (!days?.length) return ''; - const firstDayDt = DateTime.fromISO(days[0].fmt_time, { zone: 'utc' }); - const lastDayDt = DateTime.fromISO(days[days.length - 1].fmt_time, { zone: 'utc' }); + const firstDayDt = DateTime.fromISO(dateForDayOfMetricData(days[0]), { + zone: 'utc', + }); + const lastDayDt = DateTime.fromISO(dateForDayOfMetricData(days[days.length - 1]), { + zone: 'utc', + }); const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); return `${firstDay} - ${lastDay}`; @@ -115,8 +121,9 @@ export function parseDataFromMetrics(metrics, population) { } } //this section handles user lables, assuming 'label_' prefix - if (field.startsWith('label_')) { - let actualMode = field.slice(6, field.length); //remove 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] = []; @@ -137,6 +144,15 @@ 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(); + +export const valueForModeOnDay = (day: DayOfMetricData, key: string) => + day[`mode_${key}`] || day[`label_${key}`]; + export type MetricsSummary = { key: string; values: number }; export function generateSummaryFromData(modeMap, metric) { logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`); diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index cce1cd243..826d7ec70 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -2,14 +2,22 @@ import { LocalDt } from '../types/serverData'; import { METRIC_LIST } from './MetricsTab'; type MetricName = (typeof METRIC_LIST)[number]; + type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything -export type DayOfMetricData = LabelProps & { +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 & { + date: string; // yyyy-mm-dd +}; + +export type DayOfMetricData = DayOfClientMetricData | DayOfServerMetricData; + export type MetricsData = { [key in MetricName]: DayOfMetricData[]; };