diff --git a/app/src/app/(tabs)/(session)/index.tsx b/app/src/app/(tabs)/(session)/index.tsx index 7cf4321d1..04402157b 100644 --- a/app/src/app/(tabs)/(session)/index.tsx +++ b/app/src/app/(tabs)/(session)/index.tsx @@ -23,7 +23,7 @@ import { executeRemoteBackup } from '@/store/settings'; import { LocalDate } from '@js-joda/core'; import { T, useTranslate } from '@tolgee/react'; import { Stack, useFocusEffect, useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { View } from 'react-native'; import { Card, Icon as PaperIcon, Text, Tooltip } from 'react-native-paper'; import Button from '@/components/presentation/foundation/button'; @@ -32,13 +32,16 @@ import { WelcomeWizard } from '@/components/smart/welcome-wizard'; import { WhatsNewBanner } from '@/components/smart/whats-new-banner'; import { SharedSession } from '@/models/feed-models'; import { useStartWorkoutWithConfirmation } from '@/hooks/useStartWorkoutWithConfirmation'; +import { RemoteData } from '@/models/remote'; function ListUpcomingWorkouts({ upcoming, startSession, + preview = false, }: { upcoming: readonly Session[]; startSession: (s: Session) => void; + preview?: boolean; }) { const plan = useAppSelector(selectActiveProgram); const { t } = useTranslate(); @@ -115,7 +118,7 @@ function ListUpcomingWorkouts({ renderItemContent={(session) => { return ( - + ); }} @@ -126,7 +129,7 @@ function ListUpcomingWorkouts({ }; return ( - handleSharePress(session)} /> + {!preview && handleSharePress(session)} />} {sessionPlanIndex !== -1 ? ( ) : undefined} @@ -217,22 +220,47 @@ function NoUpcomingWorkouts() { ); } -function SessionCardContent({ session }: { session: Session }) { +function SessionCardContent({ session, showWeight = true }: { session: Session; showWeight?: boolean }) { return ( } - mainContent={} + mainContent={} /> ); } export default function Index() { const upcomingSessions = useAppSelector((s) => s.program.upcomingSessions); + const progressionReady = upcomingSessions.isSuccess(); + const activeSession = useAppSelector(selectActiveSession); + const plan = useAppSelector(selectActiveProgram); + const useImperialUnits = useAppSelector((s) => s.settings.useImperialUnits); + const [pendingStart, setPendingStart] = useState(); const dispatch = useDispatch(); const { t } = useTranslate(); const currentBodyweight = upcomingSessions.map((x) => x.at(0)?.bodyweight).unwrapOr(undefined); const { start, confirmationDialog } = useStartWorkoutWithConfirmation(); + const requestStart = (session: Session) => { + if (progressionReady || session.id === activeSession?.id) { + start(session); + } else { + setPendingStart(session); + dispatch(fetchUpcomingSessions()); + } + }; + + useEffect(() => { + if (!pendingStart || !progressionReady || !upcomingSessions.isSuccess()) return; + const resolved = pendingStart.isFreeform + ? Session.freeformSession(pendingStart.date, currentBodyweight) + : upcomingSessions.unwrapOr([]).find((session) => session.blueprint === pendingStart.blueprint); + if (resolved) { + setPendingStart(undefined); + start(resolved); + } + }, [pendingStart, progressionReady, upcomingSessions, currentBodyweight, start]); + useFocusEffect(() => { dispatch(fetchUpcomingSessions()); dispatch(publishUnpublishedSessions()); @@ -240,7 +268,7 @@ export default function Index() { }); const createFreeformSession = () => { - start(Session.freeformSession(LocalDate.now(), currentBodyweight)); + requestStart(Session.freeformSession(LocalDate.now(), currentBodyweight)); }; const floatingBottomContainer = ( @@ -254,6 +282,20 @@ export default function Index() { /> ); + if (pendingStart) { + return dispatch(fetchUpcomingSessions())} success={() => null} />; + } + + const displayedSessions = + progressionReady || + upcomingSessions.match({ success: () => true, error: () => true, loading: () => false, notAsked: () => false }) + ? upcomingSessions + : RemoteData.success( + plan.sessions.map((blueprint) => + Session.getEmptySession(blueprint, useImperialUnits ? 'pounds' : 'kilograms'), + ), + ); + return ( + + dispatch(fetchUpcomingSessions())} success={(upcoming) => { - return ; + return ; }} /> + {confirmationDialog} ); diff --git a/app/src/app/(tabs)/(session)/session/post-workout.tsx b/app/src/app/(tabs)/(session)/session/post-workout.tsx index 00921368e..1e30f846b 100644 --- a/app/src/app/(tabs)/(session)/session/post-workout.tsx +++ b/app/src/app/(tabs)/(session)/session/post-workout.tsx @@ -3,15 +3,61 @@ import { PageActions } from '@/components/presentation/foundation/page-actions'; import CheckIcon from '@expo/material-symbols/check.xml'; import { SessionComparisonTable } from '@/components/presentation/workout/session-comparison-table'; import { spacing } from '@/hooks/useAppTheme'; -import { useAppSelectorWithArg } from '@/store'; +import { useAppSelector, useAppSelectorWithArg } from '@/store'; import { useFinishWorkout } from '@/hooks/useFinishWorkout'; -import { selectPreviousComparableSession, selectSession } from '@/store/stored-sessions'; +import { selectSession } from '@/store/stored-sessions'; import { useTranslate } from '@tolgee/react'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { View } from 'react-native'; +import { StoredSessionGate } from '@/components/smart/stored-session-gate'; +import { useServices } from '@/components/smart/services-provider'; +import { Remote } from '@/components/presentation/foundation/remote'; +import { RemoteData } from '@/models/remote'; +import { Session } from '@/models/session-models'; export default function PostWorkoutPage() { + const { sessionId } = useLocalSearchParams<{ sessionId: string }>(); + return ( + + + + ); +} + +function PostWorkoutComparison() { + const { sessionId } = useLocalSearchParams<{ sessionId: string }>(); + const session = useAppSelectorWithArg(selectSession, sessionId); + const activeSessionId = useAppSelector((state) => state.storedSessions.activeSessionId); + const { sessionHistoryRepository } = useServices(); + const [load, setLoad] = useState>(RemoteData.loading()); + const [retry, setRetry] = useState(0); + useEffect(() => { + if (!session) return; + let cancelled = false; + setLoad(RemoteData.loading()); + void sessionHistoryRepository + .getPreviousComparableSession(session, activeSessionId) + .then((previous) => { + if (!cancelled) setLoad(RemoteData.success(previous)); + }) + .catch((error: unknown) => { + if (!cancelled) setLoad(RemoteData.error(String(error))); + }); + return () => { + cancelled = true; + }; + }, [session, activeSessionId, sessionHistoryRepository, retry]); + return ( + setRetry((value) => value + 1)} + success={(previous) => } + /> + ); +} + +function PostWorkoutContent({ previousComparableSession }: { previousComparableSession: Session | undefined }) { const { sessionId, source } = useLocalSearchParams<{ sessionId?: string; source?: 'finished' | 'live' | 'history'; @@ -20,7 +66,6 @@ export default function PostWorkoutPage() { const openedAfterFinishingWorkout = source === 'finished'; const showFinishButton = openedAfterFinishingWorkout; const showBackButton = !openedAfterFinishingWorkout; - const previousComparableSession = useAppSelectorWithArg(selectPreviousComparableSession, session); const { dismissTo, push } = useRouter(); const finishWorkout = useFinishWorkout(sessionId); const { t } = useTranslate(); diff --git a/app/src/app/(tabs)/feed/_layout.tsx b/app/src/app/(tabs)/feed/_layout.tsx index 272ec6a29..cfd69359a 100644 --- a/app/src/app/(tabs)/feed/_layout.tsx +++ b/app/src/app/(tabs)/feed/_layout.tsx @@ -1,8 +1,13 @@ import StackWithHeader from '@/components/layout/stack-with-header'; +import { SessionActivityGate } from '@/components/smart/session-activity-gate'; export const unstable_settings = { initialRouteName: 'index', }; export default function Layout() { - return ; + return ( + + + + ); } diff --git a/app/src/app/(tabs)/history/edit.tsx b/app/src/app/(tabs)/history/edit.tsx index fdb28521e..01c5484b9 100644 --- a/app/src/app/(tabs)/history/edit.tsx +++ b/app/src/app/(tabs)/history/edit.tsx @@ -1,3 +1,4 @@ +import { StoredSessionGate } from '@/components/smart/stored-session-gate'; import SessionComponent from '@/components/smart/session-component'; import SessionMoreMenuComponent from '@/components/smart/session-more-menu-component'; import { spacing } from '@/hooks/useAppTheme'; @@ -15,6 +16,15 @@ import { useTranslate } from '@tolgee/react'; import { useRef } from 'react'; export default function HistoryEditPage() { + const { sessionId } = useLocalSearchParams<{ sessionId: string }>(); + return ( + + + + ); +} + +function HistoryEditContent() { const dispatch = useDispatch(); const { sessionId } = useLocalSearchParams<{ sessionId: string }>(); const session = useAppSelectorWithArg(selectSession, sessionId); diff --git a/app/src/app/(tabs)/history/index.tsx b/app/src/app/(tabs)/history/index.tsx index a4eb8eff7..b55dca39e 100644 --- a/app/src/app/(tabs)/history/index.tsx +++ b/app/src/app/(tabs)/history/index.tsx @@ -1,3 +1,7 @@ +import { useServices } from '@/components/smart/services-provider'; +import { Remote } from '@/components/presentation/foundation/remote'; +import { RemoteData } from '@/models/remote'; +import { mergeLoadedSessions, setActivitySummaries } from '@/store/stored-sessions'; import CardActions from '@/components/presentation/foundation/card-actions'; import ConfirmationDialog from '@/components/presentation/foundation/confirmation-dialog'; import EmptyInfo from '@/components/presentation/foundation/empty-info'; @@ -28,8 +32,8 @@ import { 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 { useState } from 'react'; +import { Stack, useIsFocused, useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; import { View } from 'react-native'; import { LegendList } from '@legendapp/list'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -39,12 +43,70 @@ import { useDispatch } from 'react-redux'; import { useFormatDate } from '@/hooks/useFormatDate'; import { useStartWorkout } from '@/hooks/useStartWorkout'; import { SharedSession } from '@/models/feed-models'; +import { Loader } from '@/components/presentation/foundation/loader'; export default function History() { + const isFocused = useIsFocused(); + const [hasVisited, setHasVisited] = useState(isFocused); + useEffect(() => { + if (isFocused) setHasVisited(true); + }, [isFocused]); + + // Native tabs mount offscreen routes too. Keep the calendar and history selectors dormant until + // the first visit, then retain their state when switching tabs or opening a session for editing. + return hasVisited ? : ; +} + +function MonthHistory() { + const [currentYearMonth, setCurrentYearMonth] = useState(YearMonth.now()); + const [load, setLoad] = useState>(RemoteData.loading()); + const [retry, setRetry] = useState(0); + const loadedMonth = useRef(undefined); + const { sessionHistoryRepository, logger } = useServices(); + const dispatch = useDispatch(); + const revision = useAppSelector((state) => state.storedSessions.dataRevision); + const isFocused = useIsFocused(); + useEffect(() => { + if (!isFocused) return; + let cancelled = false; + if (loadedMonth.current !== currentYearMonth.toString()) setLoad(RemoteData.loading()); + + void (async () => { + try { + const sessions = await sessionHistoryRepository.getSessionsByMonth(currentYearMonth.toString()); + const summaries = await sessionHistoryRepository.getActivitySummaries(); + if (cancelled) return; + dispatch(mergeLoadedSessions(sessions)); + dispatch(setActivitySummaries(summaries)); + loadedMonth.current = currentYearMonth.toString(); + setLoad(RemoteData.success(true)); + } catch (error) { + if (!cancelled) setLoad(RemoteData.error(String(error))); + } + })(); + return () => { + cancelled = true; + }; + }, [currentYearMonth, revision, retry, isFocused, dispatch, sessionHistoryRepository, logger]); + return ( + setRetry((value) => value + 1)} + success={() => } + /> + ); +} + +function HistoryContent({ + currentYearMonth, + onMonthChange, +}: { + currentYearMonth: YearMonth; + onMonthChange: (month: YearMonth) => void; +}) { const { t } = useTranslate(); const dispatch = useDispatch(); const formatDate = useFormatDate(); - const [currentYearMonth, setCurrentYearMonth] = useState(YearMonth.now()); const { handleScroll } = useScroll(); const insets = useSafeAreaInsets(); const latesBodyweight = useAppSelector((x) => @@ -132,7 +194,7 @@ export default function History() { currentYearMonth={currentYearMonth} selectedDate={selectedDate} onMonthChange={(yearMonth) => { - setCurrentYearMonth(yearMonth); + onMonthChange(yearMonth); setSelectedDate(undefined); }} onDateSelect={setSelectedDate} diff --git a/app/src/app/(tabs)/settings/backup-and-restore/import-from-other-apps.tsx b/app/src/app/(tabs)/settings/backup-and-restore/import-from-other-apps.tsx index cca76d80c..f0a5f9f0b 100644 --- a/app/src/app/(tabs)/settings/backup-and-restore/import-from-other-apps.tsx +++ b/app/src/app/(tabs)/settings/backup-and-restore/import-from-other-apps.tsx @@ -1,3 +1,4 @@ +import { SessionHistoryGate } from '@/components/smart/session-history-gate'; import { SettingsPage } from '@/components/layout/settings-page'; import { EXTERNAL_IMPORT_FORMATS } from '@/services/csv-import'; import { ExternalImportFormat, importFromExternal } from '@/store/settings'; @@ -9,7 +10,7 @@ import { SegmentedListSelect } from '@/components/presentation/foundation/segmen import { PageActions } from '@/components/presentation/foundation/page-actions'; import ImportIcon from '@expo/material-symbols/download.xml'; -export default function ImportFromOtherAppsPage() { +function ImportFromOtherAppsPageContent() { const { t } = useTranslate(); const dispatch = useDispatch(); const [format, setFormat] = useState('FitNotes'); @@ -42,3 +43,11 @@ export default function ImportFromOtherAppsPage() { ); } + +export default function ImportFromOtherAppsPage() { + return ( + + + + ); +} diff --git a/app/src/app/(tabs)/settings/backup-and-restore/plain-text-export.tsx b/app/src/app/(tabs)/settings/backup-and-restore/plain-text-export.tsx index 29807566b..defa68ac1 100644 --- a/app/src/app/(tabs)/settings/backup-and-restore/plain-text-export.tsx +++ b/app/src/app/(tabs)/settings/backup-and-restore/plain-text-export.tsx @@ -1,3 +1,4 @@ +import { SessionHistoryGate } from '@/components/smart/session-history-gate'; import { SettingsPage } from '@/components/layout/settings-page'; import { exportPlainText, PlaintextExportFormat } from '@/store/settings'; import { useTranslate } from '@tolgee/react'; @@ -8,7 +9,7 @@ import { SegmentedListSelect } from '@/components/presentation/foundation/segmen import { PageActions } from '@/components/presentation/foundation/page-actions'; import ExportIcon from '@expo/material-symbols/file_export.xml'; -export default function PlainTextExportPage() { +function PlainTextExportPageContent() { const { t } = useTranslate(); const dispatch = useDispatch(); const [format, setFormat] = useState('CSV'); @@ -44,3 +45,11 @@ export default function PlainTextExportPage() { ); } + +export default function PlainTextExportPage() { + return ( + + + + ); +} diff --git a/app/src/app/(tabs)/stats/index.tsx b/app/src/app/(tabs)/stats/index.tsx index f42c98462..73b7731fc 100644 --- a/app/src/app/(tabs)/stats/index.tsx +++ b/app/src/app/(tabs)/stats/index.tsx @@ -39,7 +39,11 @@ export default function StatsPage() { dispatch(setOverallViewTime(value))} /> - } /> + dispatch(fetchOverallStats())} + success={(stats) => } + /> ); } diff --git a/app/src/app/exercise-history.tsx b/app/src/app/exercise-history.tsx index b854cb22f..4971aa1d3 100644 --- a/app/src/app/exercise-history.tsx +++ b/app/src/app/exercise-history.tsx @@ -7,5 +7,5 @@ export default function ExerciseHistoryPage() { name: string; type: ExerciseBlueprint['type']; }>(); - return ; + return ; } diff --git a/app/src/components/presentation/workout/exercise-history-list.tsx b/app/src/components/presentation/workout/exercise-history-list.tsx index dfb124edc..b99256ee0 100644 --- a/app/src/components/presentation/workout/exercise-history-list.tsx +++ b/app/src/components/presentation/workout/exercise-history-list.tsx @@ -12,19 +12,25 @@ import { formatDuration } from '@/utils/format-duration'; import { localeFormatBigNumber } from '@/utils/locale-bignumber'; import { T, useTranslate } from '@tolgee/react'; import { LegendList } from '@legendapp/list'; +import { ReactNode } from 'react'; import { StyleProp, View, ViewStyle } from 'react-native'; import { Divider } from 'react-native-paper'; import { match, P } from 'ts-pattern'; export function ExerciseHistoryList(props: { exercises: RecordedExercise[]; + onEndReached?: () => void; + footer?: ReactNode; contentContainerStyle?: StyleProp; }) { return ( exercise.latestTime?.toString() ?? index.toString()} + onEndReached={props.onEndReached} + onEndReachedThreshold={0.5} + ListFooterComponent={<>{props.footer}} + keyExtractor={(exercise, index) => `${exercise.latestTime?.toString()}:${index}`} contentContainerStyle={props.contentContainerStyle} renderItem={({ item }) => } ItemSeparatorComponent={() => } diff --git a/app/src/components/smart/app-state-provider.tsx b/app/src/components/smart/app-state-provider.tsx index 165836d5f..3997898a5 100644 --- a/app/src/components/smart/app-state-provider.tsx +++ b/app/src/components/smart/app-state-provider.tsx @@ -19,7 +19,7 @@ export function AppStateProvider({ children }: { children: ReactNode }) { getLoadMessage(s.app, 'app settings') || getLoadMessage(s.program, 'program') || getLoadMessage(s.settings, 'settings') || - getLoadMessage(s.storedSessions, 'stored sessions') || + getLoadMessage({ isHydrated: s.storedSessions.isReady }, 'current workout and exercises') || getLoadMessage(s.aiPlanner, 'ai planner'), ); const { colors } = useAppTheme(); diff --git a/app/src/components/smart/exercise-history.tsx b/app/src/components/smart/exercise-history.tsx index 395b2cc79..29afa792d 100644 --- a/app/src/components/smart/exercise-history.tsx +++ b/app/src/components/smart/exercise-history.tsx @@ -2,8 +2,12 @@ import { SurfaceText } from '@/components/presentation/foundation/surface-text'; import { ExerciseHistoryList } from '@/components/presentation/workout/exercise-history-list'; import { spacing } from '@/hooks/useAppTheme'; import { ExerciseBlueprint, MovementKey } from '@/models/blueprint-models'; -import { useAppSelectorWithArg } from '@/store'; -import { selectRecentlyCompletedExercises } from '@/store/stored-sessions'; +import { useServices } from '@/components/smart/services-provider'; +import { Remote } from '@/components/presentation/foundation/remote'; +import { RemoteData } from '@/models/remote'; +import { RecordedExercise } from '@/models/session-models'; +import { ExerciseHistoryCursor } from '@/services/session-history-repository'; +import { useEffect, useEffectEvent, useRef, useState } from 'react'; import { Href } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -12,8 +16,41 @@ export function getExerciseHistoryHref(blueprint: ExerciseBlueprint): Href { } export function ExerciseHistory(props: { movementKey: MovementKey; exerciseName: string }) { - // No session to exclude: this sheet is opened from an exercise, and shows the whole lineage. - const exercises = useAppSelectorWithArg(selectRecentlyCompletedExercises, undefined)(props.movementKey); + const { sessionHistoryRepository } = useServices(); + const [exercises, setExercises] = useState([]); + const [load, setLoad] = useState>(RemoteData.loading()); + const cursor = useRef(undefined); + const busy = useRef(false); + const done = useRef(false); + const alive = useRef(true); + const loadMore = async () => { + if (busy.current || done.current) return; + busy.current = true; + setLoad(RemoteData.loading()); + + try { + const page = await sessionHistoryRepository.getExerciseHistory(props.movementKey, cursor.current); + if (!alive.current) return; + cursor.current = page.next; + done.current = !page.next; + setExercises((existing) => [...existing, ...page.exercises]); + setLoad(RemoteData.success(true)); + } catch (error) { + if (alive.current) setLoad(RemoteData.error(String(error))); + } finally { + busy.current = false; + } + }; + const loadInitialPage = useEffectEvent(() => { + void loadMore(); + }); + useEffect(() => { + alive.current = true; + loadInitialPage(); + return () => { + alive.current = false; + }; + }, []); return ( @@ -25,14 +62,22 @@ export function ExerciseHistory(props: { movementKey: MovementKey; exerciseName: > {props.exerciseName} - + {exercises.length ? ( + { + if (load.isSuccess()) void loadMore(); + }} + footer={ void loadMore()} success={() => null} />} + contentContainerStyle={{ + paddingHorizontal: spacing.pageHorizontalMargin, + paddingTop: spacing[2], + paddingBottom: spacing[8], + }} + /> + ) : ( + void loadMore()} success={() => } /> + )} ); } diff --git a/app/src/components/smart/services-provider.tsx b/app/src/components/smart/services-provider.tsx index 39736b942..e8993fb96 100644 --- a/app/src/components/smart/services-provider.tsx +++ b/app/src/components/smart/services-provider.tsx @@ -4,7 +4,7 @@ import { registerDateTranslations } from '@/utils/date-locale'; import { TolgeeProvider } from '@tolgee/react'; import { drizzle } from 'drizzle-orm/expo-sqlite'; import { openDatabaseAsync, SQLiteDatabase } from 'expo-sqlite'; -import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; import { Provider } from 'react-redux'; // Create context for services @@ -12,7 +12,10 @@ const ServicesContext = createContext(null); let databasePromise: Promise | undefined; function openDatabase() { - return (databasePromise ??= openDatabaseAsync('db.db')); + if (!databasePromise) { + databasePromise = openDatabaseAsync('db.db'); + } + return databasePromise; } export default function ServicesProvider(props: { children: ReactNode }) { @@ -20,23 +23,22 @@ export default function ServicesProvider(props: { children: ReactNode }) { useEffect(() => { void openDatabase().then(setOpDb); }, [setOpDb]); - const db = useMemo(() => expoDb && drizzle(expoDb), [expoDb]); - const resolved = useMemo(() => (db && expoDb ? resolveStore(db, expoDb) : undefined), [db, expoDb]); - const store = resolved?.store; - const services = resolved?.services; + return expoDb ? {props.children} : null; +} + +function ResolvedServicesProvider({ expoDb, children }: { expoDb: SQLiteDatabase; children: ReactNode }) { + // The store owns loaded history and active edits. React may discard memo caches (including during + // Fast Refresh), so its lifetime must be component state rather than a useMemo calculation. + const [{ store, services }] = useState(() => resolveStore(drizzle(expoDb), expoDb)); useEffect(() => { if (services) { registerDateTranslations(services.tolgee); } }, [services]); - if (!store || !services) { - return <>; - } - return ( - {props.children} + {children} ); diff --git a/app/src/components/smart/session-activity-gate.tsx b/app/src/components/smart/session-activity-gate.tsx new file mode 100644 index 000000000..cce3d5105 --- /dev/null +++ b/app/src/components/smart/session-activity-gate.tsx @@ -0,0 +1,36 @@ +import { Remote } from '@/components/presentation/foundation/remote'; +import { useServices } from '@/components/smart/services-provider'; +import { RemoteData } from '@/models/remote'; +import { useAppSelector } from '@/store'; +import { setActivitySummaries } from '@/store/stored-sessions'; +import { useIsFocused } from 'expo-router'; +import { ReactNode, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +/** The feed's own calendar needs all-history facts, but no historical workout payloads. */ +export function SessionActivityGate({ children }: { children: ReactNode }) { + const { sessionHistoryRepository } = useServices(); + const revision = useAppSelector((state) => state.storedSessions.dataRevision); + const [load, setLoad] = useState>(RemoteData.loading()); + const [retry, setRetry] = useState(0); + const isFocused = useIsFocused(); + const dispatch = useDispatch(); + useEffect(() => { + if (!isFocused) return; + let cancelled = false; + void sessionHistoryRepository + .getActivitySummaries() + .then((summaries) => { + if (cancelled) return; + dispatch(setActivitySummaries(summaries)); + setLoad(RemoteData.success(true)); + }) + .catch((error: unknown) => { + if (!cancelled) setLoad(RemoteData.error(String(error))); + }); + return () => { + cancelled = true; + }; + }, [revision, retry, isFocused, sessionHistoryRepository, dispatch]); + return setRetry((value) => value + 1)} success={() => children} />; +} diff --git a/app/src/components/smart/session-component.tsx b/app/src/components/smart/session-component.tsx index 50757e4cb..818cb78b0 100644 --- a/app/src/components/smart/session-component.tsx +++ b/app/src/components/smart/session-component.tsx @@ -1,3 +1,5 @@ +import { useServices } from '@/components/smart/services-provider'; +import { MovementKey } from '@/models/blueprint-models'; import { showSnackbar } from '@/store/app'; import { Card, Icon, Text } from 'react-native-paper'; import { useDispatch } from 'react-redux'; @@ -20,7 +22,7 @@ import WeightedExercise from '@/components/presentation/workout/weighted/weighte import WeightDisplay from '@/components/presentation/foundation/editors/weight-display'; import BigNumber from 'bignumber.js'; import RestTimer from '@/components/presentation/workout/rest-timer'; -import { ReactNode } from 'react'; +import { ReactNode, useEffect, useEffectEvent, useState } from 'react'; import FullHeightScrollView from '@/components/layout/full-height-scroll-view'; import { getSessionExerciseEditorHref } from '@/components/smart/session-exercise-editor'; import { LocalTime, OffsetDateTime, ZoneId } from '@js-joda/core'; @@ -61,7 +63,26 @@ export default function SessionComponent(props: { const dispatch = useDispatch(); const isReadonly = !props.updateSession; const editableSessionId = isReadonly ? undefined : session.id; - const recentlyCompletedExercises = useAppSelectorWithArg(selectRecentlyCompletedExercises, session.id); + const fullHistory = useAppSelectorWithArg(selectRecentlyCompletedExercises, session.id); + const isHydrated = useAppSelector((state) => state.storedSessions.isHydrated); + const { sessionHistoryRepository, logger } = useServices(); + const [context, setContext] = useState>({}); + const contextKeys = session.recordedExercises.map((exercise) => exercise.progressionKey()).join('|'); + const getBlueprints = useEffectEvent(() => session.recordedExercises.map((exercise) => exercise.blueprint)); + useEffect(() => { + if (isHydrated) return; + let cancelled = false; + void sessionHistoryRepository + .getWorkoutContext(getBlueprints(), session.id) + .then((value) => { + if (!cancelled) setContext(value); + }) + .catch((error: unknown) => logger.error('Failed to load previous workout values', error)); + return () => { + cancelled = true; + }; + }, [contextKeys, session.id, isHydrated, sessionHistoryRepository, logger]); + const recentlyCompletedExercises = (key: MovementKey) => (isHydrated ? fullHistory(key) : (context[key] ?? [])); const addExercise = useAddExercise(editableSessionId); const updateSession = (reducer: (session: Session) => Session) => props.updateSession?.(reducer); const resetTimer = (time: OffsetDateTime | undefined) => { diff --git a/app/src/components/smart/session-history-gate.tsx b/app/src/components/smart/session-history-gate.tsx new file mode 100644 index 000000000..f66f087ac --- /dev/null +++ b/app/src/components/smart/session-history-gate.tsx @@ -0,0 +1,23 @@ +import { Remote } from '@/components/presentation/foundation/remote'; +import { useAppSelector } from '@/store'; +import { loadStoredSessionHistory } from '@/store/stored-sessions'; +import { useIsFocused } from 'expo-router'; +import { ReactNode, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +/** Offscreen routes must not request history just because native tabs mounted them. */ +export function SessionHistoryGate({ children }: { children: ReactNode }) { + const isFocused = useIsFocused(); + const isHydrated = useAppSelector((s) => s.storedSessions.isHydrated); + const load = useAppSelector((s) => s.storedSessions.historyLoad); + const dispatch = useDispatch(); + useEffect(() => { + if (isFocused && !isHydrated) dispatch(loadStoredSessionHistory()); + }, [isFocused, isHydrated, dispatch]); + + return isHydrated ? ( + children + ) : ( + dispatch(loadStoredSessionHistory())} success={() => children} /> + ); +} diff --git a/app/src/components/smart/stored-session-gate.tsx b/app/src/components/smart/stored-session-gate.tsx new file mode 100644 index 000000000..2d7f5c68f --- /dev/null +++ b/app/src/components/smart/stored-session-gate.tsx @@ -0,0 +1,39 @@ +import { Remote } from '@/components/presentation/foundation/remote'; +import { useServices } from '@/components/smart/services-provider'; +import { RemoteData } from '@/models/remote'; +import { useAppSelectorWithArg } from '@/store'; +import { mergeLoadedSessions, selectSession } from '@/store/stored-sessions'; +import { ReactNode, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +/** A direct link to an older workout needs one payload, even before History has been visited. */ +export function StoredSessionGate({ sessionId, children }: { sessionId: string; children: ReactNode }) { + const session = useAppSelectorWithArg(selectSession, sessionId); + const { sessionHistoryRepository } = useServices(); + const [load, setLoad] = useState>(RemoteData.loading()); + const [retry, setRetry] = useState(0); + const dispatch = useDispatch(); + useEffect(() => { + if (session) return; + let cancelled = false; + setLoad(RemoteData.loading()); + void sessionHistoryRepository + .getSession(sessionId) + .then((value) => { + if (cancelled) return; + if (value) dispatch(mergeLoadedSessions([value])); + setLoad(RemoteData.success(true)); + }) + .catch((error: unknown) => { + if (!cancelled) setLoad(RemoteData.error(String(error))); + }); + return () => { + cancelled = true; + }; + }, [sessionId, session, retry, dispatch, sessionHistoryRepository]); + return session ? ( + children + ) : ( + setRetry((value) => value + 1)} success={() => children} /> + ); +} diff --git a/app/src/db/schema.ts b/app/src/db/schema.ts index fb585eb8e..750b9f3e6 100644 --- a/app/src/db/schema.ts +++ b/app/src/db/schema.ts @@ -1,3 +1,4 @@ +import { SessionActivityJSON } from '@/models/session-summary'; import { AnyVersionExerciseDescriptorJSON, AnyVersionFeedIdentityJSON, @@ -13,7 +14,7 @@ import { } from '@/models/storage/versions/any'; import { BackendFeature, BackendKind } from '@/models/backend'; import { sql } from 'drizzle-orm'; -import { check, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { check, index, integer, real, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; export const sessionsSchema = sqliteTable( 'session', @@ -21,15 +22,53 @@ export const sessionsSchema = sqliteTable( id: text().primaryKey(), // The workout currently in progress, if any. At most one row may be active. active: integer({ mode: 'boolean' }).notNull().default(false), + date: text(), + referenceTime: real(), + workoutName: text(), + // Null means this row still needs the recoverable projection backfill. + searchVersion: integer(), + activity: text({ mode: 'json' }).$type(), payload: text('payload', { mode: 'json' }).$type().notNull(), }, (table) => [ + index('session_date_index').on(table.date), + index('session_reference_time_index').on(sql`${table.referenceTime} DESC`, table.id), + index('session_workout_name_index').on(table.workoutName, table.referenceTime), + index('session_search_version_index').on(table.searchVersion), uniqueIndex('single_active_session') .on(table.active) .where(sql`${table.active} = 1`), ], ); +export const recordedExerciseIndexSchema = sqliteTable( + 'recorded_exercise_index', + { + sessionId: text() + .notNull() + .references(() => sessionsSchema.id, { onDelete: 'cascade' }), + exerciseIndex: integer().notNull(), + movementKey: text().notNull(), + progressionKey: text().notNull(), + latestTime: real().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.sessionId, table.exerciseIndex] }), + index('exercise_progression_time_index').on( + table.progressionKey, + sql`${table.latestTime} DESC`, + table.sessionId, + table.exerciseIndex, + ), + index('exercise_movement_time_index').on( + table.movementKey, + sql`${table.latestTime} DESC`, + table.sessionId, + table.exerciseIndex, + ), + ], +); + export const exercisesSchema = sqliteTable('exercise', { id: text().primaryKey(), payload: text('payload', { mode: 'json' }).$type().notNull(), diff --git a/app/src/drizzle/0009_indexed_session_history.sql b/app/src/drizzle/0009_indexed_session_history.sql new file mode 100644 index 000000000..729ac78c5 --- /dev/null +++ b/app/src/drizzle/0009_indexed_session_history.sql @@ -0,0 +1,21 @@ +CREATE TABLE `recorded_exercise_index` ( + `sessionId` text NOT NULL, + `exerciseIndex` integer NOT NULL, + `movementKey` text NOT NULL, + `progressionKey` text NOT NULL, + `latestTime` real NOT NULL, + PRIMARY KEY(`sessionId`, `exerciseIndex`), + FOREIGN KEY (`sessionId`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `exercise_progression_time_index` ON `recorded_exercise_index` (`progressionKey`,"latestTime" DESC,`sessionId`,`exerciseIndex`);--> statement-breakpoint +CREATE INDEX `exercise_movement_time_index` ON `recorded_exercise_index` (`movementKey`,"latestTime" DESC,`sessionId`,`exerciseIndex`);--> statement-breakpoint +ALTER TABLE `session` ADD `date` text;--> statement-breakpoint +ALTER TABLE `session` ADD `referenceTime` real;--> statement-breakpoint +ALTER TABLE `session` ADD `workoutName` text;--> statement-breakpoint +ALTER TABLE `session` ADD `searchVersion` integer;--> statement-breakpoint +ALTER TABLE `session` ADD `activity` text;--> statement-breakpoint +CREATE INDEX `session_date_index` ON `session` (`date`);--> statement-breakpoint +CREATE INDEX `session_reference_time_index` ON `session` ("referenceTime" DESC,`id`);--> statement-breakpoint +CREATE INDEX `session_workout_name_index` ON `session` (`workoutName`,`referenceTime`);--> statement-breakpoint +CREATE INDEX `session_search_version_index` ON `session` (`searchVersion`); \ No newline at end of file diff --git a/app/src/drizzle/0010_session_search_invalidation.sql b/app/src/drizzle/0010_session_search_invalidation.sql new file mode 100644 index 000000000..324d089fd --- /dev/null +++ b/app/src/drizzle/0010_session_search_invalidation.sql @@ -0,0 +1,10 @@ +-- Legacy importers and restored backups may write payloads without the current projection helper. +-- Invalidate in the same transaction so a later indexed query can never trust stale search rows. +CREATE TRIGGER session_search_payload_changed AFTER UPDATE OF payload ON session BEGIN + UPDATE session SET searchVersion = NULL WHERE id = NEW.id; + DELETE FROM recorded_exercise_index WHERE sessionId = NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER session_search_deleted AFTER DELETE ON session BEGIN + DELETE FROM recorded_exercise_index WHERE sessionId = OLD.id; +END; diff --git a/app/src/drizzle/meta/0009_snapshot.json b/app/src/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..5ef264bd1 --- /dev/null +++ b/app/src/drizzle/meta/0009_snapshot.json @@ -0,0 +1,661 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bfffd933-5f7d-43bb-b2a8-48b1defcf87a", + "prevId": "d280a467-eda9-4e82-a924-3c9b02976d2a", + "tables": { + "backend_assignment": { + "name": "backend_assignment", + "columns": { + "feature": { + "name": "feature", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "backendId": { + "name": "backendId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backend_header": { + "name": "backend_header", + "columns": { + "backendId": { + "name": "backendId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "backend_header_backendId_backend_id_fk": { + "name": "backend_header_backendId_backend_id_fk", + "tableFrom": "backend_header", + "tableTo": "backend", + "columnsFrom": [ + "backendId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backend_header_backendId_name_pk": { + "columns": [ + "backendId", + "name" + ], + "name": "backend_header_backendId_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backend": { + "name": "backend", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "data_migration": { + "name": "data_migration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "exercise": { + "name": "exercise", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_follow_request": { + "name": "feed_follow_request", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_followed_user": { + "name": "feed_followed_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_follower_user": { + "name": "feed_follower_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_identity": { + "name": "feed_identity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "single_feed_identity": { + "name": "single_feed_identity", + "value": "id = 0" + } + } + }, + "feed_items": { + "name": "feed_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_pending_user": { + "name": "feed_pending_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_reaction": { + "name": "feed_reaction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_revoked_follow_secrets": { + "name": "feed_revoked_follow_secrets", + "columns": { + "secret": { + "name": "secret", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_sent_reaction": { + "name": "feed_sent_reaction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_unpublished_sessions": { + "name": "feed_unpublished_sessions", + "columns": { + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "program": { + "name": "program", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "single_active_program": { + "name": "single_active_program", + "columns": [ + "active" + ], + "isUnique": true, + "where": "\"program\".\"active\" = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recorded_exercise_index": { + "name": "recorded_exercise_index", + "columns": { + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exerciseIndex": { + "name": "exerciseIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "movementKey": { + "name": "movementKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progressionKey": { + "name": "progressionKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latestTime": { + "name": "latestTime", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "exercise_progression_time_index": { + "name": "exercise_progression_time_index", + "columns": [ + "progressionKey", + "\"latestTime\" DESC", + "sessionId", + "exerciseIndex" + ], + "isUnique": false + }, + "exercise_movement_time_index": { + "name": "exercise_movement_time_index", + "columns": [ + "movementKey", + "\"latestTime\" DESC", + "sessionId", + "exerciseIndex" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recorded_exercise_index_sessionId_session_id_fk": { + "name": "recorded_exercise_index_sessionId_session_id_fk", + "tableFrom": "recorded_exercise_index", + "tableTo": "session", + "columnsFrom": [ + "sessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recorded_exercise_index_sessionId_exerciseIndex_pk": { + "columns": [ + "sessionId", + "exerciseIndex" + ], + "name": "recorded_exercise_index_sessionId_exerciseIndex_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceTime": { + "name": "referenceTime", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workoutName": { + "name": "workoutName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "searchVersion": { + "name": "searchVersion", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activity": { + "name": "activity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_date_index": { + "name": "session_date_index", + "columns": [ + "date" + ], + "isUnique": false + }, + "session_reference_time_index": { + "name": "session_reference_time_index", + "columns": [ + "\"referenceTime\" DESC", + "id" + ], + "isUnique": false + }, + "session_workout_name_index": { + "name": "session_workout_name_index", + "columns": [ + "workoutName", + "referenceTime" + ], + "isUnique": false + }, + "session_search_version_index": { + "name": "session_search_version_index", + "columns": [ + "searchVersion" + ], + "isUnique": false + }, + "single_active_session": { + "name": "single_active_session", + "columns": [ + "active" + ], + "isUnique": true, + "where": "\"session\".\"active\" = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "exercise_progression_time_index": { + "columns": { + "\"latestTime\" DESC": { + "isExpression": true + } + } + }, + "exercise_movement_time_index": { + "columns": { + "\"latestTime\" DESC": { + "isExpression": true + } + } + }, + "session_reference_time_index": { + "columns": { + "\"referenceTime\" DESC": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/drizzle/meta/0010_snapshot.json b/app/src/drizzle/meta/0010_snapshot.json new file mode 100644 index 000000000..9e902e71a --- /dev/null +++ b/app/src/drizzle/meta/0010_snapshot.json @@ -0,0 +1,661 @@ +{ + "id": "afc9141e-357e-4cd0-9fd6-6684ad8cf297", + "prevId": "bfffd933-5f7d-43bb-b2a8-48b1defcf87a", + "version": "6", + "dialect": "sqlite", + "tables": { + "backend_assignment": { + "name": "backend_assignment", + "columns": { + "feature": { + "name": "feature", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "backendId": { + "name": "backendId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backend_header": { + "name": "backend_header", + "columns": { + "backendId": { + "name": "backendId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "backend_header_backendId_backend_id_fk": { + "name": "backend_header_backendId_backend_id_fk", + "tableFrom": "backend_header", + "columnsFrom": [ + "backendId" + ], + "tableTo": "backend", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "backend_header_backendId_name_pk": { + "columns": [ + "backendId", + "name" + ], + "name": "backend_header_backendId_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backend": { + "name": "backend", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "data_migration": { + "name": "data_migration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "exercise": { + "name": "exercise", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_follow_request": { + "name": "feed_follow_request", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_followed_user": { + "name": "feed_followed_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_follower_user": { + "name": "feed_follower_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_identity": { + "name": "feed_identity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "single_feed_identity": { + "name": "single_feed_identity", + "value": "id = 0" + } + } + }, + "feed_items": { + "name": "feed_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_pending_user": { + "name": "feed_pending_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_reaction": { + "name": "feed_reaction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_revoked_follow_secrets": { + "name": "feed_revoked_follow_secrets", + "columns": { + "secret": { + "name": "secret", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_sent_reaction": { + "name": "feed_sent_reaction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_unpublished_sessions": { + "name": "feed_unpublished_sessions", + "columns": { + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "program": { + "name": "program", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "single_active_program": { + "name": "single_active_program", + "columns": [ + "active" + ], + "where": "\"program\".\"active\" = 1", + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recorded_exercise_index": { + "name": "recorded_exercise_index", + "columns": { + "sessionId": { + "name": "sessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exerciseIndex": { + "name": "exerciseIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "movementKey": { + "name": "movementKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progressionKey": { + "name": "progressionKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latestTime": { + "name": "latestTime", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "exercise_progression_time_index": { + "name": "exercise_progression_time_index", + "columns": [ + "progressionKey", + "\"latestTime\" DESC", + "sessionId", + "exerciseIndex" + ], + "isUnique": false + }, + "exercise_movement_time_index": { + "name": "exercise_movement_time_index", + "columns": [ + "movementKey", + "\"latestTime\" DESC", + "sessionId", + "exerciseIndex" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recorded_exercise_index_sessionId_session_id_fk": { + "name": "recorded_exercise_index_sessionId_session_id_fk", + "tableFrom": "recorded_exercise_index", + "columnsFrom": [ + "sessionId" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "recorded_exercise_index_sessionId_exerciseIndex_pk": { + "columns": [ + "sessionId", + "exerciseIndex" + ], + "name": "recorded_exercise_index_sessionId_exerciseIndex_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceTime": { + "name": "referenceTime", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workoutName": { + "name": "workoutName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "searchVersion": { + "name": "searchVersion", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activity": { + "name": "activity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_date_index": { + "name": "session_date_index", + "columns": [ + "date" + ], + "isUnique": false + }, + "session_reference_time_index": { + "name": "session_reference_time_index", + "columns": [ + "\"referenceTime\" DESC", + "id" + ], + "isUnique": false + }, + "session_workout_name_index": { + "name": "session_workout_name_index", + "columns": [ + "workoutName", + "referenceTime" + ], + "isUnique": false + }, + "session_search_version_index": { + "name": "session_search_version_index", + "columns": [ + "searchVersion" + ], + "isUnique": false + }, + "single_active_session": { + "name": "single_active_session", + "columns": [ + "active" + ], + "where": "\"session\".\"active\" = 1", + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": { + "exercise_progression_time_index": { + "columns": { + "\"latestTime\" DESC": { + "isExpression": true + } + } + }, + "exercise_movement_time_index": { + "columns": { + "\"latestTime\" DESC": { + "isExpression": true + } + } + }, + "session_reference_time_index": { + "columns": { + "\"referenceTime\" DESC": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/drizzle/meta/_journal.json b/app/src/drizzle/meta/_journal.json index 2d3a6b598..cff6ab70e 100644 --- a/app/src/drizzle/meta/_journal.json +++ b/app/src/drizzle/meta/_journal.json @@ -64,6 +64,20 @@ "when": 1787871156116, "tag": "0008_far_catseye", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1788739945660, + "tag": "0009_indexed_session_history", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1788739946246, + "tag": "0010_session_search_invalidation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/src/drizzle/migrations.js b/app/src/drizzle/migrations.js index f4427c2fb..00de0c345 100644 --- a/app/src/drizzle/migrations.js +++ b/app/src/drizzle/migrations.js @@ -1,5 +1,3 @@ -// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo - import journal from './meta/_journal.json'; import m0000 from './0000_purple_betty_ross.sql'; import m0001 from './0001_light_cloak.sql'; @@ -10,6 +8,8 @@ import m0005 from './0005_worried_silvermane.sql'; import m0006 from './0006_far_corsair.sql'; import m0007 from './0007_brown_quicksilver.sql'; import m0008 from './0008_far_catseye.sql'; +import m0009 from './0009_indexed_session_history.sql'; +import m0010 from './0010_session_search_invalidation.sql'; export default { journal, @@ -22,7 +22,9 @@ m0004, m0005, m0006, m0007, -m0008 +m0008, +m0009, +m0010 } } \ No newline at end of file diff --git a/app/src/models/session-models/session.spec.ts b/app/src/models/session-models/session.spec.ts index 1ec668e2e..419132b7d 100644 --- a/app/src/models/session-models/session.spec.ts +++ b/app/src/models/session-models/session.spec.ts @@ -977,6 +977,26 @@ describe('Session freeform and JSON', () => { expect(restored.equals(session)).toBe(true); }); + + it('restores mixed exercise blueprints once and keeps later edits independent', () => { + const session = makeSession([makeWeightedBlueprint(), makeCardioBlueprint(2)]); + const json = session.toJSON(); + const restored = Session.fromJSON(json); + + expect(restored.toJSON()).toEqual(json); + restored.recordedExercises.forEach((exercise, index) => { + expect(restored.blueprint.exercises[index]).toBe(exercise.blueprint); + }); + + const edited = restored.withExercise( + 0, + (restored.recordedExercises[0] as RecordedWeightedExercise).with({ + blueprint: makeWeightedBlueprint({ name: 'Edited squat' }), + }), + ); + expect(edited.recordedExercises[0]!.blueprint.name).toBe('Edited squat'); + expect(restored.toJSON()).toEqual(json); + }); }); describe('Session.runningCardioSet', () => { diff --git a/app/src/models/session-models/session.ts b/app/src/models/session-models/session.ts index 177de7b68..56a91b4de 100644 --- a/app/src/models/session-models/session.ts +++ b/app/src/models/session-models/session.ts @@ -35,20 +35,23 @@ export class Session { readonly restTimer: RestTimer | undefined, ) {} get duration(): Duration | undefined { - return this.lastExercise?.latestTime && this.firstExercise?.earliestTime - ? Duration.between(this.firstExercise.earliestTime, this.lastExercise.latestTime) - : undefined; + const latest = this.lastExercise?.latestTime; + if (!latest) return undefined; + const earliest = this.firstExercise?.earliestTime; + return earliest ? Duration.between(earliest, latest) : undefined; } static fromJSON(json: SessionJSON): Session { + const recordedExercises = json.recordedExercises.map(fromRecordedExerciseJSON); return new Session( json.id, - SessionBlueprint.fromJSON({ - ...json.blueprint, - version: 6, - exercises: json.recordedExercises.map((x) => x.blueprint), - }), - json.recordedExercises.map(fromRecordedExerciseJSON), + // Blueprints are immutable values, also shared when creating a new session. + new SessionBlueprint( + json.blueprint.name, + recordedExercises.map((x) => x.blueprint), + json.blueprint.notes, + ), + recordedExercises, fromLocalDateJSON(json.date), json.bodyweight ? Weight.fromJSON(json.bodyweight) : undefined, undefined, diff --git a/app/src/models/session-summary.ts b/app/src/models/session-summary.ts new file mode 100644 index 000000000..f2b65dd78 --- /dev/null +++ b/app/src/models/session-summary.ts @@ -0,0 +1,19 @@ +import { LocalDate } from '@js-joda/core'; +import { MovementKey } from '@/models/blueprint-models'; +import { Weight } from '@/models/weight'; + +/** Small all-history facts needed by the calendar, streaks and PR badges. */ +export interface SessionActivitySummary { + id: string; + date: LocalDate; + referenceTime: number; + isStarted: boolean; + volume: number; + bests: { key: MovementKey; exerciseName: string; oneRepMax: Weight }[]; +} + +export interface SessionActivityJSON { + isStarted: boolean; + volume: number; + bests: { key: MovementKey; exerciseName: string; oneRepMax: ReturnType }[]; +} diff --git a/app/src/services/index.ts b/app/src/services/index.ts index 7fe0312f8..be87e3010 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1,3 +1,4 @@ +import { SessionHistoryRepository } from '@/services/session-history-repository'; import { AiChatServiceV2 } from '@/services/ai-chat-service-v2'; import { EncryptionService } from '@/services/encryption-service'; import { FeedApiService } from '@/services/feed-api'; @@ -55,6 +56,7 @@ export function createServices(store: Store, db: ExpoSQLiteDatabase, return { logger, keyValueStore, + sessionHistoryRepository: new SessionHistoryRepository(db), progressRepository, sessionService, notificationService, diff --git a/app/src/services/session-history-repository.spec.ts b/app/src/services/session-history-repository.spec.ts new file mode 100644 index 000000000..11fa10772 --- /dev/null +++ b/app/src/services/session-history-repository.spec.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { drizzle, ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; +import { openDatabaseAsync } from 'expo-sqlite'; +import { eq, sql } from 'drizzle-orm'; +import { LocalDate, OffsetDateTime } from '@js-joda/core'; +import { DatabaseMigrationService } from '@/services/database-migration-service'; +import { + SessionHistoryRepository, + updateSessionSearch, + withSessionTransaction, +} from '@/services/session-history-repository'; +import { recordedExerciseIndexSchema, sessionsSchema } from '@/db/schema'; +import { Logger } from '@/services/logger'; +import { + makeCardioBlueprint, + makeRecordedExercise, + makeSession, + makeWeightedBlueprint, +} from '@/models/session-models/__test__/helpers'; +import { RecordedCardioExercise, Session } from '@/models/session-models'; +import { Weight } from '@/models/weight'; +import { sessionVolume } from '@/store/activity/volume'; +import { findPersonalRecords } from '@/store/stats/personal-records'; +import { selectHistoryPersonalRecords, setActivitySummaries, storedSessionsReducer } from '@/store/stored-sessions'; +import { RootState } from '@/store'; + +const blueprint = makeWeightedBlueprint(); +const time = OffsetDateTime.parse('2026-04-05T10:00:00Z'); +function workout(id: string, day: number, weight = 100, completed = true) { + const exercise = makeRecordedExercise( + blueprint, + completed ? [10, 10, 10] : [undefined, undefined, undefined], + new Weight(weight, 'kilograms'), + () => time.plusDays(day), + ); + return makeSession([blueprint], LocalDate.of(2026, 4, 5).plusDays(day)).with({ id, recordedExercises: [exercise] }); +} +const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } as unknown as Logger; +let db: ExpoSQLiteDatabase; +let repository: SessionHistoryRepository; +async function insert(values: Session[]) { + await db.insert(sessionsSchema).values(values.map((session) => ({ id: session.id, payload: session.toJSON() }))); +} + +beforeEach(async () => { + db = drizzle(await openDatabaseAsync(':memory:')); + await new DatabaseMigrationService(db, logger, { importOldData: async () => {} }).migrate(); + repository = new SessionHistoryRepository(db); +}); + +describe('indexed session history', () => { + it('backfills search facts without modifying authoritative payloads or active flags', async () => { + const session = workout('one', 0); + await db.insert(sessionsSchema).values({ id: session.id, active: true, payload: session.toJSON() }); + const before = await Promise.resolve( + db.get<{ payload: string }>(sql`SELECT payload FROM session WHERE id = 'one'`), + ); + await Promise.all([repository.ensureIndexed(), repository.ensureIndexed()]); + const [row] = await db.select().from(sessionsSchema); + expect(row?.active).toBe(true); + expect(row?.date).toBe('2026-04-05'); + expect(row?.searchVersion).toBe(1); + expect(await db.get(sql`SELECT payload FROM session WHERE id = 'one'`)).toEqual(before); + expect(await db.select().from(recordedExerciseIndexSchema)).toHaveLength(1); + await repository.ensureIndexed(); + expect(await db.select().from(recordedExerciseIndexSchema)).toHaveLength(1); + }); + + it('selects the latest valid progression match and ignores abandoned sets and changed schemes', async () => { + const latest = workout('valid', 1, 110); + const otherBlueprint = makeWeightedBlueprint({ name: 'Other' }); + await insert([workout('old', 0), latest, workout('abandoned', 2, 120, false), makeSession([otherBlueprint])]); + const loadSession = vi.spyOn(repository, 'getSession'); + const result = await repository.getLatestExercises([blueprint.progressionKey(), otherBlueprint.progressionKey()]); + expect(result[blueprint.progressionKey()]?.toJSON()).toEqual(latest.recordedExercises[0]?.toJSON()); + expect(result[otherBlueprint.progressionKey()]).toBeUndefined(); + expect(loadSession).toHaveBeenCalledTimes(1); + loadSession.mockClear(); + const cached = await repository.getLatestExercises([blueprint.progressionKey(), otherBlueprint.progressionKey()]); + expect(cached).toEqual(result); + expect(loadSession).not.toHaveBeenCalled(); + }); + + it('recovers a projection invalidated by a raw payload edit, including a cached missing result', async () => { + const empty = workout('edit', 0, 100, false); + await insert([empty]); + expect( + (await repository.getLatestExercises([blueprint.progressionKey()]))[blueprint.progressionKey()], + ).toBeUndefined(); + const edited = workout('edit', 1, 125); + await db.update(sessionsSchema).set({ payload: edited.toJSON() }).where(eq(sessionsSchema.id, edited.id)); + expect((await db.select().from(sessionsSchema))[0]?.searchVersion).toBeNull(); + const result = await repository.getLatestExercises([blueprint.progressionKey()]); + expect(result[blueprint.progressionKey()]?.toJSON()).toEqual(edited.recordedExercises[0]?.toJSON()); + }); + + it('rolls back payload and projection together when a write fails', async () => { + const original = workout('edit', 0); + await insert([original]); + await repository.ensureIndexed(); + const edited = workout('edit', 1, 125); + await expect( + withSessionTransaction(db, async (tx) => { + await tx.update(sessionsSchema).set({ payload: edited.toJSON() }).where(eq(sessionsSchema.id, edited.id)); + await updateSessionSearch(tx, edited); + throw new Error('interrupted write'); + }), + ).rejects.toThrow('interrupted write'); + expect((await repository.getSession('edit'))?.toJSON()).toEqual(original.toJSON()); + expect( + (await repository.getLatestExercises([blueprint.progressionKey()]))[blueprint.progressionKey()]?.toJSON(), + ).toEqual(original.recordedExercises[0]?.toJSON()); + }); + + it('resumes a partially completed backfill', async () => { + await insert([workout('a', 0), workout('b', 1)]); + await withSessionTransaction(db, async (tx) => { + await updateSessionSearch(tx, workout('a', 0)); + }); + await repository.ensureIndexed(); + expect((await db.select().from(sessionsSchema)).map((row) => row.searchVersion)).toEqual([1, 1]); + expect(await db.select().from(recordedExerciseIndexSchema)).toHaveLength(2); + }); + + it('deletion removes lookup rows and invalidation exposes the preceding performance', async () => { + await insert([workout('a', 0), workout('b', 1)]); + await repository.getLatestExercises([blueprint.progressionKey()]); + repository.invalidate(); + await db.delete(sessionsSchema).where(eq(sessionsSchema.id, 'b')); + expect(await db.select().from(recordedExerciseIndexSchema)).toHaveLength(1); + expect( + (await repository.getLatestExercises([blueprint.progressionKey()]))[blueprint.progressionKey()]?.toJSON(), + ).toEqual(workout('a', 0).recordedExercises[0]?.toJSON()); + }); + + it('paginates tied exercise timestamps without duplicates or truncation', async () => { + await insert(Array.from({ length: 57 }, (_, i) => workout(`session-${String(i).padStart(3, '0')}`, 0))); + const first = await repository.getExerciseHistory(blueprint.movementKey()); + const second = await repository.getExerciseHistory(blueprint.movementKey(), first.next); + const third = await repository.getExerciseHistory(blueprint.movementKey(), second.next); + expect([first.exercises.length, second.exercises.length, third.exercises.length]).toEqual([25, 25, 7]); + expect(first.next?.sessionId).toBe('session-024'); + expect(second.next?.sessionId).toBe('session-049'); + expect(third.next).toBeUndefined(); + }); + + it('keeps weighted and cardio movements separate and excludes the viewed workout from previous values', async () => { + const cardio = makeCardioBlueprint().with({ name: blueprint.name }); + const cardioSession = makeSession([cardio]).with({ + id: 'cardio', + recordedExercises: [RecordedCardioExercise.empty(cardio)], + }); + await insert([workout('a', 0), workout('active', 1), cardioSession]); + expect((await repository.getExerciseHistory(cardio.movementKey())).exercises).toHaveLength(0); + const context = await repository.getWorkoutContext([blueprint], 'active'); + expect(context[blueprint.movementKey()]?.[0]?.toJSON()).toEqual(workout('a', 0).recordedExercises[0]?.toJSON()); + }); + + it('keeps the active workout out of exercise history and previous-value context', async () => { + await insert([workout('older', 0), workout('active', 1)]); + await db.update(sessionsSchema).set({ active: true }).where(eq(sessionsSchema.id, 'active')); + expect((await repository.getExerciseHistory(blueprint.movementKey())).exercises).toHaveLength(1); + const context = await repository.getWorkoutContext([blueprint], 'some-other-viewed-workout'); + expect(context[blueprint.movementKey()]?.[0]?.toJSON()).toEqual(workout('older', 0).recordedExercises[0]?.toJSON()); + }); + + it('finds the previous comparable workout without accepting the active session or a same-second completion', async () => { + const previous = workout('previous', 0); + const active = workout('active', 1); + const current = workout('current', 2); + await insert([previous, active, current, current.with({ id: 'same-second' })]); + expect((await repository.getPreviousComparableSession(current, active.id))?.id).toBe(previous.id); + }); + + it('uses the compound index for latest progression lookup', async () => { + const plan = await Promise.resolve( + db.all<{ detail: string }>( + sql`EXPLAIN QUERY PLAN SELECT sessionId, exerciseIndex FROM recorded_exercise_index WHERE progressionKey = ${blueprint.progressionKey()} ORDER BY latestTime DESC, sessionId ASC, exerciseIndex ASC LIMIT 1`, + ), + ); + expect(plan.some((row) => row.detail.includes('exercise_progression_time_index'))).toBe(true); + expect(plan.some((row) => row.detail.includes('TEMP B-TREE'))).toBe(false); + }); + + it('queries only the selected month/range and preserves ordering and bodyweight context', async () => { + const first = workout('april', 0).with({ bodyweight: new Weight(75, 'kilograms') }); + const next = workout('may', 30).with({ bodyweight: new Weight(76, 'kilograms') }); + await insert([first, next, Session.freeformSession(LocalDate.of(2026, 6, 1), undefined)]); + expect((await repository.getSessionsByMonth('2026-04')).map((session) => session.id)).toEqual(['april']); + expect((await repository.getSessionsInRange('2026-04-05', '2026-04-05')).map((session) => session.id)).toEqual([ + 'april', + ]); + expect((await repository.getLatestPlannedSession())?.toJSON()).toEqual(next.toJSON()); + const page = await repository.getSessionPage(undefined, 2); + expect(page).toHaveLength(2); + const last = page.at(-1)!; + const rest = await repository.getSessionPage( + { id: last.id, referenceTime: time.plusDays(30).toInstant().toEpochMilli() }, + 2, + ); + expect(rest.map((session) => session.id)).toEqual(['april']); + }); + + it('compact summaries preserve volume and all-time personal records without hydrating sessions', async () => { + const values = [workout('a', 0, 100), workout('b', 1, 110), workout('c', 2, 105)]; + await insert(values); + const summaries = await repository.getActivitySummaries(); + expect(summaries.map((summary) => summary.volume)).toEqual(values.map(sessionVolume)); + const state = { storedSessions: storedSessionsReducer(undefined, setActivitySummaries(summaries)) } as RootState; + expect(selectHistoryPersonalRecords(state)).toEqual(findPersonalRecords(values)); + expect(state.storedSessions.sessions).toEqual({}); + }); + + it('queries a generated large history without reconstructing unrelated payloads after backfill', async () => { + const extraBlueprints = Array.from({ length: 5 }, (_, i) => makeWeightedBlueprint({ name: `Other lift ${i}` })); + const values = Array.from({ length: 2235 }, (_, i) => { + const base = workout(`large-${i}`, i); + return base.with({ + recordedExercises: [ + base.recordedExercises[0]!, + ...extraBlueprints.map((exercise) => + makeRecordedExercise(exercise, [10, 10, 10], new Weight(100, 'kilograms'), () => time.plusDays(i)), + ), + ], + }); + }); + for (let i = 0; i < values.length; i += 100) await insert(values.slice(i, i + 100)); + await repository.ensureIndexed(); + const reconstruct = vi.spyOn(Session, 'fromJSON'); + try { + await repository.getLatestExercises([blueprint.progressionKey()]); + expect(reconstruct).toHaveBeenCalledTimes(1); + reconstruct.mockClear(); + await repository.getExerciseHistory(blueprint.movementKey()); + expect(reconstruct).toHaveBeenCalledTimes(25); + } finally { + reconstruct.mockRestore(); + } + }, 30000); +}); diff --git a/app/src/services/session-history-repository.ts b/app/src/services/session-history-repository.ts new file mode 100644 index 000000000..b9dd970ff --- /dev/null +++ b/app/src/services/session-history-repository.ts @@ -0,0 +1,361 @@ +import { LocalDate } from '@js-joda/core'; +import { Weight } from '@/models/weight'; +import { SessionActivitySummary } from '@/models/session-summary'; +import { sessionVolume } from '@/store/activity/volume'; +import { bestOneRepMax } from '@/store/stats/personal-records'; +import { recordedExerciseIndexSchema as exercises, sessionsSchema as sessions } from '@/db/schema'; +import { ExerciseBlueprint, MovementKey, ProgressionKey } from '@/models/blueprint-models'; +import { RecordedExercise, Session } from '@/models/session-models'; +import { sessionMigrations } from '@/models/storage/versions/migrations'; +import { getSessionReferenceTime } from '@/store/stored-sessions'; +import { and, asc, desc, eq, gte, gt, inArray, isNull, lt, lte, ne, notInArray, or, sql } from 'drizzle-orm'; +import { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; +import type { WorkCheckpoint } from '@/utils/cooperative-work'; + +const batchSize = 25; +const yieldToUI = () => new Promise((resolve) => setTimeout(resolve, 0)); +const transactions = new WeakMap>(); + +// Expo Drizzle's transaction(callback) commits synchronously; it does not await async callbacks. +// Keep the transaction open through the awaited operations, and serialize session writes/backfill. +// Operations inside this queue must only await database work, never timers or network requests. +export function withSessionTransaction( + db: ExpoSQLiteDatabase, + operation: (tx: Transaction) => Promise, +): Promise { + const previous = transactions.get(db) ?? Promise.resolve(); + const next = previous + .catch(() => {}) + .then(async () => { + await Promise.resolve(db.run(sql`BEGIN IMMEDIATE`)); + try { + const result = await operation(db); + await Promise.resolve(db.run(sql`COMMIT`)); + return result; + } catch (error) { + await Promise.resolve(db.run(sql`ROLLBACK`)); + throw error; + } + }); + transactions.set( + db, + next.catch(() => {}), + ); + return next; +} + +type Transaction = Pick; + +/** Rebuilt from model semantics, never from a second interpretation of historical JSON versions. */ +export async function updateSessionSearch(tx: Transaction, session: Session) { + await tx.delete(exercises).where(eq(exercises.sessionId, session.id)); + const rows = session.recordedExercises.flatMap((exercise, exerciseIndex) => { + const time = exercise.latestTime; + return time + ? [ + { + sessionId: session.id, + exerciseIndex, + movementKey: exercise.movementKey(), + progressionKey: exercise.progressionKey(), + latestTime: time.toInstant().toEpochMilli(), + }, + ] + : []; + }); + // Avoid SQLite's bound-parameter limit for unusually large imported workouts. + for (let offset = 0; offset < rows.length; offset += 100) { + await tx.insert(exercises).values(rows.slice(offset, offset + 100)); + } + await tx + .update(sessions) + .set({ + date: session.date.toString(), + referenceTime: getSessionReferenceTime(session).toInstant().toEpochMilli(), + workoutName: session.blueprint.name, + searchVersion: 1, + activity: { + isStarted: session.isStarted, + volume: sessionVolume(session), + bests: [...bestOneRepMax(session)].map(([key, best]) => ({ + key, + exerciseName: best.exerciseName, + oneRepMax: best.oneRepMax.toJSON(), + })), + }, + }) + .where(eq(sessions.id, session.id)); +} + +export interface ExerciseHistoryCursor { + latestTime: number; + sessionId: string; + exerciseIndex: number; +} +export interface SessionCursor { + referenceTime: number; + id: string; +} + +/** JSON remains authoritative. Only matching payloads cross the SQLite/JS boundary. */ +export class SessionHistoryRepository { + private indexing: Promise | undefined; + private latestCache = new Map(); + private revision = 0; + + invalidate() { + this.revision++; + this.latestCache.clear(); + } + + constructor(private db: ExpoSQLiteDatabase) {} + + ensureIndexed(): Promise { + if (!this.indexing) { + this.indexing = this.backfill().finally(() => { + this.indexing = undefined; + }); + } + return this.indexing; + } + + private async backfill() { + let count = 0; + while (true) { + // Each batch is atomic. A crash leaves the remaining rows marked unindexed for retry. + const loaded = await withSessionTransaction(this.db, async (tx) => { + const rows = await tx.select().from(sessions).where(isNull(sessions.searchVersion)).limit(batchSize); + for (const row of rows) { + await updateSessionSearch(tx, Session.fromJSON(sessionMigrations.migrate(row.payload))); + } + return rows.length; + }); + count += loaded; + if (loaded < batchSize) break; + await yieldToUI(); + } + if (count) this.latestCache.clear(); + } + + async getSessionIds(): Promise { + await transactions.get(this.db); + return (await this.db.select({ id: sessions.id }).from(sessions)).flatMap((row) => (row.id ? [row.id] : [])); + } + + async getSession(id: string): Promise { + await transactions.get(this.db); + const [row] = await this.db.select({ payload: sessions.payload }).from(sessions).where(eq(sessions.id, id)); + return row ? Session.fromJSON(sessionMigrations.migrate(row.payload)) : undefined; + } + + async getLatestPlannedSession(): Promise { + await this.ensureIndexed(); + const [row] = await this.db + .select({ payload: sessions.payload }) + .from(sessions) + .where(ne(sessions.workoutName, 'Freeform Workout')) + .orderBy(desc(sessions.referenceTime), asc(sessions.id)) + .limit(1); + return row ? Session.fromJSON(sessionMigrations.migrate(row.payload)) : undefined; + } + + async getLatestExercises(keys: ProgressionKey[]): Promise> { + const revision = this.revision; + await this.ensureIndexed(); + const result: Record = {}; + const payloads = new Map(); + for (const key of new Set(keys)) { + if (this.latestCache.has(key)) { + result[key] = this.latestCache.get(key); + continue; + } + const [match] = await this.db + .select() + .from(exercises) + .where(eq(exercises.progressionKey, key)) + .orderBy(desc(exercises.latestTime), asc(exercises.sessionId), asc(exercises.exerciseIndex)) + .limit(1); + if (match && !payloads.has(match.sessionId)) + payloads.set(match.sessionId, await this.getSession(match.sessionId)); + result[key] = match ? payloads.get(match.sessionId)?.recordedExercises[match.exerciseIndex] : undefined; + if (revision === this.revision) this.latestCache.set(key, result[key]); + } + + return result; + } + + async getPreviousComparableSession(session: Session, activeSessionId?: string): Promise { + await this.ensureIndexed(); + const [row] = await this.db + .select({ payload: sessions.payload }) + .from(sessions) + .where( + and( + eq(sessions.workoutName, session.blueprint.name), + ne(sessions.id, session.id), + activeSessionId ? ne(sessions.id, activeSessionId) : undefined, + // The existing comparison intentionally excludes completions in the same second. + lt(sessions.referenceTime, getSessionReferenceTime(session).toEpochSecond() * 1000), + ), + ) + .orderBy(desc(sessions.referenceTime), asc(sessions.id)) + .limit(1); + return row ? Session.fromJSON(sessionMigrations.migrate(row.payload)) : undefined; + } + + async getWorkoutContext(blueprints: ExerciseBlueprint[], excludeSessionId: string) { + await this.ensureIndexed(); + const matches = new Map(); + for (const blueprint of blueprints) { + for (const condition of [ + eq(exercises.movementKey, blueprint.movementKey()), + eq(exercises.progressionKey, blueprint.progressionKey()), + ]) { + const [row] = await this.db + .select() + .from(exercises) + .where( + and( + condition, + ne(exercises.sessionId, excludeSessionId), + notInArray( + exercises.sessionId, + this.db.select({ id: sessions.id }).from(sessions).where(eq(sessions.active, true)), + ), + ), + ) + .orderBy(desc(exercises.latestTime), asc(exercises.sessionId), asc(exercises.exerciseIndex)) + .limit(1); + if (row) matches.set(`${row.sessionId}:${row.exerciseIndex}`, row); + } + } + const models = new Map(); + const result: Record = {}; + for (const row of [...matches.values()].sort((a, b) => b.latestTime - a.latestTime)) { + if (!models.has(row.sessionId)) models.set(row.sessionId, await this.getSession(row.sessionId)); + const exercise = models.get(row.sessionId)?.recordedExercises[row.exerciseIndex]; + if (exercise) (result[exercise.movementKey()] ??= []).push(exercise); + } + return result; + } + + async getActivitySummaries(): Promise { + await this.ensureIndexed(); + const rows = await this.db + .select({ + id: sessions.id, + date: sessions.date, + referenceTime: sessions.referenceTime, + activity: sessions.activity, + }) + .from(sessions); + return rows.map((row) => { + if (!row.date || row.referenceTime === null || !row.activity) + throw new Error('Session search backfill is incomplete'); + return { + id: row.id, + date: LocalDate.parse(row.date), + referenceTime: row.referenceTime, + ...row.activity, + bests: row.activity.bests.map((best) => ({ ...best, oneRepMax: Weight.fromJSON(best.oneRepMax) })), + }; + }); + } + + async getSessionsByMonth(month: string): Promise { + await this.ensureIndexed(); + return this.readSessions(and(gt(sessions.date, `${month}-00`), lt(sessions.date, `${month}-32`))); + } + + async getSessionPage(cursor?: SessionCursor, limit = batchSize): Promise { + await this.ensureIndexed(); + const rows = await this.db + .select({ payload: sessions.payload }) + .from(sessions) + .where( + cursor + ? or( + lt(sessions.referenceTime, cursor.referenceTime), + and(eq(sessions.referenceTime, cursor.referenceTime), gt(sessions.id, cursor.id)), + ) + : undefined, + ) + .orderBy(desc(sessions.referenceTime), asc(sessions.id)) + .limit(limit); + return rows.map((row) => Session.fromJSON(sessionMigrations.migrate(row.payload))); + } + + async getSessionsInRange(from: string, to: string, checkpoint?: WorkCheckpoint): Promise { + await this.ensureIndexed(); + return this.readSessions(and(gte(sessions.date, from), lte(sessions.date, to)), checkpoint); + } + + private async readSessions(where: ReturnType, checkpoint?: WorkCheckpoint): Promise { + const result: Session[] = []; + let afterId: string | undefined; + while (true) { + const pause = checkpoint?.(); + if (pause) await pause; + const rows = await this.db + .select({ id: sessions.id, payload: sessions.payload }) + .from(sessions) + .where(and(where, afterId ? gt(sessions.id, afterId) : undefined)) + .orderBy(asc(sessions.id)) + .limit(batchSize); + for (const row of rows) { + const pause = checkpoint?.(); + if (pause) await pause; + result.push(Session.fromJSON(sessionMigrations.migrate(row.payload))); + } + + if (rows.length < batchSize) return result; + afterId = rows.at(-1)?.id ?? undefined; + await yieldToUI(); + } + } + + async getExerciseHistory(key: MovementKey, cursor?: ExerciseHistoryCursor, limit = batchSize) { + await this.ensureIndexed(); + const rows = await this.db + .select() + .from(exercises) + .where( + and( + eq(exercises.movementKey, key), + notInArray( + exercises.sessionId, + this.db.select({ id: sessions.id }).from(sessions).where(eq(sessions.active, true)), + ), + cursor + ? or( + lt(exercises.latestTime, cursor.latestTime), + and(eq(exercises.latestTime, cursor.latestTime), gt(exercises.sessionId, cursor.sessionId)), + and( + eq(exercises.latestTime, cursor.latestTime), + eq(exercises.sessionId, cursor.sessionId), + gt(exercises.exerciseIndex, cursor.exerciseIndex), + ), + ) + : undefined, + ), + ) + .orderBy(desc(exercises.latestTime), asc(exercises.sessionId), asc(exercises.exerciseIndex)) + .limit(limit + 1); + const page = rows.slice(0, limit); + const ids = [...new Set(page.map((row) => row.sessionId))]; + const payloads = ids.length + ? await this.db + .select({ id: sessions.id, payload: sessions.payload }) + .from(sessions) + .where(inArray(sessions.id, ids)) + : []; + const models = new Map(payloads.map((row) => [row.id, Session.fromJSON(sessionMigrations.migrate(row.payload))])); + return { + exercises: page.flatMap((row) => { + const exercise = models.get(row.sessionId)?.recordedExercises[row.exerciseIndex]; + return exercise ? [exercise] : []; + }), + next: rows.length > limit ? page.at(-1) : undefined, + }; + } +} diff --git a/app/src/services/session-service.ts b/app/src/services/session-service.ts index aad6eab4a..ab8d2b700 100644 --- a/app/src/services/session-service.ts +++ b/app/src/services/session-service.ts @@ -30,6 +30,7 @@ export class SessionService { async *getUpcomingSessions( sessionBlueprints: SessionBlueprint[], latestExercises: Record, + latestStoredSession?: Session | null, ): AsyncIterableIterator { const currentState = this.getState(); const currentSession = selectActiveSession(currentState); @@ -41,7 +42,10 @@ export class SessionService { await yieldToEventLoop(); let latestSession = - currentSession ?? this.progressRepository.getOrderedSessions().firstOrDefault((x) => !x.isFreeform); + currentSession ?? + (latestStoredSession === undefined + ? this.progressRepository.getOrderedSessions().firstOrDefault((x) => !x.isFreeform) + : latestStoredSession); await yieldToEventLoop(); // Track the plan position by index so progression walks the plan in order. diff --git a/app/src/store/activity/index.ts b/app/src/store/activity/index.ts index 4400fee99..49aedf241 100644 --- a/app/src/store/activity/index.ts +++ b/app/src/store/activity/index.ts @@ -1,9 +1,10 @@ +import { SessionActivitySummary } from '@/models/session-summary'; import { createSelector } from '@reduxjs/toolkit'; import { LocalDate, YearMonth } from '@js-joda/core'; import { Session } from '@/models/session-models'; import { FEED_EVENT_RETENTION_DAYS, SessionUserEvent } from '@/models/feed-models'; import { RootState } from '@/store/store'; -import { selectSessions } from '@/store/stored-sessions'; +import { selectSessionActivity } from '@/store/stored-sessions'; import { ActivityCell, ActivityMarker, ActivityRow, VolumeScale } from '@/store/activity/activity-types'; import { levelFor, sessionVolume, volumeScaleOf } from '@/store/activity/volume'; import { calculateStreak } from '@/store/activity/streak'; @@ -44,7 +45,7 @@ function groupByDate(items: T[], dateOf: (item: T) => LocalDate): Map +export const selectOwnSessionsByDate = createSelector([selectSessionActivity], (sessions) => groupByDate( sessions.filter((x) => x.isStarted), (x) => x.date, @@ -61,11 +62,11 @@ export const selectFeedEventsByDate = createSelector([selectFeedEvents], (feed) /** Per-user volume ranges, each normalised over that user's whole history rather than the visible month. */ export const selectVolumeScales = createSelector( - [selectSessions, selectFeedEvents], + [selectSessionActivity, selectFeedEvents], (sessions, feed): Map => { const volumesByUser = new Map(); - const push = (userId: string, session: Session) => { + const push = (userId: string, session: Session | SessionActivitySummary) => { if (!session.isStarted) return; const volumes = volumesByUser.get(userId); if (volumes) { @@ -111,7 +112,7 @@ export const selectFollowsOtherUsers = createSelector( ); interface CellContext { - ownSessions: Map; + ownSessions: Map; feedEvents: Map; scales: Map; names: Map; @@ -279,7 +280,7 @@ export const selectFollowingActivity = createSelector( const isOwn = userId === ownUserId; const scale = scales.get(isOwn ? OWN_USER_KEY : userId); - const sessionsOn = (date: LocalDate): Session[] => + const sessionsOn = (date: LocalDate): (Session | SessionActivitySummary)[] => isOwn ? (ownSessions.get(date.toString()) ?? []) : (feedEvents.get(date.toString()) ?? []) @@ -325,7 +326,7 @@ export const selectFollowingActivity = createSelector( ); export const selectStreakStats = createSelector( - [selectSessions, selectFirstDayOfWeek, (_: RootState, today: LocalDate) => today], + [selectSessionActivity, selectFirstDayOfWeek, (_: RootState, today: LocalDate) => today], (sessions, firstDayOfWeek, today) => calculateStreak(sessions, firstDayOfWeek, today), ); diff --git a/app/src/store/activity/streak.ts b/app/src/store/activity/streak.ts index fa854fd26..f07227028 100644 --- a/app/src/store/activity/streak.ts +++ b/app/src/store/activity/streak.ts @@ -33,7 +33,10 @@ export interface StreakStats { } /** Distinct days trained per week. A two-a-day is one day, so it can't inflate the bar. */ -function countDistinctDaysByWeek(sessions: Session[], firstDayOfWeek: DayOfWeek): Map> { +function countDistinctDaysByWeek( + sessions: Pick[], + firstDayOfWeek: DayOfWeek, +): Map> { const byWeek = new Map>(); for (const session of sessions) { @@ -66,7 +69,11 @@ function lowerMedian(ascending: number[]): number { * (ProgramBlueprint is just a rotation, with no days-per-week), so the bar comes from the user's own * trailing behaviour instead. */ -export function calculateStreak(sessions: Session[], firstDayOfWeek: DayOfWeek, today: LocalDate): StreakStats { +export function calculateStreak( + sessions: Pick[], + firstDayOfWeek: DayOfWeek, + today: LocalDate, +): StreakStats { const daysByWeek = countDistinctDaysByWeek(sessions, firstDayOfWeek); const currentWeekStart = weekStart(today, firstDayOfWeek); const currentWeekCount = daysByWeek.get(currentWeekStart.toString())?.size ?? 0; diff --git a/app/src/store/activity/volume.ts b/app/src/store/activity/volume.ts index 6aaa2c08c..c90ecacfd 100644 --- a/app/src/store/activity/volume.ts +++ b/app/src/store/activity/volume.ts @@ -1,8 +1,10 @@ +import { SessionActivitySummary } from '@/models/session-summary'; import { Session } from '@/models/session-models'; import { ActivityLevel, MAX_ACTIVITY_LEVEL, VolumeScale } from '@/store/activity/activity-types'; /** Kilograms moved. Cardio contributes nothing, so a cardio-only session scores zero - see `levelFor`. */ -export function sessionVolume(session: Session): number { +export function sessionVolume(session: Session | SessionActivitySummary): number { + if ('volume' in session) return session.volume; let total = 0; for (const exercise of session.recordedExercises) { if (exercise.type !== 'RecordedWeightedExercise') { diff --git a/app/src/store/feed/feed-items-effects.ts b/app/src/store/feed/feed-items-effects.ts index d841c742e..9374a8870 100644 --- a/app/src/store/feed/feed-items-effects.ts +++ b/app/src/store/feed/feed-items-effects.ts @@ -185,7 +185,7 @@ export function addFeedItemEffects(addEffect: AddEffectFn) { addEffect( publishUnpublishedSessions, - async (_, { dispatch, getState, extra: { db, feedApiService, encryptionService } }) => { + async (_, { dispatch, getState, extra: { db, feedApiService, encryptionService, sessionHistoryRepository } }) => { const state = getState(); const identityRemote = state.feed.identity; @@ -201,7 +201,9 @@ export function addFeedItemEffects(addEffect: AddEffectFn) { const unpublishedSessionIds = await db.select().from(feedUnpublishedSessionsSchema); for (const { sessionId } of unpublishedSessionIds) { - const session = selectSession(getState(), sessionId); + const session = + selectSession(getState(), sessionId) ?? + (getState().storedSessions.isHydrated ? undefined : await sessionHistoryRepository.getSession(sessionId)); let result; if (session) { diff --git a/app/src/store/feed/inbox-effects.spec.ts b/app/src/store/feed/inbox-effects.spec.ts index 330a82de3..98d5dd6b1 100644 --- a/app/src/store/feed/inbox-effects.spec.ts +++ b/app/src/store/feed/inbox-effects.spec.ts @@ -58,6 +58,9 @@ function makeTestBed(options?: { sessions?: Record; }) { const services = { + sessionHistoryRepository: { + getSessionIds: vi.fn().mockResolvedValue(Object.keys(options?.sessions ?? { [OWN_SESSION_ID]: ownSession() })), + }, feedApiService: { getInboxMessagesAsync: vi.fn().mockResolvedValue(ApiResult.success({ inboxMessages: [{}] })), }, diff --git a/app/src/store/feed/inbox-effects.ts b/app/src/store/feed/inbox-effects.ts index 197bc4300..90fe0ef89 100644 --- a/app/src/store/feed/inbox-effects.ts +++ b/app/src/store/feed/inbox-effects.ts @@ -27,7 +27,11 @@ import { match } from 'ts-pattern'; * * The emoji allowlist and the count bound are enforced earlier, in `Reaction.fromJSON`. */ -function acceptableReactions(messages: ReactionInboxMessage[], state: RootState): ReceivedReaction[] { +function acceptableReactions( + messages: ReactionInboxMessage[], + state: RootState, + ownedIds: Set, +): ReceivedReaction[] { const followers = new Set(selectFeedFollowers(state).map((x) => x.id)); const existing = Object.values(state.feed.receivedReactions); @@ -45,7 +49,7 @@ function acceptableReactions(messages: ReactionInboxMessage[], state: RootState) if (!followers.has(senderUserId)) { continue; } - if (!selectSession(state, payload.eventId)) { + if (!selectSession(state, payload.eventId) && !ownedIds.has(payload.eventId)) { continue; } @@ -76,7 +80,10 @@ function acceptableReactions(messages: ReactionInboxMessage[], state: RootState) export function addInboxEffects(addEffect: AddEffectFn) { addEffect( fetchInboxItems, - async (action, { dispatch, getState, extra: { feedApiService, feedInboxDecryptionService } }) => { + async ( + action, + { dispatch, getState, extra: { feedApiService, feedInboxDecryptionService, sessionHistoryRepository } }, + ) => { const state = getState(); const identityRemote = selectFeedIdentityRemote(state); @@ -86,6 +93,12 @@ export function addInboxEffects(addEffect: AddEffectFn) { const identity = identityRemote.data; + // Read ownership before consuming the inbox; SQLite failure must not lose a delivered cheer. + const ownedIds = new Set( + state.storedSessions.isHydrated + ? Object.keys(state.storedSessions.sessions) + : await sessionHistoryRepository.getSessionIds(), + ); const inboxItemsResponse = await feedApiService.getInboxMessagesAsync({ userId: identity.id, password: identity.password, @@ -131,7 +144,7 @@ export function addInboxEffects(addEffect: AddEffectFn) { // The server deletes inbox messages once we've read them, so this dispatch is the only copy that will // ever exist. Persist before anything that could throw or await. - const accepted = acceptableReactions(newReactions, getState()); + const accepted = acceptableReactions(newReactions, getState(), ownedIds); if (accepted.length > 0) { dispatch(upsertReceivedReactions(accepted)); } diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 471df5904..3ee9bb4ea 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -12,6 +12,7 @@ import { applySettingsEffects } from '@/store/settings/effects'; import { applyStoredSessionsEffects } from '@/store/stored-sessions/effects'; import { applyFeedEffects } from '@/store/feed/effects'; import { applyStatsEffects } from '@/store/stats/effects'; +import { warmAllTimeStats } from '@/store/stats'; import { applyAiPlannerEffects } from '@/store/ai-planner/effects'; import { applyBackendsEffects } from '@/store/backends/effects'; import { clearAllListeners, Store } from '@reduxjs/toolkit'; @@ -35,6 +36,16 @@ export function resolveStore(db: ExpoSQLiteDatabase, expoDb: SQLiteDatabase) { applyAiPlannerEffects(addEffect); applyBackendsEffects(addEffect); + const slices = ['app', 'program', 'settings', 'storedSessions', 'aiPlanner'] as const; + const unsubscribe = store.subscribe(() => { + const state = store.getState(); + if ( + slices.every((slice) => (slice === 'storedSessions' ? state.storedSessions.isReady : state[slice].isHydrated)) + ) { + unsubscribe(); + store.dispatch(warmAllTimeStats()); + } + }); store.dispatch(initializeAppStateSlice()); return { store, services }; } diff --git a/app/src/store/program/effects.spec.ts b/app/src/store/program/effects.spec.ts index 57f892955..a66ff9a06 100644 --- a/app/src/store/program/effects.spec.ts +++ b/app/src/store/program/effects.spec.ts @@ -97,7 +97,8 @@ function makeProgramState(savedPrograms: Record = {}, savedPrograms, upcomingSessions: RemoteData.notAsked(), }, - storedSessions: { latestExercises: {} }, + storedSessions: { latestExercises: {}, sessions: {}, isHydrated: true }, + settings: { useImperialUnits: false }, } as Partial; } @@ -321,6 +322,93 @@ describe('program effects', () => { }); describe('fetchUpcomingSessions', () => { + it('coalesces concurrent requests without preventing a later refresh', async () => { + const gate = Promise.withResolvers(); + const sessionService = { + getUpcomingSessions: vi.fn().mockImplementation(async function* () { + await gate.promise; + yield { id: 's1' }; + }), + }; + const plan = makeProgram('Plan', [new SessionBlueprint('Day 1', [], '')]); + const testBed = createAddEffectTestBed({ + initialState: makeProgramState({ plan }, 'plan'), + services: { sessionService }, + }); + applyProgramEffects(testBed.addEffect); + + const first = testBed.dispatchHandled(fetchUpcomingSessions()); + await vi.waitFor(() => expect(sessionService.getUpcomingSessions).toHaveBeenCalledTimes(1)); + await testBed.dispatchHandled(fetchUpcomingSessions()); + gate.resolve(); + await first; + + expect(sessionService.getUpcomingSessions).toHaveBeenCalledTimes(1); + expect(testBed.dispatchedActions.filter(setUpcomingSessions.match)).toHaveLength(1); + await testBed.dispatchHandled(fetchUpcomingSessions()); + expect(sessionService.getUpcomingSessions).toHaveBeenCalledTimes(2); + }); + + it('supersedes a pending request after a plan change without publishing stale results', async () => { + const gate = Promise.withResolvers(); + const sessionService = { + getUpcomingSessions: vi + .fn() + .mockImplementationOnce(async function* () { + await gate.promise; + yield { id: 'old' }; + }) + .mockImplementation(function* () { + yield { id: 'new' }; + }), + }; + const oldPlan = makeProgram('Old', [new SessionBlueprint('Old day', [], '')]); + const newPlan = makeProgram('New', [new SessionBlueprint('New day', [], '')]); + const testBed = createAddEffectTestBed({ + initialState: makeProgramState({ plan: oldPlan }, 'plan'), + services: { sessionService }, + }); + applyProgramEffects(testBed.addEffect); + + const first = testBed.dispatchHandled(fetchUpcomingSessions()); + await vi.waitFor(() => expect(sessionService.getUpcomingSessions).toHaveBeenCalledTimes(1)); + testBed.setState(makeProgramState({ plan: newPlan }, 'plan')); + await testBed.dispatchHandled(fetchUpcomingSessions()); + gate.resolve(); + await first; + + const results = testBed.dispatchedActions.filter(setUpcomingSessions.match); + expect(results).toHaveLength(1); + expect(results[0]!.payload.unwrapOr([])).toEqual([{ id: 'new' }]); + }); + + it('allows retrying the same inputs after generation fails', async () => { + const sessionService = { + getUpcomingSessions: vi + .fn() + .mockImplementationOnce(() => { + throw new Error('Generation failed'); + }) + .mockImplementation(function* () { + yield { id: 'retry' }; + }), + }; + const plan = makeProgram('Plan', [new SessionBlueprint('Day 1', [], '')]); + const testBed = createAddEffectTestBed({ + initialState: makeProgramState({ plan }, 'plan'), + services: { sessionService }, + }); + applyProgramEffects(testBed.addEffect); + + await testBed.dispatchHandled(fetchUpcomingSessions()); + await testBed.dispatchHandled(fetchUpcomingSessions()); + + expect(sessionService.getUpcomingSessions).toHaveBeenCalledTimes(2); + expect(testBed.dispatchedActions.filter(setUpcomingSessions.match).at(-1)?.payload.unwrapOr([])).toEqual([ + { id: 'retry' }, + ]); + }); + it('dispatches setUpcomingSessions with sessions from service', async () => { const prog = makeProgram('Plan', []); const state = makeProgramState({ 'id-1': prog }, 'id-1'); diff --git a/app/src/store/program/effects.ts b/app/src/store/program/effects.ts index a0869b543..6e68c98fa 100644 --- a/app/src/store/program/effects.ts +++ b/app/src/store/program/effects.ts @@ -26,13 +26,13 @@ import { TaskAbortError } from '@reduxjs/toolkit'; const builtInProgramsStorageKey = 'hasSavedDefaultPlans2'; export function applyProgramEffects(addEffect: AddEffectFn) { + let upcomingRequest: { inputs: readonly unknown[] } | undefined; addEffect( initializeProgramStateSlice, async ( _, { getState, cancelActiveListeners, dispatch, extra: { keyValueStore, logger, db }, throwIfCancelled }, ) => { - const start = performance.now(); cancelActiveListeners(); let activePlanId: string | undefined; @@ -68,8 +68,7 @@ export function applyProgramEffects(addEffect: AddEffectFn) { dispatch(setActivePlan({ activePlanId })); dispatch(setIsHydrated(true)); - const end = performance.now(); - logger.info(`initializeProgramStateSlice effect took ${(end - start).toFixed(2)} ms`); + dispatch(fetchUpcomingSessions()); }, ); @@ -81,44 +80,99 @@ export function applyProgramEffects(addEffect: AddEffectFn) { { stateBeforeReduce, stateAfterReduce, extra: { db, logger }, throwIfCancelled, cancelActiveListeners }, ) => { cancelActiveListeners(); - const start = performance.now(); + const shouldPersist = stateAfterReduce.program.isHydrated && (stateAfterReduce.program.activePlanId !== stateBeforeReduce.program.activePlanId || stateAfterReduce.program.savedPrograms !== stateBeforeReduce.program.savedPrograms); if (shouldPersist) { await persistPrograms(stateAfterReduce, db, logger, throwIfCancelled); - const end = performance.now(); - logger.info(`Persist program state effect took ${(end - start).toFixed(2)} ms`); } }, ); addEffect( fetchUpcomingSessions, - async (_, { signal, cancelActiveListeners, dispatch, getState, extra: { sessionService, logger } }) => { - const start = performance.now(); - cancelActiveListeners(); - await yieldToEventLoop(); - + async ( + _, + { + signal, + cancelActiveListeners, + dispatch, + getState, + extra: { sessionService, sessionHistoryRepository, logger }, + }, + ) => { const state = getState(); - const sessionBlueprints = selectActiveProgram(state).sessions; - const numberOfUpcomingSessions = sessionBlueprints.length; - - if (signal.aborted) { + if (!state.storedSessions.isReady && !state.storedSessions.isHydrated) return; + const program = selectActiveProgram(state); + if (!program) return; + const sessionBlueprints = program.sessions; + // Hydration and screen focus can request the same work while it is still running. + // Compare every state input used by SessionService; edits must supersede the old request. + const inputs = [ + sessionBlueprints, + state.storedSessions.sessions, + state.storedSessions.latestExercises, + state.storedSessions.activeSessionId, + state.settings.useImperialUnits, + ]; + if (upcomingRequest?.inputs.every((input, index) => input === inputs[index])) { return; } - await yieldToEventLoop(); - const sessions = await AsyncStream.from( - sessionService.getUpcomingSessions(sessionBlueprints, selectLatestExercises(state)), - ) - .takeWhile(() => !signal.aborted) - .take(numberOfUpcomingSessions) - .toArray(); - dispatch(setUpcomingSessions(RemoteData.success(sessions))); - const end = performance.now(); - logger.info(`fetchUpcomingSessions effect took ${(end - start).toFixed(2)} ms`); + const request = { inputs }; + upcomingRequest = request; + + cancelActiveListeners(); + try { + await yieldToEventLoop(); + if (signal.aborted) return; + + const latestExercises = state.storedSessions.isHydrated + ? selectLatestExercises(state) + : await sessionHistoryRepository.getLatestExercises( + sessionBlueprints.flatMap((session) => session.exercises.map((exercise) => exercise.progressionKey())), + ); + const latestSession = state.storedSessions.isHydrated + ? undefined + : ((await sessionHistoryRepository.getLatestPlannedSession()) ?? null); + // Unsaved live edits take precedence over the database projection. + if (!state.storedSessions.isHydrated) { + for (const session of Object.values(getState().storedSessions.sessions)) { + for (const exercise of session.recordedExercises) { + const key = exercise.progressionKey(); + const previous = latestExercises[key]; + if (exercise.latestTime && (!previous?.latestTime || !exercise.latestTime.isBefore(previous.latestTime))) + latestExercises[key] = exercise; + } + } + } + const sessions = await AsyncStream.from( + sessionService.getUpcomingSessions(sessionBlueprints, latestExercises, latestSession), + ) + .takeWhile(() => !signal.aborted) + .take(sessionBlueprints.length) + .toArray(); + if (signal.aborted || upcomingRequest !== request) return; + const current = getState(); + if ( + current.storedSessions.dataRevision !== state.storedSessions.dataRevision || + selectActiveProgram(current)?.sessions !== sessionBlueprints + ) { + upcomingRequest = undefined; + dispatch(fetchUpcomingSessions()); + return; + } + dispatch(setUpcomingSessions(RemoteData.success(sessions))); + } catch (error) { + if (!signal.aborted && upcomingRequest === request) { + logger.error('Failed to load upcoming workouts', error); + dispatch(setUpcomingSessions(RemoteData.error(error instanceof Error ? error.message : String(error)))); + } + } finally { + if (upcomingRequest === request) upcomingRequest = undefined; + } }, ); } diff --git a/app/src/store/settings/effects.ts b/app/src/store/settings/effects.ts index f1938adb7..a16248687 100644 --- a/app/src/store/settings/effects.ts +++ b/app/src/store/settings/effects.ts @@ -50,7 +50,6 @@ export function applySettingsEffects(addEffect: AddEffectFn) { addEffect( initializeSettingsStateSlice, async (_, { cancelActiveListeners, dispatch, extra: { preferenceService, logger } }) => { - const start = performance.now(); cancelActiveListeners(); await hydrateGenericPreferences(preferenceService, dispatch); @@ -102,8 +101,6 @@ export function applySettingsEffects(addEffect: AddEffectFn) { } dispatch(setIsHydrated(true)); dispatch(initializeStoredSessionsStateSlice()); - const end = performance.now(); - logger.log(`initializeSettingsStateSlice effect took ${(end - start).toFixed(2)}ms`); }, ); diff --git a/app/src/store/settings/import-backup-effects.ts b/app/src/store/settings/import-backup-effects.ts index ea11d2506..dfc986296 100644 --- a/app/src/store/settings/import-backup-effects.ts +++ b/app/src/store/settings/import-backup-effects.ts @@ -114,8 +114,8 @@ export function addImportBackupEffects(addEffect: AddEffectFn) { }); await migrator.migrate(); - const workouts = (await drizzleBackupDb.select().from(sessionsSchema)).map((x) => - Session.fromJSON(sessionMigrations.migrate(x.payload)), + const workouts = (await drizzleBackupDb.select({ payload: sessionsSchema.payload }).from(sessionsSchema)).map( + (x) => Session.fromJSON(sessionMigrations.migrate(x.payload)), ); const programs = (await drizzleBackupDb.select().from(programsSchema)).reduce( toRecord( diff --git a/app/src/store/stats/calculate-stats.spec.ts b/app/src/store/stats/calculate-stats.spec.ts index 91757a4e1..234ab5eb9 100644 --- a/app/src/store/stats/calculate-stats.spec.ts +++ b/app/src/store/stats/calculate-stats.spec.ts @@ -4,7 +4,7 @@ import { Weight } from '@/models/weight'; import { SessionBlueprint, WeightedExerciseBlueprint } from '@/models/blueprint-models'; import { LocalDate, LocalTime, OffsetDateTime, ZoneOffset, Duration } from '@js-joda/core'; import { LocalDateRange } from '@/models/time-models'; -import { calculateStats } from '@/store/stats/calculate-stats'; +import { calculateStats, calculateStatsAsync } from '@/store/stats/calculate-stats'; import { emptyPotentialSet, filledPotentialSet, @@ -474,3 +474,15 @@ describe('calculateStats', () => { }); }); }); + +it('cooperative calculation preserves weighted and bodyweight results across checkpoints', async () => { + const date = LocalDate.of(2024, 7, 1); + const sessions = [makeSession(date, 'Squat', 100), makeSession(date.plusDays(1), 'Pullup', 10, 8, 3, 80, true)]; + let checkpoints = 0; + const range = makeRange(date, date.plusDays(7)); + const actual = await calculateStatsAsync(sessions, 'pounds', range, async () => { + checkpoints++; + }); + expect(actual).toEqual(calculateStats(sessions, 'pounds', range)); + expect(checkpoints).toBeGreaterThan(sessions.length); +}); diff --git a/app/src/store/stats/calculate-stats.ts b/app/src/store/stats/calculate-stats.ts index 4223c8597..6a7874b06 100644 --- a/app/src/store/stats/calculate-stats.ts +++ b/app/src/store/stats/calculate-stats.ts @@ -1,4 +1,4 @@ -import { PotentialSet, RecordedCardioExercise, RecordedWeightedExercise, Session } from '@/models/session-models'; +import { PotentialSet, RecordedWeightedExercise, Session } from '@/models/session-models'; import { ExerciseBlueprint, MovementKey } from '@/models/blueprint-models'; import { LocalDateRange } from '@/models/time-models'; import { Weight, WeightUnit } from '@/models/weight'; @@ -13,9 +13,10 @@ import { WeightedStatisticOverTime, } from '@/store/stats'; import { loadOps, QuantityOps, repsOps, StatAxis } from '@/store/stats/quantity'; -import { Duration, OffsetDateTime, ZoneId } from '@js-joda/core'; +import { Duration, LocalDate, OffsetDateTime, ZoneId } from '@js-joda/core'; import BigNumber from 'bignumber.js'; import Enumerable from 'linq'; +import type { WorkCheckpoint } from '@/utils/cooperative-work'; /** Epley: 1RM = weight * (1 + reps/30). `weight` is the effective load, folding in bodyweight. */ export function calculateOneRepMax(ps: PotentialSet, weight: Weight): Weight { @@ -28,6 +29,34 @@ export function calculateStats( preferredUnit: WeightUnit, timeRange: LocalDateRange, ): GranularStatisticView { + const calculation = calculateStatsSteps(sessions, preferredUnit, timeRange); + let step = calculation.next(); + while (!step.done) step = calculation.next(); + return step.value; +} + +/** Same calculation, with checkpoints so large histories do not monopolize the JS thread. */ +export async function calculateStatsAsync( + sessions: Session[], + preferredUnit: WeightUnit, + timeRange: LocalDateRange, + checkpoint: WorkCheckpoint, +): Promise { + const calculation = calculateStatsSteps(sessions, preferredUnit, timeRange); + let step = calculation.next(); + while (!step.done) { + const pause = checkpoint(); + if (pause) await pause; + step = calculation.next(); + } + return step.value; +} + +function* calculateStatsSteps( + sessions: Session[], + preferredUnit: WeightUnit, + timeRange: LocalDateRange, +): Generator { if (!sessions.length) return { workoutsPerWeek: 0, @@ -46,6 +75,18 @@ export function calculateStats( sessionStats: [], }; + yield; + const zone = ZoneId.systemDefault(); + const noons = new Map(); + function noon(date: LocalDate) { + const day = date.toEpochDay(); + let value = noons.get(day); + if (!value) { + value = date.atTime(12, 0).atZone(zone).toOffsetDateTime(); + noons.set(day, value); + } + return value; + } // Only sessions with at least one exercise const sessionsWithExercises = sessions.filter((s) => s.recordedExercises.length > 0); const daysBetween = Enumerable.from(sessionsWithExercises) @@ -69,35 +110,50 @@ export function calculateStats( const workoutsPerWeek = workoutCount / totalWeeks; const setsPerWeek = totalSets / totalWeeks; + yield; const bodyWeightStatistics = Enumerable.from(sessions) .where((s) => !!s.bodyweight) .select((session) => ({ - dateTime: session.date.atTime(12, 0).atZone(ZoneId.systemDefault()).toOffsetDateTime(), // Use noon for LocalDate + dateTime: noon(session.date), // Use noon for LocalDate value: session.bodyweight!, })) .toArray(); // --- Bodyweight stats over time --- - const bodyweightStats: WeightedStatisticOverTime = toStatisticOverTime(bodyWeightStatistics, loadOps); + const bodyweightStats: WeightedStatisticOverTime = yield* toStatisticOverTime(bodyWeightStatistics, loadOps); + yield; // --- Session stats grouped by blueprint name --- const sessionStats: OptionalStatisticOverTime[] = []; const sessionsByBlueprint = new Map(); for (const session of sessionsWithExercises) { + yield; const key = session.blueprint.name; if (!sessionsByBlueprint.has(key)) sessionsByBlueprint.set(key, []); sessionsByBlueprint.get(key)!.push(session); } + const sortedDays = daysBetween + .sort((a, b) => a.compareTo(b)) + .map((date) => ({ + key: date.toEpochDay(), + dateTime: noon(date), + })); for (const [name, group] of sessionsByBlueprint.entries()) { - const statistics = Enumerable.from(daysBetween) - .select((date) => { - const session = group.find((s) => s.date.equals(date)); - return { - dateTime: date.atTime(12, 0).atZone(ZoneId.systemDefault()).toOffsetDateTime(), - value: session ? session.totalWeightLifted : undefined, - } satisfies TimeTrackedStatistic; - }) - .orderBy((x) => x.dateTime.toString()) - .toArray(); + // Preserve the first workout on a date without repeatedly scanning the whole group. + const firstByDate = new Map(); + for (const session of group) { + const date = session.date.toEpochDay(); + if (!firstByDate.has(date)) firstByDate.set(date, session); + } + const statistics: TimeTrackedStatistic[] = []; + for (let index = 0; index < sortedDays.length; index++) { + if (index % 32 === 0) yield; + const date = sortedDays[index]!; + const session = firstByDate.get(date.key); + statistics.push({ + dateTime: date.dateTime, + value: session ? session.totalWeightLifted : undefined, + }); + } const statsWithValue = statistics.filter((x) => x.value !== undefined); const min = statsWithValue.length ? Weight.min(...statsWithValue.map((x) => x.value!)) : Weight.NIL; const max = statsWithValue.length ? Weight.max(...statsWithValue.map((x) => x.value!)) : Weight.NIL; @@ -109,6 +165,7 @@ export function calculateStats( }); } + yield; // --- Exercise stats grouped by normalized exercise name --- interface ExerciseStatAcc { exerciseName: string; @@ -121,9 +178,20 @@ export function calculateStats( latestTime: OffsetDateTime; } const exerciseStatsMap = new Map(); + let heaviestLift: HeaviestLift | undefined; for (const session of sessionsWithExercises) { + yield; for (const ex of session.recordedExercises) { + yield; + const weighted = + ex instanceof RecordedWeightedExercise ? summarizeWeightedExercise(ex, session.bodyweight) : undefined; + if (weighted) { + const weight = + weighted.maxWeight && !Weight.NIL.isGreaterThan(weighted.maxWeight) ? weighted.maxWeight : Weight.NIL; + if (!heaviestLift || weight.isGreaterThan(heaviestLift.weight)) + heaviestLift = { exerciseName: ex.blueprint.name, weight }; + } const blueprint = ex.blueprint; const key = blueprint.movementKey(); if (!ex.isStarted) continue; @@ -143,24 +211,8 @@ export function calculateStats( continue; } const exerciseStats = exerciseStatsMap.get(key)!; - // Max weight lifted for this exercise in this session - const maxWeight = ex.potentialSets - .filter((ps) => ps.set) - .map((ps) => ex.effectiveWeight(ps, session.bodyweight)) - .reduce((a, b) => (a === null ? b : a.isGreaterThan(b) ? a : b), null as null | Weight); - if (!maxWeight) { - continue; - } - - // Max 1RM for this exercise in this session - const max1RM = ex.potentialSets - .filter((ps) => ps.set) - .filter((ps) => ps.set!.repsCompleted) - .map((ps) => calculateOneRepMax(ps, ex.effectiveWeight(ps, session.bodyweight))) - .reduce((a, b) => (a === null ? b : a.isGreaterThan(b) ? a : b), null as null | Weight); - if (!max1RM) { - continue; - } + const { maxWeight, max1RM, maxReps, volume, lastSet } = weighted!; + if (!maxWeight || !max1RM) continue; for (const set of ex.potentialSets) { if (!set.set) { @@ -173,65 +225,63 @@ export function calculateStats( } // We'll use the last set for this - const lastSet = ex.lastRecordedSet!; - if (exerciseStats.latestTime.isBefore(lastSet.set!.completionDateTime)) { - exerciseStats.latestTime = lastSet.set!.completionDateTime; + if (exerciseStats.latestTime.isBefore(lastSet!.set!.completionDateTime)) { + exerciseStats.latestTime = lastSet!.set!.completionDateTime; // How the exercise is programmed now, not how it was the first time it was logged. exerciseStats.primary = primaryAxisFor(blueprint); } exerciseStats.maxWeightStatistics.push({ - dateTime: lastSet.set!.completionDateTime, + dateTime: lastSet!.set!.completionDateTime, value: maxWeight, }); exerciseStats.maxRepsStatistics.push({ - dateTime: lastSet.set!.completionDateTime, - value: ex.potentialSets.reduce((most, ps) => Math.max(most, ps.set?.repsCompleted ?? 0), 0), + dateTime: lastSet!.set!.completionDateTime, + value: maxReps, }); exerciseStats.max1RMStatistics.push({ - dateTime: lastSet.set!.completionDateTime, + dateTime: lastSet!.set!.completionDateTime, value: max1RM, }); exerciseStats.totalVolumeStatistics.push({ - dateTime: lastSet.set!.completionDateTime, - value: ex.potentialSets - .filter((x) => x.set) - .reduce( - (accum, set) => - ex.effectiveWeight(set, session.bodyweight).multipliedBy(set.set!.repsCompleted).plus(accum), - Weight.NIL, - ), + dateTime: lastSet!.set!.completionDateTime, + value: volume, }); } } + yield; // Most recently performed first, so what the user is training now heads the list. - const exerciseStats: WeightedExerciseStatistics[] = Array.from(exerciseStatsMap.values()) - .sort((a, b) => (a.latestTime.isEqual(b.latestTime) ? 0 : a.latestTime.isAfter(b.latestTime) ? -1 : 1)) - .map((ex) => { - const maxLiftedPerSessionStatistics = toStatisticOverTime(ex.maxWeightStatistics, loadOps); - const max1RMPerSessionStatistics = toStatisticOverTime(ex.max1RMStatistics, loadOps); - return { - exerciseName: ex.exerciseName, - setsPerWeek: - Object.values(ex.repsStatistics.breakdown).reduce((accum, entry) => accum + entry.numberOfSets, 0) / - totalWeeks, - primary: ex.primary, - series: { - load: maxLiftedPerSessionStatistics, - reps: toStatisticOverTime(ex.maxRepsStatistics, repsOps), - }, - maxLiftedPerSessionStatistics, - max1RMPerSessionStatistics, - totalVolumeStatistics: toStatisticOverTime(ex.totalVolumeStatistics, loadOps), - repsStatistics: ex.repsStatistics, - } satisfies WeightedExerciseStatistics; - }); + const exerciseStats: WeightedExerciseStatistics[] = []; + for (const ex of Array.from(exerciseStatsMap.values()).sort((a, b) => + a.latestTime.isEqual(b.latestTime) ? 0 : a.latestTime.isAfter(b.latestTime) ? -1 : 1, + )) { + yield; + const maxLiftedPerSessionStatistics = yield* toStatisticOverTime(ex.maxWeightStatistics, loadOps); + const max1RMPerSessionStatistics = yield* toStatisticOverTime(ex.max1RMStatistics, loadOps); + exerciseStats.push({ + exerciseName: ex.exerciseName, + setsPerWeek: + Object.values(ex.repsStatistics.breakdown).reduce((accum, entry) => accum + entry.numberOfSets, 0) / totalWeeks, + primary: ex.primary, + series: { + load: maxLiftedPerSessionStatistics, + reps: yield* toStatisticOverTime(ex.maxRepsStatistics, repsOps), + }, + maxLiftedPerSessionStatistics, + max1RMPerSessionStatistics, + totalVolumeStatistics: yield* toStatisticOverTime(ex.totalVolumeStatistics, loadOps), + repsStatistics: ex.repsStatistics, + } satisfies WeightedExerciseStatistics); + } + yield; // --- Average session length --- const sessionDurations: Duration[] = []; for (const session of sessionsWithExercises) { - if (session.duration) { - sessionDurations.push(session.duration); + yield; + const duration = session.duration; + if (duration) { + sessionDurations.push(duration); } } let averageSessionLength = Duration.ZERO; @@ -241,26 +291,6 @@ export function calculateStats( .dividedBy(sessionDurations.length); } - // --- Heaviest lift --- - let heaviestLift: HeaviestLift | undefined = undefined; - for (const session of sessionsWithExercises) { - for (const ex of session.recordedExercises) { - if (ex instanceof RecordedCardioExercise) { - continue; - } - const maxWeight = ex.potentialSets - .filter((ps) => ps.set) - .map((ps) => ex.effectiveWeight(ps, session.bodyweight)) - .reduce((a, b) => (a.isGreaterThan(b) ? a : b), Weight.NIL); - if (!heaviestLift || maxWeight.isGreaterThan(heaviestLift.weight)) { - heaviestLift = { - exerciseName: ex.blueprint.name, - weight: maxWeight, - }; - } - } - } - return { workoutsPerWeek, setsPerWeek, @@ -283,11 +313,37 @@ export function calculateStats( }; } +/** Fold each completed set once; bodyweight loads and BigNumber arithmetic are shared by all series. */ +function summarizeWeightedExercise(ex: RecordedWeightedExercise, bodyweight: Weight | undefined) { + let maxWeight: Weight | undefined; + let max1RM: Weight | undefined; + let maxReps = 0; + let volume = Weight.NIL; + let lastSet: PotentialSet | undefined; + for (const potential of ex.potentialSets) { + const set = potential.set; + if (!set) continue; + const weight = ex.effectiveWeight(potential, bodyweight); + if (!maxWeight || !maxWeight.isGreaterThan(weight)) maxWeight = weight; + if (set.repsCompleted) { + const oneRepMax = calculateOneRepMax(potential, weight); + if (!max1RM || !max1RM.isGreaterThan(oneRepMax)) max1RM = oneRepMax; + } + maxReps = Math.max(maxReps, set.repsCompleted); + volume = weight.multipliedBy(set.repsCompleted).plus(volume); + if (!lastSet || set.completionDateTime.isAfter(lastSet.set!.completionDateTime)) lastSet = potential; + } + return { maxWeight, max1RM, maxReps, volume, lastSet }; +} + /** * Sort a series by time and roll up its extremes and total. Parametric over the axis's arithmetic, * so a rep count aggregates by the same code as a load without ever being treated as a mass. */ -function toStatisticOverTime(unsortedStats: TimeTrackedStatistic[], ops: QuantityOps): StatisticOverTime { +function* toStatisticOverTime( + unsortedStats: TimeTrackedStatistic[], + ops: QuantityOps, +): Generator> { const statistics = Enumerable.from(unsortedStats) .orderBy((x) => x.dateTime.toString()) .toArray(); @@ -295,7 +351,9 @@ function toStatisticOverTime(unsortedStats: TimeTrackedStatistic[], ops: Q let min = ops.zero; let total = ops.zero; - for (const stat of statistics) { + for (let index = 0; index < statistics.length; index++) { + if (index % 32 === 0) yield; + const stat = statistics[index]!; if (ops.isGreaterThan(stat.value, max) || ops.equals(max, ops.zero)) max = stat.value; if (ops.isGreaterThan(min, stat.value) || ops.equals(min, ops.zero)) min = stat.value; total = ops.plus(total, stat.value); diff --git a/app/src/store/stats/effects.spec.ts b/app/src/store/stats/effects.spec.ts new file mode 100644 index 000000000..55fa8a8a6 --- /dev/null +++ b/app/src/store/stats/effects.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { combineReducers } from '@reduxjs/toolkit'; +import { LocalDate } from '@js-joda/core'; +import { createAddEffectTestBed } from '@/utils/__test__/add-effect-testbed'; +import { applyStatsEffects } from '@/store/stats/effects'; +import { fetchOverallStats, setOverallViewTime, statsReducer, warmAllTimeStats } from '@/store/stats'; +import { deleteStoredSession, storedSessionsReducer, updateStoredSession } from '@/store/stored-sessions'; +import { makeSession, makeWeightedBlueprint } from '@/models/session-models/__test__/helpers'; +import { RemoteData } from '@/models/remote'; + +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })); + +const date = LocalDate.of(2026, 4, 5); +function bed() { + const session = makeSession([makeWeightedBlueprint()], date); + const sessionHistoryRepository = { + getSessionsInRange: vi.fn().mockResolvedValue([session]), + getActivitySummaries: vi.fn().mockResolvedValue([{ date }]), + }; + const testBed = createAddEffectTestBed({ + reducer: combineReducers({ + stats: statsReducer, + storedSessions: storedSessionsReducer, + settings: (state = { useImperialUnits: false }) => state, + }), + initialState: { + storedSessions: { isReady: true, isHydrated: false }, + stats: { overallViewTime: { from: date.minusDays(30), to: date } }, + }, + services: { sessionHistoryRepository }, + }); + applyStatsEffects(testBed.addEffect); + return { testBed, sessionHistoryRepository, session }; +} + +describe('selective statistics', () => { + afterEach(() => vi.useRealTimers()); + it('prepares all-time statistics without changing the visible range or hydrating Redux', async () => { + vi.useFakeTimers(); + const { testBed, sessionHistoryRepository } = bed(); + const warming = testBed.dispatchHandled(warmAllTimeStats()); + expect(sessionHistoryRepository.getSessionsInRange).not.toHaveBeenCalled(); + await vi.runAllTimersAsync(); + await warming; + expect(testBed.getState().stats.overallViewTime).not.toBe('all-time'); + expect(testBed.getState().storedSessions.sessions).toEqual({}); + testBed.dispatch(setOverallViewTime('all-time')); + await testBed.dispatchHandled(fetchOverallStats()); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledTimes(1); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + }); + + it('shares an in-flight warm-up and invalidates the result after a deletion', async () => { + vi.useFakeTimers(); + const { testBed, sessionHistoryRepository } = bed(); + let finishRead!: (sessions: []) => void; + sessionHistoryRepository.getSessionsInRange.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const warming = testBed.dispatchHandled(warmAllTimeStats()); + await vi.advanceTimersByTimeAsync(3000); + testBed.dispatch(setOverallViewTime('all-time')); + const foreground = testBed.dispatchHandled(fetchOverallStats()); + finishRead([]); + await vi.runAllTimersAsync(); + await Promise.all([warming, foreground]); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledTimes(1); + await testBed.dispatchHandled(deleteStoredSession('deleted')); + await testBed.dispatchHandled(fetchOverallStats()); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledTimes(2); + }); + + it('discards a snapshot changed while reading and allows a fresh request', async () => { + const { testBed, sessionHistoryRepository } = bed(); + let finishRead!: (sessions: []) => void; + sessionHistoryRepository.getSessionsInRange.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + testBed.dispatch(setOverallViewTime('all-time')); + const foreground = testBed.dispatchHandled(fetchOverallStats()); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + await testBed.dispatchHandled(deleteStoredSession('deleted')); + finishRead([]); + await foreground; + expect(testBed.getState().stats.isDirty).toBe(true); + await testBed.dispatchHandled(fetchOverallStats()); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledTimes(2); + }); + + it('retries a failed warm-up and recomputes when the preferred unit changes', async () => { + vi.useFakeTimers(); + const { testBed, sessionHistoryRepository } = bed(); + sessionHistoryRepository.getSessionsInRange.mockRejectedValueOnce(new Error('database unavailable')); + const warming = testBed.dispatchHandled(warmAllTimeStats()); + await vi.runAllTimersAsync(); + await warming; + testBed.dispatch(setOverallViewTime('all-time')); + await testBed.dispatchHandled(fetchOverallStats()); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + testBed.setState({ settings: { useImperialUnits: true } }); + await testBed.dispatchHandled(fetchOverallStats()); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledTimes(3); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + }); + + it('keeps prepared history when an active workout changes', async () => { + vi.useFakeTimers(); + const { testBed, sessionHistoryRepository, session } = bed(); + testBed.setState({ + storedSessions: { + ...testBed.getState().storedSessions, + activeSessionId: session.id, + sessions: { [session.id]: session }, + }, + }); + const warming = testBed.dispatchHandled(warmAllTimeStats()); + await vi.runAllTimersAsync(); + await warming; + const revision = testBed.getState().storedSessions.dataRevision; + await testBed.dispatchHandled( + updateStoredSession({ sessionId: session.id, update: (current) => current.with({ date: date.plusDays(1) }) }), + ); + expect(testBed.getState().storedSessions.dataRevision).toBeGreaterThan(revision); + testBed.dispatch(setOverallViewTime('all-time')); + await testBed.dispatchHandled(fetchOverallStats()); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledTimes(1); + expect(testBed.getState().stats.overallView.unwrapOr(undefined)?.workoutsPerWeek).toBe(0); + }); + + it('loads only the selected date range without hydrating history', async () => { + const { testBed, sessionHistoryRepository } = bed(); + await testBed.dispatchHandled(fetchOverallStats()); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledWith('2026-03-06', '2026-04-05'); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + expect(testBed.getState().storedSessions.isHydrated).toBe(false); + expect(testBed.getState().storedSessions.sessions).toEqual({}); + }); + + it('finds the all-time boundary from compact summaries', async () => { + const { testBed, sessionHistoryRepository } = bed(); + testBed.dispatch(setOverallViewTime('all-time')); + await testBed.dispatchHandled(fetchOverallStats()); + expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledWith( + date.toString(), + LocalDate.now().toString(), + expect.any(Function), + ); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + }); + + it('keeps failure retryable and marks cached statistics dirty after deletion', async () => { + const { testBed, sessionHistoryRepository, session } = bed(); + sessionHistoryRepository.getSessionsInRange.mockRejectedValueOnce(new Error('read failed')); + await testBed.dispatchHandled(fetchOverallStats()); + expect( + testBed.getState().stats.overallView.match({ success: () => '', loading: () => '', error: (e) => String(e) }), + ).toContain('read failed'); + await testBed.dispatchHandled(fetchOverallStats()); + expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); + expect(testBed.getState().stats.isDirty).toBe(false); + await testBed.dispatchHandled(deleteStoredSession(session.id)); + expect(testBed.getState().stats.isDirty).toBe(true); + }); + + it('does not replace an in-memory active workout with its older database payload', async () => { + const { testBed, session } = bed(); + testBed.setState({ + storedSessions: { + ...testBed.getState().storedSessions, + sessions: { [session.id]: session }, + activeSessionId: session.id, + }, + stats: { ...testBed.getState().stats, overallView: RemoteData.notAsked() }, + }); + await testBed.dispatchHandled(fetchOverallStats()); + expect(testBed.getState().stats.overallView.unwrapOr(undefined)?.workoutsPerWeek).toBe(0); + }); +}); diff --git a/app/src/store/stats/effects.ts b/app/src/store/stats/effects.ts index bcccb43a3..a0262aa3c 100644 --- a/app/src/store/stats/effects.ts +++ b/app/src/store/stats/effects.ts @@ -1,48 +1,196 @@ -import { setOverallViewTime, setStatsIsDirty } from './index'; +import { + putStoredSession, + updateStoredSession, + deleteStoredSession, + upsertStoredSessions, + setActiveSessionId, + setStoredSessions, + selectSessionsBy, +} from '@/store/stored-sessions'; +import { + setOverallViewTime, + setStatsIsDirty, + fetchOverallStats, + setOverallStats, + warmAllTimeStats, + GranularStatisticView, +} from './index'; import { LocalDate } from '@js-joda/core'; -import { fetchOverallStats, setOverallStats } from './index'; -import { AddEffectFn } from '@/store/store'; -import { selectSessionsBy } from '@/store/stored-sessions'; - +import { AddEffectFn, RootState } from '@/store/store'; +import { Services } from '@/services'; import { sleep } from '@/utils/sleep'; import { RemoteData } from '@/models/remote'; -import { selectPreferredWeightUnit } from '../settings'; -import { calculateStats } from '@/store/stats/calculate-stats'; +import { selectPreferredWeightUnit, setUseImperialUnits } from '../settings'; +import { calculateStatsAsync } from '@/store/stats/calculate-stats'; +import { createWorkCheckpoint } from '@/utils/cooperative-work'; + +function snapshotKey(state: RootState) { + return `${state.storedSessions.historyRevision}:${selectPreferredWeightUnit(state)}:${LocalDate.now().toString()}`; +} + +function sameData(a: RootState, b: RootState) { + return ( + a.storedSessions.historyRevision === b.storedSessions.historyRevision && + a.settings.useImperialUnits === b.settings.useImperialUnits + ); +} export function applyStatsEffects(addEffect: AddEffectFn) { - addEffect(fetchOverallStats, async (_, { getState, dispatch }) => { + // Store-local, bounded to one result. Raw history is released after calculation, rather than + // populating Redux and making every workout edit run whole-history selectors. + let allTime: { key: string; result: Promise; promote: () => void } | undefined; + let warmingEnabled = false; + let displayedKey: string | undefined; + + function getAllTime(getState: () => RootState, services: Services, foreground = false) { const state = getState(); + const key = snapshotKey(state); + if (allTime?.key === key) { + if (foreground) allTime.promote(); + return allTime.result; + } + const checkpoint = createWorkCheckpoint( + () => sameData(state, getState()), + () => (foreground ? 16 : 4), + ); + const result = (async () => { + await checkpoint(); + const dates = state.storedSessions.isHydrated + ? Object.values(state.storedSessions.sessions).map((session) => session.date) + : (await services.sessionHistoryRepository.getActivitySummaries()).map((session) => session.date); - if (state.stats.overallView.isLoading() || !state.stats.isDirty || !state.storedSessions.isHydrated) { - return; + // Include edits which may not have reached SQLite yet. + dates.push(...Object.values(state.storedSessions.sessions).map((session) => session.date)); + const earliest = dates.reduce( + (first, date) => (!first || date.isBefore(first) ? date : first), + undefined, + ); + const timeframe = { from: earliest ?? LocalDate.now(), to: LocalDate.now() }; + const sessions = state.storedSessions.isHydrated + ? selectSessionsBy(state, timeframe.from, timeframe.to) + : await services.sessionHistoryRepository.getSessionsInRange( + timeframe.from.toString(), + timeframe.to.toString(), + checkpoint, + ); + await checkpoint(); + const merged = new Map(sessions.map((session) => [session.id, session])); + for (const session of Object.values(getState().storedSessions.sessions)) { + merged.delete(session.id); + if (!session.date.isBefore(timeframe.from) && !session.date.isAfter(timeframe.to)) + merged.set(session.id, session); + } + if (state.storedSessions.activeSessionId) merged.delete(state.storedSessions.activeSessionId); + + const stats = await calculateStatsAsync( + [...merged.values()], + selectPreferredWeightUnit(state), + timeframe, + checkpoint, + ); + + await checkpoint(); + + return stats; + })(); + allTime = { + key, + result, + promote: () => { + foreground = true; + }, + }; + // A failed or superseded job must never poison a later request with a cached rejection. + void result.catch(() => { + if (allTime?.result === result) allTime = undefined; + }); + return result; + } + + addEffect(warmAllTimeStats, async (_, { getState, cancelActiveListeners, signal, extra }) => { + warmingEnabled = true; + cancelActiveListeners(); + await sleep(3000); + if (signal.aborted || !getState().storedSessions.isReady) return; + const key = snapshotKey(getState()); + try { + await getAllTime(getState, extra); + } catch (error) { + if (key === snapshotKey(getState())) extra.logger?.error('Failed to prepare statistics', error); } + }); + addEffect( + [ + putStoredSession, + updateStoredSession, + deleteStoredSession, + upsertStoredSessions, + setActiveSessionId, + setStoredSessions, + setUseImperialUnits, + ], + async (_, { dispatch, stateBeforeReduce, stateAfterReduce }) => { + if (sameData(stateBeforeReduce, stateAfterReduce)) return; + dispatch(setStatsIsDirty(true)); + if (warmingEnabled) dispatch(warmAllTimeStats()); + }, + ); + addEffect(fetchOverallStats, async (_, { getState, dispatch, cancelActiveListeners, signal, extra }) => { + const state = getState(); + if ( + (!state.stats.isDirty && displayedKey === snapshotKey(state)) || + (!state.storedSessions.isReady && !state.storedSessions.isHydrated) + ) + return; + cancelActiveListeners(); + + const key = snapshotKey(state); dispatch(setOverallStats(RemoteData.loading())); - await sleep(200); try { - let timeframe = state.stats.overallViewTime; - if (timeframe === 'all-time') { - if (!state.storedSessions.earliestSession) { - dispatch(setOverallStats(RemoteData.error('No sessions'))); - return; + let stats: GranularStatisticView; + if (state.stats.overallViewTime === 'all-time') { + stats = await getAllTime(getState, extra, true); + } else { + await sleep(200); + if (signal.aborted) return; + const timeframe = state.stats.overallViewTime; + const sessions = state.storedSessions.isHydrated + ? selectSessionsBy(state, timeframe.from, timeframe.to) + : await extra.sessionHistoryRepository.getSessionsInRange(timeframe.from.toString(), timeframe.to.toString()); + const checkpoint = createWorkCheckpoint( + () => !signal.aborted && sameData(state, getState()), + () => 16, + ); + await checkpoint(); + const merged = new Map(sessions.map((session) => [session.id, session])); + for (const session of Object.values(getState().storedSessions.sessions)) { + merged.delete(session.id); + if (!session.date.isBefore(timeframe.from) && !session.date.isAfter(timeframe.to)) + merged.set(session.id, session); } - timeframe = { - from: state.storedSessions.earliestSession.date, - to: LocalDate.now(), - }; + if (state.storedSessions.activeSessionId) merged.delete(state.storedSessions.activeSessionId); + stats = await calculateStatsAsync( + [...merged.values()], + selectPreferredWeightUnit(state), + timeframe, + checkpoint, + ); + } + if (signal.aborted) return; + if (snapshotKey(getState()) !== key) { + dispatch(fetchOverallStats()); + return; } - const stats = calculateStats( - selectSessionsBy(state, timeframe.from, timeframe.to), - selectPreferredWeightUnit(state), - timeframe, - ); dispatch(setOverallStats(RemoteData.success(stats))); dispatch(setStatsIsDirty(false)); - } catch (e) { - dispatch(setOverallStats(RemoteData.error(e))); + displayedKey = key; + } catch (error) { + if (signal.aborted) return; + if (snapshotKey(getState()) !== key) dispatch(fetchOverallStats()); + else dispatch(setOverallStats(RemoteData.error(error))); } }); - addEffect(setOverallViewTime, async (_, { dispatch }) => { dispatch(setStatsIsDirty(true)); dispatch(fetchOverallStats()); diff --git a/app/src/store/stats/index.ts b/app/src/store/stats/index.ts index 1dce59171..8b6fee444 100644 --- a/app/src/store/stats/index.ts +++ b/app/src/store/stats/index.ts @@ -132,5 +132,6 @@ export const selectExerciseView = createSelector( ); export const fetchOverallStats = createAction('fetchOverallStats'); +export const warmAllTimeStats = createAction('warmAllTimeStats'); export const statsReducer = statsSlice.reducer; diff --git a/app/src/store/stats/personal-records.ts b/app/src/store/stats/personal-records.ts index 05527bf0c..c4e38ffc9 100644 --- a/app/src/store/stats/personal-records.ts +++ b/app/src/store/stats/personal-records.ts @@ -8,7 +8,7 @@ export interface PersonalRecord { oneRepMax: Weight; } -function bestOneRepMax(session: Session): Map { +export function bestOneRepMax(session: Session): Map { const best = new Map(); for (const exercise of session.recordedExercises) { diff --git a/app/src/store/stored-sessions/effects.spec.ts b/app/src/store/stored-sessions/effects.spec.ts index 67966b3e3..2d9d50467 100644 --- a/app/src/store/stored-sessions/effects.spec.ts +++ b/app/src/store/stored-sessions/effects.spec.ts @@ -4,10 +4,14 @@ import { drizzle } from 'drizzle-orm/expo-sqlite'; import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; import { openDatabaseAsync } from 'expo-sqlite'; import { eq } from 'drizzle-orm'; +import { combineReducers } from '@reduxjs/toolkit'; import { DatabaseMigrationService } from '@/services/database-migration-service'; import { applyStoredSessionsEffects } from '@/store/stored-sessions/effects'; import { initializeStoredSessionsStateSlice, + loadStoredSessionHistory, + storedSessionsReducer, + deleteStoredSession, putStoredSession, sessionFinished, setActiveSessionId, @@ -80,6 +84,112 @@ describe('stored-sessions effects', () => { return testBed; } + describe('deferred history', () => { + function historyBed() { + const testBed = createAddEffectTestBed({ + reducer: combineReducers({ + storedSessions: storedSessionsReducer, + settings: (state = { isHydrated: true, preferredLanguage: 'en' }) => state, + }), + initialState: { settings: { isHydrated: true, preferredLanguage: 'en' } }, + services: { db, logger, keyValueStore: makeKvStore(), healthExportService: { canExport: () => false } }, + }); + applyStoredSessionsEffects(testBed.addEffect); + return testBed; + } + + it('boots with only the active workout, leaving completed rows on disk', async () => { + const active = Session.freeformSession(LocalDate.of(2026, 4, 11), undefined); + const completed = Session.freeformSession(LocalDate.of(2026, 4, 10), undefined); + await db.insert(sessionsSchema).values([ + { id: active.id, active: true, payload: active.toJSON() }, + { id: completed.id, active: false, payload: completed.toJSON() }, + ]); + const testBed = historyBed(); + await testBed.dispatchHandled(initializeStoredSessionsStateSlice()); + const state = testBed.getState().storedSessions; + expect(Object.keys(state.sessions)).toEqual([active.id]); + expect(state.activeSessionId).toBe(active.id); + expect(state.isReady).toBe(true); + expect(state.isHydrated).toBe(false); + expect(state.historyLoad.isLoading()).toBe(false); + expect(await db.select().from(sessionsSchema)).toHaveLength(2); + }); + + it('shares concurrent requests, preserves live edits, and reuses loaded history', async () => { + const active = Session.freeformSession(LocalDate.of(2026, 4, 11), undefined); + const completed = Session.freeformSession(LocalDate.of(2026, 4, 10), undefined); + await db.insert(sessionsSchema).values([ + { id: active.id, active: true, payload: active.toJSON() }, + { id: completed.id, payload: completed.toJSON() }, + ]); + const testBed = historyBed(); + const select = vi.spyOn(db, 'select'); + const loading = testBed.dispatchHandled(loadStoredSessionHistory()); + const edited = active.with({ date: LocalDate.of(2026, 4, 12) }); + testBed.dispatch(putStoredSession(edited)); + await Promise.all([loading, testBed.dispatchHandled(loadStoredSessionHistory())]); + await testBed.dispatchHandled(loadStoredSessionHistory()); + expect(select).toHaveBeenCalledTimes(1); + const state = testBed.getState().storedSessions; + expect(Object.keys(state.sessions)).toHaveLength(2); + expect(state.sessions[active.id]).toBe(edited); + expect(state.isHydrated).toBe(true); + expect(state.historyLoad.isSuccess()).toBe(true); + }); + + it('keeps the history cache when a workout is stopped and another is started', async () => { + const completed = Session.freeformSession(LocalDate.of(2026, 4, 10), undefined); + await db.insert(sessionsSchema).values({ id: completed.id, payload: completed.toJSON() }); + const testBed = historyBed(); + const select = vi.spyOn(db, 'select'); + await testBed.dispatchHandled(loadStoredSessionHistory()); + const cached = testBed.getState().storedSessions.sessions[completed.id]; + + for (let day = 11; day <= 13; day++) { + const workout = Session.freeformSession(LocalDate.of(2026, 4, day), undefined); + testBed.dispatch(putStoredSession(workout)); + testBed.dispatch(setActiveSessionId(workout.id)); + await testBed.dispatchHandled(loadStoredSessionHistory()); + await testBed.dispatchHandled(deleteStoredSession(workout.id)); + expect(testBed.getState().storedSessions.isHydrated).toBe(true); + expect(testBed.getState().storedSessions.sessions[completed.id]).toBe(cached); + } + expect(select).toHaveBeenCalledTimes(1); + }); + + it('does not resurrect a discarded workout from an in-flight snapshot', async () => { + const active = Session.freeformSession(LocalDate.of(2026, 4, 11), undefined); + await db.insert(sessionsSchema).values({ id: active.id, active: true, payload: active.toJSON() }); + const snapshot = await db.select().from(sessionsSchema); + const testBed = historyBed(); + testBed.dispatch(putStoredSession(active)); + vi.spyOn(db, 'select').mockReturnValueOnce({ from: () => Promise.resolve(snapshot) } as never); + const loading = testBed.dispatchHandled(loadStoredSessionHistory()); + await testBed.dispatchHandled(deleteStoredSession(active.id)); + await loading; + expect(testBed.getState().storedSessions.sessions[active.id]).toBeUndefined(); + }); + + it('keeps history incomplete after a failure and allows retry', async () => { + const testBed = historyBed(); + vi.spyOn(db, 'select').mockImplementationOnce(() => { + throw new Error('read failed'); + }); + await testBed.dispatchHandled(loadStoredSessionHistory()); + expect(testBed.getState().storedSessions.isHydrated).toBe(false); + expect( + testBed.getState().storedSessions.historyLoad.match({ + loading: () => '', + success: () => '', + error: (error) => error, + }), + ).toBe('read failed'); + await testBed.dispatchHandled(loadStoredSessionHistory()); + expect(testBed.getState().storedSessions.isHydrated).toBe(true); + }); + }); + describe('the active session in SQLite', () => { it('persists content without ever claiming the active flag', async () => { const session = Session.freeformSession(LocalDate.of(2026, 4, 10), undefined); diff --git a/app/src/store/stored-sessions/effects.ts b/app/src/store/stored-sessions/effects.ts index a90e49193..91b9fb304 100644 --- a/app/src/store/stored-sessions/effects.ts +++ b/app/src/store/stored-sessions/effects.ts @@ -1,8 +1,12 @@ +import { updateSessionSearch, withSessionTransaction } from '@/services/session-history-repository'; import { AddEffectFn } from '@/store/store'; import { deleteExercise, deleteStoredSession, initializeStoredSessionsStateSlice, + loadStoredSessionHistory, + setHistoryLoad, + setIsReady, putStoredSession, restoreExercise, selectSession, @@ -25,15 +29,23 @@ import { setPreferredLanguage } from '@/store/settings'; import { Session } from '@/models/session-models'; import { sessionMigrations } from '@/models/storage/versions/migrations'; import { exercisesSchema, sessionsSchema } from '@/db/schema'; -import { eq, sql } from 'drizzle-orm'; +import { asc, eq, gt, sql } from 'drizzle-orm'; import { toRecord } from '@/utils/reduce'; import { fromExerciseDescriptorJSON, toExerciseDescriptorJSON } from '@/models/exercise-models'; import { loadBuiltInExercises } from '@/services/exercise-catalog'; import { migrateLegacyCurrentSession } from '@/store/stored-sessions/legacy-current-session'; +import { RemoteData } from '@/models/remote'; // Built-ins the user deleted, so they stay hidden across restarts and locale switches. const hiddenBuiltInExerciseIdsStorageKey = 'HiddenBuiltInExerciseIdList'; export function applyStoredSessionsEffects(addEffect: AddEffectFn) { + const deletedBeforeHistoryLoaded = new Set(); + addEffect( + [putStoredSession, updateStoredSession, upsertStoredSessions, deleteStoredSession, setActiveSessionId], + async (_, { extra: { sessionHistoryRepository } }) => { + sessionHistoryRepository?.invalidate(); + }, + ); // Dispatched AFTER settings, so we can safely access settings addEffect( initializeStoredSessionsStateSlice, @@ -42,8 +54,10 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { if (!getState().settings.isHydrated) { throw new Error('Settings must be hydrated before stored sessions'); } + await logger.time('initializeStoredSessions', async () => { - const rows = await db.select().from(sessionsSchema); + const rows = await db.select().from(sessionsSchema).where(eq(sessionsSchema.active, true)); + const storedSessions = rows.reduce( toRecord( (x) => x.id, @@ -51,7 +65,9 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { ), {}, ); + dispatch(setStoredSessions(storedSessions)); + // Only when there is one: dispatching `undefined` would clear every flag in the table, and a // kill between that write and the migration below would lose the workout in progress. const activeRowId = rows.find((x) => x.active)?.id; @@ -79,14 +95,56 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { ) as string[]; dispatch(setHiddenBuiltInIds(hiddenBuiltInIds)); - dispatch(setIsHydrated(true)); + dispatch(setIsReady(true)); dispatch(fetchUpcomingSessions()); }, ); + addEffect(loadStoredSessionHistory, async (_, { getState, dispatch, extra: { db, logger } }) => { + const state = getState().storedSessions; + if (state.isHydrated) { + return; + } + if (state.historyLoad.isLoading()) return; + dispatch(setHistoryLoad(RemoteData.loading())); + + try { + // Let the existing loading indicator mount before starting the database work. + await new Promise((resolve) => setTimeout(resolve, 20)); + const sessions: Record = {}; + let afterId: string | undefined; + while (true) { + const rows = await db + .select() + .from(sessionsSchema) + .where(afterId ? gt(sessionsSchema.id, afterId) : undefined) + .orderBy(asc(sessionsSchema.id)) + .limit(25); + for (const row of rows) { + if (!deletedBeforeHistoryLoaded.has(row.id)) + sessions[row.id] = Session.fromJSON(sessionMigrations.migrate(row.payload)); + } + afterId = rows.at(-1)?.id ?? undefined; + if (rows.length < 25) break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + // The live workout may have changed while SQLite was reading. In-memory edits win. + for (const id of deletedBeforeHistoryLoaded) delete sessions[id]; + dispatch(setStoredSessions({ ...sessions, ...getState().storedSessions.sessions })); + dispatch(setIsHydrated(true)); + deletedBeforeHistoryLoaded.clear(); + dispatch(setHistoryLoad(RemoteData.success(true))); + + dispatch(fetchUpcomingSessions()); + } catch (error) { + logger.error('Failed to load session history', error); + dispatch(setHistoryLoad(RemoteData.error(error instanceof Error ? error.message : String(error)))); + } + }); + // Re-resolve the built-in catalog when the language changes (startup load is handled above). addEffect(setPreferredLanguage, async (action, { getState, dispatch }) => { - if (!getState().storedSessions.isHydrated) { + if (!getState().storedSessions.isReady) { return; } dispatch(setBuiltInExercises(await loadBuiltInExercises(action.payload))); @@ -118,9 +176,13 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { } }); - addEffect(deleteStoredSession, async (action, { extra: { logger, db } }) => { + addEffect(deleteStoredSession, async (action, { getState, extra: { logger, db } }) => { + // A read already in flight must not restore a workout the user just discarded. + if (!getState().storedSessions.isHydrated) deletedBeforeHistoryLoaded.add(action.payload); await logger.time('deleteStoredSession', async () => { - await db.delete(sessionsSchema).where(eq(sessionsSchema.id, action.payload)); + await withSessionTransaction(db, async (tx) => { + await tx.delete(sessionsSchema).where(eq(sessionsSchema.id, action.payload)); + }); }); }); addEffect(deleteStoredSession, async (action, { stateAfterReduce, extra: { healthExportService, logger } }) => { @@ -149,19 +211,22 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { return; } await logger.time('persistStoredSession', async () => { - await db - .insert(sessionsSchema) - .values({ - id: session.id, - active: false, - payload: session.toJSON(), - }) - .onConflictDoUpdate({ - target: sessionsSchema.id, - set: { - payload: sql.raw(`excluded.${sessionsSchema.payload.name}`), - }, - }); + await withSessionTransaction(db, async (tx) => { + await tx + .insert(sessionsSchema) + .values({ + id: session.id, + active: false, + payload: session.toJSON(), + }) + .onConflictDoUpdate({ + target: sessionsSchema.id, + set: { + payload: sql.raw(`excluded.${sessionsSchema.payload.name}`), + }, + }); + await updateSessionSearch(tx, session); + }); }); }); @@ -169,7 +234,7 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { // been written by the effect above first - the two are dispatched together and race. addEffect(setActiveSessionId, async (action, { getState, extra: { db, logger } }) => { await logger.time('setActiveSessionId', async () => { - await db.transaction(async (tx) => { + await withSessionTransaction(db, async (tx) => { await tx.update(sessionsSchema).set({ active: false }).where(eq(sessionsSchema.active, true)); const sessionId = action.payload; if (sessionId === undefined) { @@ -183,6 +248,9 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { .insert(sessionsSchema) .values({ id: session.id, active: true, payload: session.toJSON() }) .onConflictDoUpdate({ target: sessionsSchema.id, set: { active: true } }); + // On conflict the payload belongs to the content writer, so project that exact row. + const [row] = await tx.select().from(sessionsSchema).where(eq(sessionsSchema.id, session.id)); + if (row) await updateSessionSearch(tx, Session.fromJSON(sessionMigrations.migrate(row.payload))); }); }); }); @@ -197,15 +265,21 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { active: false, payload: x.toJSON(), })); - await db - .insert(sessionsSchema) - .values(toUpsert) - .onConflictDoUpdate({ - target: sessionsSchema.id, - set: { - payload: sql.raw(`excluded.${sessionsSchema.payload.name}`), - }, - }); + if (!toUpsert.length) return; + await withSessionTransaction(db, async (tx) => { + for (let offset = 0; offset < toUpsert.length; offset += 100) { + await tx + .insert(sessionsSchema) + .values(toUpsert.slice(offset, offset + 100)) + .onConflictDoUpdate({ + target: sessionsSchema.id, + set: { + payload: sql.raw(`excluded.${sessionsSchema.payload.name}`), + }, + }); + } + for (const session of action.payload) await updateSessionSearch(tx, session); + }); }); }); diff --git a/app/src/store/stored-sessions/index.ts b/app/src/store/stored-sessions/index.ts index 72d53454f..aaada8343 100644 --- a/app/src/store/stored-sessions/index.ts +++ b/app/src/store/stored-sessions/index.ts @@ -1,3 +1,6 @@ +import { SessionActivitySummary } from '@/models/session-summary'; +import { sessionVolume } from '@/store/activity/volume'; +import { Weight } from '@/models/weight'; import { RecordedExercise, Session } from '@/models/session-models'; import { MovementKey, ProgressionKey } from '@/models/blueprint-models'; import { LocalDate, OffsetDateTime, YearMonth, ZoneId } from '@js-joda/core'; @@ -6,10 +9,18 @@ import { shallowEqual } from 'react-redux'; import Enumerable from 'linq'; import { TemporalComparer } from '@/models/comparers'; import { ExerciseDescriptor } from '@/models/exercise-models'; -import { findPersonalRecords } from '@/store/stats/personal-records'; +import { bestOneRepMax, PersonalRecord } from '@/store/stats/personal-records'; +import { RemoteData } from '@/models/remote'; interface StoredSessionState { + // Startup needs only the active workout and exercise catalogs. isHydrated means all history. + isReady: boolean; + dataRevision: number; + // Statistics exclude the active workout, so logging a set must not restart their warm-up. + historyRevision: number; isHydrated: boolean; + historyLoad: RemoteData; + activitySummaries: SessionActivitySummary[] | undefined; sessions: Record; // The workout in progress. It lives in `sessions` like any other; this only says which one it is. activeSessionId: string | undefined; @@ -25,7 +36,12 @@ interface StoredSessionState { } const initialState: StoredSessionState = { + isReady: false, + dataRevision: 0, + historyRevision: 0, isHydrated: false, + historyLoad: RemoteData.notAsked(), + activitySummaries: undefined, sessions: {}, activeSessionId: undefined, latestExercises: {}, @@ -65,12 +81,30 @@ const storedSessionsSlice = createSlice({ name: 'storedSessions', initialState, reducers: { + setActivitySummaries(state, action: PayloadAction) { + state.activitySummaries = action.payload; + }, + mergeLoadedSessions(state, action: PayloadAction) { + // Reads are not writes. In-memory edits remain authoritative while a query is in flight. + for (const session of action.payload) { + if (!state.sessions[session.id]) state.sessions[session.id] = session; + } + }, + setIsReady(state, action: PayloadAction) { + state.isReady = action.payload; + }, + setHistoryLoad(state, action: PayloadAction>) { + state.historyLoad = action.payload; + }, setIsHydrated(state, action: PayloadAction) { state.isHydrated = action.payload; }, setStoredSessions(state, action: PayloadAction>) { + state.dataRevision++; + state.historyRevision++; state.sessions = action.payload; state.latestExercises = {}; + state.earliestSession = undefined; Object.values(action.payload).forEach((session) => { updateDerivatives(state, session); }); @@ -107,11 +141,16 @@ const storedSessionsSlice = createSlice({ setActiveSessionId(state, action: PayloadAction) { state.activeSessionId = action.payload; + state.dataRevision++; + state.historyRevision++; }, deleteStoredSession(state, action: PayloadAction) { + state.dataRevision++; + state.historyRevision++; const deletedSession = state.sessions[action.payload]; delete state.sessions[action.payload]; + state.activitySummaries = state.activitySummaries?.filter((session) => session.id !== action.payload); if (state.activeSessionId === action.payload) { state.activeSessionId = undefined; } @@ -201,6 +240,8 @@ const storedSessionsSlice = createSlice({ }); function updateDerivatives(state: WritableDraft, session: Session) { + state.dataRevision++; + if (session.id !== state.activeSessionId) state.historyRevision++; if (!state.earliestSession || state.earliestSession.date.isAfter(session.date)) { state.earliestSession = session; } @@ -230,8 +271,13 @@ export const selectSessionsBy = createSelector( ); export const initializeStoredSessionsStateSlice = createAction('initializeStoredSessionsStateSlice'); +export const loadStoredSessionHistory = createAction('loadStoredSessionHistory'); export const { + setActivitySummaries, + mergeLoadedSessions, + setIsReady, + setHistoryLoad, setIsHydrated, setStoredSessions, upsertStoredSessions, @@ -328,14 +374,44 @@ export const selectPreviousComparableSession = createSelector( * Records per session across the user's whole history. Unlike the feed, which only holds its 90-day retention * window, nothing here is truncated, so these are all-time bests. */ -export const selectHistoryPersonalRecords = createSelector([selectSessions], (sessions) => - findPersonalRecords( - Enumerable.from(sessions) - .orderBy((x) => getSessionReferenceTime(x), TemporalComparer) - .toArray(), - ), +export const selectSessionActivity = createSelector( + [ + (state: { storedSessions: StoredSessionState }) => state.storedSessions.activitySummaries, + selectSessions, + selectActiveSessionId, + ], + (summaries, loaded, activeId): SessionActivitySummary[] => { + const all = new Map(summaries?.map((summary) => [summary.id, summary])); + for (const session of loaded) + all.set(session.id, { + id: session.id, + date: session.date, + isStarted: session.isStarted, + referenceTime: getSessionReferenceTime(session).toInstant().toEpochMilli(), + volume: sessionVolume(session), + bests: [...bestOneRepMax(session)].map(([key, best]) => ({ key, ...best })), + }); + if (activeId) all.delete(activeId); + return [...all.values()]; + }, ); +export const selectHistoryPersonalRecords = createSelector([selectSessionActivity], (sessions) => { + const bests = new Map(); + const result = new Map(); + for (const session of [...sessions].sort((a, b) => a.referenceTime - b.referenceTime || a.id.localeCompare(b.id))) { + const records: PersonalRecord[] = []; + for (const candidate of session.bests) { + const previous = bests.get(candidate.key); + if (previous && candidate.oneRepMax.isGreaterThan(previous)) + records.push({ exerciseName: candidate.exerciseName, oneRepMax: candidate.oneRepMax }); + if (!previous || candidate.oneRepMax.isGreaterThan(previous)) bests.set(candidate.key, candidate.oneRepMax); + } + if (records.length) result.set(session.id, records); + } + return result; +}); + export const selectSessionsInMonth = createSelector([selectSessions, (_, ym: YearMonth) => ym], (sessions, ym) => Enumerable.from(sessions) .where((x) => x.date.year() === ym.year() && x.date.month().equals(ym.month())) diff --git a/app/src/utils/cooperative-work.spec.ts b/app/src/utils/cooperative-work.spec.ts new file mode 100644 index 000000000..0f17f24bd --- /dev/null +++ b/app/src/utils/cooperative-work.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AppState } from 'react-native'; +import { createWorkCheckpoint, type IdleTaskCallback } from './cooperative-work'; + +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })); + +describe('cooperative work', () => { + afterEach(() => { + AppState.currentState = 'active'; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('yields to timers after the CPU budget is used', async () => { + vi.useFakeTimers(); + const now = vi.spyOn(performance, 'now').mockReturnValue(0); + const checkpoint = createWorkCheckpoint(() => true); + now.mockReturnValue(5); + let completed = false; + const work = Promise.resolve(checkpoint()).then(() => { + completed = true; + }); + expect(completed).toBe(false); + await vi.advanceTimersByTimeAsync(0); + await work; + expect(completed).toBe(true); + }); + + it('pauses in the background and rejects superseded work while paused', async () => { + vi.useFakeTimers(); + AppState.currentState = 'background'; + let current = true; + const checkpoint = createWorkCheckpoint(() => current); + const work = Promise.resolve(checkpoint()); + const rejected = expect(work).rejects.toThrow('superseded'); + current = false; + await vi.advanceTimersByTimeAsync(250); + await rejected; + }); + + it('uses native idle tasks and yields again when higher-priority work needs the runtime', async () => { + const pending: IdleTaskCallback[] = []; + vi.stubGlobal('requestIdleCallback', (callback: IdleTaskCallback) => pending.push(callback)); + vi.spyOn(performance, 'now').mockReturnValue(0); + let remaining = 50; + const deadline = { didTimeout: false, timeRemaining: () => remaining }; + const checkpoint = createWorkCheckpoint(() => true); + const first = checkpoint(); + expect(pending).toHaveLength(1); + pending.shift()!(deadline); + await first; + expect(checkpoint()).toBeUndefined(); + remaining = 0; + const yielded = checkpoint(); + expect(pending).toHaveLength(1); + remaining = 50; + pending.shift()!(deadline); + await yielded; + expect(checkpoint()).toBeUndefined(); + }); + + it('rejects a stale job when its queued idle task finally runs', async () => { + let resume!: IdleTaskCallback; + vi.stubGlobal('requestIdleCallback', (callback: IdleTaskCallback) => { + resume = callback; + }); + let current = true; + const checkpoint = createWorkCheckpoint(() => current); + const work = checkpoint(); + const rejected = expect(work).rejects.toThrow('superseded'); + current = false; + resume({ didTimeout: false, timeRemaining: () => 50 }); + await rejected; + }); +}); diff --git a/app/src/utils/cooperative-work.ts b/app/src/utils/cooperative-work.ts new file mode 100644 index 000000000..f607d12d8 --- /dev/null +++ b/app/src/utils/cooperative-work.ts @@ -0,0 +1,54 @@ +import { AppState } from 'react-native'; +import { sleep } from '@/utils/sleep'; + +export interface IdleWorkDeadline { + didTimeout: boolean; + timeRemaining(): number; +} +export type IdleTaskCallback = (deadline: IdleWorkDeadline) => void; + +export type WorkCheckpoint = () => void | Promise; + +/** Bound CPU slices and stop optional work while the app is offscreen. */ +export function createWorkCheckpoint(isCurrent: () => boolean, budgetMs = () => 4) { + let sliceStarted = performance.now(); + const requestIdleTask = ( + globalThis as typeof globalThis & { + requestIdleCallback?: (callback: IdleTaskCallback) => unknown; + } + ).requestIdleCallback; + let idleDeadline: IdleWorkDeadline | undefined; + function checkCurrent() { + if (!isCurrent()) throw new Error('Statistics snapshot superseded'); + } + function isOffscreen() { + return AppState?.currentState === 'background' || AppState?.currentState === 'inactive'; + } + async function yieldWork() { + if (typeof requestIdleTask === 'function') { + await new Promise((resolve) => { + requestIdleTask((deadline) => { + idleDeadline = deadline; + resolve(); + }); + }); + } else { + await sleep(0); + } + checkCurrent(); + while (isOffscreen()) { + await sleep(250); + checkCurrent(); + } + sliceStarted = performance.now(); + } + return () => { + checkCurrent(); + if ( + isOffscreen() || + performance.now() - sliceStarted >= budgetMs() || + (typeof requestIdleTask === 'function' && (!idleDeadline || idleDeadline.timeRemaining() < 1)) + ) + return yieldWork(); + }; +} diff --git a/app/test/shims/expo-sqlite.ts b/app/test/shims/expo-sqlite.ts index ae07c221d..38b9628d8 100644 --- a/app/test/shims/expo-sqlite.ts +++ b/app/test/shims/expo-sqlite.ts @@ -43,7 +43,7 @@ export const backupDatabaseAsync: typeof expoBackupDatabaseAsync = async (opts) // Collect all DDL (tables, indexes, triggers, views) in creation order const schemaResult = await src.execute( - `SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY rootpage`, + `SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, rootpage`, ); const stmts: string[] = []; diff --git a/docs/Performance.md b/docs/Performance.md new file mode 100644 index 000000000..bde736374 --- /dev/null +++ b/docs/Performance.md @@ -0,0 +1,28 @@ +# Performance architecture + +## All-time statistics preparation + +After startup data is ready, a three-second grace period starts an all-time statistics +warm-up. It reads history in pages, yields during deserialization and calculation, +and pauses while the app is inactive or in the background. This is cooperative work +on the JavaScript thread, not a separate native worker. Checkpoints target four +milliseconds of work, then schedule a native idle task. The idle deadline also +allows higher-priority UI work to interrupt a slice early. Environments without +`requestIdleCallback` fall back to timers. An individual model operation or sort +can exceed the budget. +Foreground requests promote the shared job to a 16 ms budget rather than waiting +at background priority. Small series operations are checked in groups of 32 points. + +The store retains one calculated result, keyed by completed-history revision, weight +unit and today's date. It does not populate the global history map. Workout edits, +deletions, imports and active-workout changes invalidate the snapshot and debounce +a new warm-up. Edits to the excluded active workout do not restart preparation; +finishing it or changing which workout is active does. An early all-time request shares the running job. A completed job +can serve the request immediately; failed jobs remain retryable. Range changes +do not replace the prepared all-time result. + +The cache lasts only for the current process; subsequent history edits require fresh preparation. + +Startup loads the active workout and exercise catalogs. Completed history remains in SQLite, +with indexed queries for history pages and progression rather than eager whole-history hydration. +Session writes and index backfill are serialized; live edits take precedence over stored projections. diff --git a/docs/index.md b/docs/index.md index 43121e7de..dfde2decf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,9 @@ work to find the docs relevant to your area, and update it whenever you add, rem ## Running it yourself +- [Performance.md](./Performance.md) — background statistics preparation, cache invalidation, + cooperative scheduling, and indexed history loading. + - [SelfHosting.md](./SelfHosting.md) — quickstart for running your own backend: a copy-paste Docker Compose file, how to point the app at it, and the environment variables that switch on the feed, remote backup, and AI planner. Pairs with the [backend README](../backend/README.md).