Skip to content

Commit 94b747e

Browse files
committed
fix: Speed up calculation of upcoming workouts
The upcoming workout logic was pretty inefficient, in that it would recalculate every 'latest' exercise by enumerating ever session ever. This meant that it could take in some cases half a second to calculate and show them which is wild. The new approach maintains an index in memory of the latest exercises as sessions are added to the state. The downside of this approach is that deletes need to do this expensive calculation, but that is much less frequent than fetching upcoming workouts so this tradeoff is worth it. I've seen times go from 300-700ms to < 80ms. Which is still pretty long in the grand scheme of things but a vast improvement.
1 parent 83913d0 commit 94b747e

5 files changed

Lines changed: 100 additions & 40 deletions

File tree

app/services/progress-repository.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { RecordedExercise, SessionPOJO } from '@/models/session-models';
1+
import { SessionPOJO } from '@/models/session-models';
22
import { TemporalComparer } from '@/models/comparers';
33
import { Session } from '@/models/session-models';
44
import Enumerable from 'linq';
55
import { RootState } from '@/store';
6-
import { KeyedExerciseBlueprint } from '@/models/blueprint-models';
76
import { ZoneId } from '@js-joda/core';
87

98
export class ProgressRepository {
@@ -32,19 +31,4 @@ export class ProgressRepository {
3231
TemporalComparer,
3332
);
3433
}
35-
36-
getLatestRecordedExercises(): Enumerable.IDictionary<
37-
string,
38-
RecordedExercise
39-
> {
40-
return this.getOrderedSessions()
41-
.selectMany((x) => x.recordedExercises)
42-
.groupBy((x) =>
43-
KeyedExerciseBlueprint.fromExerciseBlueprint(x.blueprint).toString(),
44-
)
45-
.toDictionary(
46-
(x) => x.key(),
47-
(x) => x.first(),
48-
);
49-
}
5034
}

app/services/session-service.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { ProgressRepository } from '@/services/progress-repository';
1717
import type { RootState } from '@/store';
1818
import { uuid } from '@/utils/uuid';
1919
import { LocalDate } from '@js-joda/core';
20-
import Enumerable from 'linq';
2120
import { match } from 'ts-pattern';
2221

