From 54e86e575036376c73caefb2bc4de077282410f7 Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Sat, 1 Feb 2025 17:49:53 +0000 Subject: [PATCH] feat(RunDistanceByMonthForPeriod): add new chart --- .../components/Nav/TrackedCategorySubList.tsx | 2 +- .../pages/Stats/SleepChartForWeek.tsx | 2 +- .../pages/Stats/SummaryForCalendarPeriod.tsx | 2 +- .../Stats/Year/MeditationByMonthForPeriod.tsx | 2 +- .../Stats/Year/PushUpsByMonthForPeriod.tsx | 8 +- .../Year/RunDistanceByMonthForPeriod.tsx | 68 ++++++ .../Stats/Year/SleepByMonthForPeriod.tsx | 2 +- .../src/components/pages/Stats/Year/index.tsx | 8 +- .../shared/StatsViewControls/index.tsx | 2 +- client/src/formatters/formatDistance.ts | 5 +- client/src/store/eventsSlice.ts | 204 +++++++----------- 11 files changed, 169 insertions(+), 136 deletions(-) create mode 100644 client/src/components/pages/Stats/Year/RunDistanceByMonthForPeriod.tsx diff --git a/client/src/components/Nav/TrackedCategorySubList.tsx b/client/src/components/Nav/TrackedCategorySubList.tsx index 61035bd8..45651956 100644 --- a/client/src/components/Nav/TrackedCategorySubList.tsx +++ b/client/src/components/Nav/TrackedCategorySubList.tsx @@ -19,7 +19,7 @@ export default function TrackedCategorySubList({ const isTrackingEnabled = eventTypeTracking[eventType]; - if (!isTrackingEnabled && !showLog) return null; + if (!isTrackingEnabled && !showLog) return; return ( minutesSlept === undefined)) return null; + if (data.every(({ minutesSlept }) => minutesSlept === undefined)) return; return ( diff --git a/client/src/components/pages/Stats/SummaryForCalendarPeriod.tsx b/client/src/components/pages/Stats/SummaryForCalendarPeriod.tsx index fe695345..29c7bc07 100644 --- a/client/src/components/pages/Stats/SummaryForCalendarPeriod.tsx +++ b/client/src/components/pages/Stats/SummaryForCalendarPeriod.tsx @@ -92,7 +92,7 @@ export default function SummaryForCalendarPeriod({ !secondsMeditatedInCurrentPeriod && !secondsMeditatedInPreviousPeriod ) - return null; + return; return ( diff --git a/client/src/components/pages/Stats/Year/MeditationByMonthForPeriod.tsx b/client/src/components/pages/Stats/Year/MeditationByMonthForPeriod.tsx index 365dbf19..34df10f5 100644 --- a/client/src/components/pages/Stats/Year/MeditationByMonthForPeriod.tsx +++ b/client/src/components/pages/Stats/Year/MeditationByMonthForPeriod.tsx @@ -29,7 +29,7 @@ export default function MeditationByMonthForPeriod({ ); const navigate = useNavigate(); - if (!hasMeditationsInPeriod) return null; + if (!hasMeditationsInPeriod) return; const months = eachMonthOfInterval({ start: dateFrom, end: dateTo }).slice( 0, diff --git a/client/src/components/pages/Stats/Year/PushUpsByMonthForPeriod.tsx b/client/src/components/pages/Stats/Year/PushUpsByMonthForPeriod.tsx index 5c8c49e6..b9b4a5c1 100644 --- a/client/src/components/pages/Stats/Year/PushUpsByMonthForPeriod.tsx +++ b/client/src/components/pages/Stats/Year/PushUpsByMonthForPeriod.tsx @@ -10,6 +10,7 @@ import { monthShortFormatter } from "../../../../formatters/dateTimeFormatters"; import { integerFormatter } from "../../../../formatters/numberFormatters"; import { useNavigate } from "react-router-dom"; import { useSelector } from "react-redux"; +import EventIcon from "../../../shared/EventIcon"; interface Props { dateFrom: Date; @@ -25,7 +26,7 @@ export default function PushUpsByMonthForPeriod({ dateFrom, dateTo }: Props) { ); const navigate = useNavigate(); - if (!hasPushUpsInPeriod) return null; + if (!hasPushUpsInPeriod) return; const months = eachMonthOfInterval({ start: dateFrom, end: dateTo }).slice( 0, @@ -34,7 +35,10 @@ export default function PushUpsByMonthForPeriod({ dateFrom, dateTo }: Props) { return ( -

Total push-ups by month

+

+ + Total push-ups by month +

{ diff --git a/client/src/components/pages/Stats/Year/RunDistanceByMonthForPeriod.tsx b/client/src/components/pages/Stats/Year/RunDistanceByMonthForPeriod.tsx new file mode 100644 index 00000000..5848f8c6 --- /dev/null +++ b/client/src/components/pages/Stats/Year/RunDistanceByMonthForPeriod.tsx @@ -0,0 +1,68 @@ +import { Chart, Paper } from "eri"; +import { + formatIsoDateInLocalTimezone, + formatIsoMonthInLocalTimezone, +} from "../../../../utils"; +import { RootState } from "../../../../store"; +import { eachMonthOfInterval } from "date-fns"; +import eventsSlice from "../../../../store/eventsSlice"; +import { monthShortFormatter } from "../../../../formatters/dateTimeFormatters"; +import { useNavigate } from "react-router-dom"; +import { useSelector } from "react-redux"; +import EventIcon from "../../../shared/EventIcon"; +import { formatMetersToOneNumberWithUnits } from "../../../../formatters/formatDistance"; + +interface Props { + dateFrom: Date; + dateTo: Date; +} + +export default function RunDistanceByMonthForPeriod({ + dateFrom, + dateTo, +}: Props) { + const hasRunDistanceInPeriod = useSelector((state: RootState) => + eventsSlice.selectors.hasRunDistanceInPeriod(state, dateFrom, dateTo), + ); + const noramlizedTotalRunDistanceByMonth = useSelector( + eventsSlice.selectors.normalizedTotalRunDistanceByMonth, + ); + const navigate = useNavigate(); + + if (!hasRunDistanceInPeriod) return; + + const months = eachMonthOfInterval({ start: dateFrom, end: dateTo }).slice( + 0, + -1, + ); + + return ( + +

+ + Total run distance by month +

+ { + const totalMeters = + noramlizedTotalRunDistanceByMonth.byId[ + formatIsoDateInLocalTimezone(month) + ] ?? 0; + const label = monthShortFormatter.format(month); + return { + key: label, + label: label, + onClick: () => + navigate(`/stats/months/${formatIsoMonthInLocalTimezone(month)}`), + title: formatMetersToOneNumberWithUnits(totalMeters), + y: totalMeters / 1e3, + }; + })} + rotateXLabels + xAxisTitle="Month" + yAxisTitle="Kilometers" + /> +
+ ); +} diff --git a/client/src/components/pages/Stats/Year/SleepByMonthForPeriod.tsx b/client/src/components/pages/Stats/Year/SleepByMonthForPeriod.tsx index 2cab53e8..87da880c 100644 --- a/client/src/components/pages/Stats/Year/SleepByMonthForPeriod.tsx +++ b/client/src/components/pages/Stats/Year/SleepByMonthForPeriod.tsx @@ -38,7 +38,7 @@ export default function SleepByMonthForPeriod({ dateFrom, dateTo }: Props) { }; }); - if (data.every(({ y }) => y === undefined)) return null; + if (data.every(({ y }) => y === undefined)) return; return (

Average sleep by month

diff --git a/client/src/components/pages/Stats/Year/index.tsx b/client/src/components/pages/Stats/Year/index.tsx index da4bb5cb..7db8c292 100644 --- a/client/src/components/pages/Stats/Year/index.tsx +++ b/client/src/components/pages/Stats/Year/index.tsx @@ -23,6 +23,7 @@ import WeightChartForPeriod from "../WeightChartForPeriod"; import { formatIsoYearInLocalTimezone } from "../../../../utils"; import withStatsPage from "../../../hocs/withStatsPage"; import PushUpsByMonthForPeriod from "./PushUpsByMonthForPeriod"; +import RunDistanceByMonthForPeriod from "./RunDistanceByMonthForPeriod"; interface Props { date: Date; @@ -41,7 +42,12 @@ function Year({ date, nextDate, prevDate, showNext, showPrevious }: Props) { let view: ReactElement; switch (activeView) { case "exercise": - view = ; + view = ( + <> + + + + ); break; case "location": view = ; diff --git a/client/src/components/shared/StatsViewControls/index.tsx b/client/src/components/shared/StatsViewControls/index.tsx index a05c503e..0bd68c4b 100644 --- a/client/src/components/shared/StatsViewControls/index.tsx +++ b/client/src/components/shared/StatsViewControls/index.tsx @@ -111,7 +111,7 @@ export default function StatsViewControls({ icon: , }); - if (!buttons.length) return null; + if (!buttons.length) return; return ( diff --git a/client/src/formatters/formatDistance.ts b/client/src/formatters/formatDistance.ts index 7f89c7c2..452f770e 100644 --- a/client/src/formatters/formatDistance.ts +++ b/client/src/formatters/formatDistance.ts @@ -4,12 +4,13 @@ export const integerMeterFormatter = Intl.NumberFormat(undefined, { unit: "meter", }); -const kilometerFormatter = new Intl.NumberFormat(undefined, { +const kilometerFormatter = Intl.NumberFormat(undefined, { maximumFractionDigits: 1, style: "unit", unit: "kilometer", }); + export const formatMetersToOneNumberWithUnits = (meters: number) => meters < 1000 ? integerMeterFormatter.format(meters) - : kilometerFormatter.format(meters / 1000); + : kilometerFormatter.format(meters / 1e3); diff --git a/client/src/store/eventsSlice.ts b/client/src/store/eventsSlice.ts index 0ea97c18..88c62ce0 100644 --- a/client/src/store/eventsSlice.ts +++ b/client/src/store/eventsSlice.ts @@ -352,6 +352,62 @@ const initialState: EventsState = { nextCursor: undefined, }; +const createHasEventIdsInPeriodSelector = ( + normalizedSelector: (state: EventsState) => { allIds: string[] }, +) => + createSelector( + normalizedSelector, + dateFromSelector, + dateToSelector, + ({ allIds }, dateFrom: Date, dateTo: Date) => + hasIdsInInterval(allIds, dateFrom, dateTo), + ); + +const createNormalizedTotalsByMonthSelector = ( + normalizedSelector: (state: EventsState) => { + allIds: string[]; + byId: Record; + }, + getValue: (item: T) => number, +) => + createSelector(normalizedSelector, (normalizedData) => { + const allIds: string[] = []; + const byId: Record = {}; + const normalizedTotals = { allIds, byId }; + + if (!normalizedData.allIds.length) return normalizedTotals; + + const periods = eachMonthOfInterval({ + start: new Date(normalizedData.allIds[0]), + end: new Date(normalizedData.allIds[normalizedData.allIds.length - 1]), + }); + + const finalPeriod = addMonths(periods[periods.length - 1], 1); + + if (normalizedData.allIds.length === 1) { + const id = formatIsoDateInLocalTimezone(periods[0]); + allIds.push(id); + byId[id] = getValue(normalizedData.byId[normalizedData.allIds[0]]); + return normalizedTotals; + } + + periods.push(finalPeriod); + + for (let i = 1; i < periods.length; i++) { + const p0 = periods[i - 1]; + const p1 = periods[i]; + const id = formatIsoDateInLocalTimezone(p0); + allIds.push(id); + + let sum = 0; + for (const id of getIdsInInterval(normalizedData.allIds, p0, p1)) + sum += getValue(normalizedData.byId[id]); + byId[id] = sum; + } + + return normalizedTotals; + }); + export default createSlice({ name: "events", initialState, @@ -520,12 +576,8 @@ export default createSlice({ getEnvelopingIds, ), hasMoods: createSelector(normalizedMoodsSelector, normalizedStateNotEmpty), - hasMoodsInPeriod: createSelector( + hasMoodsInPeriod: createHasEventIdsInPeriodSelector( normalizedMoodsSelector, - dateFromSelector, - dateToSelector, - ({ allIds }, dateFrom: Date, dateTo: Date) => - hasIdsInInterval(allIds, dateFrom, dateTo), ), hasLegRaises: createSelector( normalizedLegRaisesSelector, @@ -535,25 +587,26 @@ export default createSlice({ normalizedMeditationsSelector, normalizedStateNotEmpty, ), - hasMeditationsInPeriod: createSelector( + hasMeditationsInPeriod: createHasEventIdsInPeriodSelector( normalizedMeditationsSelector, - dateFromSelector, - dateToSelector, - ({ allIds }, dateFrom: Date, dateTo: Date) => - hasIdsInInterval(allIds, dateFrom, dateTo), ), hasPushUps: createSelector( normalizedPushUpsSelector, normalizedStateNotEmpty, ), - hasPushUpsInPeriod: createSelector( + hasPushUpsInPeriod: createHasEventIdsInPeriodSelector( normalizedPushUpsSelector, + ), + hasRuns: createSelector(normalizedRunsSelector, normalizedStateNotEmpty), + hasRunDistanceInPeriod: createSelector( + normalizedRunsSelector, dateFromSelector, dateToSelector, - ({ allIds }, dateFrom: Date, dateTo: Date) => - hasIdsInInterval(allIds, dateFrom, dateTo), + ({ allIds, byId }, dateFrom: Date, dateTo: Date) => + getIdsInInterval(allIds, dateFrom, dateTo).some( + (id) => byId[id].meters, + ), ), - hasRuns: createSelector(normalizedRunsSelector, normalizedStateNotEmpty), hasSitUps: createSelector( normalizedSitUpsSelector, normalizedStateNotEmpty, @@ -562,23 +615,15 @@ export default createSlice({ normalizedSleepsSelector, normalizedStateNotEmpty, ), - hasSleepsInPeriod: createSelector( + hasSleepsInPeriod: createHasEventIdsInPeriodSelector( normalizedSleepsSelector, - dateFromSelector, - dateToSelector, - ({ allIds }, dateFrom: Date, dateTo: Date) => - hasIdsInInterval(allIds, dateFrom, dateTo), ), hasWeights: createSelector( normalizedWeightsSelector, normalizedStateNotEmpty, ), - hasWeightsInPeriod: createSelector( + hasWeightsInPeriod: createHasEventIdsInPeriodSelector( normalizedWeightsSelector, - dateFromSelector, - dateToSelector, - ({ allIds }, dateFrom: Date, dateTo: Date) => - hasIdsInInterval(allIds, dateFrom, dateTo), ), meanDailySleepDurationInPeriod: createSelector( minutesSleptByDateAwokeSelector, @@ -721,110 +766,19 @@ export default createSlice({ eachYearOfInterval, addYears, ), - normalizedTotalPushUpsByMonth: createSelector( + normalizedTotalPushUpsByMonth: createNormalizedTotalsByMonthSelector( normalizedPushUpsSelector, - ( - normalizedPushUps, - ): { - allIds: string[]; - byId: Record; - } => { - const allIds: string[] = []; - const byId: Record = {}; - const normalizedTotalPushUps = { allIds, byId }; - - if (!normalizedPushUps.allIds.length) return normalizedTotalPushUps; - - const periods = eachMonthOfInterval({ - start: new Date(normalizedPushUps.allIds[0]), - end: new Date( - normalizedPushUps.allIds[normalizedPushUps.allIds.length - 1], - ), - }); - - const finalPeriod = addMonths(periods[periods.length - 1], 1); - - if (normalizedPushUps.allIds.length === 1) { - const id = formatIsoDateInLocalTimezone(periods[0]); - allIds.push(id); - byId[id] = normalizedPushUps.byId[normalizedPushUps.allIds[0]].value; - return normalizedTotalPushUps; - } - - periods.push(finalPeriod); - - for (let i = 1; i < periods.length; i++) { - const p0 = periods[i - 1]; - const p1 = periods[i]; - const id = formatIsoDateInLocalTimezone(p0); - allIds.push(id); - - const foo = ( - { allIds, byId }: NormalizedPushUps, - dateFrom: Date, - dateTo: Date, - ): number => { - let sum = 0; - for (const id of getIdsInInterval(allIds, dateFrom, dateTo)) - sum += byId[id].value; - return sum; - }; - byId[id] = foo(normalizedPushUps, p0, p1); - } - - return normalizedTotalPushUps; - }, + (item) => item.value, ), - normalizedTotalSecondsMeditatedByMonth: createSelector( - normalizedMeditationsSelector, - ( - normalizedMeditations, - ): { - allIds: string[]; - byId: Record; - } => { - const allIds: string[] = []; - const byId: Record = {}; - const normalizedTotalSeconds = { allIds, byId }; - - if (!normalizedMeditations.allIds.length) return normalizedTotalSeconds; - - const periods = eachMonthOfInterval({ - start: new Date(normalizedMeditations.allIds[0]), - end: new Date( - normalizedMeditations.allIds[ - normalizedMeditations.allIds.length - 1 - ], - ), - }); - - const finalPeriod = addMonths(periods[periods.length - 1], 1); - - if (normalizedMeditations.allIds.length === 1) { - const id = formatIsoDateInLocalTimezone(periods[0]); - allIds.push(id); - byId[id] = - normalizedMeditations.byId[normalizedMeditations.allIds[0]].seconds; - return normalizedTotalSeconds; - } - - periods.push(finalPeriod); - - for (let i = 1; i < periods.length; i++) { - const p0 = periods[i - 1]; - const p1 = periods[i]; - const id = formatIsoDateInLocalTimezone(p0); - allIds.push(id); - byId[id] = secondsMeditatedInPeriodResultFunction( - normalizedMeditations, - p0, - p1, - ); - } - - return normalizedTotalSeconds; - }, + normalizedTotalRunDistanceByMonth: createNormalizedTotalsByMonthSelector( + normalizedRunsSelector, + (item) => item.meters ?? 0, ), + normalizedTotalSecondsMeditatedByMonth: + createNormalizedTotalsByMonthSelector( + normalizedMeditationsSelector, + (item) => item.seconds, + ), runMetersInPeriod: createSelector( normalizedRunsSelector, dateFromSelector,