From cb3caa931d2b8e105c83dcaa03dcd8d72ef22e55 Mon Sep 17 00:00:00 2001 From: felixer Date: Fri, 4 Sep 2026 06:37:18 -0700 Subject: [PATCH 1/5] chore: profile stored session startup --- app/src/store/stored-sessions/effects.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/src/store/stored-sessions/effects.ts b/app/src/store/stored-sessions/effects.ts index a90e4919..5fe6456d 100644 --- a/app/src/store/stored-sessions/effects.ts +++ b/app/src/store/stored-sessions/effects.ts @@ -42,8 +42,15 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { if (!getState().settings.isHydrated) { throw new Error('Settings must be hydrated before stored sessions'); } + const hydrateStoredSessionsStart = performance.now(); await logger.time('initializeStoredSessions', async () => { + const loadRowsStart = performance.now(); const rows = await db.select().from(sessionsSchema); + logger.info( + `loadStoredSessionRows completed in ${(performance.now() - loadRowsStart).toFixed(2)}ms (${rows.length} sessions)`, + ); + + const deserializeSessionsStart = performance.now(); const storedSessions = rows.reduce( toRecord( (x) => x.id, @@ -51,7 +58,13 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { ), {}, ); + logger.info( + `deserializeStoredSessions completed in ${(performance.now() - deserializeSessionsStart).toFixed(2)}ms`, + ); + + const setStoredSessionsStart = performance.now(); dispatch(setStoredSessions(storedSessions)); + logger.info(`setStoredSessions completed in ${(performance.now() - setStoredSessionsStart).toFixed(2)}ms`); // 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; @@ -62,6 +75,7 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { await migrateLegacyCurrentSession(dispatch, getState, keyValueStore, logger); + const loadSavedExercisesStart = performance.now(); const savedExercises = (await db.select().from(exercisesSchema)).reduce( toRecord( (x) => x.id, @@ -70,15 +84,29 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { {}, ); dispatch(setExercises(savedExercises)); + logger.info( + `loadSavedExercises completed in ${(performance.now() - loadSavedExercisesStart).toFixed(2)}ms (${Object.keys(savedExercises).length} exercises)`, + ); + const loadBuiltInExercisesStart = performance.now(); const builtInExercises = await loadBuiltInExercises(getState().settings.preferredLanguage); dispatch(setBuiltInExercises(builtInExercises)); + logger.info( + `loadBuiltInExercises completed in ${(performance.now() - loadBuiltInExercisesStart).toFixed(2)}ms (${Object.keys(builtInExercises).length} exercises)`, + ); + const loadHiddenBuiltInIdsStart = performance.now(); const hiddenBuiltInIds = JSON.parse( (await keyValueStore.getItem(hiddenBuiltInExerciseIdsStorageKey)) ?? '[]', ) as string[]; dispatch(setHiddenBuiltInIds(hiddenBuiltInIds)); + logger.info( + `loadHiddenBuiltInExerciseIds completed in ${(performance.now() - loadHiddenBuiltInIdsStart).toFixed(2)}ms`, + ); + logger.info( + `hydrateStoredSessionsState completed in ${(performance.now() - hydrateStoredSessionsStart).toFixed(2)}ms`, + ); dispatch(setIsHydrated(true)); dispatch(fetchUpcomingSessions()); }, From f0007831cf23e9d85ecfd67671d93c0b0d87b041 Mon Sep 17 00:00:00 2001 From: felixer Date: Fri, 4 Sep 2026 18:53:12 -0700 Subject: [PATCH 2/5] Reduce session hydration work and coalesce upcoming workout requests --- app/src/models/session-models/session.spec.ts | 20 +++++ app/src/models/session-models/session.ts | 14 +-- app/src/store/program/effects.spec.ts | 88 ++++++++++++++++++- app/src/store/program/effects.ts | 49 +++++++---- 4 files changed, 147 insertions(+), 24 deletions(-) diff --git a/app/src/models/session-models/session.spec.ts b/app/src/models/session-models/session.spec.ts index 1ec668e2..419132b7 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 177de7b6..fe06dccd 100644 --- a/app/src/models/session-models/session.ts +++ b/app/src/models/session-models/session.ts @@ -41,14 +41,16 @@ export class Session { } 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/store/program/effects.spec.ts b/app/src/store/program/effects.spec.ts index 57f89295..7c6a083b 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: {} }, + settings: { useImperialUnits: false }, } as Partial; } @@ -321,6 +322,91 @@ 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.getDispatchedAction(setUpcomingSessions).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 a0869b54..089f73a6 100644 --- a/app/src/store/program/effects.ts +++ b/app/src/store/program/effects.ts @@ -26,6 +26,7 @@ import { TaskAbortError } from '@reduxjs/toolkit'; const builtInProgramsStorageKey = 'hasSavedDefaultPlans2'; export function applyProgramEffects(addEffect: AddEffectFn) { + let upcomingRequest: { inputs: readonly unknown[] } | undefined; addEffect( initializeProgramStateSlice, async ( @@ -97,28 +98,42 @@ export function applyProgramEffects(addEffect: AddEffectFn) { addEffect( fetchUpcomingSessions, async (_, { signal, cancelActiveListeners, dispatch, getState, extra: { sessionService, logger } }) => { - const start = performance.now(); - cancelActiveListeners(); - await yieldToEventLoop(); - const state = getState(); const sessionBlueprints = selectActiveProgram(state).sessions; - const numberOfUpcomingSessions = sessionBlueprints.length; - - if (signal.aborted) { + // 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])) { + logger.info('fetchUpcomingSessions joined existing request'); 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; + const start = performance.now(); + cancelActiveListeners(); + try { + await yieldToEventLoop(); + if (signal.aborted) return; + + const sessions = await AsyncStream.from( + sessionService.getUpcomingSessions(sessionBlueprints, selectLatestExercises(state)), + ) + .takeWhile(() => !signal.aborted) + .take(sessionBlueprints.length) + .toArray(); + if (signal.aborted || upcomingRequest !== request) return; + dispatch(setUpcomingSessions(RemoteData.success(sessions))); + logger.info(`fetchUpcomingSessions effect took ${(performance.now() - start).toFixed(2)} ms`); + } finally { + if (upcomingRequest === request) upcomingRequest = undefined; + } }, ); } From 1be2a48411bdfcfe8513f312ebb7fc9612749fd8 Mon Sep 17 00:00:00 2001 From: felixer Date: Mon, 7 Sep 2026 19:58:43 -0700 Subject: [PATCH 3/5] Checkpoint indexed session history performance improvements --- app/src/app/(tabs)/(session)/_layout.tsx | 8 +- app/src/app/(tabs)/(session)/index.tsx | 85 ++- .../(tabs)/(session)/session/post-workout.tsx | 53 +- app/src/app/(tabs)/_layout.tsx | 88 +-- app/src/app/(tabs)/feed/_layout.tsx | 11 +- app/src/app/(tabs)/history/_layout.tsx | 8 +- app/src/app/(tabs)/history/edit.tsx | 10 + app/src/app/(tabs)/history/index.tsx | 73 +- app/src/app/(tabs)/settings/_layout.tsx | 8 +- .../import-from-other-apps.tsx | 11 +- .../backup-and-restore/plain-text-export.tsx | 11 +- app/src/app/(tabs)/stats/_layout.tsx | 8 +- app/src/app/(tabs)/stats/index.tsx | 6 +- app/src/app/_layout.tsx | 7 + app/src/app/exercise-history.tsx | 8 +- .../workout/exercise-history-list.tsx | 8 +- .../presentation/workout/exercise-section.tsx | 2 + .../components/smart/app-state-provider.tsx | 20 +- app/src/components/smart/exercise-history.tsx | 79 ++- .../components/smart/services-provider.tsx | 30 +- .../smart/session-activity-gate.tsx | 36 + .../components/smart/session-component.tsx | 25 +- .../components/smart/session-history-gate.tsx | 23 + .../components/smart/stored-session-gate.tsx | 39 ++ app/src/db/schema.ts | 41 +- .../drizzle/0009_indexed_session_history.sql | 21 + .../0010_session_search_invalidation.sql | 10 + app/src/drizzle/meta/0009_snapshot.json | 661 ++++++++++++++++++ app/src/drizzle/meta/0010_snapshot.json | 661 ++++++++++++++++++ app/src/drizzle/meta/_journal.json | 14 + app/src/drizzle/migrations.js | 8 +- .../hooks/useStartWorkoutWithConfirmation.tsx | 2 + app/src/models/session-summary.ts | 19 + app/src/services/index.ts | 2 + .../session-history-repository.spec.ts | 237 +++++++ .../services/session-history-repository.ts | 368 ++++++++++ app/src/services/session-service.ts | 11 +- app/src/store/activity/index.ts | 15 +- app/src/store/activity/streak.ts | 11 +- app/src/store/activity/volume.ts | 4 +- app/src/store/app/effects.ts | 3 + app/src/store/feed/feed-items-effects.ts | 6 +- app/src/store/feed/inbox-effects.spec.ts | 3 + app/src/store/feed/inbox-effects.ts | 21 +- app/src/store/index.ts | 21 + app/src/store/program/effects.spec.ts | 6 +- app/src/store/program/effects.ts | 58 +- app/src/store/settings/effects.ts | 5 + .../store/settings/import-backup-effects.ts | 4 +- app/src/store/stats/effects.spec.ts | 82 +++ app/src/store/stats/effects.ts | 88 ++- app/src/store/stats/personal-records.ts | 2 +- app/src/store/stored-sessions/effects.spec.ts | 110 +++ app/src/store/stored-sessions/effects.ts | 142 +++- app/src/store/stored-sessions/index.ts | 82 ++- app/src/utils/startup-diagnostics.ts | 51 ++ app/test/shims/expo-sqlite.ts | 2 +- 57 files changed, 3232 insertions(+), 196 deletions(-) create mode 100644 app/src/components/smart/session-activity-gate.tsx create mode 100644 app/src/components/smart/session-history-gate.tsx create mode 100644 app/src/components/smart/stored-session-gate.tsx create mode 100644 app/src/drizzle/0009_indexed_session_history.sql create mode 100644 app/src/drizzle/0010_session_search_invalidation.sql create mode 100644 app/src/drizzle/meta/0009_snapshot.json create mode 100644 app/src/drizzle/meta/0010_snapshot.json create mode 100644 app/src/models/session-summary.ts create mode 100644 app/src/services/session-history-repository.spec.ts create mode 100644 app/src/services/session-history-repository.ts create mode 100644 app/src/store/stats/effects.spec.ts create mode 100644 app/src/utils/startup-diagnostics.ts diff --git a/app/src/app/(tabs)/(session)/_layout.tsx b/app/src/app/(tabs)/(session)/_layout.tsx index f9d48511..46039efd 100644 --- a/app/src/app/(tabs)/(session)/_layout.tsx +++ b/app/src/app/(tabs)/(session)/_layout.tsx @@ -1,5 +1,11 @@ import StackWithHeader from '@/components/layout/stack-with-header'; +import { Profiler } from 'react'; +import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ; + return ( + + + + ); } diff --git a/app/src/app/(tabs)/(session)/index.tsx b/app/src/app/(tabs)/(session)/index.tsx index 7cf4321d..dd23d91d 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 { Profiler, 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,19 @@ 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 { logStartupRender, markStartup } from '@/utils/startup-diagnostics'; +import { RemoteData } from '@/models/remote'; + +markStartup('workout screen module evaluated'); function ListUpcomingWorkouts({ upcoming, startSession, + preview = false, }: { upcoming: readonly Session[]; startSession: (s: Session) => void; + preview?: boolean; }) { const plan = useAppSelector(selectActiveProgram); const { t } = useTranslate(); @@ -64,7 +70,10 @@ function ListUpcomingWorkouts({ }; return ( - + markStartup('upcoming workouts laid out')} + > {currentSession && ( @@ -115,7 +124,7 @@ function ListUpcomingWorkouts({ renderItemContent={(session) => { return ( - + ); }} @@ -126,7 +135,7 @@ function ListUpcomingWorkouts({ }; return ( - handleSharePress(session)} /> + {!preview && handleSharePress(session)} />} {sessionPlanIndex !== -1 ? ( ) : undefined} @@ -217,30 +226,59 @@ 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(() => { + markStartup('workout focus started'); dispatch(fetchUpcomingSessions()); + markStartup('workout focus requested upcoming sessions'); dispatch(publishUnpublishedSessions()); + markStartup('workout focus dispatched feed publish'); dispatch(executeRemoteBackup({})); + markStartup('workout focus dispatched backup'); }); const createFreeformSession = () => { - start(Session.freeformSession(LocalDate.now(), currentBodyweight)); + requestStart(Session.freeformSession(LocalDate.now(), currentBodyweight)); }; const floatingBottomContainer = ( @@ -254,6 +292,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 ( - - { - return ; - }} - /> + + + + + dispatch(fetchUpcomingSessions())} + success={(upcoming) => { + 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 00921368..1e30f846 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)/_layout.tsx b/app/src/app/(tabs)/_layout.tsx index c6e9d199..f350645b 100644 --- a/app/src/app/(tabs)/_layout.tsx +++ b/app/src/app/(tabs)/_layout.tsx @@ -3,6 +3,10 @@ import { useAppSelector } from '@/store'; import { selectFollowRequestCount } from '@/store/feed'; import { useTranslate } from '@tolgee/react'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; +import { logStartupRender, markStartup } from '@/utils/startup-diagnostics'; +import { Profiler } from 'react'; + +markStartup('tabs module evaluated'); export default function TabsLayout() { const { t } = useTranslate(); @@ -10,46 +14,48 @@ export default function TabsLayout() { const followRequestCount = useAppSelector(selectFollowRequestCount); const showFeed = useAppSelector((x) => x.settings.showFeed); return ( - - - {t('workout.workout.label')} - - - - - - {t('stats.stats.title')} - - - - {t('generic.history.title')} - - - - {t('settings.settings.title')} - - + + + + {t('workout.workout.label')} + + + + + + {t('stats.stats.title')} + + + + {t('generic.history.title')} + + + + {t('settings.settings.title')} + + + ); } diff --git a/app/src/app/(tabs)/feed/_layout.tsx b/app/src/app/(tabs)/feed/_layout.tsx index 272ec6a2..63d7362e 100644 --- a/app/src/app/(tabs)/feed/_layout.tsx +++ b/app/src/app/(tabs)/feed/_layout.tsx @@ -1,8 +1,17 @@ import StackWithHeader from '@/components/layout/stack-with-header'; +import { SessionActivityGate } from '@/components/smart/session-activity-gate'; +import { Profiler } from 'react'; +import { logStartupRender } from '@/utils/startup-diagnostics'; export const unstable_settings = { initialRouteName: 'index', }; export default function Layout() { - return ; + return ( + + + + + + ); } diff --git a/app/src/app/(tabs)/history/_layout.tsx b/app/src/app/(tabs)/history/_layout.tsx index f9d48511..c76318e2 100644 --- a/app/src/app/(tabs)/history/_layout.tsx +++ b/app/src/app/(tabs)/history/_layout.tsx @@ -1,5 +1,11 @@ import StackWithHeader from '@/components/layout/stack-with-header'; +import { Profiler } from 'react'; +import { logStartupRender } from '@/utils/startup-diagnostics'; 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 fdb28521..01c5484b 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 a4eb8eff..92893ee0 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,73 @@ 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()); + const start = performance.now(); + 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)); + logger.info( + `queryHistoryMonth completed in ${(performance.now() - start).toFixed(2)}ms (${sessions.length} sessions, ${summaries.length} summaries)`, + ); + } 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 +197,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/_layout.tsx b/app/src/app/(tabs)/settings/_layout.tsx index f9d48511..e4850b66 100644 --- a/app/src/app/(tabs)/settings/_layout.tsx +++ b/app/src/app/(tabs)/settings/_layout.tsx @@ -1,5 +1,11 @@ import StackWithHeader from '@/components/layout/stack-with-header'; +import { Profiler } from 'react'; +import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ; + return ( + + + + ); } 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 cca76d80..f0a5f9f0 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 29807566..defa68ac 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/_layout.tsx b/app/src/app/(tabs)/stats/_layout.tsx index f9d48511..2f9f1889 100644 --- a/app/src/app/(tabs)/stats/_layout.tsx +++ b/app/src/app/(tabs)/stats/_layout.tsx @@ -1,5 +1,11 @@ import StackWithHeader from '@/components/layout/stack-with-header'; +import { Profiler } from 'react'; +import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ; + return ( + + + + ); } diff --git a/app/src/app/(tabs)/stats/index.tsx b/app/src/app/(tabs)/stats/index.tsx index f42c9846..73b7731f 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/_layout.tsx b/app/src/app/_layout.tsx index 933e7461..89ae96c3 100644 --- a/app/src/app/_layout.tsx +++ b/app/src/app/_layout.tsx @@ -12,8 +12,12 @@ import StackWithHeader from '@/components/layout/stack-with-header'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { requireOptionalNativeModule } from 'expo'; +import { useEffect } from 'react'; +import { markStartup } from '@/utils/startup-diagnostics'; +markStartup('root layout module evaluated'); install(); +markStartup('crypto installed'); if (__DEV__) { // oxlint-disable-next-line typescript/no-unsafe-assignment @@ -29,6 +33,9 @@ if (Platform.OS !== 'web') { } export default function RootLayout() { + useEffect(() => { + markStartup('root layout committed'); + }, []); return ( diff --git a/app/src/app/exercise-history.tsx b/app/src/app/exercise-history.tsx index b854cb22..aade629b 100644 --- a/app/src/app/exercise-history.tsx +++ b/app/src/app/exercise-history.tsx @@ -1,11 +1,17 @@ import { ExerciseHistory } from '@/components/smart/exercise-history'; import { ExerciseBlueprint, movementKeyFor } from '@/models/blueprint-models'; import { useLocalSearchParams } from 'expo-router'; +import { Profiler } from 'react'; +import { logStartupRender } from '@/utils/startup-diagnostics'; export default function ExerciseHistoryPage() { const { name, type } = useLocalSearchParams<{ 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 dfb124ed..b99256ee 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/presentation/workout/exercise-section.tsx b/app/src/components/presentation/workout/exercise-section.tsx index 435dbb42..28b09bcc 100644 --- a/app/src/components/presentation/workout/exercise-section.tsx +++ b/app/src/components/presentation/workout/exercise-section.tsx @@ -14,6 +14,7 @@ import IconButton from '@/components/presentation/foundation/icon-button'; import { useRouter } from 'expo-router'; import { getExerciseHistoryHref } from '@/components/smart/exercise-history'; import { Updater } from '@/utils/types'; +import { markStartup } from '@/utils/startup-diagnostics'; interface ExerciseSectionProps { recordedExercise: T; @@ -38,6 +39,7 @@ export default function ExerciseSection(props: Exerc const [removeExerciseDialogOpen, setRemoveExerciseDialogOpen] = useState(false); const showStats = recordedExercise instanceof RecordedWeightedExercise; const showPrevious = () => { + markStartup('exercise history requested'); push(getExerciseHistoryHref(recordedExercise.blueprint), { withAnchor: true }); }; diff --git a/app/src/components/smart/app-state-provider.tsx b/app/src/components/smart/app-state-provider.tsx index 165836d5..c17fd694 100644 --- a/app/src/components/smart/app-state-provider.tsx +++ b/app/src/components/smart/app-state-provider.tsx @@ -5,10 +5,11 @@ import { useAppSelector } from '@/store'; import { copyLogs } from '@/store/app'; import { T } from '@tolgee/react'; import * as Application from 'expo-application'; -import { ReactNode, useEffect, useRef, useState } from 'react'; +import { Profiler, ReactNode, useEffect, useRef, useState } from 'react'; import { Animated, Platform, Text, View } from 'react-native'; import { openUrl } from '@/utils/open-url'; import { useDispatch } from 'react-redux'; +import { logNativeStartupTiming, logStartupRender, markStartup } from '@/utils/startup-diagnostics'; // How long to wait before assuming startup has stalled and offering an escape hatch. const STUCK_TIMEOUT_MS = 7_000; @@ -19,13 +20,22 @@ 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(); const isWaiting = !!waitingOn; const anim = useRef(new Animated.Value(1)).current; + useEffect(() => { + if (waitingOn) { + markStartup(`loading screen: ${waitingOn}`); + } else { + markStartup('hydrated UI committed'); + logNativeStartupTiming(); + } + }, [waitingOn]); + if (isWaiting) { return ( + {children} + + ); } function StuckHelp() { diff --git a/app/src/components/smart/exercise-history.tsx b/app/src/components/smart/exercise-history.tsx index 395b2cc7..61996295 100644 --- a/app/src/components/smart/exercise-history.tsx +++ b/app/src/components/smart/exercise-history.tsx @@ -2,21 +2,66 @@ 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'; +import { markStartup } from '@/utils/startup-diagnostics'; export function getExerciseHistoryHref(blueprint: ExerciseBlueprint): Href { return `/exercise-history?name=${encodeURIComponent(blueprint.name)}&type=${blueprint.type}` as 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, logger } = 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()); + const start = performance.now(); + 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)); + logger.info( + `queryExerciseHistory completed in ${(performance.now() - start).toFixed(2)}ms (${page.exercises.length} performances)`, + ); + } 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 ( - + markStartup('exercise history laid out', `matches=${exercises.length}`)} + > {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 39736b94..103b8ce9 100644 --- a/app/src/components/smart/services-provider.tsx +++ b/app/src/components/smart/services-provider.tsx @@ -4,15 +4,23 @@ 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'; +import { markStartup } from '@/utils/startup-diagnostics'; // Create context for services const ServicesContext = createContext(null); let databasePromise: Promise | undefined; function openDatabase() { - return (databasePromise ??= openDatabaseAsync('db.db')); + if (!databasePromise) { + markStartup('database open started'); + databasePromise = openDatabaseAsync('db.db').then((db) => { + markStartup('database open finished'); + return db; + }); + } + return databasePromise; } export default function ServicesProvider(props: { children: ReactNode }) { @@ -20,23 +28,23 @@ 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); + markStartup('services provider committed'); } }, [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 00000000..cce3d510 --- /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 50757e4c..818cb78b 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 00000000..f66f087a --- /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 00000000..2d7f5c68 --- /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 fb585eb8..750b9f3e 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 00000000..729ac78c --- /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 00000000..324d089f --- /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 00000000..5ef264bd --- /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 00000000..9e902e71 --- /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 2d3a6b59..cff6ab70 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 f4427c2f..00de0c34 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/hooks/useStartWorkoutWithConfirmation.tsx b/app/src/hooks/useStartWorkoutWithConfirmation.tsx index 3259e70e..a16e44dc 100644 --- a/app/src/hooks/useStartWorkoutWithConfirmation.tsx +++ b/app/src/hooks/useStartWorkoutWithConfirmation.tsx @@ -6,6 +6,7 @@ import { useStartWorkout } from '@/hooks/useStartWorkout'; import { T, useTranslate } from '@tolgee/react'; import { useRouter } from 'expo-router'; import { useState } from 'react'; +import { markStartup } from '@/utils/startup-diagnostics'; /** * Opens a session as the workout in progress, asking first when that would discard a different @@ -22,6 +23,7 @@ export function useStartWorkoutWithConfirmation({ onStarted }: { onStarted?: (se const [pendingReplace, setPendingReplace] = useState(); const open = (session: Session) => { + markStartup('workout opened'); if (activeSession?.id !== session.id) { startWorkout(session); } diff --git a/app/src/models/session-summary.ts b/app/src/models/session-summary.ts new file mode 100644 index 00000000..f2b65dd7 --- /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 7fe0312f..0c623958 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, logger), 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 00000000..c12299d2 --- /dev/null +++ b/app/src/services/session-history-repository.spec.ts @@ -0,0 +1,237 @@ +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, logger); +}); + +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 result = await repository.getLatestExercises([blueprint.progressionKey(), otherBlueprint.progressionKey()]); + expect(result[blueprint.progressionKey()]?.toJSON()).toEqual(latest.recordedExercises[0]?.toJSON()); + expect(result[otherBlueprint.progressionKey()]).toBeUndefined(); + expect(logger.info).toHaveBeenLastCalledWith(expect.stringContaining('2 keys, 1 sessions')); + await repository.getLatestExercises([blueprint.progressionKey(), otherBlueprint.progressionKey()]); + expect(logger.info).toHaveBeenLastCalledWith(expect.stringContaining('2 keys, 0 sessions')); + }); + + 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 00000000..212a861d --- /dev/null +++ b/app/src/services/session-history-repository.ts @@ -0,0 +1,368 @@ +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 { Logger } from '@/services/logger'; + +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, + private logger: Logger, + ) {} + + ensureIndexed(): Promise { + if (!this.indexing) { + this.indexing = this.backfill().finally(() => { + this.indexing = undefined; + }); + } + return this.indexing; + } + + private async backfill() { + const started = performance.now(); + let count = 0; + let slowestBatch = 0; + while (true) { + const batchStarted = performance.now(); + // 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; + slowestBatch = Math.max(slowestBatch, performance.now() - batchStarted); + if (loaded < batchSize) break; + await yieldToUI(); + } + if (count) this.latestCache.clear(); + if (count) + this.logger.info( + `indexSessionHistory completed in ${(performance.now() - started).toFixed(2)}ms (${count} sessions, slowest batch ${slowestBatch.toFixed(2)}ms)`, + ); + } + + 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 start = performance.now(); + 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]); + } + this.logger.info( + `queryLatestProgression completed in ${(performance.now() - start).toFixed(2)}ms (${keys.length} keys, ${payloads.size} sessions)`, + ); + 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): Promise { + await this.ensureIndexed(); + return this.readSessions(and(gte(sessions.date, from), lte(sessions.date, to))); + } + + private async readSessions(where: ReturnType): Promise { + const result: Session[] = []; + let afterId: string | undefined; + while (true) { + 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); + result.push(...rows.map((row) => 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 aad6eab4..dac5874b 100644 --- a/app/src/services/session-service.ts +++ b/app/src/services/session-service.ts @@ -20,6 +20,7 @@ import { selectActiveSession } from '@/store/stored-sessions'; import { uuid } from '@/utils/uuid'; import { LocalDate } from '@js-joda/core'; import { match } from 'ts-pattern'; +import { markStartup } from '@/utils/startup-diagnostics'; export class SessionService { constructor( @@ -30,6 +31,7 @@ export class SessionService { async *getUpcomingSessions( sessionBlueprints: SessionBlueprint[], latestExercises: Record, + latestStoredSession?: Session | null, ): AsyncIterableIterator { const currentState = this.getState(); const currentSession = selectActiveSession(currentState); @@ -38,12 +40,19 @@ export class SessionService { if (!firstSessionBlueprint) { return; } + markStartup('upcoming service first yield started'); await yieldToEventLoop(); + markStartup('upcoming service first yield finished'); let latestSession = - currentSession ?? this.progressRepository.getOrderedSessions().firstOrDefault((x) => !x.isFreeform); + currentSession ?? + (latestStoredSession === undefined + ? this.progressRepository.getOrderedSessions().firstOrDefault((x) => !x.isFreeform) + : latestStoredSession); + markStartup('upcoming latest session found'); await yieldToEventLoop(); + markStartup('upcoming service second yield finished'); // Track the plan position by index so progression walks the plan in order. // Matching only by name would stall on duplicate-named workouts, always // resolving to the first one and never advancing past it. diff --git a/app/src/store/activity/index.ts b/app/src/store/activity/index.ts index 4400fee9..49aedf24 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 fa854fd2..f0722702 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 6aaa2c08..c90ecacf 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/app/effects.ts b/app/src/store/app/effects.ts index d21e0c3b..9054f693 100644 --- a/app/src/store/app/effects.ts +++ b/app/src/store/app/effects.ts @@ -12,13 +12,16 @@ import { initializeSettingsStateSlice } from '../settings'; import { initializeProgramStateSlice } from '../program'; import { setStringAsync } from 'expo-clipboard'; import { initializeBackendsStateSlice } from '@/store/backends'; +import { markStartup } from '@/utils/startup-diagnostics'; export function applyAppEffects(addEffect: AddEffectFn) { addEffect( initializeAppStateSlice, async (_, { cancelActiveListeners, dispatch, extra: { databaseMigrationService } }) => { cancelActiveListeners(); + markStartup('database migrations started'); await databaseMigrationService.migrate(); + markStartup('database migrations finished'); dispatch(initializeSettingsStateSlice()); dispatch(initializeProgramStateSlice()); dispatch(initializeBackendsStateSlice()); diff --git a/app/src/store/feed/feed-items-effects.ts b/app/src/store/feed/feed-items-effects.ts index d841c742..9374a887 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 330a82de..98d5dd6b 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 197bc430..90fe0ef8 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 471df590..9db1258d 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -18,11 +18,15 @@ import { clearAllListeners, Store } from '@reduxjs/toolkit'; import { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; import { useIsFocused } from 'expo-router'; import { SQLiteDatabase } from 'expo-sqlite'; +import { attachStartupLogger, markStartup } from '@/utils/startup-diagnostics'; export { RootState }; export function resolveStore(db: ExpoSQLiteDatabase, expoDb: SQLiteDatabase) { + markStartup('store creation started'); const { store, services, addEffect } = createStore(db, expoDb); + attachStartupLogger(services.logger); + markStartup('store and services created'); store.dispatch(clearAllListeners()); applyProgramEffects(addEffect); applyProgramImportExportEffects(addEffect); @@ -35,6 +39,23 @@ export function resolveStore(db: ExpoSQLiteDatabase, expoDb: SQLiteDatabase) { applyAiPlannerEffects(addEffect); applyBackendsEffects(addEffect); + markStartup('effects registered'); + const slices = ['app', 'program', 'settings', 'storedSessions', 'aiPlanner'] as const; + const unsubscribe = store.subscribe(() => { + const state = store.getState(); + for (const slice of slices) { + if (slice === 'storedSessions' ? state.storedSessions.isReady : state[slice].isHydrated) { + markStartup(`${slice} startup ready`); + } + } + if ( + slices.every((slice) => (slice === 'storedSessions' ? state.storedSessions.isReady : state[slice].isHydrated)) + ) { + markStartup('all startup data ready'); + unsubscribe(); + } + }); + markStartup('app initialization dispatched'); 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 7c6a083b..a66ff9a0 100644 --- a/app/src/store/program/effects.spec.ts +++ b/app/src/store/program/effects.spec.ts @@ -97,7 +97,7 @@ function makeProgramState(savedPrograms: Record = {}, savedPrograms, upcomingSessions: RemoteData.notAsked(), }, - storedSessions: { latestExercises: {}, sessions: {} }, + storedSessions: { latestExercises: {}, sessions: {}, isHydrated: true }, settings: { useImperialUnits: false }, } as Partial; } @@ -404,7 +404,9 @@ describe('program effects', () => { await testBed.dispatchHandled(fetchUpcomingSessions()); expect(sessionService.getUpcomingSessions).toHaveBeenCalledTimes(2); - expect(testBed.getDispatchedAction(setUpcomingSessions).payload.unwrapOr([])).toEqual([{ id: 'retry' }]); + expect(testBed.dispatchedActions.filter(setUpcomingSessions.match).at(-1)?.payload.unwrapOr([])).toEqual([ + { id: 'retry' }, + ]); }); it('dispatches setUpcomingSessions with sessions from service', async () => { diff --git a/app/src/store/program/effects.ts b/app/src/store/program/effects.ts index 089f73a6..ba3ecbaa 100644 --- a/app/src/store/program/effects.ts +++ b/app/src/store/program/effects.ts @@ -23,6 +23,7 @@ import { LocalDate } from '@js-joda/core'; import { toRecord } from '@/utils/reduce'; import { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; import { TaskAbortError } from '@reduxjs/toolkit'; +import { markStartup } from '@/utils/startup-diagnostics'; const builtInProgramsStorageKey = 'hasSavedDefaultPlans2'; export function applyProgramEffects(addEffect: AddEffectFn) { @@ -69,6 +70,7 @@ export function applyProgramEffects(addEffect: AddEffectFn) { dispatch(setActivePlan({ activePlanId })); dispatch(setIsHydrated(true)); + dispatch(fetchUpcomingSessions()); const end = performance.now(); logger.info(`initializeProgramStateSlice effect took ${(end - start).toFixed(2)} ms`); }, @@ -97,9 +99,21 @@ export function applyProgramEffects(addEffect: AddEffectFn) { addEffect( fetchUpcomingSessions, - async (_, { signal, cancelActiveListeners, dispatch, getState, extra: { sessionService, logger } }) => { + async ( + _, + { + signal, + cancelActiveListeners, + dispatch, + getState, + extra: { sessionService, sessionHistoryRepository, logger }, + }, + ) => { const state = getState(); - const sessionBlueprints = selectActiveProgram(state).sessions; + 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 = [ @@ -119,18 +133,56 @@ export function applyProgramEffects(addEffect: AddEffectFn) { const start = performance.now(); cancelActiveListeners(); try { + markStartup('upcoming effect yield started'); await yieldToEventLoop(); + markStartup('upcoming effect yield finished'); 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; + } + } + } + markStartup('upcoming latest exercises selected'); const sessions = await AsyncStream.from( - sessionService.getUpcomingSessions(sessionBlueprints, selectLatestExercises(state)), + sessionService.getUpcomingSessions(sessionBlueprints, latestExercises, latestSession), ) .takeWhile(() => !signal.aborted) .take(sessionBlueprints.length) .toArray(); + markStartup('upcoming generation finished'); 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))); + markStartup('first upcoming workouts published'); logger.info(`fetchUpcomingSessions effect took ${(performance.now() - start).toFixed(2)} ms`); + } 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 f1938adb..d91f0c14 100644 --- a/app/src/store/settings/effects.ts +++ b/app/src/store/settings/effects.ts @@ -29,6 +29,7 @@ import { detectLanguageFromDateLocale } from '@/utils/language-detector'; import { supportedLanguages } from '@/services/tolgee'; import { initializeStoredSessionsStateSlice } from '@/store/stored-sessions'; import { builtInBackendId } from '@/models/backend'; +import { markStartup } from '@/utils/startup-diagnostics'; // Read every generically-hydrated key, then dispatch its setter. async function hydrateGenericPreferences( @@ -53,7 +54,9 @@ export function applySettingsEffects(addEffect: AddEffectFn) { const start = performance.now(); cancelActiveListeners(); + markStartup('generic preferences started'); await hydrateGenericPreferences(preferenceService, dispatch); + markStartup('generic preferences finished'); // Bespoke hydration: sync read, composite keys, and composed values. dispatch(setPreferredLanguage(preferenceService.getPreferredLanguage())); @@ -77,6 +80,7 @@ export function applySettingsEffects(addEffect: AddEffectFn) { const proToken = await preferenceService.getProToken(); dispatch(setProToken(proToken)); + markStartup('bespoke preferences finished'); if (!__DEV__) { if (Platform.OS === 'ios') { @@ -90,6 +94,7 @@ export function applySettingsEffects(addEffect: AddEffectFn) { } } // migrate pro token to a revenuecat + markStartup('purchases configured or skipped'); if (proToken && !proToken.startsWith('$RCAnonymousID')) { try { const customerInfo = await Purchases.getCustomerInfo(); diff --git a/app/src/store/settings/import-backup-effects.ts b/app/src/store/settings/import-backup-effects.ts index ea11d250..dfc98629 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/effects.spec.ts b/app/src/store/stats/effects.spec.ts new file mode 100644 index 00000000..33055940 --- /dev/null +++ b/app/src/store/stats/effects.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } 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 } from '@/store/stats'; +import { deleteStoredSession, storedSessionsReducer } from '@/store/stored-sessions'; +import { makeSession, makeWeightedBlueprint } from '@/models/session-models/__test__/helpers'; +import { RemoteData } from '@/models/remote'; + +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', () => { + 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(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 bcccb43a..4f1780b1 100644 --- a/app/src/store/stats/effects.ts +++ b/app/src/store/stats/effects.ts @@ -1,3 +1,9 @@ +import { + putStoredSession, + updateStoredSession, + deleteStoredSession, + upsertStoredSessions, +} from '@/store/stored-sessions'; import { setOverallViewTime, setStatsIsDirty } from './index'; import { LocalDate } from '@js-joda/core'; import { fetchOverallStats, setOverallStats } from './index'; @@ -10,38 +16,66 @@ import { selectPreferredWeightUnit } from '../settings'; import { calculateStats } from '@/store/stats/calculate-stats'; export function applyStatsEffects(addEffect: AddEffectFn) { - addEffect(fetchOverallStats, async (_, { getState, dispatch }) => { - const state = getState(); + addEffect( + [putStoredSession, updateStoredSession, deleteStoredSession, upsertStoredSessions], + async (_, { dispatch }) => { + dispatch(setStatsIsDirty(true)); + }, + ); + addEffect( + fetchOverallStats, + async (_, { getState, dispatch, cancelActiveListeners, signal, extra: { sessionHistoryRepository, logger } }) => { + const state = getState(); - if (state.stats.overallView.isLoading() || !state.stats.isDirty || !state.storedSessions.isHydrated) { - return; - } + if (!state.stats.isDirty || (!state.storedSessions.isReady && !state.storedSessions.isHydrated)) { + return; + } - 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'))); + cancelActiveListeners(); + dispatch(setOverallStats(RemoteData.loading())); + const started = performance.now(); + await sleep(200); + try { + let timeframe = state.stats.overallViewTime; + if (timeframe === 'all-time') { + const earliest = state.storedSessions.isHydrated + ? state.storedSessions.earliestSession?.date + : (await sessionHistoryRepository.getActivitySummaries()) + .map((session) => session.date) + .sort((a, b) => a.compareTo(b))[0]; + if (!earliest) { + if (!signal.aborted) dispatch(setOverallStats(RemoteData.error('No sessions'))); + return; + } + timeframe = { from: earliest, to: LocalDate.now() }; + } + const sessions = state.storedSessions.isHydrated + ? selectSessionsBy(state, timeframe.from, timeframe.to) + : await sessionHistoryRepository.getSessionsInRange(timeframe.from.toString(), timeframe.to.toString()); + if (signal.aborted) return; + const current = getState(); + if (current.storedSessions.dataRevision !== state.storedSessions.dataRevision) { + dispatch(fetchOverallStats()); return; } - timeframe = { - from: state.storedSessions.earliestSession.date, - to: LocalDate.now(), - }; + const merged = new Map(sessions.map((session) => [session.id, session])); + for (const session of Object.values(current.storedSessions.sessions)) { + merged.delete(session.id); + if (!session.date.isBefore(timeframe.from) && !session.date.isAfter(timeframe.to)) + merged.set(session.id, session); + } + if (current.storedSessions.activeSessionId) merged.delete(current.storedSessions.activeSessionId); + const stats = calculateStats([...merged.values()], selectPreferredWeightUnit(state), timeframe); + dispatch(setOverallStats(RemoteData.success(stats))); + dispatch(setStatsIsDirty(false)); + logger?.info( + `queryOverallStats completed in ${(performance.now() - started).toFixed(2)}ms (${merged.size} sessions)`, + ); + } catch (e) { + if (!signal.aborted) dispatch(setOverallStats(RemoteData.error(e))); } - 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))); - } - }); + }, + ); addEffect(setOverallViewTime, async (_, { dispatch }) => { dispatch(setStatsIsDirty(true)); diff --git a/app/src/store/stats/personal-records.ts b/app/src/store/stats/personal-records.ts index 05527bf0..c4e38ffc 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 67966b3e..2d9d5046 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 5fe6456d..8aad9679 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,24 @@ 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 { markStartup } from '@/utils/startup-diagnostics'; +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, @@ -43,9 +56,10 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { throw new Error('Settings must be hydrated before stored sessions'); } const hydrateStoredSessionsStart = performance.now(); + markStartup('sessions loading started'); await logger.time('initializeStoredSessions', async () => { const loadRowsStart = performance.now(); - const rows = await db.select().from(sessionsSchema); + const rows = await db.select().from(sessionsSchema).where(eq(sessionsSchema.active, true)); logger.info( `loadStoredSessionRows completed in ${(performance.now() - loadRowsStart).toFixed(2)}ms (${rows.length} sessions)`, ); @@ -73,7 +87,9 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { } }); - await migrateLegacyCurrentSession(dispatch, getState, keyValueStore, logger); + await logger.time('migrateLegacyCurrentSession', () => + migrateLegacyCurrentSession(dispatch, getState, keyValueStore, logger), + ); const loadSavedExercisesStart = performance.now(); const savedExercises = (await db.select().from(exercisesSchema)).reduce( @@ -107,14 +123,64 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { logger.info( `hydrateStoredSessionsState completed in ${(performance.now() - hydrateStoredSessionsStart).toFixed(2)}ms`, ); - dispatch(setIsHydrated(true)); + dispatch(setIsReady(true)); + markStartup('startup session data ready; completed history deferred'); dispatch(fetchUpcomingSessions()); }, ); + addEffect(loadStoredSessionHistory, async (_, { getState, dispatch, extra: { db, logger } }) => { + const state = getState().storedSessions; + if (state.isHydrated) { + logger.info(`loadCompletedSessionHistory reused cache (${Object.keys(state.sessions).length} sessions)`); + return; + } + if (state.historyLoad.isLoading()) return; + dispatch(setHistoryLoad(RemoteData.loading())); + const start = performance.now(); + markStartup('completed history requested'); + try { + // Let the existing loading indicator mount before starting the database work. + await new Promise((resolve) => setTimeout(resolve, 20)); + const sessions: Record = {}; + let count = 0; + 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)); + } + count += rows.length; + 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))); + logger.info( + `loadCompletedSessionHistory completed in ${(performance.now() - start).toFixed(2)}ms (${count} sessions)`, + ); + markStartup('completed history ready'); + 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))); @@ -146,9 +212,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 } }) => { @@ -177,19 +247,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); + }); }); }); @@ -197,7 +270,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) { @@ -211,6 +284,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))); }); }); }); @@ -225,15 +301,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 72d53454..a8807d2c 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,16 @@ 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; 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 +34,11 @@ interface StoredSessionState { } const initialState: StoredSessionState = { + isReady: false, + dataRevision: 0, isHydrated: false, + historyLoad: RemoteData.notAsked(), + activitySummaries: undefined, sessions: {}, activeSessionId: undefined, latestExercises: {}, @@ -65,12 +78,28 @@ 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.sessions = action.payload; state.latestExercises = {}; + state.earliestSession = undefined; Object.values(action.payload).forEach((session) => { updateDerivatives(state, session); }); @@ -107,11 +136,14 @@ const storedSessionsSlice = createSlice({ setActiveSessionId(state, action: PayloadAction) { state.activeSessionId = action.payload; + state.dataRevision++; }, deleteStoredSession(state, action: PayloadAction) { + state.dataRevision++; 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 +233,7 @@ const storedSessionsSlice = createSlice({ }); function updateDerivatives(state: WritableDraft, session: Session) { + state.dataRevision++; if (!state.earliestSession || state.earliestSession.date.isAfter(session.date)) { state.earliestSession = session; } @@ -230,8 +263,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 +366,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/startup-diagnostics.ts b/app/src/utils/startup-diagnostics.ts new file mode 100644 index 00000000..ac58911e --- /dev/null +++ b/app/src/utils/startup-diagnostics.ts @@ -0,0 +1,51 @@ +import type { Logger } from '@/services/logger'; +import type { ProfilerOnRenderCallback } from 'react'; + +const startedAt = performance.now(); +const milestones = new Set(); +const pending: string[] = []; +let logger: Pick | undefined; + +// One timeline per JS runtime. Fast Refresh is not a fresh launch; force-stop the app to measure boot. +export function markStartup(name: string, detail?: string) { + if (milestones.has(name)) return; + milestones.add(name); + const now = performance.now(); + const message = `[startup] ${name}: +${(now - startedAt).toFixed(2)}ms (clock=${now.toFixed(2)}ms)${detail ? `; ${detail}` : ''}`; + if (logger) logger.info(message); + else pending.push(message); +} + +export const logStartupRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => { + markStartup(`React ${id} ${phase}`, `render=${actualDuration.toFixed(2)}ms; base=${baseDuration.toFixed(2)}ms`); +}; + +export function attachStartupLogger(startupLogger: Pick) { + logger = startupLogger; + for (const message of pending.splice(0)) logger.info(message); + logger.info(`[startup] mode=${__DEV__ ? 'development' : 'release'}; offsets are from diagnostics module evaluation`); +} + +export function logNativeStartupTiming() { + // Optional RN extension; absent on web and some native runtimes. Its timestamps share performance.now's origin. + const timing = ( + performance as typeof performance & { + rnStartupTiming?: { + startTime?: number | null; + initializeRuntimeStart?: number | null; + executeJavaScriptBundleEntryPointStart?: number | null; + endTime?: number | null; + }; + } + ).rnStartupTiming; + const timestamps = { + appStart: timing?.startTime ?? null, + runtimeInit: timing?.initializeRuntimeStart ?? null, + bundleEntry: timing?.executeJavaScriptBundleEntryPointStart ?? null, + nativeEnd: timing?.endTime ?? null, + clock: performance.now(), + }; + logger?.info(`[startup] native timing: ${JSON.stringify(timestamps)}`); +} + +markStartup('diagnostics loaded'); diff --git a/app/test/shims/expo-sqlite.ts b/app/test/shims/expo-sqlite.ts index ae07c221..38b9628d 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[] = []; From 2f37a39a5270dc86a4f92cdda219661fa24b1079 Mon Sep 17 00:00:00 2001 From: felixer Date: Tue, 8 Sep 2026 14:01:24 -0700 Subject: [PATCH 4/5] Prepare all-time statistics during idle time and remove profiling instrumentation Keep indexed history loading and shared background statistics preparation, invalidate cached results after history changes, and preserve startup when purchase configuration is unavailable. Remove temporary startup markers, render profilers, and measurement counters. Validation: 301 focused tests, typecheck, lint, ARM64 release build, and Pixel 9 Pro smoke checks for startup, History, and all-time Stats passed. --- app/src/app/(tabs)/(session)/_layout.tsx | 8 +- app/src/app/(tabs)/(session)/index.tsx | 37 +-- app/src/app/(tabs)/_layout.tsx | 88 +++---- app/src/app/(tabs)/feed/_layout.tsx | 10 +- app/src/app/(tabs)/history/_layout.tsx | 8 +- app/src/app/(tabs)/history/index.tsx | 5 +- app/src/app/(tabs)/settings/_layout.tsx | 8 +- app/src/app/(tabs)/stats/_layout.tsx | 8 +- app/src/app/_layout.tsx | 7 - app/src/app/exercise-history.tsx | 8 +- .../presentation/workout/exercise-section.tsx | 2 - .../components/smart/app-state-provider.tsx | 18 +- app/src/components/smart/exercise-history.tsx | 14 +- .../components/smart/services-provider.tsx | 8 +- .../hooks/useStartWorkoutWithConfirmation.tsx | 2 - app/src/models/session-models/session.ts | 7 +- app/src/services/index.ts | 2 +- .../session-history-repository.spec.ts | 11 +- .../services/session-history-repository.ts | 35 +-- app/src/services/session-service.ts | 5 - app/src/store/app/effects.ts | 3 - app/src/store/index.ts | 14 +- app/src/store/program/effects.ts | 17 +- app/src/store/settings/effects.ts | 34 +-- app/src/store/settings/startup.spec.ts | 77 ++++++ app/src/store/stats/calculate-stats.spec.ts | 14 +- app/src/store/stats/calculate-stats.ts | 242 +++++++++++------- app/src/store/stats/effects.spec.ts | 109 +++++++- app/src/store/stats/effects.ts | 222 ++++++++++++---- app/src/store/stats/index.ts | 1 + app/src/store/stored-sessions/effects.ts | 46 +--- app/src/store/stored-sessions/index.ts | 8 + app/src/utils/cooperative-work.spec.ts | 76 ++++++ app/src/utils/cooperative-work.ts | 54 ++++ app/src/utils/startup-diagnostics.ts | 51 ---- docs/Performance.md | 28 ++ docs/index.md | 3 + 37 files changed, 805 insertions(+), 485 deletions(-) create mode 100644 app/src/store/settings/startup.spec.ts create mode 100644 app/src/utils/cooperative-work.spec.ts create mode 100644 app/src/utils/cooperative-work.ts delete mode 100644 app/src/utils/startup-diagnostics.ts create mode 100644 docs/Performance.md diff --git a/app/src/app/(tabs)/(session)/_layout.tsx b/app/src/app/(tabs)/(session)/_layout.tsx index 46039efd..f9d48511 100644 --- a/app/src/app/(tabs)/(session)/_layout.tsx +++ b/app/src/app/(tabs)/(session)/_layout.tsx @@ -1,11 +1,5 @@ import StackWithHeader from '@/components/layout/stack-with-header'; -import { Profiler } from 'react'; -import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ( - - - - ); + return ; } diff --git a/app/src/app/(tabs)/(session)/index.tsx b/app/src/app/(tabs)/(session)/index.tsx index dd23d91d..04402157 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 { Profiler, useEffect, 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,11 +32,8 @@ 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 { logStartupRender, markStartup } from '@/utils/startup-diagnostics'; import { RemoteData } from '@/models/remote'; -markStartup('workout screen module evaluated'); - function ListUpcomingWorkouts({ upcoming, startSession, @@ -70,10 +67,7 @@ function ListUpcomingWorkouts({ }; return ( - markStartup('upcoming workouts laid out')} - > + {currentSession && ( @@ -268,13 +262,9 @@ export default function Index() { }, [pendingStart, progressionReady, upcomingSessions, currentBodyweight, start]); useFocusEffect(() => { - markStartup('workout focus started'); dispatch(fetchUpcomingSessions()); - markStartup('workout focus requested upcoming sessions'); dispatch(publishUnpublishedSessions()); - markStartup('workout focus dispatched feed publish'); dispatch(executeRemoteBackup({})); - markStartup('workout focus dispatched backup'); }); const createFreeformSession = () => { @@ -318,18 +308,17 @@ export default function Index() { headerBackVisible: false, }} /> - - - - - dispatch(fetchUpcomingSessions())} - success={(upcoming) => { - return ; - }} - /> - + + + + dispatch(fetchUpcomingSessions())} + success={(upcoming) => { + return ; + }} + /> + {confirmationDialog} ); diff --git a/app/src/app/(tabs)/_layout.tsx b/app/src/app/(tabs)/_layout.tsx index f350645b..c6e9d199 100644 --- a/app/src/app/(tabs)/_layout.tsx +++ b/app/src/app/(tabs)/_layout.tsx @@ -3,10 +3,6 @@ import { useAppSelector } from '@/store'; import { selectFollowRequestCount } from '@/store/feed'; import { useTranslate } from '@tolgee/react'; import { NativeTabs } from 'expo-router/unstable-native-tabs'; -import { logStartupRender, markStartup } from '@/utils/startup-diagnostics'; -import { Profiler } from 'react'; - -markStartup('tabs module evaluated'); export default function TabsLayout() { const { t } = useTranslate(); @@ -14,48 +10,46 @@ export default function TabsLayout() { const followRequestCount = useAppSelector(selectFollowRequestCount); const showFeed = useAppSelector((x) => x.settings.showFeed); return ( - - - - {t('workout.workout.label')} - - - - - - {t('stats.stats.title')} - - - - {t('generic.history.title')} - - - - {t('settings.settings.title')} - - - + + + {t('workout.workout.label')} + + + + + + {t('stats.stats.title')} + + + + {t('generic.history.title')} + + + + {t('settings.settings.title')} + + ); } diff --git a/app/src/app/(tabs)/feed/_layout.tsx b/app/src/app/(tabs)/feed/_layout.tsx index 63d7362e..cfd69359 100644 --- a/app/src/app/(tabs)/feed/_layout.tsx +++ b/app/src/app/(tabs)/feed/_layout.tsx @@ -1,17 +1,13 @@ import StackWithHeader from '@/components/layout/stack-with-header'; import { SessionActivityGate } from '@/components/smart/session-activity-gate'; -import { Profiler } from 'react'; -import { logStartupRender } from '@/utils/startup-diagnostics'; export const unstable_settings = { initialRouteName: 'index', }; export default function Layout() { return ( - - - - - + + + ); } diff --git a/app/src/app/(tabs)/history/_layout.tsx b/app/src/app/(tabs)/history/_layout.tsx index c76318e2..f9d48511 100644 --- a/app/src/app/(tabs)/history/_layout.tsx +++ b/app/src/app/(tabs)/history/_layout.tsx @@ -1,11 +1,5 @@ import StackWithHeader from '@/components/layout/stack-with-header'; -import { Profiler } from 'react'; -import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ( - - - - ); + return ; } diff --git a/app/src/app/(tabs)/history/index.tsx b/app/src/app/(tabs)/history/index.tsx index 92893ee0..b55dca39 100644 --- a/app/src/app/(tabs)/history/index.tsx +++ b/app/src/app/(tabs)/history/index.tsx @@ -70,7 +70,7 @@ function MonthHistory() { if (!isFocused) return; let cancelled = false; if (loadedMonth.current !== currentYearMonth.toString()) setLoad(RemoteData.loading()); - const start = performance.now(); + void (async () => { try { const sessions = await sessionHistoryRepository.getSessionsByMonth(currentYearMonth.toString()); @@ -80,9 +80,6 @@ function MonthHistory() { dispatch(setActivitySummaries(summaries)); loadedMonth.current = currentYearMonth.toString(); setLoad(RemoteData.success(true)); - logger.info( - `queryHistoryMonth completed in ${(performance.now() - start).toFixed(2)}ms (${sessions.length} sessions, ${summaries.length} summaries)`, - ); } catch (error) { if (!cancelled) setLoad(RemoteData.error(String(error))); } diff --git a/app/src/app/(tabs)/settings/_layout.tsx b/app/src/app/(tabs)/settings/_layout.tsx index e4850b66..f9d48511 100644 --- a/app/src/app/(tabs)/settings/_layout.tsx +++ b/app/src/app/(tabs)/settings/_layout.tsx @@ -1,11 +1,5 @@ import StackWithHeader from '@/components/layout/stack-with-header'; -import { Profiler } from 'react'; -import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ( - - - - ); + return ; } diff --git a/app/src/app/(tabs)/stats/_layout.tsx b/app/src/app/(tabs)/stats/_layout.tsx index 2f9f1889..f9d48511 100644 --- a/app/src/app/(tabs)/stats/_layout.tsx +++ b/app/src/app/(tabs)/stats/_layout.tsx @@ -1,11 +1,5 @@ import StackWithHeader from '@/components/layout/stack-with-header'; -import { Profiler } from 'react'; -import { logStartupRender } from '@/utils/startup-diagnostics'; export default function Layout() { - return ( - - - - ); + return ; } diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx index 89ae96c3..933e7461 100644 --- a/app/src/app/_layout.tsx +++ b/app/src/app/_layout.tsx @@ -12,12 +12,8 @@ import StackWithHeader from '@/components/layout/stack-with-header'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { requireOptionalNativeModule } from 'expo'; -import { useEffect } from 'react'; -import { markStartup } from '@/utils/startup-diagnostics'; -markStartup('root layout module evaluated'); install(); -markStartup('crypto installed'); if (__DEV__) { // oxlint-disable-next-line typescript/no-unsafe-assignment @@ -33,9 +29,6 @@ if (Platform.OS !== 'web') { } export default function RootLayout() { - useEffect(() => { - markStartup('root layout committed'); - }, []); return ( diff --git a/app/src/app/exercise-history.tsx b/app/src/app/exercise-history.tsx index aade629b..4971aa1d 100644 --- a/app/src/app/exercise-history.tsx +++ b/app/src/app/exercise-history.tsx @@ -1,17 +1,11 @@ import { ExerciseHistory } from '@/components/smart/exercise-history'; import { ExerciseBlueprint, movementKeyFor } from '@/models/blueprint-models'; import { useLocalSearchParams } from 'expo-router'; -import { Profiler } from 'react'; -import { logStartupRender } from '@/utils/startup-diagnostics'; export default function ExerciseHistoryPage() { const { name, type } = useLocalSearchParams<{ name: string; type: ExerciseBlueprint['type']; }>(); - return ( - - - - ); + return ; } diff --git a/app/src/components/presentation/workout/exercise-section.tsx b/app/src/components/presentation/workout/exercise-section.tsx index 28b09bcc..435dbb42 100644 --- a/app/src/components/presentation/workout/exercise-section.tsx +++ b/app/src/components/presentation/workout/exercise-section.tsx @@ -14,7 +14,6 @@ import IconButton from '@/components/presentation/foundation/icon-button'; import { useRouter } from 'expo-router'; import { getExerciseHistoryHref } from '@/components/smart/exercise-history'; import { Updater } from '@/utils/types'; -import { markStartup } from '@/utils/startup-diagnostics'; interface ExerciseSectionProps { recordedExercise: T; @@ -39,7 +38,6 @@ export default function ExerciseSection(props: Exerc const [removeExerciseDialogOpen, setRemoveExerciseDialogOpen] = useState(false); const showStats = recordedExercise instanceof RecordedWeightedExercise; const showPrevious = () => { - markStartup('exercise history requested'); push(getExerciseHistoryHref(recordedExercise.blueprint), { withAnchor: true }); }; diff --git a/app/src/components/smart/app-state-provider.tsx b/app/src/components/smart/app-state-provider.tsx index c17fd694..3997898a 100644 --- a/app/src/components/smart/app-state-provider.tsx +++ b/app/src/components/smart/app-state-provider.tsx @@ -5,11 +5,10 @@ import { useAppSelector } from '@/store'; import { copyLogs } from '@/store/app'; import { T } from '@tolgee/react'; import * as Application from 'expo-application'; -import { Profiler, ReactNode, useEffect, useRef, useState } from 'react'; +import { ReactNode, useEffect, useRef, useState } from 'react'; import { Animated, Platform, Text, View } from 'react-native'; import { openUrl } from '@/utils/open-url'; import { useDispatch } from 'react-redux'; -import { logNativeStartupTiming, logStartupRender, markStartup } from '@/utils/startup-diagnostics'; // How long to wait before assuming startup has stalled and offering an escape hatch. const STUCK_TIMEOUT_MS = 7_000; @@ -27,15 +26,6 @@ export function AppStateProvider({ children }: { children: ReactNode }) { const isWaiting = !!waitingOn; const anim = useRef(new Animated.Value(1)).current; - useEffect(() => { - if (waitingOn) { - markStartup(`loading screen: ${waitingOn}`); - } else { - markStartup('hydrated UI committed'); - logNativeStartupTiming(); - } - }, [waitingOn]); - if (isWaiting) { return ( - {children} - - ); + return children; } function StuckHelp() { diff --git a/app/src/components/smart/exercise-history.tsx b/app/src/components/smart/exercise-history.tsx index 61996295..29afa792 100644 --- a/app/src/components/smart/exercise-history.tsx +++ b/app/src/components/smart/exercise-history.tsx @@ -10,14 +10,13 @@ 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'; -import { markStartup } from '@/utils/startup-diagnostics'; export function getExerciseHistoryHref(blueprint: ExerciseBlueprint): Href { return `/exercise-history?name=${encodeURIComponent(blueprint.name)}&type=${blueprint.type}` as Href; } export function ExerciseHistory(props: { movementKey: MovementKey; exerciseName: string }) { - const { sessionHistoryRepository, logger } = useServices(); + const { sessionHistoryRepository } = useServices(); const [exercises, setExercises] = useState([]); const [load, setLoad] = useState>(RemoteData.loading()); const cursor = useRef(undefined); @@ -28,7 +27,7 @@ export function ExerciseHistory(props: { movementKey: MovementKey; exerciseName: if (busy.current || done.current) return; busy.current = true; setLoad(RemoteData.loading()); - const start = performance.now(); + try { const page = await sessionHistoryRepository.getExerciseHistory(props.movementKey, cursor.current); if (!alive.current) return; @@ -36,9 +35,6 @@ export function ExerciseHistory(props: { movementKey: MovementKey; exerciseName: done.current = !page.next; setExercises((existing) => [...existing, ...page.exercises]); setLoad(RemoteData.success(true)); - logger.info( - `queryExerciseHistory completed in ${(performance.now() - start).toFixed(2)}ms (${page.exercises.length} performances)`, - ); } catch (error) { if (alive.current) setLoad(RemoteData.error(String(error))); } finally { @@ -57,11 +53,7 @@ export function ExerciseHistory(props: { movementKey: MovementKey; exerciseName: }, []); return ( - markStartup('exercise history laid out', `matches=${exercises.length}`)} - > + (null); @@ -14,11 +13,7 @@ const ServicesContext = createContext(null); let databasePromise: Promise | undefined; function openDatabase() { if (!databasePromise) { - markStartup('database open started'); - databasePromise = openDatabaseAsync('db.db').then((db) => { - markStartup('database open finished'); - return db; - }); + databasePromise = openDatabaseAsync('db.db'); } return databasePromise; } @@ -38,7 +33,6 @@ function ResolvedServicesProvider({ expoDb, children }: { expoDb: SQLiteDatabase useEffect(() => { if (services) { registerDateTranslations(services.tolgee); - markStartup('services provider committed'); } }, [services]); return ( diff --git a/app/src/hooks/useStartWorkoutWithConfirmation.tsx b/app/src/hooks/useStartWorkoutWithConfirmation.tsx index a16e44dc..3259e70e 100644 --- a/app/src/hooks/useStartWorkoutWithConfirmation.tsx +++ b/app/src/hooks/useStartWorkoutWithConfirmation.tsx @@ -6,7 +6,6 @@ import { useStartWorkout } from '@/hooks/useStartWorkout'; import { T, useTranslate } from '@tolgee/react'; import { useRouter } from 'expo-router'; import { useState } from 'react'; -import { markStartup } from '@/utils/startup-diagnostics'; /** * Opens a session as the workout in progress, asking first when that would discard a different @@ -23,7 +22,6 @@ export function useStartWorkoutWithConfirmation({ onStarted }: { onStarted?: (se const [pendingReplace, setPendingReplace] = useState(); const open = (session: Session) => { - markStartup('workout opened'); if (activeSession?.id !== session.id) { startWorkout(session); } diff --git a/app/src/models/session-models/session.ts b/app/src/models/session-models/session.ts index fe06dccd..56a91b4d 100644 --- a/app/src/models/session-models/session.ts +++ b/app/src/models/session-models/session.ts @@ -35,9 +35,10 @@ 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 { diff --git a/app/src/services/index.ts b/app/src/services/index.ts index 0c623958..be87e301 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -56,7 +56,7 @@ export function createServices(store: Store, db: ExpoSQLiteDatabase, return { logger, keyValueStore, - sessionHistoryRepository: new SessionHistoryRepository(db, logger), + 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 index c12299d2..11fa1077 100644 --- a/app/src/services/session-history-repository.spec.ts +++ b/app/src/services/session-history-repository.spec.ts @@ -45,7 +45,7 @@ async function insert(values: Session[]) { beforeEach(async () => { db = drizzle(await openDatabaseAsync(':memory:')); await new DatabaseMigrationService(db, logger, { importOldData: async () => {} }).migrate(); - repository = new SessionHistoryRepository(db, logger); + repository = new SessionHistoryRepository(db); }); describe('indexed session history', () => { @@ -70,12 +70,15 @@ describe('indexed session history', () => { 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(logger.info).toHaveBeenLastCalledWith(expect.stringContaining('2 keys, 1 sessions')); - await repository.getLatestExercises([blueprint.progressionKey(), otherBlueprint.progressionKey()]); - expect(logger.info).toHaveBeenLastCalledWith(expect.stringContaining('2 keys, 0 sessions')); + 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 () => { diff --git a/app/src/services/session-history-repository.ts b/app/src/services/session-history-repository.ts index 212a861d..b9dd970f 100644 --- a/app/src/services/session-history-repository.ts +++ b/app/src/services/session-history-repository.ts @@ -10,7 +10,7 @@ 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 { Logger } from '@/services/logger'; +import type { WorkCheckpoint } from '@/utils/cooperative-work'; const batchSize = 25; const yieldToUI = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -108,10 +108,7 @@ export class SessionHistoryRepository { this.latestCache.clear(); } - constructor( - private db: ExpoSQLiteDatabase, - private logger: Logger, - ) {} + constructor(private db: ExpoSQLiteDatabase) {} ensureIndexed(): Promise { if (!this.indexing) { @@ -123,11 +120,8 @@ export class SessionHistoryRepository { } private async backfill() { - const started = performance.now(); let count = 0; - let slowestBatch = 0; while (true) { - const batchStarted = performance.now(); // 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); @@ -137,15 +131,10 @@ export class SessionHistoryRepository { return rows.length; }); count += loaded; - slowestBatch = Math.max(slowestBatch, performance.now() - batchStarted); if (loaded < batchSize) break; await yieldToUI(); } if (count) this.latestCache.clear(); - if (count) - this.logger.info( - `indexSessionHistory completed in ${(performance.now() - started).toFixed(2)}ms (${count} sessions, slowest batch ${slowestBatch.toFixed(2)}ms)`, - ); } async getSessionIds(): Promise { @@ -171,7 +160,6 @@ export class SessionHistoryRepository { } async getLatestExercises(keys: ProgressionKey[]): Promise> { - const start = performance.now(); const revision = this.revision; await this.ensureIndexed(); const result: Record = {}; @@ -192,9 +180,7 @@ export class SessionHistoryRepository { result[key] = match ? payloads.get(match.sessionId)?.recordedExercises[match.exerciseIndex] : undefined; if (revision === this.revision) this.latestCache.set(key, result[key]); } - this.logger.info( - `queryLatestProgression completed in ${(performance.now() - start).toFixed(2)}ms (${keys.length} keys, ${payloads.size} sessions)`, - ); + return result; } @@ -299,22 +285,29 @@ export class SessionHistoryRepository { return rows.map((row) => Session.fromJSON(sessionMigrations.migrate(row.payload))); } - async getSessionsInRange(from: string, to: string): Promise { + async getSessionsInRange(from: string, to: string, checkpoint?: WorkCheckpoint): Promise { await this.ensureIndexed(); - return this.readSessions(and(gte(sessions.date, from), lte(sessions.date, to))); + return this.readSessions(and(gte(sessions.date, from), lte(sessions.date, to)), checkpoint); } - private async readSessions(where: ReturnType): Promise { + 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); - result.push(...rows.map((row) => Session.fromJSON(sessionMigrations.migrate(row.payload)))); + 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(); diff --git a/app/src/services/session-service.ts b/app/src/services/session-service.ts index dac5874b..ab8d2b70 100644 --- a/app/src/services/session-service.ts +++ b/app/src/services/session-service.ts @@ -20,7 +20,6 @@ import { selectActiveSession } from '@/store/stored-sessions'; import { uuid } from '@/utils/uuid'; import { LocalDate } from '@js-joda/core'; import { match } from 'ts-pattern'; -import { markStartup } from '@/utils/startup-diagnostics'; export class SessionService { constructor( @@ -40,19 +39,15 @@ export class SessionService { if (!firstSessionBlueprint) { return; } - markStartup('upcoming service first yield started'); await yieldToEventLoop(); - markStartup('upcoming service first yield finished'); let latestSession = currentSession ?? (latestStoredSession === undefined ? this.progressRepository.getOrderedSessions().firstOrDefault((x) => !x.isFreeform) : latestStoredSession); - markStartup('upcoming latest session found'); await yieldToEventLoop(); - markStartup('upcoming service second yield finished'); // Track the plan position by index so progression walks the plan in order. // Matching only by name would stall on duplicate-named workouts, always // resolving to the first one and never advancing past it. diff --git a/app/src/store/app/effects.ts b/app/src/store/app/effects.ts index 9054f693..d21e0c3b 100644 --- a/app/src/store/app/effects.ts +++ b/app/src/store/app/effects.ts @@ -12,16 +12,13 @@ import { initializeSettingsStateSlice } from '../settings'; import { initializeProgramStateSlice } from '../program'; import { setStringAsync } from 'expo-clipboard'; import { initializeBackendsStateSlice } from '@/store/backends'; -import { markStartup } from '@/utils/startup-diagnostics'; export function applyAppEffects(addEffect: AddEffectFn) { addEffect( initializeAppStateSlice, async (_, { cancelActiveListeners, dispatch, extra: { databaseMigrationService } }) => { cancelActiveListeners(); - markStartup('database migrations started'); await databaseMigrationService.migrate(); - markStartup('database migrations finished'); dispatch(initializeSettingsStateSlice()); dispatch(initializeProgramStateSlice()); dispatch(initializeBackendsStateSlice()); diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 9db1258d..3ee9bb4e 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -12,21 +12,18 @@ 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'; import { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; import { useIsFocused } from 'expo-router'; import { SQLiteDatabase } from 'expo-sqlite'; -import { attachStartupLogger, markStartup } from '@/utils/startup-diagnostics'; export { RootState }; export function resolveStore(db: ExpoSQLiteDatabase, expoDb: SQLiteDatabase) { - markStartup('store creation started'); const { store, services, addEffect } = createStore(db, expoDb); - attachStartupLogger(services.logger); - markStartup('store and services created'); store.dispatch(clearAllListeners()); applyProgramEffects(addEffect); applyProgramImportExportEffects(addEffect); @@ -39,23 +36,16 @@ export function resolveStore(db: ExpoSQLiteDatabase, expoDb: SQLiteDatabase) { applyAiPlannerEffects(addEffect); applyBackendsEffects(addEffect); - markStartup('effects registered'); const slices = ['app', 'program', 'settings', 'storedSessions', 'aiPlanner'] as const; const unsubscribe = store.subscribe(() => { const state = store.getState(); - for (const slice of slices) { - if (slice === 'storedSessions' ? state.storedSessions.isReady : state[slice].isHydrated) { - markStartup(`${slice} startup ready`); - } - } if ( slices.every((slice) => (slice === 'storedSessions' ? state.storedSessions.isReady : state[slice].isHydrated)) ) { - markStartup('all startup data ready'); unsubscribe(); + store.dispatch(warmAllTimeStats()); } }); - markStartup('app initialization dispatched'); store.dispatch(initializeAppStateSlice()); return { store, services }; } diff --git a/app/src/store/program/effects.ts b/app/src/store/program/effects.ts index ba3ecbaa..6e68c98f 100644 --- a/app/src/store/program/effects.ts +++ b/app/src/store/program/effects.ts @@ -23,7 +23,6 @@ import { LocalDate } from '@js-joda/core'; import { toRecord } from '@/utils/reduce'; import { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; import { TaskAbortError } from '@reduxjs/toolkit'; -import { markStartup } from '@/utils/startup-diagnostics'; const builtInProgramsStorageKey = 'hasSavedDefaultPlans2'; export function applyProgramEffects(addEffect: AddEffectFn) { @@ -34,7 +33,6 @@ export function applyProgramEffects(addEffect: AddEffectFn) { _, { getState, cancelActiveListeners, dispatch, extra: { keyValueStore, logger, db }, throwIfCancelled }, ) => { - const start = performance.now(); cancelActiveListeners(); let activePlanId: string | undefined; @@ -71,8 +69,6 @@ export function applyProgramEffects(addEffect: AddEffectFn) { dispatch(setIsHydrated(true)); dispatch(fetchUpcomingSessions()); - const end = performance.now(); - logger.info(`initializeProgramStateSlice effect took ${(end - start).toFixed(2)} ms`); }, ); @@ -84,15 +80,13 @@ 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`); } }, ); @@ -124,18 +118,15 @@ export function applyProgramEffects(addEffect: AddEffectFn) { state.settings.useImperialUnits, ]; if (upcomingRequest?.inputs.every((input, index) => input === inputs[index])) { - logger.info('fetchUpcomingSessions joined existing request'); return; } const request = { inputs }; upcomingRequest = request; - const start = performance.now(); + cancelActiveListeners(); try { - markStartup('upcoming effect yield started'); await yieldToEventLoop(); - markStartup('upcoming effect yield finished'); if (signal.aborted) return; const latestExercises = state.storedSessions.isHydrated @@ -157,14 +148,12 @@ export function applyProgramEffects(addEffect: AddEffectFn) { } } } - markStartup('upcoming latest exercises selected'); const sessions = await AsyncStream.from( sessionService.getUpcomingSessions(sessionBlueprints, latestExercises, latestSession), ) .takeWhile(() => !signal.aborted) .take(sessionBlueprints.length) .toArray(); - markStartup('upcoming generation finished'); if (signal.aborted || upcomingRequest !== request) return; const current = getState(); if ( @@ -176,8 +165,6 @@ export function applyProgramEffects(addEffect: AddEffectFn) { return; } dispatch(setUpcomingSessions(RemoteData.success(sessions))); - markStartup('first upcoming workouts published'); - logger.info(`fetchUpcomingSessions effect took ${(performance.now() - start).toFixed(2)} ms`); } catch (error) { if (!signal.aborted && upcomingRequest === request) { logger.error('Failed to load upcoming workouts', error); diff --git a/app/src/store/settings/effects.ts b/app/src/store/settings/effects.ts index d91f0c14..cd55a115 100644 --- a/app/src/store/settings/effects.ts +++ b/app/src/store/settings/effects.ts @@ -29,7 +29,6 @@ import { detectLanguageFromDateLocale } from '@/utils/language-detector'; import { supportedLanguages } from '@/services/tolgee'; import { initializeStoredSessionsStateSlice } from '@/store/stored-sessions'; import { builtInBackendId } from '@/models/backend'; -import { markStartup } from '@/utils/startup-diagnostics'; // Read every generically-hydrated key, then dispatch its setter. async function hydrateGenericPreferences( @@ -51,12 +50,9 @@ export function applySettingsEffects(addEffect: AddEffectFn) { addEffect( initializeSettingsStateSlice, async (_, { cancelActiveListeners, dispatch, extra: { preferenceService, logger } }) => { - const start = performance.now(); cancelActiveListeners(); - markStartup('generic preferences started'); await hydrateGenericPreferences(preferenceService, dispatch); - markStartup('generic preferences finished'); // Bespoke hydration: sync read, composite keys, and composed values. dispatch(setPreferredLanguage(preferenceService.getPreferredLanguage())); @@ -80,22 +76,28 @@ export function applySettingsEffects(addEffect: AddEffectFn) { const proToken = await preferenceService.getProToken(); dispatch(setProToken(proToken)); - markStartup('bespoke preferences finished'); + let purchasesConfigured = false; if (!__DEV__) { - if (Platform.OS === 'ios') { - Purchases.configure({ - apiKey: process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY!, - }); - } else if (Platform.OS === 'android') { - Purchases.configure({ - apiKey: process.env.EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY!, - }); + const apiKey = + Platform.OS === 'ios' + ? process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY + : Platform.OS === 'android' + ? process.env.EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY + : undefined; + if (apiKey) { + try { + Purchases.configure({ apiKey }); + purchasesConfigured = true; + } catch (error) { + logger.error('Failed to configure purchases; continuing local startup', error); + } + } else { + logger.info('Purchase configuration unavailable; continuing local startup'); } } // migrate pro token to a revenuecat - markStartup('purchases configured or skipped'); - if (proToken && !proToken.startsWith('$RCAnonymousID')) { + if (purchasesConfigured && proToken && !proToken.startsWith('$RCAnonymousID')) { try { const customerInfo = await Purchases.getCustomerInfo(); await Purchases.syncPurchases(); @@ -107,8 +109,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/startup.spec.ts b/app/src/store/settings/startup.spec.ts new file mode 100644 index 00000000..4b65c40f --- /dev/null +++ b/app/src/store/settings/startup.spec.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native-purchases', () => ({ + default: { configure: vi.fn(), getCustomerInfo: vi.fn(), syncPurchases: vi.fn() }, +})); +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })); + +import Purchases from 'react-native-purchases'; +import { createAddEffectTestBed } from '@/utils/__test__/add-effect-testbed'; +import { applySettingsEffects } from './effects'; +import { initializeSettingsStateSlice, setIsHydrated } from '@/store/settings'; +import { initializeStoredSessionsStateSlice } from '@/store/stored-sessions'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetAllMocks(); +}); + +function startupTestBed() { + vi.stubGlobal('__DEV__', false); + const testBed = createAddEffectTestBed({ + services: { + preferenceService: { + getPreference: vi.fn().mockResolvedValue(undefined), + getPreferredLanguage: vi.fn().mockReturnValue('en'), + getLastSuccessfulRemoteBackupHash: vi.fn().mockResolvedValue(undefined), + getLastBackupTime: vi.fn().mockResolvedValue(undefined), + getLastBackupBackendId: vi.fn().mockResolvedValue(undefined), + getProToken: vi.fn().mockResolvedValue('legacy-token'), + setProToken: vi.fn().mockResolvedValue(undefined), + }, + logger: { info: vi.fn(), log: vi.fn(), error: vi.fn() }, + }, + }); + applySettingsEffects(testBed.addEffect); + return testBed; +} + +describe('release settings startup', () => { + it('finishes hydration without a purchase key and preserves the legacy token', async () => { + vi.stubEnv('EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY', undefined); + const bed = startupTestBed(); + await bed.dispatchHandled(initializeSettingsStateSlice()); + expect(bed.getDispatchedAction(setIsHydrated).payload).toBe(true); + expect(bed.dispatchedActions).toContainEqual(initializeStoredSessionsStateSlice()); + expect(Purchases.configure).not.toHaveBeenCalled(); + expect(Purchases.getCustomerInfo).not.toHaveBeenCalled(); + expect(bed.mockServices.preferenceService.setProToken).not.toHaveBeenCalled(); + }); + + it('finishes hydration when purchase configuration throws', async () => { + vi.stubEnv('EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY', 'test-key'); + vi.mocked(Purchases.configure).mockImplementation(() => { + throw new Error('Invalid API key'); + }); + const bed = startupTestBed(); + await bed.dispatchHandled(initializeSettingsStateSlice()); + expect(bed.getDispatchedAction(setIsHydrated).payload).toBe(true); + expect(bed.mockServices.logger.error).toHaveBeenCalledWith( + 'Failed to configure purchases; continuing local startup', + expect.any(Error), + ); + expect(Purchases.getCustomerInfo).not.toHaveBeenCalled(); + }); + + it('still migrates the legacy token when purchases are configured', async () => { + vi.stubEnv('EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY', 'test-key'); + vi.mocked(Purchases.getCustomerInfo).mockResolvedValue({ originalAppUserId: '$RCAnonymousID:test' } as never); + const bed = startupTestBed(); + await bed.dispatchHandled(initializeSettingsStateSlice()); + expect(Purchases.configure).toHaveBeenCalledWith({ apiKey: 'test-key' }); + expect(Purchases.syncPurchases).toHaveBeenCalled(); + expect(bed.mockServices.preferenceService.setProToken).toHaveBeenCalledWith('$RCAnonymousID:test'); + expect(bed.getDispatchedAction(setIsHydrated).payload).toBe(true); + }); +}); diff --git a/app/src/store/stats/calculate-stats.spec.ts b/app/src/store/stats/calculate-stats.spec.ts index 91757a4e..234ab5eb 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 4223c859..6a7874b0 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 index 33055940..55fa8a8a 100644 --- a/app/src/store/stats/effects.spec.ts +++ b/app/src/store/stats/effects.spec.ts @@ -1,13 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; +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 } from '@/store/stats'; -import { deleteStoredSession, storedSessionsReducer } from '@/store/stored-sessions'; +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); @@ -32,6 +34,106 @@ function bed() { } 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()); @@ -48,6 +150,7 @@ describe('selective statistics', () => { expect(sessionHistoryRepository.getSessionsInRange).toHaveBeenCalledWith( date.toString(), LocalDate.now().toString(), + expect.any(Function), ); expect(testBed.getState().stats.overallView.isSuccess()).toBe(true); }); diff --git a/app/src/store/stats/effects.ts b/app/src/store/stats/effects.ts index 4f1780b1..a0262aa3 100644 --- a/app/src/store/stats/effects.ts +++ b/app/src/store/stats/effects.ts @@ -3,80 +3,194 @@ import { updateStoredSession, deleteStoredSession, upsertStoredSessions, + setActiveSessionId, + setStoredSessions, + selectSessionsBy, } from '@/store/stored-sessions'; -import { setOverallViewTime, setStatsIsDirty } from './index'; +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) { + // 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); + + // 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], - async (_, { dispatch }) => { + [ + 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: { sessionHistoryRepository, logger } }) => { - const state = getState(); - - if (!state.stats.isDirty || (!state.storedSessions.isReady && !state.storedSessions.isHydrated)) { - return; - } + 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(); - cancelActiveListeners(); - dispatch(setOverallStats(RemoteData.loading())); - const started = performance.now(); - await sleep(200); - try { - let timeframe = state.stats.overallViewTime; - if (timeframe === 'all-time') { - const earliest = state.storedSessions.isHydrated - ? state.storedSessions.earliestSession?.date - : (await sessionHistoryRepository.getActivitySummaries()) - .map((session) => session.date) - .sort((a, b) => a.compareTo(b))[0]; - if (!earliest) { - if (!signal.aborted) dispatch(setOverallStats(RemoteData.error('No sessions'))); - return; - } - timeframe = { from: earliest, to: LocalDate.now() }; - } + const key = snapshotKey(state); + dispatch(setOverallStats(RemoteData.loading())); + try { + 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 sessionHistoryRepository.getSessionsInRange(timeframe.from.toString(), timeframe.to.toString()); - if (signal.aborted) return; - const current = getState(); - if (current.storedSessions.dataRevision !== state.storedSessions.dataRevision) { - dispatch(fetchOverallStats()); - return; - } + : 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(current.storedSessions.sessions)) { + 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 (current.storedSessions.activeSessionId) merged.delete(current.storedSessions.activeSessionId); - const stats = calculateStats([...merged.values()], selectPreferredWeightUnit(state), timeframe); - dispatch(setOverallStats(RemoteData.success(stats))); - dispatch(setStatsIsDirty(false)); - logger?.info( - `queryOverallStats completed in ${(performance.now() - started).toFixed(2)}ms (${merged.size} sessions)`, + if (state.storedSessions.activeSessionId) merged.delete(state.storedSessions.activeSessionId); + stats = await calculateStatsAsync( + [...merged.values()], + selectPreferredWeightUnit(state), + timeframe, + checkpoint, ); - } catch (e) { - if (!signal.aborted) dispatch(setOverallStats(RemoteData.error(e))); } - }, - ); - + if (signal.aborted) return; + if (snapshotKey(getState()) !== key) { + dispatch(fetchOverallStats()); + return; + } + dispatch(setOverallStats(RemoteData.success(stats))); + dispatch(setStatsIsDirty(false)); + 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 1dce5917..8b6fee44 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/stored-sessions/effects.ts b/app/src/store/stored-sessions/effects.ts index 8aad9679..91b9fb30 100644 --- a/app/src/store/stored-sessions/effects.ts +++ b/app/src/store/stored-sessions/effects.ts @@ -34,7 +34,6 @@ 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 { markStartup } from '@/utils/startup-diagnostics'; import { RemoteData } from '@/models/remote'; // Built-ins the user deleted, so they stay hidden across restarts and locale switches. @@ -55,16 +54,10 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { if (!getState().settings.isHydrated) { throw new Error('Settings must be hydrated before stored sessions'); } - const hydrateStoredSessionsStart = performance.now(); - markStartup('sessions loading started'); + await logger.time('initializeStoredSessions', async () => { - const loadRowsStart = performance.now(); const rows = await db.select().from(sessionsSchema).where(eq(sessionsSchema.active, true)); - logger.info( - `loadStoredSessionRows completed in ${(performance.now() - loadRowsStart).toFixed(2)}ms (${rows.length} sessions)`, - ); - const deserializeSessionsStart = performance.now(); const storedSessions = rows.reduce( toRecord( (x) => x.id, @@ -72,13 +65,9 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { ), {}, ); - logger.info( - `deserializeStoredSessions completed in ${(performance.now() - deserializeSessionsStart).toFixed(2)}ms`, - ); - const setStoredSessionsStart = performance.now(); dispatch(setStoredSessions(storedSessions)); - logger.info(`setStoredSessions completed in ${(performance.now() - setStoredSessionsStart).toFixed(2)}ms`); + // 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; @@ -87,11 +76,8 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { } }); - await logger.time('migrateLegacyCurrentSession', () => - migrateLegacyCurrentSession(dispatch, getState, keyValueStore, logger), - ); + await migrateLegacyCurrentSession(dispatch, getState, keyValueStore, logger); - const loadSavedExercisesStart = performance.now(); const savedExercises = (await db.select().from(exercisesSchema)).reduce( toRecord( (x) => x.id, @@ -100,31 +86,16 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { {}, ); dispatch(setExercises(savedExercises)); - logger.info( - `loadSavedExercises completed in ${(performance.now() - loadSavedExercisesStart).toFixed(2)}ms (${Object.keys(savedExercises).length} exercises)`, - ); - const loadBuiltInExercisesStart = performance.now(); const builtInExercises = await loadBuiltInExercises(getState().settings.preferredLanguage); dispatch(setBuiltInExercises(builtInExercises)); - logger.info( - `loadBuiltInExercises completed in ${(performance.now() - loadBuiltInExercisesStart).toFixed(2)}ms (${Object.keys(builtInExercises).length} exercises)`, - ); - const loadHiddenBuiltInIdsStart = performance.now(); const hiddenBuiltInIds = JSON.parse( (await keyValueStore.getItem(hiddenBuiltInExerciseIdsStorageKey)) ?? '[]', ) as string[]; dispatch(setHiddenBuiltInIds(hiddenBuiltInIds)); - logger.info( - `loadHiddenBuiltInExerciseIds completed in ${(performance.now() - loadHiddenBuiltInIdsStart).toFixed(2)}ms`, - ); - logger.info( - `hydrateStoredSessionsState completed in ${(performance.now() - hydrateStoredSessionsStart).toFixed(2)}ms`, - ); dispatch(setIsReady(true)); - markStartup('startup session data ready; completed history deferred'); dispatch(fetchUpcomingSessions()); }, ); @@ -132,18 +103,15 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { addEffect(loadStoredSessionHistory, async (_, { getState, dispatch, extra: { db, logger } }) => { const state = getState().storedSessions; if (state.isHydrated) { - logger.info(`loadCompletedSessionHistory reused cache (${Object.keys(state.sessions).length} sessions)`); return; } if (state.historyLoad.isLoading()) return; dispatch(setHistoryLoad(RemoteData.loading())); - const start = performance.now(); - markStartup('completed history requested'); + try { // Let the existing loading indicator mount before starting the database work. await new Promise((resolve) => setTimeout(resolve, 20)); const sessions: Record = {}; - let count = 0; let afterId: string | undefined; while (true) { const rows = await db @@ -156,7 +124,6 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { if (!deletedBeforeHistoryLoaded.has(row.id)) sessions[row.id] = Session.fromJSON(sessionMigrations.migrate(row.payload)); } - count += rows.length; afterId = rows.at(-1)?.id ?? undefined; if (rows.length < 25) break; await new Promise((resolve) => setTimeout(resolve, 0)); @@ -167,10 +134,7 @@ export function applyStoredSessionsEffects(addEffect: AddEffectFn) { dispatch(setIsHydrated(true)); deletedBeforeHistoryLoaded.clear(); dispatch(setHistoryLoad(RemoteData.success(true))); - logger.info( - `loadCompletedSessionHistory completed in ${(performance.now() - start).toFixed(2)}ms (${count} sessions)`, - ); - markStartup('completed history ready'); + dispatch(fetchUpcomingSessions()); } catch (error) { logger.error('Failed to load session history', error); diff --git a/app/src/store/stored-sessions/index.ts b/app/src/store/stored-sessions/index.ts index a8807d2c..aaada834 100644 --- a/app/src/store/stored-sessions/index.ts +++ b/app/src/store/stored-sessions/index.ts @@ -16,6 +16,8 @@ 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; @@ -36,6 +38,7 @@ interface StoredSessionState { const initialState: StoredSessionState = { isReady: false, dataRevision: 0, + historyRevision: 0, isHydrated: false, historyLoad: RemoteData.notAsked(), activitySummaries: undefined, @@ -97,6 +100,8 @@ const storedSessionsSlice = createSlice({ state.isHydrated = action.payload; }, setStoredSessions(state, action: PayloadAction>) { + state.dataRevision++; + state.historyRevision++; state.sessions = action.payload; state.latestExercises = {}; state.earliestSession = undefined; @@ -137,10 +142,12 @@ 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); @@ -234,6 +241,7 @@ 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; } diff --git a/app/src/utils/cooperative-work.spec.ts b/app/src/utils/cooperative-work.spec.ts new file mode 100644 index 00000000..0f17f24b --- /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 00000000..f607d12d --- /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/src/utils/startup-diagnostics.ts b/app/src/utils/startup-diagnostics.ts deleted file mode 100644 index ac58911e..00000000 --- a/app/src/utils/startup-diagnostics.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Logger } from '@/services/logger'; -import type { ProfilerOnRenderCallback } from 'react'; - -const startedAt = performance.now(); -const milestones = new Set(); -const pending: string[] = []; -let logger: Pick | undefined; - -// One timeline per JS runtime. Fast Refresh is not a fresh launch; force-stop the app to measure boot. -export function markStartup(name: string, detail?: string) { - if (milestones.has(name)) return; - milestones.add(name); - const now = performance.now(); - const message = `[startup] ${name}: +${(now - startedAt).toFixed(2)}ms (clock=${now.toFixed(2)}ms)${detail ? `; ${detail}` : ''}`; - if (logger) logger.info(message); - else pending.push(message); -} - -export const logStartupRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => { - markStartup(`React ${id} ${phase}`, `render=${actualDuration.toFixed(2)}ms; base=${baseDuration.toFixed(2)}ms`); -}; - -export function attachStartupLogger(startupLogger: Pick) { - logger = startupLogger; - for (const message of pending.splice(0)) logger.info(message); - logger.info(`[startup] mode=${__DEV__ ? 'development' : 'release'}; offsets are from diagnostics module evaluation`); -} - -export function logNativeStartupTiming() { - // Optional RN extension; absent on web and some native runtimes. Its timestamps share performance.now's origin. - const timing = ( - performance as typeof performance & { - rnStartupTiming?: { - startTime?: number | null; - initializeRuntimeStart?: number | null; - executeJavaScriptBundleEntryPointStart?: number | null; - endTime?: number | null; - }; - } - ).rnStartupTiming; - const timestamps = { - appStart: timing?.startTime ?? null, - runtimeInit: timing?.initializeRuntimeStart ?? null, - bundleEntry: timing?.executeJavaScriptBundleEntryPointStart ?? null, - nativeEnd: timing?.endTime ?? null, - clock: performance.now(), - }; - logger?.info(`[startup] native timing: ${JSON.stringify(timestamps)}`); -} - -markStartup('diagnostics loaded'); diff --git a/docs/Performance.md b/docs/Performance.md new file mode 100644 index 00000000..bde73637 --- /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 43121e7d..dfde2dec 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). From ec8b5c43d5d0a6a6de05b963e3adeb22cbc3642a Mon Sep 17 00:00:00 2001 From: felixer Date: Tue, 8 Sep 2026 15:26:51 -0700 Subject: [PATCH 5/5] Remove unrelated RevenueCat startup workaround Restore upstream purchase initialization and remove its workaround-specific tests. Keep the performance changes and timing instrumentation cleanup. --- app/src/store/settings/effects.ts | 26 +++------ app/src/store/settings/startup.spec.ts | 77 -------------------------- 2 files changed, 9 insertions(+), 94 deletions(-) delete mode 100644 app/src/store/settings/startup.spec.ts diff --git a/app/src/store/settings/effects.ts b/app/src/store/settings/effects.ts index cd55a115..a1624868 100644 --- a/app/src/store/settings/effects.ts +++ b/app/src/store/settings/effects.ts @@ -77,27 +77,19 @@ export function applySettingsEffects(addEffect: AddEffectFn) { const proToken = await preferenceService.getProToken(); dispatch(setProToken(proToken)); - let purchasesConfigured = false; if (!__DEV__) { - const apiKey = - Platform.OS === 'ios' - ? process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY - : Platform.OS === 'android' - ? process.env.EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY - : undefined; - if (apiKey) { - try { - Purchases.configure({ apiKey }); - purchasesConfigured = true; - } catch (error) { - logger.error('Failed to configure purchases; continuing local startup', error); - } - } else { - logger.info('Purchase configuration unavailable; continuing local startup'); + if (Platform.OS === 'ios') { + Purchases.configure({ + apiKey: process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY!, + }); + } else if (Platform.OS === 'android') { + Purchases.configure({ + apiKey: process.env.EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY!, + }); } } // migrate pro token to a revenuecat - if (purchasesConfigured && proToken && !proToken.startsWith('$RCAnonymousID')) { + if (proToken && !proToken.startsWith('$RCAnonymousID')) { try { const customerInfo = await Purchases.getCustomerInfo(); await Purchases.syncPurchases(); diff --git a/app/src/store/settings/startup.spec.ts b/app/src/store/settings/startup.spec.ts deleted file mode 100644 index 4b65c40f..00000000 --- a/app/src/store/settings/startup.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('react-native-purchases', () => ({ - default: { configure: vi.fn(), getCustomerInfo: vi.fn(), syncPurchases: vi.fn() }, -})); -vi.mock('react-native', () => ({ Platform: { OS: 'android' } })); - -import Purchases from 'react-native-purchases'; -import { createAddEffectTestBed } from '@/utils/__test__/add-effect-testbed'; -import { applySettingsEffects } from './effects'; -import { initializeSettingsStateSlice, setIsHydrated } from '@/store/settings'; -import { initializeStoredSessionsStateSlice } from '@/store/stored-sessions'; - -afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.resetAllMocks(); -}); - -function startupTestBed() { - vi.stubGlobal('__DEV__', false); - const testBed = createAddEffectTestBed({ - services: { - preferenceService: { - getPreference: vi.fn().mockResolvedValue(undefined), - getPreferredLanguage: vi.fn().mockReturnValue('en'), - getLastSuccessfulRemoteBackupHash: vi.fn().mockResolvedValue(undefined), - getLastBackupTime: vi.fn().mockResolvedValue(undefined), - getLastBackupBackendId: vi.fn().mockResolvedValue(undefined), - getProToken: vi.fn().mockResolvedValue('legacy-token'), - setProToken: vi.fn().mockResolvedValue(undefined), - }, - logger: { info: vi.fn(), log: vi.fn(), error: vi.fn() }, - }, - }); - applySettingsEffects(testBed.addEffect); - return testBed; -} - -describe('release settings startup', () => { - it('finishes hydration without a purchase key and preserves the legacy token', async () => { - vi.stubEnv('EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY', undefined); - const bed = startupTestBed(); - await bed.dispatchHandled(initializeSettingsStateSlice()); - expect(bed.getDispatchedAction(setIsHydrated).payload).toBe(true); - expect(bed.dispatchedActions).toContainEqual(initializeStoredSessionsStateSlice()); - expect(Purchases.configure).not.toHaveBeenCalled(); - expect(Purchases.getCustomerInfo).not.toHaveBeenCalled(); - expect(bed.mockServices.preferenceService.setProToken).not.toHaveBeenCalled(); - }); - - it('finishes hydration when purchase configuration throws', async () => { - vi.stubEnv('EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY', 'test-key'); - vi.mocked(Purchases.configure).mockImplementation(() => { - throw new Error('Invalid API key'); - }); - const bed = startupTestBed(); - await bed.dispatchHandled(initializeSettingsStateSlice()); - expect(bed.getDispatchedAction(setIsHydrated).payload).toBe(true); - expect(bed.mockServices.logger.error).toHaveBeenCalledWith( - 'Failed to configure purchases; continuing local startup', - expect.any(Error), - ); - expect(Purchases.getCustomerInfo).not.toHaveBeenCalled(); - }); - - it('still migrates the legacy token when purchases are configured', async () => { - vi.stubEnv('EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY', 'test-key'); - vi.mocked(Purchases.getCustomerInfo).mockResolvedValue({ originalAppUserId: '$RCAnonymousID:test' } as never); - const bed = startupTestBed(); - await bed.dispatchHandled(initializeSettingsStateSlice()); - expect(Purchases.configure).toHaveBeenCalledWith({ apiKey: 'test-key' }); - expect(Purchases.syncPurchases).toHaveBeenCalled(); - expect(bed.mockServices.preferenceService.setProToken).toHaveBeenCalledWith('$RCAnonymousID:test'); - expect(bed.getDispatchedAction(setIsHydrated).payload).toBe(true); - }); -});