Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,25 @@
"noResults": "No results",
"noResultsDescription": "No results found for this query, consider reducing the number of filters.",
"routines": {
"analytics": {
"title": "Exercise analytics",
"menu": "Routine options",
"description": "Your exercise history across all routines and sessions.",
"exercise": "Exercise",
"metric": "Metric",
"weightHelp": "Daily maximum weight, converted to kg.",
"volumeHelp": "Daily volume (weight × reps), converted to kg × reps. Bodyweight sets without external weight use reps; timed sets use seconds. These are shown separately.",
"repsHelp": "Daily total repetitions. Timed and distance-based sets are not counted as reps.",
"muscleHelp": "Muscle groups include primary and secondary muscles; each set is counted once.",
"kgReps": "kg × reps",
"tableView": "Show data table",
"chartView": "Show chart",
"loadError": "Could not load your workout history.",
"retry": "Retry",
"empty": "No data for these filters and metric. Try another metric or a longer time range.",
"skipped_one": "{{count}} log has missing values or incompatible units for this metric and is not shown.",
"skipped_other": "{{count}} logs have missing values or incompatible units for this metric and are not shown."
},
"sets": "Sets",
"reps": "Reps",
"volume": "Volume",
Expand Down
34 changes: 33 additions & 1 deletion src/components/Routines/api/workoutLogs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getExercisesByIds } from "@/components/Exercises/api/exercise";
import { addLogs, deleteLog, editLog, getRoutineLogs } from "@/components/Routines/api/workoutLogs";
import { addLogs, deleteLog, editLog, getRoutineLogs, getWorkoutLogs } from "@/components/Routines/api/workoutLogs";
import { getRoutineRepUnits, getRoutineWeightUnits } from "@/components/Routines/api/workoutUnits";
import { WorkoutLog } from "@/components/Routines/models/WorkoutLog";
import { testExerciseSquats } from "@/tests/exerciseTestdata";
Expand All @@ -25,6 +25,38 @@ describe("workout logs service tests", () => {
vi.resetAllMocks();
});

test('loads all pages across routines with server-side date bounds and attaches exercises', async () => {
const next = 'https://example.com/api/v2/workoutlog/?offset=1';
const [first, second] = responseRoutineLogs.results;
(axios.get as Mock)
.mockResolvedValueOnce({ data: { results: [{ ...first, routine: 1 }], next } })
.mockResolvedValueOnce({ data: { results: [{ ...second, routine: 2 }], next: null } });
(getRoutineRepUnits as Mock).mockResolvedValue([testRepUnit1]);
(getRoutineWeightUnits as Mock).mockResolvedValue([testWeightUnit1]);
(getExercisesByIds as Mock).mockResolvedValue([testExerciseSquats]);
const filter = { date__gte: '2026-06-01T00:00:00Z', date__lt: '2026-07-01T00:00:00Z' };

const logs = await getWorkoutLogs({ loadExercises: true, filtersetQuery: filter });

const params = new URL((axios.get as Mock).mock.calls[0][0]).searchParams;
expect(params.has('routine')).toBe(false);
expect(params.get('date__gte')).toBe(filter.date__gte);
expect(params.get('date__lt')).toBe(filter.date__lt);
expect((axios.get as Mock).mock.calls[1][0]).toBe(next);
expect(logs.map(log => log.routineId)).toEqual([1, 2]);
expect(logs.every(log => log.exerciseObj === testExerciseSquats)).toBe(true);
expect(getExercisesByIds).toHaveBeenCalledWith([345]);
});

test('does not return a partial history when a later page fails', async () => {
(axios.get as Mock)
.mockResolvedValueOnce({ data: { results: responseRoutineLogs.results, next: 'https://example.com/next' } })
.mockRejectedValueOnce(new Error('offline'));
(getRoutineRepUnits as Mock).mockResolvedValue([]);
(getRoutineWeightUnits as Mock).mockResolvedValue([]);
await expect(getWorkoutLogs()).rejects.toThrow('offline');
});