2322
export class SessionService {
@@ -26,9 +25,9 @@ export class SessionService {
2625
private getState: () => RootState,
2726
) {}
2827

29-
// TODO this is super inefficient, it should probably be done ahead of time or with a dirty mark.
3028
async *getUpcomingSessions(
3129
sessionBlueprints: SessionBlueprint[],
30+
latestExercises: Record<string, RecordedExercise | undefined>, // KeyedExerciseBlueprint -> Exercise
3231
): AsyncIterableIterator<Session> {
3332
const currentState = this.getState();
3433
const currentSession = Session.fromPOJO(
@@ -40,9 +39,6 @@ export class SessionService {
4039
}
4140
await yieldToEventLoop();
4241

43-
const latestRecordedExercises =
44-
this.progressRepository.getLatestRecordedExercises();
45-
await yieldToEventLoop();
4642
let latestSession =
4743
currentSession ??
4844
this.progressRepository
@@ -53,7 +49,7 @@ export class SessionService {
5349
if (!latestSession) {
5450
latestSession = this.createNewSession(
5551
sessionBlueprints[0],
56-
latestRecordedExercises,
52+
latestExercises,
5753
);
5854
yield latestSession;
5955
}
@@ -62,24 +58,25 @@ export class SessionService {
6258
latestSession = this.getNextSession(
6359
latestSession,
6460
sessionBlueprints,
65-
latestRecordedExercises,
61+
latestExercises,
6662
);
6763
yield latestSession;
6864
}
6965
}
7066

71-
public hydrateSessionFromBlueprint(blueprint: SessionBlueprint): Session {
72-
const latestRecordedExercises =
73-
this.progressRepository.getLatestRecordedExercises();
74-
return this.createNewSession(blueprint, latestRecordedExercises);
67+
public hydrateSessionFromBlueprint(
68+
blueprint: SessionBlueprint,
69+
latestExercises: Record<string, RecordedExercise | undefined>, // KeyedExerciseBlueprint -> Exercise
70+
): Session {
71+
return this.createNewSession(blueprint, latestExercises);
7572
}
7673

7774
private getNextSession(
7875
previousSession: Session,
7976
sessionBlueprints: SessionBlueprint[],
80-
latestRecordedExercises: Enumerable.IDictionary<
77+
latestRecordedExercises: Record<
8178
string, //KeyedExerciseBlueprint,
82-
RecordedExercise
79+
RecordedExercise | undefined
8380
>,
8481
): Session {
8582
const lastBlueprint = previousSession.blueprint;
@@ -96,17 +93,18 @@ export class SessionService {
9693

9794
private createNewSession(
9895
sessionBlueprint: SessionBlueprint,
99-
latestRecordedExercises: Enumerable.IDictionary<
96+
latestRecordedExercises: Record<
10097
string, //KeyedExerciseBlueprint,
101-
RecordedExercise
98+
RecordedExercise | undefined
10299
>,
103100
): Session {
104101
// eslint-disable-next-line @typescript-eslint/no-this-alias
105102
const $this = this;
106103
function getNextExercise(e: ExerciseBlueprint): RecordedExercise {
107-
const lastExercise = latestRecordedExercises.get(
108-
KeyedExerciseBlueprint.fromExerciseBlueprint(e).toString(),
109-
);
104+
const lastExercise =
105+
latestRecordedExercises[
106+
KeyedExerciseBlueprint.fromExerciseBlueprint(e).toString()
107+
];
110108
if (e instanceof CardioExerciseBlueprint) {
111109
const cardioLastExercise =
112110
lastExercise instanceof RecordedCardioExercise

app/store/current-session/effects.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
} from '@/store/current-session';
2121
import { addEffect } from '@/store/store';
2222
import { fetchUpcomingSessions, selectActiveProgram } from '@/store/program';
23-
import { addStoredSession } from '@/store/stored-sessions';
23+
import {
24+
addStoredSession,
25+
selectLatestExercises,
26+
} from '@/store/stored-sessions';
2427
import { selectPreferredWeightUnit } from '@/store/settings';
2528
import { diffSessionBlueprints } from '@/models/blueprint-diff';
2629
import { addUnpublishedSessionId } from '@/store/feed';
@@ -271,9 +274,13 @@ export function applyCurrentSessionEffects() {
271274

272275
addEffect(
273276
setCurrentSessionFromBlueprint,
274-
async (action, { dispatch, extra: { sessionService } }) => {
277+
async (
278+
action,
279+
{ stateAfterReduce, dispatch, extra: { sessionService } },
280+
) => {
275281
const session = sessionService.hydrateSessionFromBlueprint(
276282
action.payload.blueprint,
283+
selectLatestExercises(stateAfterReduce),
277284
);
278285
dispatch(setCurrentSession({ session, target: action.payload.target }));
279286
},

app/store/program/effects.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { LocalDate } from '@js-joda/core';
1919
import { AsyncStream } from 'data-async-iterators';
2020
import { KeyValueStore } from '@/services/key-value-store';
2121
import { Logger } from '@/services/logger';
22+
import { selectLatestExercises } from '../stored-sessions';
2223

2324
const storageKey = 'SavedPrograms';
2425
const builtInProgramsStorageKey = 'hasSavedDefaultPlans2';
@@ -176,7 +177,10 @@ export function applyProgramEffects() {
176177
await yieldToEventLoop();
177178

178179
const sessions = await AsyncStream.from(
179-
sessionService.getUpcomingSessions(sessionBlueprints),
180+
sessionService.getUpcomingSessions(
181+
sessionBlueprints,
182+
selectLatestExercises(state),
183+
),
180184
)
181185
.takeWhile(() => !signal.aborted)
182186
.take(numberOfUpcomingSessions)

app/store/stored-sessions/index.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import {
2+
fromRecordedExercisePOJO,
23
RecordedExercise,
4+
RecordedExercisePOJO,
35
Session,
46
SessionPOJO,
57
} from '@/models/session-models';
68
import {
79
NormalizedName,
810
NormalizedNameKey,
911
ExerciseBlueprint,
12+
KeyedExerciseBlueprint,
13+
fromExerciseBlueprintPOJO,
14+
ExerciseBlueprintPOJO,
1015
} from '@/models/blueprint-models';
11-
import { LocalDate, YearMonth, ZoneId } from '@js-joda/core';
16+
import { LocalDate, OffsetDateTime, YearMonth, ZoneId } from '@js-joda/core';
1217
import {
1318
createAction,
1419
createSelector,
1520
createSlice,
1621
PayloadAction,
22+
WritableDraft,
1723
} from '@reduxjs/toolkit';
1824
import Enumerable from 'linq';
1925
import { WeightUnit } from '@/models/weight';
@@ -38,6 +44,7 @@ export interface WeightMigrateableExercise {
3844
interface StoredSessionState {
3945
isHydrated: boolean;
4046
sessions: Record<string, SessionPOJO>;
47+
latestExercises: Record<string, RecordedExercisePOJO | undefined>; // KeyedExerciseBlueprint -> RecordedExercise
4148
savedExercises: Record<string, ExerciseDescriptor>;
4249
filteredExerciseIds: string[];
4350
exercisesRequiringWeightMigration: WeightMigrateableExercise[];
@@ -46,6 +53,7 @@ interface StoredSessionState {
4653
const initialState: StoredSessionState = {
4754
isHydrated: false,
4855
sessions: {},
56+
latestExercises: {},
4957
savedExercises: {},
5058
filteredExerciseIds: [],
5159
exercisesRequiringWeightMigration: [],
@@ -66,15 +74,43 @@ const storedSessionsSlice = createSlice({
6674
},
6775

6876
upsertStoredSessions(state, action: PayloadAction<Session[]>) {
69-
action.payload.forEach((s) => (state.sessions[s.id] = s.toPOJO()));
77+
action.payload.forEach((session) => {
78+
state.sessions[session.id] = session.toPOJO();
79+
updateLatestExercises(state, session);
80+
});
7081
},
7182

7283
addStoredSession(state, action: PayloadAction<Session>) {
7384
state.sessions[action.payload.id] = action.payload.toPOJO();
85+
updateLatestExercises(state, action.payload);
7486
},
7587

7688
deleteStoredSession(state, action: PayloadAction<string>) {
89+
const deletedSession = state.sessions[action.payload];
7790
delete state.sessions[action.payload];
91+
92+
if (!deletedSession) return;
93+
94+
// Collect the exercise keys that were in the deleted session
95+
const affectedKeys = new Set(
96+
deletedSession.recordedExercises.map((e) =>
97+
KeyedExerciseBlueprint.fromExerciseBlueprint(
98+
fromExerciseBlueprintPOJO(e.blueprint as ExerciseBlueprintPOJO),
99+
).toString(),
100+
),
101+
);
102+
103+
// For each affected key, clear and recalculate from remaining sessions
104+
affectedKeys.forEach((key) => {
105+
delete state.latestExercises[key];
106+
});
107+
108+
Object.values(state.sessions).forEach((sessionPOJO) => {
109+
updateLatestExercises(
110+
state,
111+
Session.fromPOJO(sessionPOJO as SessionPOJO),
112+
);
113+
});
78114
},
79115
updateExercise(
80116
state,
@@ -112,6 +148,16 @@ const storedSessionsSlice = createSlice({
112148
},
113149

114150
selectors: {
151+
selectLatestExercises: createSelector(
152+
[(state: StoredSessionState) => state.latestExercises],
153+
(exercises) =>
154+
Object.fromEntries(
155+
Object.entries(exercises).map(([key, exercise]) => [
156+
key,
157+
exercise ? fromRecordedExercisePOJO(exercise) : undefined,
158+
]),
159+
),
160+
),
115161
selectSessions: createSelector(
116162
[(state: StoredSessionState) => state.sessions],
117163
(sessions) => Object.values(sessions).map((x) => Session.fromPOJO(x)),
@@ -147,6 +193,26 @@ const storedSessionsSlice = createSlice({
147193
},
148194
});
149195

196+
function updateLatestExercises(
197+
state: WritableDraft<StoredSessionState>,
198+
session: Session,
199+
) {
200+
session.recordedExercises.forEach((exercise) => {
201+
const key = KeyedExerciseBlueprint.fromExerciseBlueprint(
202+
exercise.blueprint,
203+
).toString();
204+
const latestExercise = state.latestExercises[key];
205+
if (
206+
!latestExercise ||
207+
fromRecordedExercisePOJO(
208+
latestExercise as RecordedExercisePOJO,
209+
).latestTime?.isBefore(exercise.latestTime ?? OffsetDateTime.MIN)
210+
) {
211+
state.latestExercises[key] = exercise.toPOJO();
212+
}
213+
});
214+
}
215+
150216
export const selectSessionsBy = createSelector(
151217
[
152218
storedSessionsSlice.selectors.selectSessions,
@@ -189,6 +255,7 @@ export const {
189255
selectCompletedDistinctSessionNames,
190256
selectSession,
191257
selectExercises,
258+
selectLatestExercises,
192259
selectExerciseById,
193260
} = storedSessionsSlice.selectors;
194261

0 commit comments

Comments
 (0)