From b5bc39871170359153116228c4d34afc23ebd61d Mon Sep 17 00:00:00 2001 From: MohamedKiouaz Date: Tue, 7 Apr 2026 07:57:55 +0200 Subject: [PATCH 01/12] refactor(stats): compute exercise detail metrics locally --- app/store/stats/effects.ts | 5 --- app/store/stats/index.ts | 1 - app/utils/weighted-exercise-stats.ts | 61 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 app/utils/weighted-exercise-stats.ts diff --git a/app/store/stats/effects.ts b/app/store/stats/effects.ts index 525b197ad..b56bdfc70 100644 --- a/app/store/stats/effects.ts +++ b/app/store/stats/effects.ts @@ -247,11 +247,6 @@ function computeStats( ); return { exerciseName: ex.exerciseName, - setsPerWeek: - Object.values(ex.repsStatistics.breakdown).reduce( - (accum, entry) => accum + entry.numberOfSets, - 0, - ) / totalWeeks, maxLiftedPerSessionStatistics, max1RMPerSessionStatistics, totalVolumeStatistics: unsortedStatsToWeightedStatisticOverTime( diff --git a/app/store/stats/index.ts b/app/store/stats/index.ts index e97820789..d39b9b9ed 100644 --- a/app/store/stats/index.ts +++ b/app/store/stats/index.ts @@ -40,7 +40,6 @@ export interface RepsBreakdownStatistics { export interface WeightedExerciseStatistics { exerciseName: string; - setsPerWeek: number; maxLiftedPerSessionStatistics: WeightedStatisticOverTime; max1RMPerSessionStatistics: WeightedStatisticOverTime; totalVolumeStatistics: WeightedStatisticOverTime; diff --git a/app/utils/weighted-exercise-stats.ts b/app/utils/weighted-exercise-stats.ts new file mode 100644 index 000000000..d940a82ba --- /dev/null +++ b/app/utils/weighted-exercise-stats.ts @@ -0,0 +1,61 @@ +import { LocalDateRange } from '@/models/time-models'; +import { WeightedExerciseStatistics } from '@/store/stats'; + +export function formatWeeklyRate(value: number) { + return Math.abs(value - Math.round(value)) < 0.05 + ? Math.round(value).toString() + : value.toFixed(1); +} + +export function getWeightedExerciseSetsPerWeek( + stats: WeightedExerciseStatistics, + timePeriod: LocalDateRange, +) { + const totalSets = Object.values(stats.repsStatistics.breakdown).reduce( + (sum, { numberOfSets }) => sum + numberOfSets, + 0, + ); + const totalDays = + timePeriod.to.toEpochDay() - timePeriod.from.toEpochDay() + 1; + return totalSets / Math.max(totalDays / 7, 1 / 7); +} + +export function getUsualRepRange(stats: WeightedExerciseStatistics) { + const sortedBreakdown = Object.entries(stats.repsStatistics.breakdown) + .map(([reps, { numberOfSets }]) => ({ + reps: Number(reps), + numberOfSets, + })) + .sort((a, b) => a.reps - b.reps); + + if (!sortedBreakdown.length) { + return '-'; + } + + const totalSets = sortedBreakdown.reduce( + (sum, { numberOfSets }) => sum + numberOfSets, + 0, + ); + + const lowerBound = getRepsAtPercentile(sortedBreakdown, totalSets, 0.1); + const upperBound = getRepsAtPercentile(sortedBreakdown, totalSets, 0.9); + return `${lowerBound}-${upperBound}`; +} + +function getRepsAtPercentile( + sortedBreakdown: { reps: number; numberOfSets: number }[], + totalSets: number, + percentile: number, +) { + const targetSet = Math.ceil(totalSets * percentile); + let seenSets = 0; + + for (const { reps, numberOfSets } of sortedBreakdown) { + seenSets += numberOfSets; + if (seenSets >= targetSet) { + return reps.toString(); + } + } + + return sortedBreakdown.at(-1)?.reps.toString() ?? '-'; +} From 15f014d411d56713ec5bbb69a18b934d9d64aaed Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 10:04:59 +0200 Subject: [PATCH 02/12] refactor(stats): extract reusable stats screens --- .../stats/expanded-weighted-exercise.tsx | 202 +----------------- app/app/(tabs)/stats/index.tsx | 137 +----------- .../data/exercise-stats-content.tsx | 149 +++++++++++++ .../expanded-weighted-exercise-content.tsx | 164 ++++++++++++++ .../stats/single-value-statistics-grid.tsx | 7 +- 5 files changed, 325 insertions(+), 334 deletions(-) create mode 100644 app/components/presentation/data/exercise-stats-content.tsx create mode 100644 app/components/presentation/data/expanded-weighted-exercise-content.tsx diff --git a/app/app/(tabs)/stats/expanded-weighted-exercise.tsx b/app/app/(tabs)/stats/expanded-weighted-exercise.tsx index b267ca7bb..d8a1eaf14 100644 --- a/app/app/(tabs)/stats/expanded-weighted-exercise.tsx +++ b/app/app/(tabs)/stats/expanded-weighted-exercise.tsx @@ -1,203 +1,5 @@ -import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; -import { Remote } from '@/components/presentation/foundation/remote'; -import { RepsBarChart } from '@/components/presentation/stats/reps-bar-chart'; -import SingleValueStatisticCard from '@/components/presentation/stats/single-value-statistic-card'; -import { SingleValueStatisticsGrid } from '@/components/presentation/stats/single-value-statistics-grid'; -import { TimePeriodSelector } from '@/components/presentation/stats/time-period-selector'; -import { TitledSection } from '@/components/presentation/stats/titled-section'; -import { WeightBarChart } from '@/components/presentation/stats/weight-bar-chart'; -import { WeightLineChart } from '@/components/presentation/stats/weight-line-chart'; -import { spacing, useAppTheme } from '@/hooks/useAppTheme'; -import { useAppSelector, useAppSelectorWithArg } from '@/store'; -import { - fetchOverallStats, - selectExerciseView, - setOverallViewTime, - WeightedExerciseStatistics, -} from '@/store/stats'; -import { T, useTranslate } from '@tolgee/react'; -import { Stack, useFocusEffect } from 'expo-router'; -import { useLocalSearchParams, useRouter } from 'expo-router/build/hooks'; -import { ReactNode, useEffect } from 'react'; -import { View } from 'react-native'; -import { Card, Text } from 'react-native-paper'; -import { useDispatch } from 'react-redux'; +import { ExpandedWeightedExerciseContent } from '@/components/presentation/data/expanded-weighted-exercise-content'; export default function ExpandedExercisePage() { - const dispatch = useDispatch(); - const timePeriod = useAppSelector((x) => x.stats.overallViewTime); - const { exerciseName } = useLocalSearchParams<{ exerciseName: string }>(); - const { dismissTo } = useRouter(); - useFocusEffect(() => { - dispatch(fetchOverallStats()); - }); - useEffect(() => { - if (!exerciseName) { - dismissTo('/stats'); - } - }, [exerciseName, dismissTo]); - const stats = useAppSelectorWithArg(selectExerciseView, exerciseName); - return ( - - - - dispatch(setOverallViewTime(value))} - /> - - } - /> - - ); -} - -function LoadedStats({ - stats, -}: { - stats: WeightedExerciseStatistics | undefined; -}) { - return stats ? ( - - ) : ( - - - - ); -} - -function LoadedStatsFilled({ stats }: { stats: WeightedExerciseStatistics }) { - const { t } = useTranslate(); - return ( - - - - - - - - - - - - - - - {t('stats.exercise.reps_breakdown_sets_x_axis.label')} - - - - ); -} - -function StatCardWithTitle(props: { title: string; children: ReactNode }) { - const { colors } = useAppTheme(); - return ( - - - - {props.children} - - - - ); -} - -function OverallStatsGrid({ stats }: { stats: WeightedExerciseStatistics }) { - const { t } = useTranslate(); - const usualRepRange = getUsualRepRange(stats); - return ( - - - - - - - - - - - ); -} - -function formatWeeklyRate(value: number) { - return Math.abs(value - Math.round(value)) < 0.05 - ? Math.round(value).toString() - : value.toFixed(1); -} - -function getUsualRepRange(stats: WeightedExerciseStatistics) { - const breakdown = Object.entries(stats.repsStatistics.breakdown) - .map(([reps, { numberOfSets }]) => ({ - reps: Number(reps), - numberOfSets, - })) - .sort((a, b) => a.reps - b.reps); - - const totalSets = breakdown.reduce( - (sum, entry) => sum + entry.numberOfSets, - 0, - ); - if (!totalSets) { - return '-'; - } - - const lowerBound = getPercentileRepCount(breakdown, totalSets, 0.1); - const upperBound = getPercentileRepCount(breakdown, totalSets, 0.9); - return `${lowerBound}-${upperBound}`; -} - -function getPercentileRepCount( - breakdown: { reps: number; numberOfSets: number }[], - totalSets: number, - percentile: number, -) { - const target = Math.ceil(totalSets * percentile); - let cumulativeSets = 0; - - for (const entry of breakdown) { - cumulativeSets += entry.numberOfSets; - if (cumulativeSets >= target) { - return entry.reps.toString(); - } - } - - return breakdown.at(-1)?.reps.toString() ?? '-'; + return ; } diff --git a/app/app/(tabs)/stats/index.tsx b/app/app/(tabs)/stats/index.tsx index 600f07016..d117eadf1 100644 --- a/app/app/(tabs)/stats/index.tsx +++ b/app/app/(tabs)/stats/index.tsx @@ -1,144 +1,19 @@ -import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; -import Icon from '@/components/presentation/foundation/gesture-wrappers/icon'; -import { Remote } from '@/components/presentation/foundation/remote'; -import { ExerciseListSummary } from '@/components/presentation/stats/exercise-list-summary'; -import SingleValueStatisticCard from '@/components/presentation/stats/single-value-statistic-card'; -import { SingleValueStatisticsGrid } from '@/components/presentation/stats/single-value-statistics-grid'; -import { TimePeriodSelector } from '@/components/presentation/stats/time-period-selector'; -import { TitledSection } from '@/components/presentation/stats/titled-section'; +import { ExerciseStatsContent } from '@/components/presentation/data/exercise-stats-content'; import { spacing } from '@/hooks/useAppTheme'; -import { Weight } from '@/models/weight'; -import { useAppSelector } from '@/store'; -import { - fetchOverallStats, - GranularStatisticView, - selectOverallView, - setOverallViewTime, -} from '@/store/stats'; -import { formatDuration } from '@/utils/format-date'; import { useTranslate } from '@tolgee/react'; -import { Stack, useFocusEffect } from 'expo-router'; -import { View, Text } from 'react-native'; -import { useDispatch } from 'react-redux'; -import { match } from 'ts-pattern'; +import { Stack } from 'expo-router'; export default function StatsPage() { const { t } = useTranslate(); - const timePeriod = useAppSelector((x) => x.stats.overallViewTime); - const dispatch = useDispatch(); - useFocusEffect(() => { - dispatch(fetchOverallStats()); - }); - const stats = useAppSelector(selectOverallView); + return ( - + <> - - dispatch(setOverallViewTime(value))} - /> - - } - /> - - ); -} - -function LoadedStats({ stats }: { stats: GranularStatisticView }) { - return ( - - - - - ); -} - -function OverallStatsGrid({ stats }: { stats: GranularStatisticView }) { - const { t } = useTranslate(); - return ( - - - - - - - } - /> - - - - ); -} - -function formatWeeklyRate(value: number) { - const rounded = - Math.abs(value - Math.round(value)) < 0.05 - ? Math.round(value).toString() - : value.toFixed(1); - return rounded; -} - -function BodyweightStatValue({ - stats: { bodyweightStats }, -}: { - stats: GranularStatisticView; -}) { - const showBodyweight = useAppSelector((x) => x.settings.showBodyweight); - if (!showBodyweight) { - return -; - } - const currentValue = bodyweightStats.currentValue; - const earliestValue = bodyweightStats.statistics[0]?.value ?? Weight.NIL; - const change = currentValue.minus(earliestValue); - const changeDirection = match({ - zero: change.value.isZero(), - positive: change.value.isPositive(), - }) - .with({ zero: true }, () => ) - .with({ positive: true }, () => ) - .with({ positive: false }, () => ) - .exhaustive(); - - return ( - - {currentValue.shortLocaleFormat(0)} ({changeDirection} - {change.abs().shortLocaleFormat(2)}) - + + ); } diff --git a/app/components/presentation/data/exercise-stats-content.tsx b/app/components/presentation/data/exercise-stats-content.tsx new file mode 100644 index 000000000..85014506f --- /dev/null +++ b/app/components/presentation/data/exercise-stats-content.tsx @@ -0,0 +1,149 @@ +import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; +import Icon from '@/components/presentation/foundation/gesture-wrappers/icon'; +import { Remote } from '@/components/presentation/foundation/remote'; +import { ExerciseListSummary } from '@/components/presentation/stats/exercise-list-summary'; +import SingleValueStatisticCard from '@/components/presentation/stats/single-value-statistic-card'; +import { SingleValueStatisticsGrid } from '@/components/presentation/stats/single-value-statistics-grid'; +import { TimePeriodSelector } from '@/components/presentation/stats/time-period-selector'; +import { TitledSection } from '@/components/presentation/stats/titled-section'; +import { Weight } from '@/models/weight'; +import { useAppSelector } from '@/store'; +import { + fetchOverallStats, + GranularStatisticView, + selectOverallView, + setOverallViewTime, +} from '@/store/stats'; +import { formatDuration } from '@/utils/format-date'; +import { formatWeeklyRate } from '@/utils/weighted-exercise-stats'; +import { useTranslate } from '@tolgee/react'; +import { useFocusEffect } from 'expo-router'; +import { ReactNode } from 'react'; +import { + NativeScrollEvent, + NativeSyntheticEvent, + StyleProp, + Text, + View, + ViewStyle, +} from 'react-native'; +import { useDispatch } from 'react-redux'; +import { match } from 'ts-pattern'; + +export function ExerciseStatsContent(props: { + header?: ReactNode; + contentContainerStyle?: StyleProp; + onScroll?: (event: NativeSyntheticEvent) => void; +}) { + const timePeriod = useAppSelector((x) => x.stats.overallViewTime); + const dispatch = useDispatch(); + const stats = useAppSelector(selectOverallView); + + useFocusEffect(() => { + dispatch(fetchOverallStats()); + }); + + return ( + + {props.header} + + dispatch(setOverallViewTime(value))} + /> + + } + /> + + ); +} + +function LoadedStats({ stats }: { stats: GranularStatisticView }) { + return ( + + + + + ); +} + +function OverallStatsGrid({ stats }: { stats: GranularStatisticView }) { + const { t } = useTranslate(); + return ( + + + + + + + } + /> + + + + ); +} + +function BodyweightStatValue({ + stats: { bodyweightStats }, +}: { + stats: GranularStatisticView; +}) { + const showBodyweight = useAppSelector((x) => x.settings.showBodyweight); + if (!showBodyweight) { + return -; + } + + const currentValue = bodyweightStats.currentValue; + const earliestValue = bodyweightStats.statistics[0]?.value ?? Weight.NIL; + const change = currentValue.minus(earliestValue); + const changeDirection = match({ + zero: change.value.isZero(), + positive: change.value.isPositive(), + }) + .with({ zero: true }, () => ) + .with({ positive: true }, () => ) + .with({ positive: false }, () => ) + .exhaustive(); + + return ( + + {currentValue.shortLocaleFormat(0)} ({changeDirection} + {change.abs().shortLocaleFormat(2)}) + + ); +} diff --git a/app/components/presentation/data/expanded-weighted-exercise-content.tsx b/app/components/presentation/data/expanded-weighted-exercise-content.tsx new file mode 100644 index 000000000..5b77f76b7 --- /dev/null +++ b/app/components/presentation/data/expanded-weighted-exercise-content.tsx @@ -0,0 +1,164 @@ +import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; +import { Remote } from '@/components/presentation/foundation/remote'; +import { RepsBarChart } from '@/components/presentation/stats/reps-bar-chart'; +import SingleValueStatisticCard from '@/components/presentation/stats/single-value-statistic-card'; +import { SingleValueStatisticsGrid } from '@/components/presentation/stats/single-value-statistics-grid'; +import { TimePeriodSelector } from '@/components/presentation/stats/time-period-selector'; +import { TitledSection } from '@/components/presentation/stats/titled-section'; +import { WeightBarChart } from '@/components/presentation/stats/weight-bar-chart'; +import { WeightLineChart } from '@/components/presentation/stats/weight-line-chart'; +import { spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { useAppSelector, useAppSelectorWithArg } from '@/store'; +import { + selectExerciseView, + setOverallViewTime, + WeightedExerciseStatistics, +} from '@/store/stats'; +import { + formatWeeklyRate, + getUsualRepRange, + getWeightedExerciseSetsPerWeek, +} from '@/utils/weighted-exercise-stats'; +import { T, useTranslate } from '@tolgee/react'; +import { Stack } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router/build/hooks'; +import { ReactNode, useEffect } from 'react'; +import { View } from 'react-native'; +import { Card, Text } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; + +export function ExpandedWeightedExerciseContent(props: { + emptyRoute: '/stats' | '/(tabs)/progress'; +}) { + const dispatch = useDispatch(); + const timePeriod = useAppSelector((x) => x.stats.overallViewTime); + const { exerciseName } = useLocalSearchParams<{ exerciseName: string }>(); + const { dismissTo } = useRouter(); + + useEffect(() => { + if (!exerciseName) { + dismissTo(props.emptyRoute); + } + }, [dismissTo, exerciseName, props.emptyRoute]); + + const stats = useAppSelectorWithArg(selectExerciseView, exerciseName); + + return ( + + + + dispatch(setOverallViewTime(value))} + /> + + } + /> + + ); +} + +function LoadedStats({ + stats, +}: { + stats: WeightedExerciseStatistics | undefined; +}) { + return stats ? ( + + ) : ( + + + + ); +} + +function LoadedStatsFilled({ stats }: { stats: WeightedExerciseStatistics }) { + const { t } = useTranslate(); + return ( + + + + + + + + + + + + {t('stats.exercise.reps_breakdown_sets_x_axis.label')} + + + + ); +} + +function StatCardWithTitle(props: { title: string; children: ReactNode }) { + const { colors } = useAppTheme(); + return ( + + + + {props.children} + + + + ); +} + +function OverallStatsGrid({ stats }: { stats: WeightedExerciseStatistics }) { + const { t } = useTranslate(); + const timePeriod = useAppSelector((x) => x.stats.overallViewTime); + const setsPerWeek = getWeightedExerciseSetsPerWeek(stats, timePeriod); + const usualRepRange = getUsualRepRange(stats); + + return ( + + + + + + + + + + + ); +} diff --git a/app/components/presentation/stats/single-value-statistics-grid.tsx b/app/components/presentation/stats/single-value-statistics-grid.tsx index 6b08c54f1..a284adf52 100644 --- a/app/components/presentation/stats/single-value-statistics-grid.tsx +++ b/app/components/presentation/stats/single-value-statistics-grid.tsx @@ -1,14 +1,15 @@ import { spacing } from '@/hooks/useAppTheme'; -import { ReactNode } from 'react'; +import { Children, ReactNode } from 'react'; import { View } from 'react-native'; import { FlatGrid } from 'react-native-super-grid'; -export function SingleValueStatisticsGrid(props: { children: ReactNode[] }) { +export function SingleValueStatisticsGrid(props: { children: ReactNode }) { const gridSpacing = spacing[2]; + const items = Children.toArray(props.children); return ( Date: Wed, 8 Apr 2026 10:05:29 +0200 Subject: [PATCH 03/12] refactor(history): extract reusable history screen --- app/app/(tabs)/history/index.tsx | 218 +--------------- .../presentation/data/history-content.tsx | 235 ++++++++++++++++++ 2 files changed, 240 insertions(+), 213 deletions(-) create mode 100644 app/components/presentation/data/history-content.tsx diff --git a/app/app/(tabs)/history/index.tsx b/app/app/(tabs)/history/index.tsx index 5329e1e10..3f3cc21c2 100644 --- a/app/app/(tabs)/history/index.tsx +++ b/app/app/(tabs)/history/index.tsx @@ -1,114 +1,11 @@ -import CardActions from '@/components/presentation/foundation/card-actions'; -import CardList from '@/components/presentation/foundation/card-list'; -import ConfirmationDialog from '@/components/presentation/foundation/confirmation-dialog'; -import EmptyInfo from '@/components/presentation/foundation/empty-info'; -import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; -import IconButton from '@/components/presentation/foundation/gesture-wrappers/icon-button'; -import HistoryCalendarCard from '@/components/presentation/summary/history-calendar-card'; -import LimitedHtml from '@/components/presentation/foundation/limited-html'; -import SessionSummary from '@/components/presentation/summary/session-summary'; -import SessionSummaryTitle from '@/components/presentation/summary/session-summary-title'; -import SplitCardControl from '@/components/presentation/foundation/split-card-control'; import { spacing } from '@/hooks/useAppTheme'; -import { Session } from '@/models/session-models'; -import { useAppSelector, useAppSelectorWithArg } from '@/store'; -import { - selectCurrentSession, - setCurrentSession, -} from '@/store/current-session'; -import { addUnpublishedSessionId, encryptAndShare } from '@/store/feed'; -import { - deleteStoredSession, - selectSessions, - selectSessionsInMonth, -} from '@/store/stored-sessions'; -import { uuid } from '@/utils/uuid'; -import { LocalDate, YearMonth } from '@js-joda/core'; -import { T, useTranslate } from '@tolgee/react'; -import { Stack, useRouter } from 'expo-router'; -import React, { useState } from 'react'; -import { Card, Tooltip } from 'react-native-paper'; -import Button from '@/components/presentation/foundation/gesture-wrappers/button'; -import { useDispatch } from 'react-redux'; -import { useFormatDate } from '@/hooks/useFormatDate'; -import { SharedSession } from '@/models/feed-models'; +import { useTranslate } from '@tolgee/react'; +import { Stack } from 'expo-router'; +import { HistoryContent } from '@/components/presentation/data/history-content'; -export default function History() { +export default function HistoryPage() { const { t } = useTranslate(); - const dispatch = useDispatch(); - const formatDate = useFormatDate(); - const [currentYearMonth, setCurrentYearMonth] = useState(YearMonth.now()); - const latesBodyweight = useAppSelector((x) => - x.program.upcomingSessions - .map((x) => x.at(0)?.bodyweight) - .unwrapOr(undefined), - ); - const sessions = useAppSelector(selectSessions); - const sessionsInMonth = useAppSelectorWithArg( - selectSessionsInMonth, - currentYearMonth, - ); - const { push } = useRouter(); - const currentWorkoutSession = useAppSelectorWithArg( - selectCurrentSession, - 'workoutSession', - ); - const onSelectSession = (session: Session) => { - dispatch(setCurrentSession({ target: 'historySession', session })); - push('/history/edit'); - }; - const createSessionAtDate = (date: LocalDate) => { - const newSession = Session.freeformSession(date, latesBodyweight); - onSelectSession(newSession); - }; - const [ - replaceCurrentSessionConfirmOpen, - setReplaceCurrentSessionConfirmOpen, - ] = useState(false); - const [ - deleteSelectedWorkoutConfirmOpen, - setDeleteSelectedWorkoutConfirmOpen, - ] = useState(false); - const [selectedWorkout, setSelectedWorkout] = useState(); - const deleteWorkout = (session: Session, force = false) => { - if (!force) { - setSelectedWorkout(session); - setDeleteSelectedWorkoutConfirmOpen(true); - } else if (selectedWorkout) { - dispatch(deleteStoredSession(selectedWorkout.id)); - dispatch(addUnpublishedSessionId(selectedWorkout.id)); - setDeleteSelectedWorkoutConfirmOpen(false); - setSelectedWorkout(undefined); - } - }; - - const startWorkout = (session: Session, force = false) => { - if (currentWorkoutSession && !force) { - setSelectedWorkout(session); - setReplaceCurrentSessionConfirmOpen(true); - } else { - dispatch( - setCurrentSession({ - target: 'workoutSession', - session: session - .withNothingCompleted() - .with({ date: LocalDate.now(), id: uuid() }), - }), - ); - setReplaceCurrentSessionConfirmOpen(false); - setSelectedWorkout(undefined); - push('/(tabs)/(session)/session', { withAnchor: true }); - } - }; - const handleSharePress = (session: Session) => { - dispatch( - encryptAndShare({ - item: new SharedSession(session), - title: t('workout.shared_item.title'), - }), - ); - }; return ( <> - - { - deleteWorkout(s); - }} - onSessionSelect={onSelectSession} - /> - ( - - - } - mainContent={ - - } - /> - - )} - renderItemActions={(session) => ( - - - handleSharePress(session)} - /> - - - startWorkout(session)} - /> - - - deleteWorkout(session)} - /> - - - - )} - emptyTemplate={ - - - - } - /> - - selectedWorkout && startWorkout(selectedWorkout, true)} - onCancel={() => { - setSelectedWorkout(undefined); - setReplaceCurrentSessionConfirmOpen(false); - }} - /> - - } - open={deleteSelectedWorkoutConfirmOpen} - okText={t('generic.delete.button')} - onOk={() => selectedWorkout && deleteWorkout(selectedWorkout, true)} - onCancel={() => { - setSelectedWorkout(undefined); - setDeleteSelectedWorkoutConfirmOpen(false); - }} /> ); diff --git a/app/components/presentation/data/history-content.tsx b/app/components/presentation/data/history-content.tsx new file mode 100644 index 000000000..7f752b494 --- /dev/null +++ b/app/components/presentation/data/history-content.tsx @@ -0,0 +1,235 @@ +import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; +import CardActions from '@/components/presentation/foundation/card-actions'; +import CardList from '@/components/presentation/foundation/card-list'; +import ConfirmationDialog from '@/components/presentation/foundation/confirmation-dialog'; +import EmptyInfo from '@/components/presentation/foundation/empty-info'; +import Button from '@/components/presentation/foundation/gesture-wrappers/button'; +import IconButton from '@/components/presentation/foundation/gesture-wrappers/icon-button'; +import LimitedHtml from '@/components/presentation/foundation/limited-html'; +import SplitCardControl from '@/components/presentation/foundation/split-card-control'; +import HistoryCalendarCard from '@/components/presentation/summary/history-calendar-card'; +import SessionSummaryTitle from '@/components/presentation/summary/session-summary-title'; +import SessionSummary from '@/components/presentation/summary/session-summary'; +import { spacing } from '@/hooks/useAppTheme'; +import { useFormatDate } from '@/hooks/useFormatDate'; +import { SharedSession } from '@/models/feed-models'; +import { Session } from '@/models/session-models'; +import { useAppSelector, useAppSelectorWithArg } from '@/store'; +import { + selectCurrentSession, + setCurrentSession, +} from '@/store/current-session'; +import { addUnpublishedSessionId, encryptAndShare } from '@/store/feed'; +import { + deleteStoredSession, + selectSessions, + selectSessionsInMonth, +} from '@/store/stored-sessions'; +import { uuid } from '@/utils/uuid'; +import { LocalDate, YearMonth } from '@js-joda/core'; +import { T, useTranslate } from '@tolgee/react'; +import { useRouter } from 'expo-router'; +import { ReactNode, useState } from 'react'; +import { + NativeScrollEvent, + NativeSyntheticEvent, + StyleProp, + ViewStyle, +} from 'react-native'; +import { Card, Tooltip } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; + +export function HistoryContent(props: { + header?: ReactNode; + contentContainerStyle?: StyleProp; + renderItemActionsLeading?: ((session: Session) => ReactNode) | undefined; + onScroll?: (event: NativeSyntheticEvent) => void; +}) { + const { t } = useTranslate(); + const dispatch = useDispatch(); + const formatDate = useFormatDate(); + const { push } = useRouter(); + const [currentYearMonth, setCurrentYearMonth] = useState(YearMonth.now()); + const latestBodyweight = useAppSelector((x) => + x.program.upcomingSessions + .map((session) => session.at(0)?.bodyweight) + .unwrapOr(undefined), + ); + const sessions = useAppSelector(selectSessions); + const sessionsInMonth = useAppSelectorWithArg( + selectSessionsInMonth, + currentYearMonth, + ); + const currentWorkoutSession = useAppSelectorWithArg( + selectCurrentSession, + 'workoutSession', + ); + const [replaceCurrentSessionConfirmOpen, setReplaceCurrentSessionConfirmOpen] = + useState(false); + const [deleteSelectedWorkoutConfirmOpen, setDeleteSelectedWorkoutConfirmOpen] = + useState(false); + const [selectedWorkout, setSelectedWorkout] = useState(); + + const onSelectSession = (session: Session) => { + dispatch(setCurrentSession({ target: 'historySession', session })); + push('/history/edit'); + }; + + const createSessionAtDate = (date: LocalDate) => { + onSelectSession(Session.freeformSession(date, latestBodyweight)); + }; + + const deleteWorkout = (session: Session, force = false) => { + if (!force) { + setSelectedWorkout(session); + setDeleteSelectedWorkoutConfirmOpen(true); + return; + } + + if (selectedWorkout) { + dispatch(deleteStoredSession(selectedWorkout.id)); + dispatch(addUnpublishedSessionId(selectedWorkout.id)); + setDeleteSelectedWorkoutConfirmOpen(false); + setSelectedWorkout(undefined); + } + }; + + const startWorkout = (session: Session, force = false) => { + if (currentWorkoutSession && !force) { + setSelectedWorkout(session); + setReplaceCurrentSessionConfirmOpen(true); + return; + } + + dispatch( + setCurrentSession({ + target: 'workoutSession', + session: session + .withNothingCompleted() + .with({ date: LocalDate.now(), id: uuid() }), + }), + ); + setReplaceCurrentSessionConfirmOpen(false); + setSelectedWorkout(undefined); + push('/(tabs)/(session)/session', { withAnchor: true }); + }; + + const handleSharePress = (session: Session) => { + dispatch( + encryptAndShare({ + item: new SharedSession(session), + title: t('workout.shared_item.title'), + }), + ); + }; + + return ( + <> + + {props.header} + + ( + + } + mainContent={} + /> + + )} + renderItemActions={(session) => ( + + {props.renderItemActionsLeading?.(session)} + + handleSharePress(session)} + /> + + + startWorkout(session)} + /> + + + deleteWorkout(session)} + /> + + + + )} + emptyTemplate={ + + + + } + /> + + selectedWorkout && startWorkout(selectedWorkout, true)} + onCancel={() => { + setSelectedWorkout(undefined); + setReplaceCurrentSessionConfirmOpen(false); + }} + /> + + } + open={deleteSelectedWorkoutConfirmOpen} + okText={t('generic.delete.button')} + onOk={() => selectedWorkout && deleteWorkout(selectedWorkout, true)} + onCancel={() => { + setSelectedWorkout(undefined); + setDeleteSelectedWorkoutConfirmOpen(false); + }} + /> + + ); +} From b6a936723cd13ca4f95796771ae9154286969314 Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 10:06:01 +0200 Subject: [PATCH 04/12] refactor(history): extract reusable edit screen --- app/app/(tabs)/history/edit.tsx | 82 +---------------- .../data/history-edit-content.tsx | 87 +++++++++++++++++++ 2 files changed, 89 insertions(+), 80 deletions(-) create mode 100644 app/components/presentation/data/history-edit-content.tsx diff --git a/app/app/(tabs)/history/edit.tsx b/app/app/(tabs)/history/edit.tsx index c55c91886..d2c552864 100644 --- a/app/app/(tabs)/history/edit.tsx +++ b/app/app/(tabs)/history/edit.tsx @@ -1,83 +1,5 @@ -import SessionComponent from '@/components/smart/session-component'; -import SessionMoreMenuComponent from '@/components/smart/session-more-menu-component'; -import { spacing } from '@/hooks/useAppTheme'; -import { useAppSelector, RootState } from '@/store'; -import { - finishCurrentWorkout, - setActiveSessionDate, -} from '@/store/current-session'; -import { LocalDate } from '@js-joda/core'; -import { useRouter, Stack } from 'expo-router'; -import { useEffect } from 'react'; -import { View } from 'react-native'; -import { DatePickerInput } from 'react-native-paper-dates'; -import { useDispatch } from 'react-redux'; +import { HistoryEditContent } from '@/components/presentation/data/history-edit-content'; export default function HistoryEditPage() { - const dispatch = useDispatch(); - const session = useAppSelector( - (state: RootState) => state.currentSession.historySession, - ); - const { dismissTo } = useRouter(); - - const save = () => { - dispatch(finishCurrentWorkout('historySession')); - }; - useEffect(() => { - if (!session) { - dismissTo('/history'); - } - }, [session, dismissTo]); - const showBodyweight = useAppSelector((x) => x.settings.showBodyweight); - const jsDate = - session && - new Date( - session.date.year(), - session.date.month().ordinal(), - session.date.dayOfMonth(), - ); - - return ( - <> - ( - - ), - }} - /> - - { - if (e) - dispatch( - setActiveSessionDate({ - target: 'historySession', - payload: LocalDate.of( - e.getFullYear(), - e.getMonth() + 1, - e.getDate(), - ), - }), - ); - }} - value={jsDate} - /> - - - - ); + return ; } diff --git a/app/components/presentation/data/history-edit-content.tsx b/app/components/presentation/data/history-edit-content.tsx new file mode 100644 index 000000000..9eff2ce29 --- /dev/null +++ b/app/components/presentation/data/history-edit-content.tsx @@ -0,0 +1,87 @@ +import SessionComponent from '@/components/smart/session-component'; +import SessionMoreMenuComponent from '@/components/smart/session-more-menu-component'; +import { spacing } from '@/hooks/useAppTheme'; +import { LocalDate } from '@js-joda/core'; +import { Stack, useRouter } from 'expo-router'; +import { useEffect } from 'react'; +import { View } from 'react-native'; +import { DatePickerInput } from 'react-native-paper-dates'; +import { useDispatch } from 'react-redux'; +import { useAppSelector, RootState } from '@/store'; +import { + finishCurrentWorkout, + setActiveSessionDate, +} from '@/store/current-session'; + +export function HistoryEditContent(props: { + emptyRoute: '/history' | '/(tabs)/progress'; +}) { + const dispatch = useDispatch(); + const session = useAppSelector( + (state: RootState) => state.currentSession.historySession, + ); + const { dismissTo } = useRouter(); + + useEffect(() => { + if (!session) { + dismissTo(props.emptyRoute); + } + }, [dismissTo, props.emptyRoute, session]); + + const save = () => { + dispatch(finishCurrentWorkout('historySession')); + }; + const showBodyweight = useAppSelector((x) => x.settings.showBodyweight); + const jsDate = + session && + new Date( + session.date.year(), + session.date.month().ordinal(), + session.date.dayOfMonth(), + ); + + return ( + <> + ( + + ), + }} + /> + + { + if (value) { + dispatch( + setActiveSessionDate({ + target: 'historySession', + payload: LocalDate.of( + value.getFullYear(), + value.getMonth() + 1, + value.getDate(), + ), + }), + ); + } + }} + value={jsDate} + /> + + + + ); +} From 26b3bc78ad9ed6501444c52285d24081e9474504 Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 10:07:25 +0200 Subject: [PATCH 05/12] feat(progress): add personal best views --- app/app/(tabs)/progress/personal-best.tsx | 5 + .../data/personal-best-detail-content.tsx | 219 ++++++ .../data/personal-bests-content.tsx | 261 ++++++ app/i18n/en.json | 38 + app/i18n/fr.json | 38 + app/utils/personal-bests.ts | 742 ++++++++++++++++++ 6 files changed, 1303 insertions(+) create mode 100644 app/app/(tabs)/progress/personal-best.tsx create mode 100644 app/components/presentation/data/personal-best-detail-content.tsx create mode 100644 app/components/presentation/data/personal-bests-content.tsx create mode 100644 app/utils/personal-bests.ts diff --git a/app/app/(tabs)/progress/personal-best.tsx b/app/app/(tabs)/progress/personal-best.tsx new file mode 100644 index 000000000..6606cb4b9 --- /dev/null +++ b/app/app/(tabs)/progress/personal-best.tsx @@ -0,0 +1,5 @@ +import { PersonalBestDetailContent } from '@/components/presentation/data/personal-best-detail-content'; + +export default function PersonalBestDetailPage() { + return ; +} diff --git a/app/components/presentation/data/personal-best-detail-content.tsx b/app/components/presentation/data/personal-best-detail-content.tsx new file mode 100644 index 000000000..9f70d04cb --- /dev/null +++ b/app/components/presentation/data/personal-best-detail-content.tsx @@ -0,0 +1,219 @@ +import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; +import EmptyInfo from '@/components/presentation/foundation/empty-info'; +import Button from '@/components/presentation/foundation/gesture-wrappers/button'; +import { SegmentedList } from '@/components/presentation/foundation/segmented-list'; +import { TitledSection } from '@/components/presentation/stats/titled-section'; +import { font, spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { useFormatDate } from '@/hooks/useFormatDate'; +import { useAppSelector } from '@/store'; +import { selectPreferredWeightUnit } from '@/store/settings'; +import { selectSessions } from '@/store/stored-sessions'; +import { + buildPersonalBestOverview, + formatPersonalBestValue, + getPersonalBestCategoryLabelKey, + PersonalBestCategorySummary, +} from '@/utils/personal-bests'; +import { T, useTranslate } from '@tolgee/react'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { View } from 'react-native'; +import { Card, Text } from 'react-native-paper'; + +export function PersonalBestDetailContent() { + const { t } = useTranslate(); + const { colors } = useAppTheme(); + const { exerciseKey } = useLocalSearchParams<{ exerciseKey: string }>(); + const { dismissTo, push } = useRouter(); + const sessions = useAppSelector(selectSessions); + const preferredUnit = useAppSelector(selectPreferredWeightUnit); + const entry = buildPersonalBestOverview(sessions, preferredUnit).entries.find( + (item) => item.exerciseKey === exerciseKey, + ); + + if (!entry) { + return ( + + + + + + + ); + } + + const timeline = [...entry.mainCategory.history].reverse(); + + return ( + + + + + {formatPersonalBestValue(entry.mainCategory.current.value)} + + + {t(getPersonalBestCategoryLabelKey(entry.mainCategory.id) as never)} + + + + item.id} + renderItem={(item) => } + /> + + + item.achievedAt.toString()} + renderItem={(item, index) => ( + + )} + /> + + {entry.kind === 'weighted' ? ( + + ) : null} + + + ); +} + +function CategorySummaryCard({ + category, +}: { + category: PersonalBestCategorySummary; +}) { + const { t } = useTranslate(); + const formatDate = useFormatDate(); + const { colors } = useAppTheme(); + + return ( + + + + + {t(getPersonalBestCategoryLabelKey(category.id) as never)} + + + {formatDate(category.current.achievedOn, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + + {formatPersonalBestValue(category.current.value)} + + + + {category.previous + ? t('progress.pbs.detail.previous_best', { + value: formatPersonalBestValue(category.previous.value), + date: formatDate(category.previous.achievedOn, { + day: 'numeric', + month: 'short', + year: 'numeric', + }), + }) + : t('progress.pbs.detail.first_best')} + + + ); +} + +function TimelineRow({ + currentValue, + previousValue, + isLatest, +}: { + currentValue: PersonalBestCategorySummary['history'][number]; + previousValue?: PersonalBestCategorySummary['history'][number]; + isLatest: boolean; +}) { + const formatDate = useFormatDate(); + const { colors } = useAppTheme(); + const { t } = useTranslate(); + + return ( + + + + + + {formatDate(currentValue.achievedOn, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + {isLatest + ? t('progress.pbs.detail.current_record') + : t('progress.pbs.detail.record_progression')} + + + + {formatPersonalBestValue(currentValue.value)} + + + {previousValue ? ( + + {t('progress.pbs.detail.beat_previous', { + value: formatPersonalBestValue(previousValue.value), + })} + + ) : ( + + + + )} + + + ); +} diff --git a/app/components/presentation/data/personal-bests-content.tsx b/app/components/presentation/data/personal-bests-content.tsx new file mode 100644 index 000000000..dd27dd438 --- /dev/null +++ b/app/components/presentation/data/personal-bests-content.tsx @@ -0,0 +1,261 @@ +import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; +import EmptyInfo from '@/components/presentation/foundation/empty-info'; +import SelectButton, { + SelectButtonOption, +} from '@/components/presentation/foundation/select-button'; +import { SegmentedList } from '@/components/presentation/foundation/segmented-list'; +import { TitledSection } from '@/components/presentation/stats/titled-section'; +import { font, spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { useFormatDate } from '@/hooks/useFormatDate'; +import { useAppSelector } from '@/store'; +import { selectSessions } from '@/store/stored-sessions'; +import { selectPreferredWeightUnit } from '@/store/settings'; +import { + buildPersonalBestOverview, + filterAndSortPersonalBestEntries, + formatPersonalBestValue, + getPersonalBestCategoryLabelKey, + PersonalBestFilter, + PersonalBestListEntry, + PersonalBestSort, +} from '@/utils/personal-bests'; +import { T, useTranslate } from '@tolgee/react'; +import { useRouter } from 'expo-router'; +import { useState, ReactNode } from 'react'; +import { + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleProp, + View, + ViewStyle, +} from 'react-native'; +import { Card, Chip, SegmentedButtons, Text } from 'react-native-paper'; + +const filterOptions: SelectButtonOption[] = [ + { value: 'most-recent', label: 'Most recent' }, + { value: 'heaviest', label: 'Heaviest' }, + { value: 'biggest-improvement', label: 'Biggest improvement' }, + { value: 'alphabetical', label: 'Alphabetical' }, +]; + +export function PersonalBestsContent(props: { + header?: ReactNode; + contentContainerStyle?: StyleProp; + onScroll?: (event: NativeSyntheticEvent) => void; +}) { + const { t } = useTranslate(); + const { push } = useRouter(); + const sessions = useAppSelector(selectSessions); + const preferredUnit = useAppSelector(selectPreferredWeightUnit); + const overview = buildPersonalBestOverview(sessions, preferredUnit); + const [filter, setFilter] = useState('all'); + const [sort, setSort] = useState('most-recent'); + const filteredEntries = filterAndSortPersonalBestEntries( + overview.entries, + filter, + sort, + ); + + return ( + + {props.header} + + ({ + ...option, + label: t(`progress.pbs.sort.${option.value}`), + }))} + renderLabel={(value) => + t('progress.pbs.sort.button', { + value: t(`progress.pbs.sort.${value}`), + }) + } + value={sort} + /> + } + > + + {overview.entries.length === 0 ? ( + + + + ) : filteredEntries.length === 0 ? ( + + + + ) : ( + item.exerciseKey} + onItemPress={(item) => + push( + `/(tabs)/progress/personal-best?exerciseKey=${encodeURIComponent(item.exerciseKey)}`, + ) + } + renderItem={(item) => } + /> + )} + + + ); +} + +function PersonalBestSummaryRow({ + overview, +}: { + overview: ReturnType; +}) { + const { t } = useTranslate(); + + return ( + + + + + + + + + + + ); +} + +function SummaryCard({ title, value }: { title: string; value: string }) { + const { colors } = useAppTheme(); + return ( + + + + {title} + + {value} + + + ); +} + +function FilterBar({ + filter, + setFilter, +}: { + filter: PersonalBestFilter; + setFilter: (value: PersonalBestFilter) => void; +}) { + const { t } = useTranslate(); + + return ( + + setFilter(value as PersonalBestFilter)} + value={filter} + buttons={['all', 'strength', 'volume', 'reps', 'cardio', 'recent'].map( + (value) => ({ + value, + label: t(`progress.pbs.filter.${value}` as never), + }), + )} + /> + + ); +} + +function PersonalBestRow({ entry }: { entry: PersonalBestListEntry }) { + const { colors } = useAppTheme(); + const { t } = useTranslate(); + const formatDate = useFormatDate(); + + return ( + + + + {entry.exerciseName} + + {t(getPersonalBestCategoryLabelKey(entry.mainCategory.id) as never)}{' '} + ·{' '} + {formatDate(entry.mainCategory.current.achievedOn, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + + {formatPersonalBestValue(entry.mainCategory.current.value)} + + + + + {entry.improvementDisplay + ? `${t('progress.pbs.improved.label')} ${entry.improvementDisplay}` + : t('progress.pbs.best.label')} + + {entry.isRecent ? ( + + {t('progress.pbs.badge.new')} + + ) : null} + + + ); +} diff --git a/app/i18n/en.json b/app/i18n/en.json index 7e3a2a51e..53860ae35 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -201,6 +201,44 @@ "generic.update.button": "Update", "generic.upgrade.button": "Upgrade", "generic.yes.button": "Yes", + "progress.pbs.title": "PBs", + "progress.pbs.list.title": "Personal Bests", + "progress.pbs.summary.total": "Total PBs", + "progress.pbs.summary.recent": "New in 30 days", + "progress.pbs.summary.strongest": "Strongest lift", + "progress.pbs.summary.improved": "Most improved", + "progress.pbs.sort.button": "Sort: {value}", + "progress.pbs.sort.most-recent": "Most recent", + "progress.pbs.sort.heaviest": "Heaviest", + "progress.pbs.sort.biggest-improvement": "Biggest improvement", + "progress.pbs.sort.alphabetical": "Alphabetical", + "progress.pbs.filter.all": "All", + "progress.pbs.filter.strength": "Strength", + "progress.pbs.filter.volume": "Volume", + "progress.pbs.filter.reps": "Reps", + "progress.pbs.filter.cardio": "Cardio", + "progress.pbs.filter.recent": "Recent", + "progress.pbs.empty.message": "Complete more workouts to unlock personal bests", + "progress.pbs.empty.filtered": "No personal bests match this filter yet", + "progress.pbs.improved.label": "Improved", + "progress.pbs.best.label": "Best effort", + "progress.pbs.badge.new": "NEW", + "progress.pbs.detail.title": "Personal Best", + "progress.pbs.detail.categories": "Best categories", + "progress.pbs.detail.timeline": "Record timeline", + "progress.pbs.detail.open_progress": "Open full exercise progress", + "progress.pbs.detail.back_to_progress": "Back to Progress", + "progress.pbs.detail.previous_best": "Previous best: {value} on {date}", + "progress.pbs.detail.first_best": "This is the first recorded best in this category", + "progress.pbs.detail.current_record": "Current record", + "progress.pbs.detail.record_progression": "Record progression", + "progress.pbs.detail.beat_previous": "Beat previous record: {value}", + "progress.pbs.category.max_weight": "Max weight", + "progress.pbs.category.estimated_1rm": "Best estimated 1RM", + "progress.pbs.category.session_volume": "Highest volume in one session", + "progress.pbs.category.reps_at_weight": "Most reps at a given weight", + "progress.pbs.category.longest_duration": "Longest duration", + "progress.pbs.category.longest_distance": "Longest distance", "muscles.arms.label": "Arms", "muscles.back.label": "Back", "muscles.biceps.label": "Biceps", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index f7b0126f3..4836b50ab 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -156,6 +156,44 @@ "generic.update.button": "Mettre à jour", "generic.upgrade.button": "Mettre à niveau", "generic.yes.button": "Oui", + "progress.pbs.title": "Records", + "progress.pbs.list.title": "Records personnels", + "progress.pbs.summary.total": "Total des records", + "progress.pbs.summary.recent": "Nouveaux en 30 jours", + "progress.pbs.summary.strongest": "Mouvement le plus fort", + "progress.pbs.summary.improved": "Plus forte progression", + "progress.pbs.sort.button": "Tri : {value}", + "progress.pbs.sort.most-recent": "Plus récent", + "progress.pbs.sort.heaviest": "Le plus lourd", + "progress.pbs.sort.biggest-improvement": "Plus grosse progression", + "progress.pbs.sort.alphabetical": "Alphabétique", + "progress.pbs.filter.all": "Tout", + "progress.pbs.filter.strength": "Force", + "progress.pbs.filter.volume": "Volume", + "progress.pbs.filter.reps": "Répétitions", + "progress.pbs.filter.cardio": "Cardio", + "progress.pbs.filter.recent": "Récent", + "progress.pbs.empty.message": "Terminez davantage d'entraînements pour débloquer vos records personnels", + "progress.pbs.empty.filtered": "Aucun record personnel ne correspond encore à ce filtre", + "progress.pbs.improved.label": "Progression", + "progress.pbs.best.label": "Meilleure perf", + "progress.pbs.badge.new": "NOUVEAU", + "progress.pbs.detail.title": "Record personnel", + "progress.pbs.detail.categories": "Meilleures catégories", + "progress.pbs.detail.timeline": "Chronologie des records", + "progress.pbs.detail.open_progress": "Ouvrir la progression complète", + "progress.pbs.detail.back_to_progress": "Retour à Progression", + "progress.pbs.detail.previous_best": "Record précédent : {value} le {date}", + "progress.pbs.detail.first_best": "C'est le premier record enregistré dans cette catégorie", + "progress.pbs.detail.current_record": "Record actuel", + "progress.pbs.detail.record_progression": "Progression du record", + "progress.pbs.detail.beat_previous": "Ancien record battu : {value}", + "progress.pbs.category.max_weight": "Charge max", + "progress.pbs.category.estimated_1rm": "Meilleur 1RM estimé", + "progress.pbs.category.session_volume": "Volume le plus élevé sur une séance", + "progress.pbs.category.reps_at_weight": "Plus de répétitions à une charge donnée", + "progress.pbs.category.longest_duration": "Durée la plus longue", + "progress.pbs.category.longest_distance": "Distance la plus longue", "muscles.arms.label": "Bras", "muscles.back.label": "Dos", "muscles.biceps.label": "Biceps", diff --git a/app/utils/personal-bests.ts b/app/utils/personal-bests.ts new file mode 100644 index 000000000..4e2fdc591 --- /dev/null +++ b/app/utils/personal-bests.ts @@ -0,0 +1,742 @@ +import { + Distance, + DistanceUnit, + NormalizedName, +} from '@/models/blueprint-models'; +import { Weight, WeightUnit } from '@/models/weight'; +import { + RecordedCardioExercise, + RecordedWeightedExercise, + Session, +} from '@/models/session-models'; +import { formatDuration } from '@/utils/format-date'; +import { formatDistance } from '@/utils/distance'; +import { LocalDate, OffsetDateTime, ZoneId, Duration } from '@js-joda/core'; +import BigNumber from 'bignumber.js'; + +export type PersonalBestFilter = + | 'all' + | 'strength' + | 'volume' + | 'reps' + | 'cardio' + | 'recent'; + +export type PersonalBestSort = + | 'most-recent' + | 'heaviest' + | 'biggest-improvement' + | 'alphabetical'; + +export type PersonalBestEntryKind = 'weighted' | 'cardio'; + +export type PersonalBestCategoryId = + | 'max-weight' + | 'estimated-1rm' + | 'session-volume' + | 'reps-at-weight' + | 'longest-duration' + | 'longest-distance'; + +export type PersonalBestValue = + | { kind: 'weight'; weight: Weight } + | { kind: 'volume'; weight: Weight } + | { kind: 'reps-at-weight'; reps: number; weight: Weight } + | { kind: 'duration'; duration: Duration } + | { kind: 'distance'; distance: Distance }; + +export interface PersonalBestRecord { + value: PersonalBestValue; + achievedOn: LocalDate; + achievedAt: OffsetDateTime; +} + +export interface PersonalBestCategorySummary { + id: PersonalBestCategoryId; + current: PersonalBestRecord; + previous?: PersonalBestRecord; + history: PersonalBestRecord[]; + isRecent: boolean; +} + +export interface PersonalBestListEntry { + exerciseKey: string; + exerciseName: string; + kind: PersonalBestEntryKind; + mainCategory: PersonalBestCategorySummary; + categories: PersonalBestCategorySummary[]; + availableFilters: PersonalBestFilter[]; + improvementDisplay?: string; + improvementScore: number; + heaviestComparable: Weight; + mostRecentDate: LocalDate; + isRecent: boolean; +} + +export interface PersonalBestSummary { + totalPbs: number; + recentPbs: number; + strongestLift?: PersonalBestListEntry; + mostImprovedLift?: PersonalBestListEntry; +} + +export interface PersonalBestOverview { + summary: PersonalBestSummary; + entries: PersonalBestListEntry[]; +} + +type Accumulator = { + exerciseKey: string; + exerciseName: string; + kind: PersonalBestEntryKind; + categories: Partial< + Record + >; +}; + +export function buildPersonalBestOverview( + sessions: Session[], + preferredUnit: WeightUnit, + now = LocalDate.now(), +): PersonalBestOverview { + const orderedSessions = [...sessions].sort((a, b) => + a.date.compareTo(b.date), + ); + const accumulators = new Map(); + + for (const session of orderedSessions) { + for (const exercise of session.recordedExercises) { + if (!exercise.isStarted) { + continue; + } + + if (exercise instanceof RecordedWeightedExercise) { + const exerciseKey = getExerciseKey('weighted', exercise.blueprint.name); + const accumulator = getOrCreateAccumulator( + accumulators, + exerciseKey, + exercise.blueprint.name, + 'weighted', + ); + collectWeightedPersonalBests(accumulator, exercise, session.date); + continue; + } + + const exerciseKey = getExerciseKey('cardio', exercise.blueprint.name); + const accumulator = getOrCreateAccumulator( + accumulators, + exerciseKey, + exercise.blueprint.name, + 'cardio', + ); + collectCardioPersonalBests(accumulator, exercise, session.date); + } + } + + const rawEntries = Array.from(accumulators.values()).map((accumulator) => + accumulatorToListEntry(accumulator, preferredUnit, now), + ); + const entries = rawEntries.filter( + (entry): entry is NonNullable<(typeof rawEntries)[number]> => + entry !== undefined, + ); + + const weightedEntries = entries.filter((entry) => entry.kind === 'weighted'); + + const strongestLift = weightedEntries + .slice() + .sort((a, b) => compareWeights(b.heaviestComparable, a.heaviestComparable)) + .at(0); + const mostImprovedLift = entries + .filter((entry) => entry.improvementScore > 0) + .slice() + .sort((a, b) => b.improvementScore - a.improvementScore) + .at(0); + + return { + summary: { + totalPbs: entries.length, + recentPbs: entries.filter((entry) => entry.isRecent).length, + ...(strongestLift ? { strongestLift } : {}), + ...(mostImprovedLift ? { mostImprovedLift } : {}), + }, + entries, + }; +} + +export function filterAndSortPersonalBestEntries( + entries: PersonalBestListEntry[], + filter: PersonalBestFilter, + sort: PersonalBestSort, +) { + return entries + .filter((entry) => entryMatchesFilter(entry, filter)) + .slice() + .sort((a, b) => compareEntries(a, b, sort)); +} + +export function formatPersonalBestValue( + value: PersonalBestValue, + decimalPlaces = 0, +) { + switch (value.kind) { + case 'weight': + case 'volume': + return value.weight.shortLocaleFormat(decimalPlaces); + case 'reps-at-weight': + return `${value.reps} @ ${value.weight.shortLocaleFormat(decimalPlaces)}`; + case 'duration': + return formatDuration(value.duration, 'hours-mins'); + case 'distance': + return formatDistance(value.distance); + } +} + +export function getPersonalBestCategoryLabelKey( + categoryId: PersonalBestCategoryId, +) { + switch (categoryId) { + case 'max-weight': + return 'progress.pbs.category.max_weight'; + case 'estimated-1rm': + return 'progress.pbs.category.estimated_1rm'; + case 'session-volume': + return 'progress.pbs.category.session_volume'; + case 'reps-at-weight': + return 'progress.pbs.category.reps_at_weight'; + case 'longest-duration': + return 'progress.pbs.category.longest_duration'; + case 'longest-distance': + return 'progress.pbs.category.longest_distance'; + } +} + +function collectWeightedPersonalBests( + accumulator: Accumulator, + exercise: RecordedWeightedExercise, + fallbackDate: LocalDate, +) { + for (const set of exercise.potentialSets) { + if (!set.set) { + continue; + } + + const achievedAt = set.set.completionDateTime; + const achievedOn = achievedAt.toLocalDate(); + recordPersonalBest( + accumulator, + 'max-weight', + { + value: { kind: 'weight', weight: set.weight }, + achievedAt, + achievedOn, + }, + comparePersonalBestValues, + ); + + const estimatedOneRepMax = set.weight.multipliedBy( + new BigNumber(1).plus(new BigNumber(set.set.repsCompleted).div(30)), + ); + recordPersonalBest( + accumulator, + 'estimated-1rm', + { + value: { kind: 'weight', weight: estimatedOneRepMax }, + achievedAt, + achievedOn, + }, + comparePersonalBestValues, + ); + + recordPersonalBest( + accumulator, + 'reps-at-weight', + { + value: { + kind: 'reps-at-weight', + reps: set.set.repsCompleted, + weight: set.weight, + }, + achievedAt, + achievedOn, + }, + comparePersonalBestValues, + ); + } + + const completedSets = exercise.potentialSets.filter((set) => !!set.set); + if (!completedSets.length) { + return; + } + + const achievedAt = + exercise.latestTime ?? + fallbackDate + .atTime(12, 0) + .atZone(ZoneId.systemDefault()) + .toOffsetDateTime(); + recordPersonalBest( + accumulator, + 'session-volume', + { + value: { + kind: 'volume', + weight: completedSets.reduce( + (sum, set) => + sum.plus(set.weight.multipliedBy(set.set?.repsCompleted ?? 0)), + Weight.NIL, + ), + }, + achievedAt, + achievedOn: achievedAt.toLocalDate(), + }, + comparePersonalBestValues, + ); +} + +function collectCardioPersonalBests( + accumulator: Accumulator, + exercise: RecordedCardioExercise, + fallbackDate: LocalDate, +) { + for (const set of exercise.sets) { + const achievedAt = + set.completionDateTime ?? + fallbackDate + .atTime(12, 0) + .atZone(ZoneId.systemDefault()) + .toOffsetDateTime(); + const achievedOn = achievedAt.toLocalDate(); + + if (set.duration && !set.duration.equals(Duration.ZERO)) { + recordPersonalBest( + accumulator, + 'longest-duration', + { + value: { kind: 'duration', duration: set.duration }, + achievedAt, + achievedOn, + }, + comparePersonalBestValues, + ); + } + + if (set.distance) { + recordPersonalBest( + accumulator, + 'longest-distance', + { + value: { kind: 'distance', distance: set.distance }, + achievedAt, + achievedOn, + }, + comparePersonalBestValues, + ); + } + } +} + +function recordPersonalBest( + accumulator: Accumulator, + categoryId: PersonalBestCategoryId, + record: PersonalBestRecord, + compare: (left: PersonalBestValue, right: PersonalBestValue) => number, +) { + const existing = accumulator.categories[categoryId]; + if (!existing) { + accumulator.categories[categoryId] = { + id: categoryId, + current: record, + history: [record], + isRecent: false, + }; + return; + } + + if (compare(record.value, existing.current.value) > 0) { + existing.previous = existing.current; + existing.current = record; + existing.history = [...existing.history, record]; + } +} + +function accumulatorToListEntry( + accumulator: Accumulator, + preferredUnit: WeightUnit, + now: LocalDate, +) { + const categories = Object.values(accumulator.categories) + .filter( + (category): category is PersonalBestCategorySummary => + !!category?.history.length, + ) + .map((category) => ({ + ...category, + isRecent: isRecentDate(category.current.achievedOn, now), + })); + + if (!categories.length) { + return undefined; + } + + const mainCategory = chooseMainCategory(accumulator.kind, categories); + const availableFilters = buildAvailableFilters(accumulator.kind, categories); + const heaviestComparable = getComparableWeight( + mainCategory.current.value, + ).convertTo(preferredUnit); + const improvementDisplay = formatImprovement(mainCategory, preferredUnit); + const improvementScore = getImprovementScore(mainCategory, preferredUnit); + const mostRecentDate = categories + .map((category) => category.current.achievedOn) + .sort((a, b) => b.compareTo(a)) + .at(0)!; + const isRecent = categories.some((category) => category.isRecent); + + return { + exerciseKey: accumulator.exerciseKey, + exerciseName: accumulator.exerciseName, + kind: accumulator.kind, + mainCategory, + categories, + availableFilters, + improvementScore, + heaviestComparable, + mostRecentDate, + isRecent, + ...(improvementDisplay ? { improvementDisplay } : {}), + }; +} + +function chooseMainCategory( + kind: PersonalBestEntryKind, + categories: PersonalBestCategorySummary[], +) { + if (kind === 'cardio') { + return ( + categories.find((category) => category.id === 'longest-distance') ?? + categories.find((category) => category.id === 'longest-duration') ?? + categories[0] + ); + } + + return ( + categories.find((category) => category.id === 'estimated-1rm') ?? + categories.find((category) => category.id === 'max-weight') ?? + categories[0] + ); +} + +function buildAvailableFilters( + kind: PersonalBestEntryKind, + categories: PersonalBestCategorySummary[], +): PersonalBestFilter[] { + const filters = new Set(['all']); + if (kind === 'cardio') { + filters.add('cardio'); + } else { + if ( + categories.some((category) => + ['max-weight', 'estimated-1rm'].includes(category.id), + ) + ) { + filters.add('strength'); + } + if (categories.some((category) => category.id === 'session-volume')) { + filters.add('volume'); + } + if (categories.some((category) => category.id === 'reps-at-weight')) { + filters.add('reps'); + } + } + return [...filters]; +} + +function formatImprovement( + category: PersonalBestCategorySummary, + preferredUnit: WeightUnit, +) { + if (!category.previous) { + return undefined; + } + + const currentValue = category.current.value; + const previousValue = category.previous.value; + if (currentValue.kind !== previousValue.kind) { + return undefined; + } + + switch (currentValue.kind) { + case 'weight': + case 'volume': { + const previousWeightValue = previousValue as Extract< + PersonalBestValue, + { kind: 'weight' | 'volume' } + >; + const change = currentValue.weight + .convertTo(preferredUnit) + .minus(previousWeightValue.weight.convertTo(preferredUnit)); + return `+${change.shortLocaleFormat(0)}`; + } + case 'reps-at-weight': { + const previousRepsValue = previousValue as Extract< + PersonalBestValue, + { kind: 'reps-at-weight' } + >; + const repsChange = currentValue.reps - previousRepsValue.reps; + if (repsChange > 0) { + return `+${repsChange}`; + } + const weightChange = currentValue.weight + .convertTo(preferredUnit) + .minus(previousRepsValue.weight.convertTo(preferredUnit)); + return `+${weightChange.shortLocaleFormat(0)}`; + } + case 'duration': { + const previousDurationValue = previousValue as Extract< + PersonalBestValue, + { kind: 'duration' } + >; + const minutes = + currentValue.duration.toMinutes() - + previousDurationValue.duration.toMinutes(); + return minutes > 0 ? `+${minutes} min` : undefined; + } + case 'distance': { + const previousDistanceValue = previousValue as Extract< + PersonalBestValue, + { kind: 'distance' } + >; + const metres = + toMetres(currentValue.distance) - + toMetres(previousDistanceValue.distance); + return metres > 0 + ? `+${formatDistance({ + unit: metres >= 1000 ? 'kilometre' : 'metre', + value: new BigNumber(metres >= 1000 ? metres / 1000 : metres), + })}` + : undefined; + } + } +} + +function getImprovementScore( + category: PersonalBestCategorySummary, + preferredUnit: WeightUnit, +) { + if (!category.previous) { + return 0; + } + + const currentValue = category.current.value; + const previousValue = category.previous.value; + if (currentValue.kind !== previousValue.kind) { + return 0; + } + + switch (currentValue.kind) { + case 'weight': + case 'volume': { + const previousWeightValue = previousValue as Extract< + PersonalBestValue, + { kind: 'weight' | 'volume' } + >; + return currentValue.weight + .convertTo(preferredUnit) + .minus(previousWeightValue.weight.convertTo(preferredUnit)) + .value.toNumber(); + } + case 'reps-at-weight': { + const previousRepsValue = previousValue as Extract< + PersonalBestValue, + { kind: 'reps-at-weight' } + >; + return ( + (currentValue.reps - previousRepsValue.reps) * 1000 + + currentValue.weight + .convertTo(preferredUnit) + .minus(previousRepsValue.weight.convertTo(preferredUnit)) + .value.toNumber() + ); + } + case 'duration': { + const previousDurationValue = previousValue as Extract< + PersonalBestValue, + { kind: 'duration' } + >; + return ( + currentValue.duration.seconds() - + previousDurationValue.duration.seconds() + ); + } + case 'distance': { + const previousDistanceValue = previousValue as Extract< + PersonalBestValue, + { kind: 'distance' } + >; + return ( + toMetres(currentValue.distance) - + toMetres(previousDistanceValue.distance) + ); + } + } +} + +function entryMatchesFilter( + entry: PersonalBestListEntry, + filter: PersonalBestFilter, +) { + if (filter === 'all') { + return true; + } + if (filter === 'recent') { + return entry.isRecent; + } + return entry.availableFilters.includes(filter); +} + +function compareEntries( + left: PersonalBestListEntry, + right: PersonalBestListEntry, + sort: PersonalBestSort, +) { + switch (sort) { + case 'alphabetical': + return left.exerciseName.localeCompare(right.exerciseName); + case 'biggest-improvement': + return ( + right.improvementScore - left.improvementScore || + left.exerciseName.localeCompare(right.exerciseName) + ); + case 'heaviest': + return ( + compareWeights(right.heaviestComparable, left.heaviestComparable) || + left.exerciseName.localeCompare(right.exerciseName) + ); + case 'most-recent': + return ( + right.mostRecentDate.compareTo(left.mostRecentDate) || + left.exerciseName.localeCompare(right.exerciseName) + ); + } +} + +function comparePersonalBestValues( + left: PersonalBestValue, + right: PersonalBestValue, +) { + if (left.kind !== right.kind) { + return 0; + } + + switch (left.kind) { + case 'weight': + case 'volume': { + const rightWeightValue = right as Extract< + PersonalBestValue, + { kind: 'weight' | 'volume' } + >; + return compareWeights(left.weight, rightWeightValue.weight); + } + case 'reps-at-weight': + return ( + left.reps - + (right as Extract) + .reps || + compareWeights( + left.weight, + (right as Extract) + .weight, + ) + ); + case 'duration': + return left.duration.compareTo( + (right as Extract).duration, + ); + case 'distance': + return ( + toMetres(left.distance) - + toMetres( + (right as Extract).distance, + ) + ); + } +} + +function compareWeights(left: Weight, right: Weight) { + if (left.equals(right, true)) { + return 0; + } + return left.isGreaterThan(right) ? 1 : -1; +} + +function getComparableWeight(value: PersonalBestValue) { + switch (value.kind) { + case 'weight': + case 'volume': + return value.weight; + case 'reps-at-weight': + return value.weight; + case 'duration': + return new Weight(value.duration.seconds(), 'kilograms'); + case 'distance': + return new Weight(toMetres(value.distance), 'kilograms'); + } +} + +function getExerciseKey(kind: PersonalBestEntryKind, exerciseName: string) { + return `${kind}:${new NormalizedName(exerciseName).toString()}`; +} + +function getOrCreateAccumulator( + accumulators: Map, + exerciseKey: string, + exerciseName: string, + kind: PersonalBestEntryKind, +) { + const existing = accumulators.get(exerciseKey); + if (existing) { + return existing; + } + + const accumulator: Accumulator = { + exerciseKey, + exerciseName, + kind, + categories: {}, + }; + accumulators.set(exerciseKey, accumulator); + return accumulator; +} + +function isRecentDate(date: LocalDate, now: LocalDate) { + return !date.isBefore(now.minusDays(30)); +} + +function toMetres(distance: Distance) { + switch (distance.unit) { + case 'metre': + return distance.value.toNumber(); + case 'kilometre': + return distance.value.multipliedBy(1000).toNumber(); + case 'mile': + return distance.value.multipliedBy(1609.344).toNumber(); + case 'yard': + return distance.value.multipliedBy(0.9144).toNumber(); + } +} + +export function metresToDistance( + metres: number, + unit: DistanceUnit = metres >= 1000 ? 'kilometre' : 'metre', +): Distance { + switch (unit) { + case 'metre': + return { unit, value: new BigNumber(metres) }; + case 'kilometre': + return { unit, value: new BigNumber(metres).dividedBy(1000) }; + case 'mile': + return { unit, value: new BigNumber(metres).dividedBy(1609.344) }; + case 'yard': + return { unit, value: new BigNumber(metres).dividedBy(0.9144) }; + } +} From 6addcebeb590cedd9c180466cb8f5431382fc693 Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 10:09:40 +0200 Subject: [PATCH 06/12] feat(progress): add combined progress tab --- app/app/(tabs)/_layout.tsx | 19 +- app/app/(tabs)/progress/_layout.tsx | 5 + app/app/(tabs)/progress/edit.tsx | 5 + .../progress/expanded-weighted-exercise.tsx | 5 + app/app/(tabs)/progress/index.tsx | 272 ++++++++++++++++++ .../layout/full-height-scroll-view.tsx | 20 +- .../layout/material-bottom-tabs.tsx | 16 +- .../stats/exercise-list-summary.tsx | 2 +- app/i18n/en.json | 1 + app/i18n/fr.json | 3 +- 10 files changed, 322 insertions(+), 26 deletions(-) create mode 100644 app/app/(tabs)/progress/_layout.tsx create mode 100644 app/app/(tabs)/progress/edit.tsx create mode 100644 app/app/(tabs)/progress/expanded-weighted-exercise.tsx create mode 100644 app/app/(tabs)/progress/index.tsx diff --git a/app/app/(tabs)/_layout.tsx b/app/app/(tabs)/_layout.tsx index b8360bd30..5add6a3a4 100644 --- a/app/app/(tabs)/_layout.tsx +++ b/app/app/(tabs)/_layout.tsx @@ -48,10 +48,10 @@ export default function Layout() { /> { return ( - - { - return ; - }, - }} - /> + + ; +} diff --git a/app/app/(tabs)/progress/edit.tsx b/app/app/(tabs)/progress/edit.tsx new file mode 100644 index 000000000..5dc1e0627 --- /dev/null +++ b/app/app/(tabs)/progress/edit.tsx @@ -0,0 +1,5 @@ +import { HistoryEditContent } from '@/components/presentation/data/history-edit-content'; + +export default function ProgressHistoryEditPage() { + return ; +} diff --git a/app/app/(tabs)/progress/expanded-weighted-exercise.tsx b/app/app/(tabs)/progress/expanded-weighted-exercise.tsx new file mode 100644 index 000000000..b21d16e40 --- /dev/null +++ b/app/app/(tabs)/progress/expanded-weighted-exercise.tsx @@ -0,0 +1,5 @@ +import { ExpandedWeightedExerciseContent } from '@/components/presentation/data/expanded-weighted-exercise-content'; + +export default function ExpandedExercisePage() { + return ; +} diff --git a/app/app/(tabs)/progress/index.tsx b/app/app/(tabs)/progress/index.tsx new file mode 100644 index 000000000..44dd506a3 --- /dev/null +++ b/app/app/(tabs)/progress/index.tsx @@ -0,0 +1,272 @@ +import { ExerciseStatsContent } from '@/components/presentation/data/exercise-stats-content'; +import { HistoryContent } from '@/components/presentation/data/history-content'; +import { PersonalBestsContent } from '@/components/presentation/data/personal-bests-content'; +import { spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { useTranslate } from '@tolgee/react'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Stack } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; + +type ProgressTab = 'exercises' | 'pbs' | 'history'; + +const FLOATING_TAB_TOP_OFFSET = spacing[2]; +const FLOATING_TAB_FLOAT_THRESHOLD = 4; + +export default function ProgressPage() { + const { t } = useTranslate(); + const { colors, colorScheme } = useAppTheme(); + const [tab, setTab] = useState('exercises'); + const [isDockFloating, setIsDockFloating] = useState(false); + const dockBackgroundColor = + colorScheme === 'dark' + ? colors.surfaceContainerHighest + : colors.surfaceContainer; + const dockBorderColor = colors.outlineVariant; + + useEffect(() => { + setIsDockFloating(false); + }, [tab]); + + const header = ( + + ); + + return ( + <> + + + {isDockFloating ? ( + + + + + ) : null} + {tab === 'exercises' ? ( + + setIsDockFloating( + event.nativeEvent.contentOffset.y > + FLOATING_TAB_FLOAT_THRESHOLD, + ) + } + contentContainerStyle={{ + gap: spacing[2], + paddingHorizontal: spacing.pageHorizontalMargin, + paddingBottom: spacing[6], + }} + /> + ) : tab === 'pbs' ? ( + + setIsDockFloating( + event.nativeEvent.contentOffset.y > + FLOATING_TAB_FLOAT_THRESHOLD, + ) + } + contentContainerStyle={{ + gap: spacing[4], + paddingHorizontal: spacing.pageHorizontalMargin, + paddingBottom: spacing[6], + }} + /> + ) : ( + + setIsDockFloating( + event.nativeEvent.contentOffset.y > + FLOATING_TAB_FLOAT_THRESHOLD, + ) + } + contentContainerStyle={{ + gap: spacing[4], + paddingHorizontal: spacing.pageHorizontalMargin, + paddingBottom: spacing[6], + }} + /> + )} + + + ); +} + +function ProgressTabsHeader({ + currentTab, + dockBackgroundColor, + dockBorderColor, + floating = false, + setTab, +}: { + currentTab: ProgressTab; + dockBackgroundColor: string; + dockBorderColor: string; + floating?: boolean; + setTab: (tab: ProgressTab) => void; +}) { + const { t } = useTranslate(); + const { colors } = useAppTheme(); + + return ( + + + setTab('exercises')} + /> + setTab('pbs')} + /> + setTab('history')} + /> + + + ); +} + +function ProgressTabButton({ + active, + color, + label, + onPress, +}: { + active: boolean; + color: ReturnType['colors']; + label: string; + onPress: () => void; +}) { + const contentColor = active ? color.onPrimaryContainer : '#ffffff'; + + return ( + [ + styles.tabButton, + { + backgroundColor: active ? color.primaryContainer : 'transparent', + opacity: pressed ? 0.92 : 1, + } as const, + ]} + > + + + {label} + + + + ); +} + +const styles = { + floatingTabWidgetOuter: { + position: 'absolute' as const, + left: 0, + right: 0, + top: FLOATING_TAB_TOP_OFFSET, + zIndex: 20, + }, + floatingTabWidgetShade: { + position: 'absolute' as const, + left: 0, + right: 0, + top: -spacing[2], + height: 72, + }, + tabWidgetOuter: { + paddingHorizontal: spacing.pageHorizontalMargin, + paddingTop: spacing[1], + paddingBottom: spacing[1], + }, + tabWidgetOuterFloating: { + paddingTop: 0, + paddingBottom: 0, + }, + tabWidget: { + flexDirection: 'row' as const, + alignItems: 'stretch' as const, + gap: spacing[1], + borderRadius: 24, + paddingHorizontal: spacing[1], + paddingVertical: spacing[0.5], + borderWidth: 1, + shadowOpacity: 0.16, + shadowRadius: 16, + shadowOffset: { width: 0, height: 8 }, + elevation: 8, + }, + tabButton: { + flex: 1, + minHeight: 32, + borderRadius: 14, + alignItems: 'center' as const, + justifyContent: 'center' as const, + paddingHorizontal: spacing[2], + paddingVertical: 0, + }, + tabButtonContent: { + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + tabLabel: { + fontSize: 11, + lineHeight: 14, + }, +}; diff --git a/app/components/layout/full-height-scroll-view.tsx b/app/components/layout/full-height-scroll-view.tsx index 35e2c7df5..50e1b7088 100644 --- a/app/components/layout/full-height-scroll-view.tsx +++ b/app/components/layout/full-height-scroll-view.tsx @@ -1,7 +1,13 @@ import { useAppTheme } from '@/hooks/useAppTheme'; import { useScroll } from '@/hooks/useScrollListener'; import { useState } from 'react'; -import { View, StyleProp, ViewStyle } from 'react-native'; +import { + NativeSyntheticEvent, + StyleProp, + View, + ViewStyle, + NativeScrollEvent, +} from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import type { AnimatedScrollViewComponent } from 'react-native-keyboard-controller/lib/typescript/components/ScrollViewWithBottomPadding'; @@ -12,16 +18,24 @@ export default function FullHeightScrollView({ scrollStyle, avoidKeyboard, contentContainerStyle, + onScroll, }: { children: React.ReactNode; floatingChildren?: React.ReactNode; avoidKeyboard?: boolean; scrollStyle?: StyleProp; contentContainerStyle?: StyleProp; + onScroll?: (event: NativeSyntheticEvent) => void; }) { const { colors } = useAppTheme(); const { handleScroll } = useScroll(); const [floatingBottomSize, setFloatingBottomSize] = useState(0); + const handleCombinedScroll = ( + event: NativeSyntheticEvent, + ) => { + handleScroll(event); + onScroll?.(event); + }; return ( {!avoidKeyboard ? ( @@ -44,7 +58,7 @@ export default function FullHeightScrollView({ ) : ( diff --git a/app/components/layout/material-bottom-tabs.tsx b/app/components/layout/material-bottom-tabs.tsx index e8d4a0bfb..f90795821 100644 --- a/app/components/layout/material-bottom-tabs.tsx +++ b/app/components/layout/material-bottom-tabs.tsx @@ -25,6 +25,7 @@ export function MaterialBottomTabs({ }: MaterialBottomTabsProps) { const showFeed = useAppSelector((x) => x.settings.showFeed); const { dismissTo } = useRouter(); + return ( { + const hiddenRouteNames = new Set(['stats', 'history']); const routes = state.routes.filter( - (x) => showFeed || !x.name.includes('feed'), + (route) => + (showFeed || !route.name.includes('feed')) && + !hiddenRouteNames.has(route.name), ); + return ( r.key === route.key); + state.routes.findIndex((candidate) => candidate.key === route.key); if (shouldPopToTop) { navigation.navigate(route.name, route.params); @@ -72,7 +74,7 @@ export function MaterialBottomTabs({ // Not focused: navigate normally navigation.dispatch({ ...CommonActions.navigate(route.name, route.params), - target: state.key, // target the tab navigator + target: state.key, }); } }} diff --git a/app/components/presentation/stats/exercise-list-summary.tsx b/app/components/presentation/stats/exercise-list-summary.tsx index d69ad0eed..37ef29f3a 100644 --- a/app/components/presentation/stats/exercise-list-summary.tsx +++ b/app/components/presentation/stats/exercise-list-summary.tsx @@ -28,7 +28,7 @@ export function ExerciseListSummary(props: { stats: GranularStatisticView }) { const onItemPress = (item: WeightedExerciseStatistics) => { bottomSheetRef.current?.close(); push( - `/(tabs)/stats/expanded-weighted-exercise?exerciseName=${encodeURIComponent(item.exerciseName)}`, + `/(tabs)/progress/expanded-weighted-exercise?exerciseName=${encodeURIComponent(item.exerciseName)}`, ); }; return ( diff --git a/app/i18n/en.json b/app/i18n/en.json index 53860ae35..b84bb4ded 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -202,6 +202,7 @@ "generic.upgrade.button": "Upgrade", "generic.yes.button": "Yes", "progress.pbs.title": "PBs", + "progress.title": "Progress", "progress.pbs.list.title": "Personal Bests", "progress.pbs.summary.total": "Total PBs", "progress.pbs.summary.recent": "New in 30 days", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index 4836b50ab..0ba2df2ec 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -157,6 +157,7 @@ "generic.upgrade.button": "Mettre à niveau", "generic.yes.button": "Oui", "progress.pbs.title": "Records", + "progress.title": "Progrès", "progress.pbs.list.title": "Records personnels", "progress.pbs.summary.total": "Total des records", "progress.pbs.summary.recent": "Nouveaux en 30 jours", @@ -331,4 +332,4 @@ "workout.update_existing.confirm.body": "Voulez-vous mettre à jour l'entraînement existant nommé {name}, ou ajouter un nouvel entraînement ?", "workout.workout.label": "Entraînement", "workout.workout_lowercase.label": "Entraînement" -} +} \ No newline at end of file From ed40a5baa650ee5db41bcd144509f5a7ddbe161d Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 10:46:07 +0200 Subject: [PATCH 07/12] style(tabs): align progress sub tabs with feed --- app/app/(tabs)/feed/index.tsx | 6 +- app/app/(tabs)/progress/index.tsx | 335 ++++++++---------------------- 2 files changed, 86 insertions(+), 255 deletions(-) diff --git a/app/app/(tabs)/feed/index.tsx b/app/app/(tabs)/feed/index.tsx index 64d2b7c3e..6ef221691 100644 --- a/app/app/(tabs)/feed/index.tsx +++ b/app/app/(tabs)/feed/index.tsx @@ -6,6 +6,7 @@ import { useScroll, useScrollHeaderColor, } from '@/hooks/useScrollListener'; +import { spacing } from '@/hooks/useAppTheme'; import { useAppSelector } from '@/store'; import { selectFollowRequestCount } from '@/store/feed'; import { useTranslate } from '@tolgee/react'; @@ -38,7 +39,10 @@ export default function FeedIndexPage() { diff --git a/app/app/(tabs)/progress/index.tsx b/app/app/(tabs)/progress/index.tsx index 44dd506a3..ce3196f6f 100644 --- a/app/app/(tabs)/progress/index.tsx +++ b/app/app/(tabs)/progress/index.tsx @@ -1,272 +1,99 @@ import { ExerciseStatsContent } from '@/components/presentation/data/exercise-stats-content'; import { HistoryContent } from '@/components/presentation/data/history-content'; import { PersonalBestsContent } from '@/components/presentation/data/personal-bests-content'; -import { spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { + ScrollProvider, + useScroll, + useScrollHeaderColor, +} from '@/hooks/useScrollListener'; +import { spacing } from '@/hooks/useAppTheme'; import { useTranslate } from '@tolgee/react'; -import { LinearGradient } from 'expo-linear-gradient'; import { Stack } from 'expo-router'; import { useEffect, useState } from 'react'; -import { Pressable, Text, View } from 'react-native'; - -type ProgressTab = 'exercises' | 'pbs' | 'history'; - -const FLOATING_TAB_TOP_OFFSET = spacing[2]; -const FLOATING_TAB_FLOAT_THRESHOLD = 4; +import { Tabs, TabScreen, TabsProvider } from 'react-native-paper-tabs'; export default function ProgressPage() { const { t } = useTranslate(); - const { colors, colorScheme } = useAppTheme(); - const [tab, setTab] = useState('exercises'); - const [isDockFloating, setIsDockFloating] = useState(false); - const dockBackgroundColor = - colorScheme === 'dark' - ? colors.surfaceContainerHighest - : colors.surfaceContainer; - const dockBorderColor = colors.outlineVariant; + const { setScrolled } = useScroll(); + const headerColor = useScrollHeaderColor(); - useEffect(() => { - setIsDockFloating(false); - }, [tab]); + const [activeTabIndex, setActiveTabIndex] = useState(0); + const [tabScrolls, setTabScrolls] = useState>({}); - const header = ( - - ); + const setTabScrolled = (isScrolled: boolean, tabIndex: number) => { + if (tabScrolls[tabIndex] !== isScrolled) { + setTabScrolls((current) => ({ ...current, [tabIndex]: isScrolled })); + } + }; + + useEffect(() => { + setScrolled(!!tabScrolls[activeTabIndex]); + }, [activeTabIndex, setScrolled, tabScrolls]); return ( <> - - {isDockFloating ? ( - - - - - ) : null} - {tab === 'exercises' ? ( - - setIsDockFloating( - event.nativeEvent.contentOffset.y > - FLOATING_TAB_FLOAT_THRESHOLD, - ) - } - contentContainerStyle={{ - gap: spacing[2], - paddingHorizontal: spacing.pageHorizontalMargin, - paddingBottom: spacing[6], - }} - /> - ) : tab === 'pbs' ? ( - - setIsDockFloating( - event.nativeEvent.contentOffset.y > - FLOATING_TAB_FLOAT_THRESHOLD, - ) - } - contentContainerStyle={{ - gap: spacing[4], - paddingHorizontal: spacing.pageHorizontalMargin, - paddingBottom: spacing[6], - }} - /> - ) : ( - - setIsDockFloating( - event.nativeEvent.contentOffset.y > - FLOATING_TAB_FLOAT_THRESHOLD, - ) - } - contentContainerStyle={{ - gap: spacing[4], - paddingHorizontal: spacing.pageHorizontalMargin, - paddingBottom: spacing[6], - }} - /> - )} - - - ); -} - -function ProgressTabsHeader({ - currentTab, - dockBackgroundColor, - dockBorderColor, - floating = false, - setTab, -}: { - currentTab: ProgressTab; - dockBackgroundColor: string; - dockBorderColor: string; - floating?: boolean; - setTab: (tab: ProgressTab) => void; -}) { - const { t } = useTranslate(); - const { colors } = useAppTheme(); - - return ( - - - setTab('exercises')} - /> - setTab('pbs')} - /> - setTab('history')} - /> - - - ); -} - -function ProgressTabButton({ - active, - color, - label, - onPress, -}: { - active: boolean; - color: ReturnType['colors']; - label: string; - onPress: () => void; -}) { - const contentColor = active ? color.onPrimaryContainer : '#ffffff'; - - return ( - [ - styles.tabButton, - { - backgroundColor: active ? color.primaryContainer : 'transparent', - opacity: pressed ? 0.92 : 1, - } as const, - ]} - > - - + - {label} - - - + + setTabScrolled(scrolled, 0)} + > + + setTabScrolled(event.nativeEvent.contentOffset.y > 0, 0) + } + contentContainerStyle={{ + gap: spacing[2], + paddingHorizontal: spacing.pageHorizontalMargin, + paddingBottom: spacing[6], + }} + /> + + + + setTabScrolled(scrolled, 1)} + > + + setTabScrolled(event.nativeEvent.contentOffset.y > 0, 1) + } + contentContainerStyle={{ + gap: spacing[4], + paddingHorizontal: spacing.pageHorizontalMargin, + paddingBottom: spacing[6], + }} + /> + + + + setTabScrolled(scrolled, 2)} + > + + setTabScrolled(event.nativeEvent.contentOffset.y > 0, 2) + } + contentContainerStyle={{ + gap: spacing[4], + paddingHorizontal: spacing.pageHorizontalMargin, + paddingBottom: spacing[6], + }} + /> + + + + + ); } - -const styles = { - floatingTabWidgetOuter: { - position: 'absolute' as const, - left: 0, - right: 0, - top: FLOATING_TAB_TOP_OFFSET, - zIndex: 20, - }, - floatingTabWidgetShade: { - position: 'absolute' as const, - left: 0, - right: 0, - top: -spacing[2], - height: 72, - }, - tabWidgetOuter: { - paddingHorizontal: spacing.pageHorizontalMargin, - paddingTop: spacing[1], - paddingBottom: spacing[1], - }, - tabWidgetOuterFloating: { - paddingTop: 0, - paddingBottom: 0, - }, - tabWidget: { - flexDirection: 'row' as const, - alignItems: 'stretch' as const, - gap: spacing[1], - borderRadius: 24, - paddingHorizontal: spacing[1], - paddingVertical: spacing[0.5], - borderWidth: 1, - shadowOpacity: 0.16, - shadowRadius: 16, - shadowOffset: { width: 0, height: 8 }, - elevation: 8, - }, - tabButton: { - flex: 1, - minHeight: 32, - borderRadius: 14, - alignItems: 'center' as const, - justifyContent: 'center' as const, - paddingHorizontal: spacing[2], - paddingVertical: 0, - }, - tabButtonContent: { - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - tabLabel: { - fontSize: 11, - lineHeight: 14, - }, -}; From 76c24d1512ea9529617770639bcdb79efd470af6 Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 11:21:26 +0200 Subject: [PATCH 08/12] feat(progress): redesign personal best views --- .../expanded-weighted-exercise-content.tsx | 2 +- .../data/history-edit-content.tsx | 2 +- .../data/personal-best-detail-content.tsx | 90 +++-- .../data/personal-best-visuals.tsx | 345 ++++++++++++++++++ .../data/personal-bests-content.tsx | 121 +++--- .../stats/exercise-list-summary.tsx | 2 +- app/i18n/en.json | 18 +- app/i18n/fr.json | 21 +- app/utils/personal-bests.ts | 224 +++++++++--- 9 files changed, 692 insertions(+), 133 deletions(-) create mode 100644 app/components/presentation/data/personal-best-visuals.tsx diff --git a/app/components/presentation/data/expanded-weighted-exercise-content.tsx b/app/components/presentation/data/expanded-weighted-exercise-content.tsx index 5b77f76b7..49290d1d5 100644 --- a/app/components/presentation/data/expanded-weighted-exercise-content.tsx +++ b/app/components/presentation/data/expanded-weighted-exercise-content.tsx @@ -37,7 +37,7 @@ export function ExpandedWeightedExerciseContent(props: { useEffect(() => { if (!exerciseName) { - dismissTo(props.emptyRoute); + dismissTo(props.emptyRoute as never); } }, [dismissTo, exerciseName, props.emptyRoute]); diff --git a/app/components/presentation/data/history-edit-content.tsx b/app/components/presentation/data/history-edit-content.tsx index 9eff2ce29..d984870f7 100644 --- a/app/components/presentation/data/history-edit-content.tsx +++ b/app/components/presentation/data/history-edit-content.tsx @@ -24,7 +24,7 @@ export function HistoryEditContent(props: { useEffect(() => { if (!session) { - dismissTo(props.emptyRoute); + dismissTo(props.emptyRoute as never); } }, [dismissTo, props.emptyRoute, session]); diff --git a/app/components/presentation/data/personal-best-detail-content.tsx b/app/components/presentation/data/personal-best-detail-content.tsx index 9f70d04cb..f223ea677 100644 --- a/app/components/presentation/data/personal-best-detail-content.tsx +++ b/app/components/presentation/data/personal-best-detail-content.tsx @@ -1,4 +1,8 @@ import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; +import { + PersonalBestCategoryBadge, + PersonalBestTrendChart, +} from '@/components/presentation/data/personal-best-visuals'; import EmptyInfo from '@/components/presentation/foundation/empty-info'; import Button from '@/components/presentation/foundation/gesture-wrappers/button'; import { SegmentedList } from '@/components/presentation/foundation/segmented-list'; @@ -22,12 +26,16 @@ import { Card, Text } from 'react-native-paper'; export function PersonalBestDetailContent() { const { t } = useTranslate(); const { colors } = useAppTheme(); - const { exerciseKey } = useLocalSearchParams<{ exerciseKey: string }>(); + const { exerciseKey, categoryId } = useLocalSearchParams<{ + exerciseKey: string; + categoryId: PersonalBestCategorySummary['id']; + }>(); const { dismissTo, push } = useRouter(); const sessions = useAppSelector(selectSessions); const preferredUnit = useAppSelector(selectPreferredWeightUnit); const entry = buildPersonalBestOverview(sessions, preferredUnit).entries.find( - (item) => item.exerciseKey === exerciseKey, + (item) => + item.exerciseKey === exerciseKey && item.category.id === categoryId, ); if (!entry) { @@ -41,21 +49,28 @@ export function PersonalBestDetailContent() { ); } - const timeline = [...entry.mainCategory.history].reverse(); + const timeline = [...entry.category.history].reverse(); return ( - - - {formatPersonalBestValue(entry.mainCategory.current.value)} - - - {t(getPersonalBestCategoryLabelKey(entry.mainCategory.id) as never)} - + + + + + {formatPersonalBestValue(entry.category.current.value)} + + + {t(getPersonalBestCategoryLabelKey(entry.category.id) as never)} + + + push( - `/(tabs)/progress/expanded-weighted-exercise?exerciseName=${encodeURIComponent(entry.exerciseName)}`, + `/(tabs)/progress/expanded-weighted-exercise?exerciseName=${encodeURIComponent(entry.exerciseName)}` as never, ) } > {t('progress.pbs.detail.open_progress')} ) : null} - @@ -111,30 +126,43 @@ function CategorySummaryCard({ style={{ alignItems: 'flex-start', flexDirection: 'row', - justifyContent: 'space-between', gap: spacing[2], }} > - - - {t(getPersonalBestCategoryLabelKey(category.id) as never)} - - - {formatDate(category.current.achievedOn, { - day: 'numeric', - month: 'short', - year: 'numeric', - })} + + + + + {t(getPersonalBestCategoryLabelKey(category.id) as never)} + + + {formatDate(category.current.achievedOn, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + + {formatPersonalBestValue(category.current.value)} - - {formatPersonalBestValue(category.current.value)} - {category.previous diff --git a/app/components/presentation/data/personal-best-visuals.tsx b/app/components/presentation/data/personal-best-visuals.tsx new file mode 100644 index 000000000..a6ac7757e --- /dev/null +++ b/app/components/presentation/data/personal-best-visuals.tsx @@ -0,0 +1,345 @@ +import Icon from '@/components/presentation/foundation/gesture-wrappers/icon'; +import { AppIconSource } from '@/components/presentation/foundation/ms-icon-source'; +import { lineGraphProps } from '@/components/presentation/stats/line-graph-props'; +import { font, spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { useFormatDate } from '@/hooks/useFormatDate'; +import { Distance } from '@/models/blueprint-models'; +import { WeightUnit } from '@/models/weight'; +import { + formatPersonalBestValue, + PersonalBestCategoryId, + PersonalBestCategorySummary, + PersonalBestValue, +} from '@/utils/personal-bests'; +import { useTranslate } from '@tolgee/react'; +import { lineDataItem, LineChart } from 'react-native-gifted-charts'; +import { useEffect, useMemo, useState } from 'react'; +import { View } from 'react-native'; +import { Card, Text } from 'react-native-paper'; + +export function PersonalBestCategoryBadge({ + categoryId, + size = 40, +}: { + categoryId: PersonalBestCategoryId; + size?: number; +}) { + const { colors } = useAppTheme(); + + return ( + + + + ); +} + +export function PersonalBestTrendChart({ + category, + preferredUnit, +}: { + category: PersonalBestCategorySummary; + preferredUnit: WeightUnit; +}) { + const formatDate = useFormatDate(); + const { colors } = useAppTheme(); + const { t } = useTranslate(); + const [width, setWidth] = useState(0); + const [areaChart, setAreaChart] = useState(false); + const measure = useMemo( + () => getChartMeasure(category.history, preferredUnit, t), + [category.history, preferredUnit, t], + ); + + useEffect(() => { + setAreaChart(!!width); + }, [width]); + + if (category.history.length < 2) { + return ( + + + {measure.title} + + {measure.singlePointLabel} + + + + ); + } + + const points: lineDataItem[] = category.history.map((record) => { + const value = getChartValue( + record.value, + preferredUnit, + measure.distanceUnit, + ); + const label = formatDate(record.achievedOn, { + day: 'numeric', + month: 'short', + }); + + return { + value, + label, + focusedDataPointLabelComponent: () => ( + + ), + }; + }); + + const yValues = points.map((point) => point.value ?? 0); + const maxValue = Math.max(...yValues); + const minValue = Math.min(...yValues); + const offset = minValue > 0 ? Math.max(Math.floor(minValue * 0.95), 0) : 0; + + return ( + + + + {measure.title} + + {measure.subtitle} + + + setWidth(event.nativeEvent.layout.width)}> + + + + + ); +} + +export function getPersonalBestCategoryIcon( + categoryId: PersonalBestCategoryId, +): AppIconSource { + switch (categoryId) { + case 'max-weight': + return 'fitnessCenter'; + case 'estimated-1rm': + return 'function'; + case 'session-volume': + return 'anchor'; + case 'reps-at-weight': + return 'barChart'; + case 'longest-duration': + return 'timer'; + case 'longest-distance': + return 'trailLength'; + } +} + +interface ChartMeasure { + axisSuffix: string; + distanceUnit: Distance['unit'] | undefined; + showFractionalValues: boolean; + singlePointLabel: string; + subtitle: string; + title: string; +} + +function getChartMeasure( + history: PersonalBestCategorySummary['history'], + preferredUnit: WeightUnit, + t: ReturnType['t'], +): ChartMeasure { + const currentValue = history.at(-1)?.value; + if (!currentValue) { + return { + axisSuffix: '', + distanceUnit: undefined as Distance['unit'] | undefined, + showFractionalValues: false, + singlePointLabel: '', + subtitle: '', + title: '', + }; + } + + switch (currentValue.kind) { + case 'weight': + return { + axisSuffix: preferredUnit === 'kilograms' ? 'kg' : 'lb', + distanceUnit: undefined, + showFractionalValues: false, + singlePointLabel: formatPersonalBestValue(currentValue), + subtitle: t('progress.pbs.detail.chart.weight.subtitle'), + title: t('progress.pbs.detail.chart.weight.title'), + }; + case 'volume': + return { + axisSuffix: preferredUnit === 'kilograms' ? 'kg' : 'lb', + distanceUnit: undefined, + showFractionalValues: false, + singlePointLabel: formatPersonalBestValue(currentValue), + subtitle: t('progress.pbs.detail.chart.volume.subtitle'), + title: t('progress.pbs.detail.chart.volume.title'), + }; + case 'reps-at-weight': + return { + axisSuffix: ' reps', + distanceUnit: undefined, + showFractionalValues: false, + singlePointLabel: formatPersonalBestValue(currentValue), + subtitle: t('progress.pbs.detail.chart.reps.subtitle'), + title: t('progress.pbs.detail.chart.reps.title'), + }; + case 'duration': + return { + axisSuffix: ' min', + distanceUnit: undefined, + showFractionalValues: false, + singlePointLabel: formatPersonalBestValue(currentValue), + subtitle: t('progress.pbs.detail.chart.duration.subtitle'), + title: t('progress.pbs.detail.chart.duration.title'), + }; + case 'distance': { + const distanceHistory = history + .map((record) => record.value) + .filter( + (value): value is Extract => + value.kind === 'distance', + ); + const maxMetres = Math.max( + ...distanceHistory.map((value) => toMetres(value.distance)), + ); + const distanceUnit = maxMetres >= 1000 ? 'kilometre' : 'metre'; + return { + axisSuffix: distanceUnit === 'kilometre' ? 'km' : 'm', + distanceUnit, + showFractionalValues: distanceUnit === 'kilometre', + singlePointLabel: formatPersonalBestValue(currentValue), + subtitle: t('progress.pbs.detail.chart.distance.subtitle'), + title: t('progress.pbs.detail.chart.distance.title'), + }; + } + } +} + +function getChartValue( + value: PersonalBestValue, + preferredUnit: WeightUnit, + distanceUnit?: ChartMeasure['distanceUnit'], +) { + switch (value.kind) { + case 'weight': + case 'volume': + return value.weight.convertTo(preferredUnit).value.toNumber(); + case 'reps-at-weight': + return value.reps; + case 'duration': + return value.duration.toMinutes(); + case 'distance': + return convertDistanceForChart(value.distance, distanceUnit); + } +} + +function convertDistanceForChart( + distance: Distance, + unit: Distance['unit'] = 'metre', +) { + const metres = toMetres(distance); + switch (unit) { + case 'kilometre': + return metres / 1000; + case 'mile': + return metres / 1609.344; + case 'yard': + return metres / 0.9144; + case 'metre': + return metres; + } +} + +function toMetres(distance: Distance) { + switch (distance.unit) { + case 'metre': + return distance.value.toNumber(); + case 'kilometre': + return distance.value.multipliedBy(1000).toNumber(); + case 'mile': + return distance.value.multipliedBy(1609.344).toNumber(); + case 'yard': + return distance.value.multipliedBy(0.9144).toNumber(); + } +} + +function FocusedRecordPoint({ + label, + value, +}: { + label: string; + value: string; +}) { + const { colors } = useAppTheme(); + + return ( + + + {label} + + {value} + + ); +} diff --git a/app/components/presentation/data/personal-bests-content.tsx b/app/components/presentation/data/personal-bests-content.tsx index dd27dd438..43bab4ce1 100644 --- a/app/components/presentation/data/personal-bests-content.tsx +++ b/app/components/presentation/data/personal-bests-content.tsx @@ -3,6 +3,7 @@ import EmptyInfo from '@/components/presentation/foundation/empty-info'; import SelectButton, { SelectButtonOption, } from '@/components/presentation/foundation/select-button'; +import { PersonalBestCategoryBadge } from '@/components/presentation/data/personal-best-visuals'; import { SegmentedList } from '@/components/presentation/foundation/segmented-list'; import { TitledSection } from '@/components/presentation/stats/titled-section'; import { font, spacing, useAppTheme } from '@/hooks/useAppTheme'; @@ -30,7 +31,7 @@ import { View, ViewStyle, } from 'react-native'; -import { Card, Chip, SegmentedButtons, Text } from 'react-native-paper'; +import { Card, SegmentedButtons, Text } from 'react-native-paper'; const filterOptions: SelectButtonOption[] = [ { value: 'most-recent', label: 'Most recent' }, @@ -95,10 +96,10 @@ export function PersonalBestsContent(props: { ) : ( item.exerciseKey} + itemKey={(item) => item.entryKey} onItemPress={(item) => push( - `/(tabs)/progress/personal-best?exerciseKey=${encodeURIComponent(item.exerciseKey)}`, + `/(tabs)/progress/personal-best?exerciseKey=${encodeURIComponent(item.exerciseKey)}&categoryId=${encodeURIComponent(item.category.id)}` as never, ) } renderItem={(item) => } @@ -134,7 +135,7 @@ function PersonalBestSummaryRow({ value={ overview.summary.strongestLift ? `${overview.summary.strongestLift.exerciseName} · ${formatPersonalBestValue( - overview.summary.strongestLift.mainCategory.current.value, + overview.summary.strongestLift.category.current.value, )}` : '-' } @@ -179,25 +180,52 @@ function FilterBar({ setFilter: (value: PersonalBestFilter) => void; }) { const { t } = useTranslate(); + const { colors } = useAppTheme(); return ( - - setFilter(value as PersonalBestFilter)} - value={filter} - buttons={['all', 'strength', 'volume', 'reps', 'cardio', 'recent'].map( - (value) => ({ + + + setFilter(value as PersonalBestFilter)} + value={filter} + buttons={[ + 'all', + 'strength', + 'volume', + 'reps', + 'cardio', + 'recent', + ].map((value) => ({ value, label: t(`progress.pbs.filter.${value}` as never), - }), - )} - /> - + }))} + /> + + + {filter !== 'all' ? ( + + {t(`progress.pbs.filter.subtitle.${filter}` as never)} + + ) : null} + + ); } @@ -212,30 +240,42 @@ function PersonalBestRow({ entry }: { entry: PersonalBestListEntry }) { style={{ alignItems: 'flex-start', flexDirection: 'row', - justifyContent: 'space-between', gap: spacing[2], }} > - - {entry.exerciseName} - - {t(getPersonalBestCategoryLabelKey(entry.mainCategory.id) as never)}{' '} - ·{' '} - {formatDate(entry.mainCategory.current.achievedOn, { - day: 'numeric', - month: 'short', - year: 'numeric', - })} + + + + {entry.exerciseName} + + {t(getPersonalBestCategoryLabelKey(entry.category.id) as never)} ·{' '} + {formatDate(entry.category.current.achievedOn, { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + + {formatPersonalBestValue(entry.category.current.value)} - - {formatPersonalBestValue(entry.mainCategory.current.value)} - - {entry.isRecent ? ( - - {t('progress.pbs.badge.new')} - - ) : null} ); diff --git a/app/components/presentation/stats/exercise-list-summary.tsx b/app/components/presentation/stats/exercise-list-summary.tsx index 37ef29f3a..9be42c74f 100644 --- a/app/components/presentation/stats/exercise-list-summary.tsx +++ b/app/components/presentation/stats/exercise-list-summary.tsx @@ -28,7 +28,7 @@ export function ExerciseListSummary(props: { stats: GranularStatisticView }) { const onItemPress = (item: WeightedExerciseStatistics) => { bottomSheetRef.current?.close(); push( - `/(tabs)/progress/expanded-weighted-exercise?exerciseName=${encodeURIComponent(item.exerciseName)}`, + `/(tabs)/progress/expanded-weighted-exercise?exerciseName=${encodeURIComponent(item.exerciseName)}` as never, ); }; return ( diff --git a/app/i18n/en.json b/app/i18n/en.json index b84bb4ded..9b4f15c5d 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -219,11 +219,16 @@ "progress.pbs.filter.reps": "Reps", "progress.pbs.filter.cardio": "Cardio", "progress.pbs.filter.recent": "Recent", + "progress.pbs.filter.subtitle.all": "All personal record types.", + "progress.pbs.filter.subtitle.strength": "Heaviest weight and estimated strength.", + "progress.pbs.filter.subtitle.volume": "Most total weight moved in one workout.", + "progress.pbs.filter.subtitle.reps": "Most reps completed at a given weight.", + "progress.pbs.filter.subtitle.cardio": "Longest cardio time and distance.", + "progress.pbs.filter.subtitle.recent": "Records achieved in the last 30 days.", "progress.pbs.empty.message": "Complete more workouts to unlock personal bests", "progress.pbs.empty.filtered": "No personal bests match this filter yet", "progress.pbs.improved.label": "Improved", "progress.pbs.best.label": "Best effort", - "progress.pbs.badge.new": "NEW", "progress.pbs.detail.title": "Personal Best", "progress.pbs.detail.categories": "Best categories", "progress.pbs.detail.timeline": "Record timeline", @@ -234,6 +239,16 @@ "progress.pbs.detail.current_record": "Current record", "progress.pbs.detail.record_progression": "Record progression", "progress.pbs.detail.beat_previous": "Beat previous record: {value}", + "progress.pbs.detail.chart.weight.title": "Weight over time", + "progress.pbs.detail.chart.weight.subtitle": "Each point shows the top weight achieved for this record.", + "progress.pbs.detail.chart.volume.title": "Volume over time", + "progress.pbs.detail.chart.volume.subtitle": "Each point shows the best total session volume reached.", + "progress.pbs.detail.chart.reps.title": "Reps over time", + "progress.pbs.detail.chart.reps.subtitle": "Each point shows how many reps were completed at the record weight.", + "progress.pbs.detail.chart.duration.title": "Duration over time", + "progress.pbs.detail.chart.duration.subtitle": "Each point shows the longest duration recorded for this exercise.", + "progress.pbs.detail.chart.distance.title": "Distance over time", + "progress.pbs.detail.chart.distance.subtitle": "Each point shows the farthest distance recorded for this exercise.", "progress.pbs.category.max_weight": "Max weight", "progress.pbs.category.estimated_1rm": "Best estimated 1RM", "progress.pbs.category.session_volume": "Highest volume in one session", @@ -466,6 +481,7 @@ "workout.replace_current_session.button": "Replace current session", "workout.replace_in_progress.confirm.body": "There is already a workout in progress, replace it without saving?", "workout.resume.button": "Resume workout", + "workout.summary.personal_bests.title": "Personal bests", "workout.summary.title": "Workout summary", "workout.session_in_progress.message": "There is already a workout in progress, start a new one without saving?", "workout.sessions.title": "Workouts", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index 0ba2df2ec..2795a6df8 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -174,11 +174,16 @@ "progress.pbs.filter.reps": "Répétitions", "progress.pbs.filter.cardio": "Cardio", "progress.pbs.filter.recent": "Récent", + "progress.pbs.filter.subtitle.all": "Tous les types de records personnels.", + "progress.pbs.filter.subtitle.strength": "Charge la plus lourde et force estimée.", + "progress.pbs.filter.subtitle.volume": "Le plus grand volume total sur une séance.", + "progress.pbs.filter.subtitle.reps": "Le plus de répétitions à une charge donnée.", + "progress.pbs.filter.subtitle.cardio": "Durée et distance cardio maximales.", + "progress.pbs.filter.subtitle.recent": "Records réalisés sur les 30 derniers jours.", "progress.pbs.empty.message": "Terminez davantage d'entraînements pour débloquer vos records personnels", "progress.pbs.empty.filtered": "Aucun record personnel ne correspond encore à ce filtre", "progress.pbs.improved.label": "Progression", "progress.pbs.best.label": "Meilleure perf", - "progress.pbs.badge.new": "NOUVEAU", "progress.pbs.detail.title": "Record personnel", "progress.pbs.detail.categories": "Meilleures catégories", "progress.pbs.detail.timeline": "Chronologie des records", @@ -189,6 +194,16 @@ "progress.pbs.detail.current_record": "Record actuel", "progress.pbs.detail.record_progression": "Progression du record", "progress.pbs.detail.beat_previous": "Ancien record battu : {value}", + "progress.pbs.detail.chart.weight.title": "Charge au fil du temps", + "progress.pbs.detail.chart.weight.subtitle": "Chaque point montre la meilleure charge atteinte pour ce record.", + "progress.pbs.detail.chart.volume.title": "Volume au fil du temps", + "progress.pbs.detail.chart.volume.subtitle": "Chaque point montre le meilleur volume total atteint sur une séance.", + "progress.pbs.detail.chart.reps.title": "Répétitions au fil du temps", + "progress.pbs.detail.chart.reps.subtitle": "Chaque point montre le nombre de répétitions réalisées à la charge du record.", + "progress.pbs.detail.chart.duration.title": "Durée au fil du temps", + "progress.pbs.detail.chart.duration.subtitle": "Chaque point montre la durée la plus longue enregistrée pour cet exercice.", + "progress.pbs.detail.chart.distance.title": "Distance au fil du temps", + "progress.pbs.detail.chart.distance.subtitle": "Chaque point montre la plus grande distance enregistrée pour cet exercice.", "progress.pbs.category.max_weight": "Charge max", "progress.pbs.category.estimated_1rm": "Meilleur 1RM estimé", "progress.pbs.category.session_volume": "Volume le plus élevé sur une séance", @@ -323,6 +338,8 @@ "workout.replace_current_session.button": "Remplacer la séance actuelle", "workout.replace_in_progress.confirm.body": "Une séance d'entraînement est déjà en cours, la remplacer sans sauvegarder ?", "workout.resume.button": "Reprendre l'entraînement", + "workout.summary.personal_bests.title": "Records personnels", + "workout.summary.title": "Résumé de l'entraînement", "workout.session_in_progress.message": "Une séance d'entraînement est déjà en cours, en commencer une nouvelle sans sauvegarder ?", "workout.sessions.title": "Séances", "workout.start.button": "Commencer l'entraînement", @@ -332,4 +349,4 @@ "workout.update_existing.confirm.body": "Voulez-vous mettre à jour l'entraînement existant nommé {name}, ou ajouter un nouvel entraînement ?", "workout.workout.label": "Entraînement", "workout.workout_lowercase.label": "Entraînement" -} \ No newline at end of file +} diff --git a/app/utils/personal-bests.ts b/app/utils/personal-bests.ts index 4e2fdc591..954f44a08 100644 --- a/app/utils/personal-bests.ts +++ b/app/utils/personal-bests.ts @@ -60,10 +60,11 @@ export interface PersonalBestCategorySummary { } export interface PersonalBestListEntry { + entryKey: string; exerciseKey: string; exerciseName: string; kind: PersonalBestEntryKind; - mainCategory: PersonalBestCategorySummary; + category: PersonalBestCategorySummary; categories: PersonalBestCategorySummary[]; availableFilters: PersonalBestFilter[]; improvementDisplay?: string; @@ -85,6 +86,12 @@ export interface PersonalBestOverview { entries: PersonalBestListEntry[]; } +export interface PersonalBestPill { + key: string; + label: string; + ariaLabel: string; +} + type Accumulator = { exerciseKey: string; exerciseName: string; @@ -133,12 +140,8 @@ export function buildPersonalBestOverview( } } - const rawEntries = Array.from(accumulators.values()).map((accumulator) => - accumulatorToListEntry(accumulator, preferredUnit, now), - ); - const entries = rawEntries.filter( - (entry): entry is NonNullable<(typeof rawEntries)[number]> => - entry !== undefined, + const entries = Array.from(accumulators.values()).flatMap((accumulator) => + accumulatorToListEntries(accumulator, preferredUnit, now), ); const weightedEntries = entries.filter((entry) => entry.kind === 'weighted'); @@ -175,6 +178,87 @@ export function filterAndSortPersonalBestEntries( .sort((a, b) => compareEntries(a, b, sort)); } +export function getSessionPersonalBestEntries( + sessions: Session[], + session: Session, + preferredUnit: WeightUnit, +) { + const sessionsWithTarget = sessions.some((item) => item.id === session.id) + ? sessions + : [...sessions, session]; + const allEntries = buildPersonalBestOverview( + sessionsWithTarget, + preferredUnit, + ).entries; + const previousEntries = buildPersonalBestOverview( + sessionsWithTarget.filter((item) => item.id !== session.id), + preferredUnit, + ).entries; + const previousEntriesByKey = new Map( + previousEntries.map((entry) => [entry.entryKey, entry]), + ); + + return allEntries + .filter((entry) => { + if (!entry.category.current.achievedOn.equals(session.date)) { + return false; + } + + const previousEntry = previousEntriesByKey.get(entry.entryKey); + if (!previousEntry) { + return true; + } + + return ( + comparePersonalBestValues( + entry.category.current.value, + previousEntry.category.current.value, + ) > 0 + ); + }) + .sort((left, right) => compareEntries(left, right, 'most-recent')); +} + +export function getWeightedExercisePersonalBestPills( + previousSessions: Session[], + currentSession: Session, + currentExercise: RecordedWeightedExercise, + preferredUnit: WeightUnit, +): PersonalBestPill[] { + const exerciseKey = getExerciseKey( + 'weighted', + currentExercise.blueprint.name, + ); + const categoryOrder: PersonalBestCategoryId[] = [ + 'estimated-1rm', + 'session-volume', + 'max-weight', + 'reps-at-weight', + ]; + const entries = getSessionPersonalBestEntries( + previousSessions, + currentSession, + preferredUnit, + ).filter((entry) => entry.exerciseKey === exerciseKey); + const entriesByCategory = new Map( + entries.map((entry) => [entry.category.id, entry]), + ); + + return categoryOrder.flatMap((categoryId) => { + const entry = entriesByCategory.get(categoryId); + if (!entry) { + return []; + } + return [ + { + key: entry.entryKey, + label: formatPersonalBestPillLabel(entry.category, preferredUnit), + ariaLabel: getPersonalBestCategoryLabelKey(entry.category.id), + }, + ]; + }); +} + export function formatPersonalBestValue( value: PersonalBestValue, decimalPlaces = 0, @@ -211,6 +295,62 @@ export function getPersonalBestCategoryLabelKey( } } +function formatPersonalBestPillLabel( + category: PersonalBestCategorySummary, + preferredUnit: WeightUnit, +) { + const prefix = getPersonalBestPillPrefix(category.id); + if (!category.previous) { + return prefix; + } + + if (category.id === 'reps-at-weight') { + const current = category.current.value; + const previous = category.previous.value; + if (current.kind === 'reps-at-weight' && previous.kind === 'reps-at-weight') { + const repsDiff = current.reps - previous.reps; + if (repsDiff > 0) { + return `${prefix}+${repsDiff}`; + } + } + } + + const currentComparable = getComparableWeight( + category.current.value, + ).convertTo(preferredUnit); + const previousComparable = getComparableWeight( + category.previous.value, + ).convertTo(preferredUnit); + if (previousComparable.value.lte(0)) { + return prefix; + } + + const percent = currentComparable + .minus(previousComparable) + .value.multipliedBy(100) + .dividedBy(previousComparable.value) + .decimalPlaces(0, BigNumber.ROUND_HALF_UP); + + return percent.gt(0) ? `${prefix}+${percent.toString()}%` : prefix; +} + +function getPersonalBestPillPrefix(categoryId: PersonalBestCategoryId) { + switch (categoryId) { + case 'estimated-1rm': + return 'eRM'; + case 'session-volume': + return 'TW'; + case 'max-weight': + return 'MW'; + case 'reps-at-weight': + return 'R@W'; + case 'longest-duration': + return 'DUR'; + case 'longest-distance': + return 'DIST'; + } +} + function collectWeightedPersonalBests( accumulator: Accumulator, exercise: RecordedWeightedExercise, @@ -360,7 +500,7 @@ function recordPersonalBest( } } -function accumulatorToListEntry( +function accumulatorToListEntries( accumulator: Accumulator, preferredUnit: WeightUnit, now: LocalDate, @@ -376,54 +516,32 @@ function accumulatorToListEntry( })); if (!categories.length) { - return undefined; + return []; } - const mainCategory = chooseMainCategory(accumulator.kind, categories); const availableFilters = buildAvailableFilters(accumulator.kind, categories); - const heaviestComparable = getComparableWeight( - mainCategory.current.value, - ).convertTo(preferredUnit); - const improvementDisplay = formatImprovement(mainCategory, preferredUnit); - const improvementScore = getImprovementScore(mainCategory, preferredUnit); - const mostRecentDate = categories - .map((category) => category.current.achievedOn) - .sort((a, b) => b.compareTo(a)) - .at(0)!; - const isRecent = categories.some((category) => category.isRecent); - - return { - exerciseKey: accumulator.exerciseKey, - exerciseName: accumulator.exerciseName, - kind: accumulator.kind, - mainCategory, - categories, - availableFilters, - improvementScore, - heaviestComparable, - mostRecentDate, - isRecent, - ...(improvementDisplay ? { improvementDisplay } : {}), - }; -} - -function chooseMainCategory( - kind: PersonalBestEntryKind, - categories: PersonalBestCategorySummary[], -) { - if (kind === 'cardio') { - return ( - categories.find((category) => category.id === 'longest-distance') ?? - categories.find((category) => category.id === 'longest-duration') ?? - categories[0] - ); - } - - return ( - categories.find((category) => category.id === 'estimated-1rm') ?? - categories.find((category) => category.id === 'max-weight') ?? - categories[0] - ); + return categories.map((category) => { + const heaviestComparable = getComparableWeight( + category.current.value, + ).convertTo(preferredUnit); + const improvementDisplay = formatImprovement(category, preferredUnit); + const improvementScore = getImprovementScore(category, preferredUnit); + + return { + entryKey: `${accumulator.exerciseKey}:${category.id}`, + exerciseKey: accumulator.exerciseKey, + exerciseName: accumulator.exerciseName, + kind: accumulator.kind, + category, + categories, + availableFilters, + improvementScore, + heaviestComparable, + mostRecentDate: category.current.achievedOn, + isRecent: category.isRecent, + ...(improvementDisplay ? { improvementDisplay } : {}), + }; + }); } function buildAvailableFilters( From 9d6fb614068c20279a485edd6717e6f88abf2539 Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Wed, 8 Apr 2026 11:21:42 +0200 Subject: [PATCH 09/12] feat(workout): show personal bests in summary --- .../(tabs)/(session)/session/post-workout.tsx | 14 ++- app/app/(tabs)/history/post-workout.tsx | 14 ++- .../workout/session-comparison-table.tsx | 88 ++++++++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/app/app/(tabs)/(session)/session/post-workout.tsx b/app/app/(tabs)/(session)/session/post-workout.tsx index 78c8b5ccf..ed56e9312 100644 --- a/app/app/(tabs)/(session)/session/post-workout.tsx +++ b/app/app/(tabs)/(session)/session/post-workout.tsx @@ -2,15 +2,18 @@ import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; import FloatingBottomContainer from '@/components/presentation/foundation/floating-bottom-container'; import { SessionComparisonTable } from '@/components/presentation/workout/session-comparison-table'; import { spacing } from '@/hooks/useAppTheme'; -import { useAppSelectorWithArg } from '@/store'; +import { useAppSelector, useAppSelectorWithArg } from '@/store'; import { finishCurrentWorkout, selectCurrentSession, } from '@/store/current-session'; +import { selectPreferredWeightUnit } from '@/store/settings'; import { selectPreviousComparableSession, + selectSessions, selectSession, } from '@/store/stored-sessions'; +import { getSessionPersonalBestEntries } from '@/utils/personal-bests'; import { useTranslate } from '@tolgee/react'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect } from 'react'; @@ -36,6 +39,8 @@ export default function PostWorkoutPage() { const openedAfterFinishingWorkout = source === 'finished'; const showFinishButton = openedAfterFinishingWorkout; const showBackButton = !openedAfterFinishingWorkout; + const sessions = useAppSelector(selectSessions); + const preferredUnit = useAppSelector(selectPreferredWeightUnit); const previousComparableSession = useAppSelectorWithArg( selectPreviousComparableSession, session, @@ -54,6 +59,12 @@ export default function PostWorkoutPage() { return null; } + const personalBestEntries = getSessionPersonalBestEntries( + sessions, + session, + preferredUnit, + ); + const floatingBottomContainer = showFinishButton ? ( diff --git a/app/app/(tabs)/history/post-workout.tsx b/app/app/(tabs)/history/post-workout.tsx index 8c5c33d31..d7f471b80 100644 --- a/app/app/(tabs)/history/post-workout.tsx +++ b/app/app/(tabs)/history/post-workout.tsx @@ -2,12 +2,15 @@ import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; import FloatingBottomContainer from '@/components/presentation/foundation/floating-bottom-container'; import { SessionComparisonTable } from '@/components/presentation/workout/session-comparison-table'; import { spacing } from '@/hooks/useAppTheme'; -import { useAppSelectorWithArg } from '@/store'; +import { useAppSelector, useAppSelectorWithArg } from '@/store'; import { selectCurrentSession } from '@/store/current-session'; +import { selectPreferredWeightUnit } from '@/store/settings'; import { selectPreviousComparableSession, + selectSessions, selectSession, } from '@/store/stored-sessions'; +import { getSessionPersonalBestEntries } from '@/utils/personal-bests'; import { useTranslate } from '@tolgee/react'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect } from 'react'; @@ -32,6 +35,8 @@ export default function PostWorkoutPage() { const openedAfterFinishingWorkout = source === 'finished'; const showFinishButton = openedAfterFinishingWorkout; const showBackButton = !openedAfterFinishingWorkout; + const sessions = useAppSelector(selectSessions); + const preferredUnit = useAppSelector(selectPreferredWeightUnit); const previousComparableSession = useAppSelectorWithArg( selectPreviousComparableSession, session, @@ -49,6 +54,12 @@ export default function PostWorkoutPage() { return null; } + const personalBestEntries = getSessionPersonalBestEntries( + sessions, + session, + preferredUnit, + ); + const floatingBottomContainer = showFinishButton ? ( diff --git a/app/components/presentation/workout/session-comparison-table.tsx b/app/components/presentation/workout/session-comparison-table.tsx index 7fde370b9..09c5fba76 100644 --- a/app/components/presentation/workout/session-comparison-table.tsx +++ b/app/components/presentation/workout/session-comparison-table.tsx @@ -1,3 +1,4 @@ +import { PersonalBestCategoryBadge } from '@/components/presentation/data/personal-best-visuals'; import { NormalizedName } from '@/models/blueprint-models'; import WeightFormat from '@/components/presentation/foundation/weight-format'; import { useAppTheme, spacing } from '@/hooks/useAppTheme'; @@ -5,13 +6,20 @@ import { RecordedWeightedExercise, Session } from '@/models/session-models'; import { Weight } from '@/models/weight'; import { formatDuration } from '@/utils/format-date'; import { localeFormatBigNumber } from '@/utils/locale-bignumber'; +import { + formatPersonalBestValue, + getPersonalBestCategoryLabelKey, + PersonalBestListEntry, +} from '@/utils/personal-bests'; import { T, useTranslate } from '@tolgee/react'; import BigNumber from 'bignumber.js'; import { View } from 'react-native'; -import { Text } from 'react-native-paper'; +import { Card, Text } from 'react-native-paper'; interface SessionComparisonTableProps { mode: 'compact' | 'full'; + onPress?: (() => void) | undefined; + personalBestEntries?: PersonalBestListEntry[] | undefined; previousSession?: Session | undefined; session: Session; } @@ -355,12 +363,90 @@ export function SessionComparisonTable(props: SessionComparisonTableProps) { ))} ) : null} + {props.mode === 'full' && props.personalBestEntries?.length ? ( + + + {t('workout.summary.personal_bests.title')} + + + {props.personalBestEntries.map((entry) => ( + + ))} + + + ) : null} ); return content; } +function PersonalBestWorkoutCard({ entry }: { entry: PersonalBestListEntry }) { + const { colors } = useAppTheme(); + const { t } = useTranslate(); + + return ( + + + + + + + + {entry.exerciseName} + + {t( + getPersonalBestCategoryLabelKey(entry.category.id) as never, + )} + + + + {formatPersonalBestValue(entry.category.current.value)} + + + + + {entry.improvementDisplay + ? `${t('progress.pbs.improved.label')} ${entry.improvementDisplay}` + : t('progress.pbs.best.label')} + + + + + ); +} + function getWeightedExerciseComparisons( session: Session, previousSession: Session | undefined, From 141691b588d5949c0cb6fa7e5329161bb6ec414b Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Thu, 16 Apr 2026 15:32:29 +0200 Subject: [PATCH 10/12] refactor(personal-bests): remove alphabetical sort option --- app/components/presentation/data/personal-bests-content.tsx | 1 - app/i18n/en.json | 1 - app/i18n/fr.json | 1 - app/utils/personal-bests.ts | 5 +---- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/components/presentation/data/personal-bests-content.tsx b/app/components/presentation/data/personal-bests-content.tsx index 43bab4ce1..59f0ca0d8 100644 --- a/app/components/presentation/data/personal-bests-content.tsx +++ b/app/components/presentation/data/personal-bests-content.tsx @@ -37,7 +37,6 @@ const filterOptions: SelectButtonOption[] = [ { value: 'most-recent', label: 'Most recent' }, { value: 'heaviest', label: 'Heaviest' }, { value: 'biggest-improvement', label: 'Biggest improvement' }, - { value: 'alphabetical', label: 'Alphabetical' }, ]; export function PersonalBestsContent(props: { diff --git a/app/i18n/en.json b/app/i18n/en.json index 9b4f15c5d..82e016daf 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -212,7 +212,6 @@ "progress.pbs.sort.most-recent": "Most recent", "progress.pbs.sort.heaviest": "Heaviest", "progress.pbs.sort.biggest-improvement": "Biggest improvement", - "progress.pbs.sort.alphabetical": "Alphabetical", "progress.pbs.filter.all": "All", "progress.pbs.filter.strength": "Strength", "progress.pbs.filter.volume": "Volume", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index 2795a6df8..0f6c4a0e8 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -167,7 +167,6 @@ "progress.pbs.sort.most-recent": "Plus récent", "progress.pbs.sort.heaviest": "Le plus lourd", "progress.pbs.sort.biggest-improvement": "Plus grosse progression", - "progress.pbs.sort.alphabetical": "Alphabétique", "progress.pbs.filter.all": "Tout", "progress.pbs.filter.strength": "Force", "progress.pbs.filter.volume": "Volume", diff --git a/app/utils/personal-bests.ts b/app/utils/personal-bests.ts index 954f44a08..49fb27442 100644 --- a/app/utils/personal-bests.ts +++ b/app/utils/personal-bests.ts @@ -25,8 +25,7 @@ export type PersonalBestFilter = export type PersonalBestSort = | 'most-recent' | 'heaviest' - | 'biggest-improvement' - | 'alphabetical'; + | 'biggest-improvement'; export type PersonalBestEntryKind = 'weighted' | 'cardio'; @@ -718,8 +717,6 @@ function compareEntries( sort: PersonalBestSort, ) { switch (sort) { - case 'alphabetical': - return left.exerciseName.localeCompare(right.exerciseName); case 'biggest-improvement': return ( right.improvementScore - left.improvementScore || From 2b16dfb10c48e89955789bc8ca29afb8db4df3ed Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Thu, 16 Apr 2026 19:46:09 +0200 Subject: [PATCH 11/12] feat(workout): add current workout personal best rail --- .../workout/weighted/personal-best-rail.tsx | 62 ++++++++ app/utils/personal-bests.spec.ts | 139 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 app/components/presentation/workout/weighted/personal-best-rail.tsx create mode 100644 app/utils/personal-bests.spec.ts diff --git a/app/components/presentation/workout/weighted/personal-best-rail.tsx b/app/components/presentation/workout/weighted/personal-best-rail.tsx new file mode 100644 index 000000000..abb4eb378 --- /dev/null +++ b/app/components/presentation/workout/weighted/personal-best-rail.tsx @@ -0,0 +1,62 @@ +import { useAppTheme, spacing } from '@/hooks/useAppTheme'; +import { PersonalBestPill } from '@/utils/personal-bests'; +import { useTranslate } from '@tolgee/react'; +import { View } from 'react-native'; +import { SurfaceText } from '@/components/presentation/foundation/surface-text'; + +export default function PersonalBestRail({ + pills, +}: { + pills: PersonalBestPill[]; +}) { + const { colors } = useAppTheme(); + const { t } = useTranslate(); + + if (!pills.length) { + return null; + } + + return ( + + + {pills.map((pill) => ( + + + {pill.label} + + + ))} + + + ); +} diff --git a/app/utils/personal-bests.spec.ts b/app/utils/personal-bests.spec.ts new file mode 100644 index 000000000..24694a38b --- /dev/null +++ b/app/utils/personal-bests.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LocalDate, ZoneId } from '@js-joda/core'; +import { benchPress } from '@/models/test-data'; +import { + PotentialSetPOJO, + RecordedWeightedExercise, + Session, +} from '@/models/session-models'; +import { Weight } from '@/models/weight'; +import { + getWeightedExercisePersonalBestPills, + type PersonalBestPill, +} from '@/utils/personal-bests'; + +vi.mock('expo-localization', () => ({ + getLocales: () => [ + { + decimalSeparator: '.', + }, + ], +})); + +function buildSession( + id: string, + date: LocalDate, + exercise: RecordedWeightedExercise, +) { + return Session.fromPOJO({ + id, + blueprint: { + type: 'SessionBlueprint', + name: 'Workout', + notes: '', + exercises: [exercise.blueprint.toPOJO()], + }, + recordedExercises: [exercise.toPOJO()], + date, + bodyweight: undefined, + }); +} + +function buildWeightedExercise( + weight: number, + reps: number, + date: LocalDate, +) { + const completionDateTime = date + .atTime(10, 0) + .atZone(ZoneId.systemDefault()) + .toOffsetDateTime(); + + return benchPress.with({ + potentialSets: [ + { + type: 'PotentialSet', + weight: new Weight(weight, 'kilograms'), + set: { + type: 'RecordedSet', + repsCompleted: reps, + completionDateTime, + }, + } satisfies PotentialSetPOJO, + { + type: 'PotentialSet', + weight: new Weight(weight, 'kilograms'), + set: { + type: 'RecordedSet', + repsCompleted: reps, + completionDateTime, + }, + } satisfies PotentialSetPOJO, + ], + }); +} + +describe('getWeightedExercisePersonalBestPills', () => { + it('returns compact PB pills for a current workout exercise', () => { + const currentDate = LocalDate.of(2026, 4, 16); + const previousDate = LocalDate.of(2026, 4, 9); + const currentExercise = buildWeightedExercise(101, 11, currentDate); + const previousExercise = buildWeightedExercise(100, 10, previousDate); + const currentSession = buildSession('current', currentDate, currentExercise); + const previousSession = buildSession( + 'previous', + previousDate, + previousExercise, + ); + + const pills = getWeightedExercisePersonalBestPills( + [previousSession], + currentSession, + currentExercise, + 'kilograms', + ); + + expect(pills).toEqual([ + { + key: 'weighted:bench pres:estimated-1rm', + label: 'eRM+4%', + ariaLabel: 'progress.pbs.category.estimated_1rm', + }, + { + key: 'weighted:bench pres:session-volume', + label: 'TW+11%', + ariaLabel: 'progress.pbs.category.session_volume', + }, + { + key: 'weighted:bench pres:max-weight', + label: 'MW+1%', + ariaLabel: 'progress.pbs.category.max_weight', + }, + { + key: 'weighted:bench pres:reps-at-weight', + label: 'R@W+1', + ariaLabel: 'progress.pbs.category.reps_at_weight', + }, + ]); + }); + + it('omits the delta when there is no previous personal best', () => { + const currentDate = LocalDate.of(2026, 4, 16); + const currentExercise = buildWeightedExercise(101, 10, currentDate); + const currentSession = buildSession('current', currentDate, currentExercise); + + const pills = getWeightedExercisePersonalBestPills( + [], + currentSession, + currentExercise, + 'kilograms', + ); + + expect(pills.map((pill) => pill.label)).toEqual([ + 'eRM', + 'TW', + 'MW', + 'R@W', + ]); + }); +}); From 286ccd1939c50aa098e8df0518d3accd2d82a655 Mon Sep 17 00:00:00 2001 From: MohamedKiouaz Date: Sun, 12 Apr 2026 11:33:44 +0200 Subject: [PATCH 12/12] fix(web): add browser service resolver --- app/app.json | 10 ++- app/services/index.web.ts | 132 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/services/index.web.ts diff --git a/app/app.json b/app/app.json index f74ac06d8..63afecd24 100644 --- a/app/app.json +++ b/app/app.json @@ -17,7 +17,15 @@ "backgroundColor": "#fcfdf6", "primaryColor": "#046F03", "plugins": [ - "expo-router", + [ + "expo-router", + { + "headers": { + "Cross-Origin-Embedder-Policy": "credentialless", + "Cross-Origin-Opener-Policy": "same-origin" + } + } + ], [ "expo-dev-client", { diff --git a/app/services/index.web.ts b/app/services/index.web.ts new file mode 100644 index 000000000..f5037e210 --- /dev/null +++ b/app/services/index.web.ts @@ -0,0 +1,132 @@ +import { AiChatService } from '@/services/ai-chat-service'; +import { EncryptionService } from '@/services/encryption-service'; +import { FeedApiService } from '@/services/feed-api'; +import { FeedFollowService } from '@/services/feed-follow-service'; +import { FeedIdentityService } from '@/services/feed-identity-service'; +import { FeedInboxDecryptionService } from '@/services/feed-inbox-decryption-service'; +import { FileExportService } from '@/services/file-export-service'; +import { FilePickerService } from '@/services/file-picker-service'; +import { HubConnectionFactory } from '@/services/hub-connection-factory'; +import { KeyValueStore } from '@/services/key-value-store'; +import { Logger } from '@/services/logger'; +import { NotificationService } from '@/services/notification-service'; +import { PreferenceService } from '@/services/preference-service'; +import { ProgressRepository } from '@/services/progress-repository'; +import { SessionService } from '@/services/session-service'; +import { StringSharer } from '@/services/string-sharer'; +import { getTolgee } from '@/services/tolgee'; +import { WorkoutWorker } from '@/services/workout-worker'; +import type { RootState } from '@/store'; +import type { Store } from '@reduxjs/toolkit'; +import { HealthExportService } from './health-export-service'; +import type { HealthExportService as HES } from './health-export-service-shared'; +import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; + +export type Services = Awaited>; + +class WebDatabaseMigrationService { + constructor( + private readonly db: ExpoSQLiteDatabase, + private readonly keyValueStore: KeyValueStore, + private readonly preferenceService: PreferenceService, + ) {} + + async migrate(): Promise { + void this.db; + void this.keyValueStore; + void this.preferenceService; + } +} + +function createWebDbProxy(): ExpoSQLiteDatabase { + return new Proxy( + {}, + { + get(_target, prop) { + throw new Error( + `SQLite is not available on web in LiftLog (attempted to access db.${String(prop)}).`, + ); + }, + }, + ) as ExpoSQLiteDatabase; +} + +const db = createWebDbProxy(); +let resolvedServices: Services | undefined; + +function resolveServicesInternal(store: Store) { + if (!store) { + throw new Error('Tried to resolve services without store'); + } + const logger = new Logger(); + const keyValueStore = new KeyValueStore(); + const progressRepository = new ProgressRepository(store.getState); + const sessionService = new SessionService(progressRepository, store.getState); + const notificationService = new NotificationService( + store.getState, + store.dispatch, + ); + const encryptionService = new EncryptionService(); + const feedApiService = new FeedApiService(); + const feedIdentityService = new FeedIdentityService( + feedApiService, + encryptionService, + ); + const feedInboxDecryptionService = new FeedInboxDecryptionService( + encryptionService, + feedApiService, + ); + const feedFollowService = new FeedFollowService( + feedApiService, + encryptionService, + ); + const stringSharer = new StringSharer(); + const fileExportService = new FileExportService(); + const filePickerService = new FilePickerService(); + const preferenceService = new PreferenceService(keyValueStore); + const aiChatService = new AiChatService( + new HubConnectionFactory(), + store.getState, + ); + const tolgee = getTolgee(preferenceService); + const workoutWorkerService = new WorkoutWorker( + store.dispatch, + store.getState, + tolgee, + ); + const healthExportService: HES = new HealthExportService(); + const databaseMigrationService = new WebDatabaseMigrationService( + db, + keyValueStore, + preferenceService, + ); + + return { + logger, + keyValueStore, + progressRepository, + sessionService, + notificationService, + encryptionService, + feedFollowService, + feedInboxDecryptionService, + feedApiService, + feedIdentityService, + healthExportService, + stringSharer, + fileExportService, + filePickerService, + preferenceService, + aiChatService, + workoutWorkerService, + tolgee, + db, + databaseMigrationService, + }; +} + +function resolveServices(store: Store) { + return (resolvedServices ??= resolveServicesInternal(store)); +} + +export { resolveServices, db };