diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 108600872..e7cf0ba13 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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", diff --git a/src/components/Routines/api/workoutLogs.test.ts b/src/components/Routines/api/workoutLogs.test.ts index af8d50ee7..42d09f98d 100644 --- a/src/components/Routines/api/workoutLogs.test.ts +++ b/src/components/Routines/api/workoutLogs.test.ts @@ -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"; @@ -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 })); diff --git a/src/components/Routines/api/workoutLogs.ts b/src/components/Routines/api/workoutLogs.ts index 9789f649c..b3c4346d7 100644 --- a/src/components/Routines/api/workoutLogs.ts +++ b/src/components/Routines/api/workoutLogs.ts @@ -46,13 +46,22 @@ export const addLogs = async (entries: any[]): Promise => { * Retrieves the training logs for a routine */ export const getRoutineLogs = async (id: number, options? +: { loadExercises?: boolean, filtersetQuery?: object }): Promise => { + 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 => { 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()]); @@ -103,4 +112,4 @@ export const attachExercises = async (logs: WorkoutLog[]): Promise => { for (const log of logs) { log.exerciseObj = exercises.get(log.exerciseId); } -}; \ No newline at end of file +}; diff --git a/src/components/Routines/index.ts b/src/components/Routines/index.ts index 815992f89..364303894 100644 --- a/src/components/Routines/index.ts +++ b/src/components/Routines/index.ts @@ -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 { diff --git a/src/components/Routines/models/RepetitionUnit.ts b/src/components/Routines/models/RepetitionUnit.ts index e2fe0d6b4..d0f7d560a 100644 --- a/src/components/Routines/models/RepetitionUnit.ts +++ b/src/components/Routines/models/RepetitionUnit.ts @@ -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; } } @@ -17,6 +21,8 @@ export class RepetitionUnitAdapter implements Adapter { return new RepetitionUnit( item.id, item.name, + item.unit_type ?? null, + item.multiplier ?? null, ); } -} \ No newline at end of file +} diff --git a/src/components/Routines/models/analytics.test.ts b/src/components/Routines/models/analytics.test.ts new file mode 100644 index 000000000..68a2db083 --- /dev/null +++ b/src/components/Routines/models/analytics.test.ts @@ -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()); + }); +}); diff --git a/src/components/Routines/models/analytics.ts b/src/components/Routines/models/analytics.ts new file mode 100644 index 000000000..6c5b97ebd --- /dev/null +++ b/src/components/Routines/models/analytics.ts @@ -0,0 +1,138 @@ +import { ChartRange, cutoffFor } from "@/components/Measurements"; +import { WorkoutLog } from "@/components/Routines/models/WorkoutLog"; +import { KG_PER_LB } from "@/core/lib/weightUnit"; +import { WEIGHT_UNIT_KG, WEIGHT_UNIT_LB } from "@/core/lib/consts"; +import { DAY_MS } from "@/core/lib/date"; + +export type AnalyticsMetric = 'weight' | 'volume' | 'reps'; +export type AnalyticsUnit = 'kg' | 'kgReps' | 'reps' | 'seconds'; +export type AnalyticsFilter = { exerciseId: number | null, muscleId: number | null }; +export type AnalyticsDateFilter = { date__gte?: string, date__lt?: string }; + +export interface AnalyticsSeries { + key: string; + exerciseId: number | null; + unit: AnalyticsUnit; + points: { date: number, value: number }[]; +} + +/** Whole local days, including today. An exclusive end avoids cutting off today's logs at midnight. */ +export const analyticsDateFilter = (range: ChartRange, now = new Date()): AnalyticsDateFilter => { + const cutoff = cutoffFor(range, now); + if (cutoff === null) { + return {}; + } + // Reuse the measurement ranges, but subtract calendar days rather than + // hours so a daylight-saving transition cannot change the boundary. + const days = Math.round((now.getTime() - cutoff.getTime()) / DAY_MS); + return { + 'date__gte': new Date(now.getFullYear(), now.getMonth(), now.getDate() - days).toISOString(), + 'date__lt': new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString(), + }; +}; + +export const matchesAnalyticsFilter = (log: WorkoutLog, filter: AnalyticsFilter): boolean => { + if (filter.exerciseId !== null && log.exerciseId !== filter.exerciseId) { + return false; + } + return filter.muscleId === null || [ + ...log.exerciseObj?.muscles ?? [], + ...log.exerciseObj?.musclesSecondary ?? [], + ].some(muscle => muscle.id === filter.muscleId); +}; + +const finite = (value: number | null): value is number => value !== null && Number.isFinite(value); + +/** kg/lb are comparable; plates, speed and body weight are not external kilograms. */ +const weightInKg = (log: WorkoutLog): number | null => { + if (!finite(log.weight)) { + return null; + } + if (log.weightUnitId === WEIGHT_UNIT_KG) { + return log.weight; + } + return log.weightUnitId === WEIGHT_UNIT_LB ? log.weight * KG_PER_LB : null; +}; + +const valueFor = (log: WorkoutLog, metric: AnalyticsMetric): { value: number, unit: AnalyticsUnit } | null => { + const weight = weightInKg(log); + if (metric === 'weight') { + return weight === null ? null : { value: weight, unit: 'kg' }; + } + if (!finite(log.repetitions) || log.repetitions < 0) { + return null; + } + + const unit = log.repetitionUnitObj; + const type = unit?.unitType; + if (metric === 'reps') { + return type === 'REPETITIONS' ? { value: log.repetitions, unit: 'reps' } : null; + } + // A hold is duration, never repetitions. Keep it separate from kg × reps. + if (type === 'TIME') { + const multiplier = unit?.multiplier; + if (multiplier === null || multiplier === undefined) { + return null; + } + const seconds = log.repetitions * multiplier; + return Number.isFinite(seconds) && multiplier > 0 ? { value: seconds, unit: 'seconds' } : null; + } + if (type !== 'REPETITIONS') { + return null; + } + if (weight !== null && weight > 0) { + return { value: weight * log.repetitions, unit: 'kgReps' }; + } + // Zero/missing external weight or the explicit Body Weight unit: count + // repetitions. Unknown units and negative assistance are not weighted volume. + if (log.weightUnitId === 3 || ((log.weight === null || log.weight === 0) + && [null, WEIGHT_UNIT_KG, WEIGHT_UNIT_LB].includes(log.weightUnitId))) { + return { value: log.repetitions, unit: 'reps' }; + } + return null; +}; + +/** + * Daily maxima for weight, daily sums for reps/volume, across every routine. + * A muscle selection combines its exercises once (primary OR secondary). + * Otherwise each exercise keeps its own series. Units always stay separate. + */ +export const aggregateAnalytics = ( + logs: WorkoutLog[], metric: AnalyticsMetric, filter: AnalyticsFilter, +): { series: AnalyticsSeries[], skipped: number } => { + const groups = new Map }>(); + const seen = new Set(); + let skipped = 0; + + for (const log of logs) { + if (seen.has(log.id) || !matchesAnalyticsFilter(log, filter)) { + continue; + } + seen.add(log.id); + const entry = valueFor(log, metric); + if (entry === null || !Number.isFinite(entry.value) || !Number.isFinite(log.date.getTime())) { + skipped++; + continue; + } + const exerciseId = filter.muscleId !== null && filter.exerciseId === null ? null : log.exerciseId; + const key = `${exerciseId ?? 'muscle'}-${entry.unit}`; + if (!groups.has(key)) { + groups.set(key, { exerciseId, unit: entry.unit, days: new Map() }); + } + const group = groups.get(key)!; + const day = new Date(log.date.getFullYear(), log.date.getMonth(), log.date.getDate()).getTime(); + const previous = group.days.get(day); + group.days.set(day, metric === 'weight' + ? Math.max(previous ?? -Infinity, entry.value) + : (previous ?? 0) + entry.value); + } + return { + skipped, + series: Array.from(groups, ([key, group]) => ({ + key, + exerciseId: group.exerciseId, + unit: group.unit, + points: Array.from(group.days, ([date, value]) => ({ date, value })).sort((a, b) => a.date - b.date), + })).sort((a, b) => a.key.localeCompare(b.key)), + }; +}; diff --git a/src/components/Routines/queries/analytics.test.tsx b/src/components/Routines/queries/analytics.test.tsx new file mode 100644 index 000000000..6e010cb43 --- /dev/null +++ b/src/components/Routines/queries/analytics.test.tsx @@ -0,0 +1,43 @@ +import { getWorkoutLogs, editLog } from "@/components/Routines/api/workoutLogs"; +import { useExerciseAnalyticsQuery } from "@/components/Routines/queries/analytics"; +import { useEditRoutineLogQuery } from "@/components/Routines/queries/logs"; +import { QueryKey } from "@/core/lib/consts"; +import { analyticsLog } from "@/tests/analyticsTestData"; +import { getTestQueryClient } from "@/tests/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from '@testing-library/react'; +import React from "react"; + +vi.mock('@/components/Routines/api/workoutLogs'); + +beforeEach(() => vi.clearAllMocks()); + +test('switching ranges fetches distinct histories instead of reusing an unrelated cache entry', async () => { + const client = getTestQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + {children}; + vi.mocked(getWorkoutLogs).mockResolvedValueOnce([analyticsLog()]).mockResolvedValueOnce([]); + const { result, rerender } = renderHook(({ start }) => useExerciseAnalyticsQuery( + start ? { date__gte: start } : {}, + ), { wrapper, initialProps: { start: '' } }); + await waitFor(() => expect(result.current.data).toHaveLength(1)); + + rerender({ start: '2026-07-01T00:00:00Z' }); + await waitFor(() => expect(result.current.data).toEqual([])); + expect(getWorkoutLogs).toHaveBeenLastCalledWith({ + loadExercises: true, filtersetQuery: { date__gte: '2026-07-01T00:00:00Z' }, + }); + expect(client.getQueryData([QueryKey.EXERCISE_ANALYTICS, {}])).toHaveLength(1); +}); + +test('editing a log invalidates every cached analytics range', async () => { + const client = getTestQueryClient(); + const keys = [[QueryKey.EXERCISE_ANALYTICS, {}], [QueryKey.EXERCISE_ANALYTICS, { date__gte: '2026-06-01' }]]; + keys.forEach(key => client.setQueryData(key, [analyticsLog()])); + vi.mocked(editLog).mockResolvedValue(analyticsLog({ weight: 60 })); + const { result } = renderHook(() => useEditRoutineLogQuery(1), { + wrapper: ({ children }) => {children}, + }); + await act(() => result.current.mutateAsync(analyticsLog({ weight: 60 }))); + keys.forEach(key => expect(client.getQueryState(key)?.isInvalidated).toBe(true)); +}); diff --git a/src/components/Routines/queries/analytics.ts b/src/components/Routines/queries/analytics.ts new file mode 100644 index 000000000..9e0faf33d --- /dev/null +++ b/src/components/Routines/queries/analytics.ts @@ -0,0 +1,9 @@ +import { getWorkoutLogs } from "@/components/Routines/api/workoutLogs"; +import { AnalyticsDateFilter } from "@/components/Routines/models/analytics"; +import { QueryKey } from "@/core/lib/consts"; +import { useQuery } from "@tanstack/react-query"; + +export const useExerciseAnalyticsQuery = (filter: AnalyticsDateFilter) => useQuery({ + queryKey: [QueryKey.EXERCISE_ANALYTICS, filter], + queryFn: () => getWorkoutLogs({ loadExercises: true, filtersetQuery: filter }), +}); diff --git a/src/components/Routines/queries/logs.ts b/src/components/Routines/queries/logs.ts index 4c820a06e..0a0f31f51 100644 --- a/src/components/Routines/queries/logs.ts +++ b/src/components/Routines/queries/logs.ts @@ -9,6 +9,7 @@ export function useDeleteRoutineLogQuery(routineId: number) { return useMutation({ mutationFn: (logId: string) => deleteLog(logId), onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_ANALYTICS] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_LOG_DATA, routineId] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_STATS, routineId] }); } @@ -29,6 +30,7 @@ export function useEditRoutineLogQuery(routineId: number) { return useMutation({ mutationFn: (log: WorkoutLog) => editLog(log), onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_ANALYTICS] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_LOGS, routineId] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_STATS, routineId] }); } @@ -42,6 +44,7 @@ export function useAddRoutineLogsQuery(routineId: number) { // eslint-disable-next-line @typescript-eslint/no-explicit-any mutationFn: (entries: any[]) => addLogs(entries), onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_ANALYTICS] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_LOGS, routineId] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_STATS, routineId] }); } diff --git a/src/components/Routines/queries/routines.ts b/src/components/Routines/queries/routines.ts index dc6a5ab11..59127dedc 100644 --- a/src/components/Routines/queries/routines.ts +++ b/src/components/Routines/queries/routines.ts @@ -113,9 +113,9 @@ export const useDeleteRoutineQuery = (id: number) => { return useMutation({ mutationFn: () => deleteRoutine(id), onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_ANALYTICS] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_OVERVIEW] }); queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, id] }); } }); }; - diff --git a/src/components/Routines/screens/Overview/ExerciseAnalytics.test.tsx b/src/components/Routines/screens/Overview/ExerciseAnalytics.test.tsx new file mode 100644 index 000000000..e3fc7f1d3 --- /dev/null +++ b/src/components/Routines/screens/Overview/ExerciseAnalytics.test.tsx @@ -0,0 +1,144 @@ +import { Exercise } from '@/components/Exercises'; +import { analyticsDateFilter } from '@/components/Routines/models/analytics'; +import { getLanguages } from "@/components/Exercises/api/language"; +import { getWorkoutLogs } from "@/components/Routines/api/workoutLogs"; +import { getRoutinesShallow } from "@/components/Routines/api/routine"; +import { ExerciseAnalytics } from "@/components/Routines/screens/Overview/ExerciseAnalytics"; +import { analyticsLog } from "@/tests/analyticsTestData"; +import { testExerciseBenchPress, testExerciseSquats, testLanguages, testMuscleBiggus } from "@/tests/exerciseTestdata"; +import { getTestQueryClient } from "@/tests/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { WgerRoutes } from "@/routes"; + +vi.mock('@/components/Routines/api/workoutLogs'); +vi.mock('@/components/Exercises/api/language'); +vi.mock('@/components/Routines/api/routine'); + +const renderPage = () => render( + +); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.removeItem('wgerAnalyticsRange'); + vi.mocked(getLanguages).mockResolvedValue(testLanguages); + vi.mocked(getWorkoutLogs).mockResolvedValue([ + analyticsLog(), analyticsLog({ id: 'second', routineId: 2, weight: 60, repetitions: 5 }), + ]); +}); + +test('defaults to weight and one year; metrics and table reflect logs across routines', async () => { + const user = userEvent.setup(); + renderPage(); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenCalledWith({ loadExercises: true, filtersetQuery: analyticsDateFilter('lastYear') })); + expect(screen.getByRole('combobox', { name: 'routines.analytics.metric' })).toHaveTextContent('weight'); + await user.click(screen.getByRole('button', { name: 'routines.analytics.tableView' })); + expect(await screen.findByRole('cell', { name: '60' })).toBeInTheDocument(); + await user.click(screen.getByRole('combobox', { name: 'routines.analytics.metric' })); + await user.click(screen.getByRole('option', { name: 'routines.volume' })); + expect(screen.getByRole('cell', { name: '800' })).toBeInTheDocument(); + await user.click(screen.getByRole('combobox', { name: 'routines.analytics.metric' })); + await user.click(screen.getByRole('option', { name: 'routines.reps' })); + expect(screen.getByRole('cell', { name: '15' })).toBeInTheDocument(); + expect(getWorkoutLogs).toHaveBeenCalledTimes(1); +}); + +test('exercise and muscle selectors filter the displayed history', async () => { + const user = userEvent.setup(); + vi.mocked(getWorkoutLogs).mockResolvedValue([ + analyticsLog(), analyticsLog({ id: 'other', exerciseId: 999, + exerciseObj: new Exercise({ ...testExerciseBenchPress, id: 999, muscles: [], musclesSecondary: [] }), weight: 999 }), + ]); + renderPage(); + await user.click(screen.getByRole('button', { name: 'routines.analytics.tableView' })); + expect(await screen.findByRole('cell', { name: '999' })).toBeInTheDocument(); + await user.click(screen.getByRole('combobox', { name: 'exercises.muscles' })); + await user.click(await screen.findByRole('option', { name: testMuscleBiggus.getName() })); + expect(screen.queryByRole('cell', { name: '999' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('combobox', { name: 'routines.analytics.exercise' })); + await user.click(screen.getByRole('option', { name: testExerciseSquats.getTranslation().name })); + expect(screen.getByRole('cell', { name: '50' })).toBeInTheDocument(); +}); + +test('a new date range queries the server and shows an empty state without losing controls', async () => { + const user = userEvent.setup(); + renderPage(); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole('combobox', { name: 'routines.analytics.exercise' })); + await user.click(await screen.findByRole('option', { name: testExerciseSquats.getTranslation().name })); + vi.mocked(getWorkoutLogs).mockResolvedValue([]); + await user.click(screen.getByRole('button', { name: 'measurements.chartRangeWeeks' })); + expect(await screen.findByText('routines.analytics.empty')).toBeInTheDocument(); + expect(getWorkoutLogs).toHaveBeenLastCalledWith({ loadExercises: true, filtersetQuery: { + date__gte: expect.any(String), date__lt: expect.any(String), + } }); + expect(screen.getByRole('combobox', { name: 'routines.analytics.exercise' })).toBeEnabled(); + expect(screen.getByRole('combobox', { name: 'routines.analytics.exercise' })).toHaveValue('Squats'); + await user.click(screen.getByRole('combobox', { name: 'routines.analytics.exercise' })); + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(screen.getByRole('combobox', { name: 'routines.analytics.exercise' })).toHaveValue(''); +}); + +test('bodyweight volume is visible in its own unit instead of a zero weighted total', async () => { + const user = userEvent.setup(); + vi.mocked(getWorkoutLogs).mockResolvedValue([analyticsLog(), analyticsLog({ id: 'body', weight: 0, repetitions: 20 })]); + renderPage(); + await user.click(screen.getByRole('combobox', { name: 'routines.analytics.metric' })); + await user.click(screen.getByRole('option', { name: 'routines.volume' })); + await user.click(screen.getByRole('button', { name: 'routines.analytics.tableView' })); + const weighted = await screen.findByRole('table', { name: 'routines.analytics.kgReps' }); + expect(within(weighted).getByRole('cell', { name: '500' })).toBeInTheDocument(); + const bodyweight = screen.getByRole('table', { name: 'routines.reps' }); + expect(within(bodyweight).getByRole('cell', { name: '20' })).toBeInTheDocument(); +}); + +test('shows a pending state and can retry a failed history request', async () => { + const user = userEvent.setup(); + let rejectRequest: (error: Error) => void = () => {}; + vi.mocked(getWorkoutLogs).mockImplementationOnce(() => new Promise((_, reject) => { rejectRequest = reject; })); + renderPage(); + expect(screen.queryByText('routines.analytics.empty')).not.toBeInTheDocument(); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenCalled()); + rejectRequest(new Error('offline')); + expect(await screen.findByText('routines.analytics.loadError')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'routines.analytics.retry' })); + await waitFor(() => expect(screen.queryByText('routines.analytics.loadError')).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'routines.analytics.tableView' })); + expect(await screen.findByRole('cell', { name: '60' })).toBeInTheDocument(); +}); + +test('routine overview menu opens the independent analytics route even without routines', async () => { + const user = userEvent.setup(); + vi.mocked(getRoutinesShallow).mockResolvedValue([]); + render( + + ); + await user.click(await screen.findByRole('button', { name: 'routines.analytics.menu' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('menuitem')).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'routines.analytics.menu' })); + const link = screen.getByRole('menuitem', { name: 'routines.analytics.title' }); + expect(link).toHaveAttribute('href', '/en/routine/analytics'); + await user.click(link); + expect(await screen.findByText('routines.analytics.description')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'routines.routines' })).toHaveAttribute('href', '/en/routine/overview'); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenCalledWith({ loadExercises: true, filtersetQuery: analyticsDateFilter('lastYear') })); +}); + +test('persists the selected range and restores it when the page is reopened', async () => { + const user = userEvent.setup(); + const page = renderPage(); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole('button', { name: 'measurements.chartRangeAll' })); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenLastCalledWith({ loadExercises: true, filtersetQuery: {} })); + expect(window.localStorage.getItem('wgerAnalyticsRange')).toBe('all'); + page.unmount(); + vi.mocked(getWorkoutLogs).mockClear(); + renderPage(); + await waitFor(() => expect(getWorkoutLogs).toHaveBeenCalledWith({ loadExercises: true, filtersetQuery: {} })); + expect(screen.getByRole('button', { name: 'measurements.chartRangeAll' })).toHaveAttribute('aria-pressed', 'true'); +}); diff --git a/src/components/Routines/screens/Overview/ExerciseAnalytics.tsx b/src/components/Routines/screens/Overview/ExerciseAnalytics.tsx new file mode 100644 index 000000000..be05b4dba --- /dev/null +++ b/src/components/Routines/screens/Overview/ExerciseAnalytics.tsx @@ -0,0 +1,153 @@ +import BarChartIcon from '@mui/icons-material/BarChart'; +import TableChartIcon from '@mui/icons-material/TableChart'; +import { + Alert, Autocomplete, Box, Button, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, + TextField, Tooltip, Typography, +} from "@mui/material"; +import Grid from '@mui/material/Grid'; +import { getLanguageByShortName, useLanguageQuery } from "@/components/Exercises"; +import { ChartRange, ChartRangeSelector } from "@/components/Measurements"; +import { + aggregateAnalytics, analyticsDateFilter, AnalyticsMetric, AnalyticsUnit, +} from "@/components/Routines/models/analytics"; +import { loadAnalyticsRange, saveAnalyticsRange } from "@/components/Routines/state/analyticsRange"; +import { WorkoutLog } from "@/components/Routines/models/WorkoutLog"; +import { useExerciseAnalyticsQuery } from "@/components/Routines/queries/analytics"; +import { ExerciseAnalyticsChart } from "@/components/Routines/widgets/ExerciseAnalyticsChart"; +import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; +import { WgerContainerFullWidth } from "@/core/ui/Widgets/Container"; +import { makeLink, WgerLink } from "@/core/lib/url"; +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +type Option = { id: number, name: string }; +const EMPTY_LOGS: WorkoutLog[] = []; +const UNITS: AnalyticsUnit[] = ['kg', 'kgReps', 'reps', 'seconds']; + +// A selected exercise/muscle remains clearable even when a shorter range has no logs for it. +const withSelection = (options: Option[], selected: Option | null): Option[] => + selected && !options.some(option => option.id === selected.id) ? [selected, ...options] : options; + +export const ExerciseAnalytics = () => { + const [t, i18n] = useTranslation(); + const [metric, setMetric] = useState('weight'); + const [range, setRange] = useState(loadAnalyticsRange); + const [exercise, setExercise] = useState