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