test('GET the routine logs', async () => {

(axios.get as Mock).mockImplementation(() => Promise.resolve({ data: responseRoutineLogs }));
Expand Down
13 changes: 11 additions & 2 deletions src/components/Routines/api/workoutLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,22 @@ export const addLogs = async (entries: any[]): Promise<WorkoutLog[]> => {
* Retrieves the training logs for a routine
*/
export const getRoutineLogs = async (id: number, options?
: { loadExercises?: boolean, filtersetQuery?: object }): Promise<WorkoutLog[]> => {
return getWorkoutLogs({
...options,
filtersetQuery: { routine: id.toString(), ...options?.filtersetQuery },
});
};

/* Logs across all routines, including entries without a routine. */
export const getWorkoutLogs = async (options?
: { loadExercises?: boolean, filtersetQuery?: object }): Promise<WorkoutLog[]> => {
const { loadExercises = false, filtersetQuery = {} } = options || {};

const adapter = new WorkoutLogAdapter();
const url = makeUrl(
ApiPath.WORKOUT_LOG,
{ query: { routine: id.toString(), limit: API_MAX_PAGE_SIZE, ordering: '-date', ...filtersetQuery } }
{ query: { limit: API_MAX_PAGE_SIZE, ordering: '-date', ...filtersetQuery } }
);

const unitResponses = await Promise.all([getRoutineRepUnits(), getRoutineWeightUnits()]);
Expand Down Expand Up @@ -103,4 +112,4 @@ export const attachExercises = async (logs: WorkoutLog[]): Promise<void> => {
for (const log of logs) {
log.exerciseObj = exercises.get(log.exerciseId);
}
};
};
1 change: 1 addition & 0 deletions src/components/Routines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { WorkoutStats } from "./screens/Detail/WorkoutStats";
export { PrivateTemplateOverview } from "./screens/Overview/PrivateTemplateOverview";
export { PublicTemplateOverview } from "./screens/Overview/PublicTemplateOverview";
export { RoutineOverview } from "./screens/Overview/RoutineOverview";
export { ExerciseAnalytics } from "./screens/Overview/ExerciseAnalytics";

// Models
export {
Expand Down
10 changes: 8 additions & 2 deletions src/components/Routines/models/RepetitionUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { Adapter } from "@/core/lib/Adapter";
export class RepetitionUnit {
id: number;
name: string;
unitType: 'REPETITIONS' | 'TIME' | 'DISTANCE' | null;
multiplier: number | null;

constructor(id: number, description: string) {
constructor(id: number, description: string, unitType: RepetitionUnit['unitType'] = null, multiplier: number | null = null) {
this.id = id;
this.name = description;
this.unitType = unitType;
this.multiplier = multiplier;
}
}

Expand All @@ -17,6 +21,8 @@ export class RepetitionUnitAdapter implements Adapter<RepetitionUnit> {
return new RepetitionUnit(
item.id,
item.name,
item.unit_type ?? null,
item.multiplier ?? null,
);
}
}
}
137 changes: 137 additions & 0 deletions src/components/Routines/models/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Exercise } from "@/components/Exercises";
import { aggregateAnalytics, analyticsDateFilter, AnalyticsMetric } from "@/components/Routines/models/analytics";
import { RepetitionUnit, RepetitionUnitAdapter } from "@/components/Routines/models/RepetitionUnit";
import { analyticsLog } from "@/tests/analyticsTestData";
import { testExerciseSquats, testMuscleBiggus } from "@/tests/exerciseTestdata";

const all = { exerciseId: null, muscleId: null };
const day = new Date(2026, 5, 1).getTime();

describe('exercise analytics aggregation', () => {
test.each<[AnalyticsMetric, number]>([['weight', 60], ['reps', 25], ['volume', 1150]])(
'%s combines multiple routines, sessions and unassigned logs by calendar day', (metric, expected) => {
const logs = [
analyticsLog({ id: 'b', routineId: 2, sessionId: 'second', weight: 60, repetitions: 5 }),
analyticsLog({ id: 'a' }),
analyticsLog({ id: 'c', routineId: null, sessionId: null, weight: 35 }),
];
expect(aggregateAnalytics(logs, metric, all).series[0].points).toEqual([{ date: day, value: expected }]);
},
);

test('sorts days chronologically, keeps exercise IDs distinct and does not count duplicate logs', () => {
const first = analyticsLog();
const result = aggregateAnalytics([
analyticsLog({ id: 'later', date: new Date(2026, 5, 2, 18), weight: 80 }),
analyticsLog({ id: 'other', exerciseId: 999, exerciseObj: undefined, weight: 20 }),
first, first,
], 'volume', all);
expect(result.series.find(s => s.exerciseId === first.exerciseId)?.points).toEqual([
{ date: day, value: 500 }, { date: new Date(2026, 5, 2).getTime(), value: 800 },
]);
expect(result.series).toHaveLength(2);
});

test('normalizes pounds before comparing weight or adding volume', () => {
const logs = [analyticsLog(), analyticsLog({ id: 'lb', weight: 100, weightUnitId: 2 })];
expect(aggregateAnalytics(logs, 'weight', all).series[0].points[0].value).toBe(50);
expect(aggregateAnalytics(logs, 'volume', all).series[0].points[0].value).toBeCloseTo(953.59237);
});

test('bodyweight fallback separates reps, time and weighted volume for the same exercise', () => {
const logs = [
analyticsLog(),
analyticsLog({ id: 'zero', weight: 0 }),
analyticsLog({ id: 'null', weight: null }),
analyticsLog({ id: 'body', weightUnitId: 3, weight: 100 }),
analyticsLog({ id: 'seconds', repetitions: 30, weight: null, repetitionUnitId: 3,
repetitionUnitObj: new RepetitionUnit(3, 'Seconds', 'TIME', 1) }),
analyticsLog({ id: 'minutes', repetitions: 2, weight: 0, repetitionUnitId: 4,
repetitionUnitObj: new RepetitionUnit(4, 'Minutes', 'TIME', 60) }),
];
const result = aggregateAnalytics(logs, 'volume', all);
expect(result.skipped).toBe(0);
expect(Object.fromEntries(result.series.map(s => [s.unit, s.points[0].value]))).toEqual({
kgReps: 500, reps: 30, seconds: 150,
});
const reps = aggregateAnalytics(logs, 'reps', all);
expect(reps.series[0].points[0].value).toBe(40);
expect(reps.skipped).toBe(2);
});

test('uses API time metadata independently of the unit ID', () => {
const unit = new RepetitionUnitAdapter().fromJson({ id: 99, name: 'Minutes', unit_type: 'TIME', multiplier: 60 });
const result = aggregateAnalytics([
analyticsLog({ id: 'modern', repetitions: 2, repetitionUnitId: 99, repetitionUnitObj: unit }),
], 'volume', all);
expect(result.series[0].points[0].value).toBe(120);
});

test('muscle filters include primary and secondary matches once, and intersect with exercise', () => {
const secondary = new Exercise({ ...testExerciseSquats, id: 999, muscles: [], musclesSecondary: [testMuscleBiggus] });
const both = new Exercise({ ...testExerciseSquats, musclesSecondary: [testMuscleBiggus] });
const logs = [
analyticsLog({ exerciseObj: both }),
analyticsLog({ id: 'secondary', exerciseId: 999, exerciseObj: secondary, repetitions: 5 }),
analyticsLog({ id: 'unrelated', exerciseId: 1000, exerciseObj: undefined }),
];
const filter = { ...all, muscleId: testMuscleBiggus.id };
const result = aggregateAnalytics(logs, 'reps', filter);
expect(result.series).toHaveLength(1);
expect(result.series[0]).toMatchObject({ exerciseId: null, points: [{ date: day, value: 15 }] });
expect(aggregateAnalytics(logs, 'reps', { ...filter, exerciseId: 999 }).series[0].points[0].value).toBe(5);
});

test('does not turn missing values, distance, plates or assistance into weighted volume', () => {
const result = aggregateAnalytics([
analyticsLog({ id: 'missing', repetitions: null }),
analyticsLog({ id: 'invalid', repetitions: NaN }),
analyticsLog({ id: 'plates', weightUnitId: 4 }),
analyticsLog({ id: 'assistance', weight: -20 }),
analyticsLog({ id: 'distance', repetitionUnitId: 8, repetitionUnitObj: new RepetitionUnit(8, 'Meters', 'DISTANCE', 1) }),
analyticsLog({ id: 'date', date: new Date('invalid') }),
], 'volume', all);
expect(result).toEqual({ series: [], skipped: 6 });
});

test('keeps actual zero values and leaves empty results empty', () => {
expect(aggregateAnalytics([analyticsLog({ weight: 0 })], 'weight', all).series[0].points[0].value).toBe(0);
expect(aggregateAnalytics([], 'volume', all)).toEqual({ series: [], skipped: 0 });
});

test('unknown units and invalid duration multipliers cannot create a misleading value', () => {
const logs = [
analyticsLog({ id: 'no-rep-unit', repetitionUnitId: 1, repetitionUnitObj: null }),
analyticsLog({ id: 'no-multiplier', repetitionUnitId: 4,
repetitionUnitObj: new RepetitionUnit(4, 'Minutes', 'TIME', null) }),
analyticsLog({ id: 'negative-reps', repetitions: -1 }),
analyticsLog({ id: 'duration', repetitionUnitId: 3,
repetitionUnitObj: new RepetitionUnit(3, 'Seconds', 'TIME', 0) }),
];
expect(aggregateAnalytics(logs, 'volume', all)).toEqual({ series: [], skipped: 4 });
expect(aggregateAnalytics([analyticsLog({ weight: null })], 'weight', all)).toEqual({ series: [], skipped: 1 });
});
});

describe('analytics date filters', () => {
test('all history has no date bounds', () => {
expect(analyticsDateFilter('all')).toEqual({});
});

test('a week includes seven complete local calendar days and has stable cache boundaries', () => {
const now = new Date(2026, 5, 8, 10, 30);
const filter = analyticsDateFilter('lastWeek', now);
expect(filter).toEqual({
date__gte: new Date(2026, 5, 2).toISOString(),
date__lt: new Date(2026, 5, 9).toISOString(),
});
expect(analyticsDateFilter('lastWeek', new Date(2026, 5, 8, 23, 59))).toEqual(filter);
});

test.each([[2, 10], [2, 31], [10, 3]])('calendar bounds stay stable across DST around month %s day %s', (month, date) => {
const early = analyticsDateFilter('lastWeek', new Date(2026, month, date, 0, 15));
const late = analyticsDateFilter('lastWeek', new Date(2026, month, date, 23, 45));
expect(early).toEqual(late);
expect(early.date__gte).toBe(new Date(2026, month, date - 6).toISOString());
});
});
Loading
Loading