Skip to content

Commit 8c0eb39

Browse files
aree6Copilot
andcommitted
feat: Enhance exercise trend summary and set classification
- Refactor exercise trend summary to utilize `getWeeklyVolumeSetWeight` for set counting. - Introduce new set type 'negative' in set type configuration with appropriate hypertrophy factor. - Update CSV value parsers to recognize 'negative' and 'eccentric' set types. - Modify clipboard export calculations to incorporate weekly volume for set counting. - Adjust weekly sets dashboard to account for set weight factors in muscle contributions. - Revise muscle analytics to apply weekly volume weight in muscle volume time series calculations. - Improve hypertrophy score calculations by filtering out non-contributing sets based on weekly volume. - Update muscle volume calculations to reflect weighted set contributions for full body and primary/secondary muscles. - Refine rolling volume calculations to include set weight factors for daily muscle volumes. - Enhance windowed exercise breakdown to utilize set weight for accurate contribution tracking. - Adjust tier utility functions to reflect updated half-life values for achievement calculations. Co-authored-by: Copilot <copilot@github.com>
1 parent 06668f3 commit 8c0eb39

44 files changed

Lines changed: 638 additions & 229 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/lyfta.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,56 @@ export const lyfatGetWorkoutSummaries = async (
164164
return (await res.json()) as LyfatGetWorkoutSummaryResponse;
165165
};
166166

167+
interface LyfatGetExerciseProgressResponse {
168+
status: boolean;
169+
weight_unit: string;
170+
data: unknown[];
171+
}
172+
173+
export const lyfatGetExerciseWeightUnit = async (
174+
apiKey: string,
175+
exerciseId: string | number,
176+
): Promise<string | null> => {
177+
try {
178+
const params = new URLSearchParams({
179+
exercise_id: String(exerciseId),
180+
duration: '1',
181+
});
182+
const res = await fetch(`${LYFTA_BASE_URL}/api/v1/exercises/progress?${params.toString()}`, {
183+
method: 'GET',
184+
headers: buildHeaders(apiKey),
185+
});
186+
if (!res.ok) return null;
187+
const json = (await res.json()) as LyfatGetExerciseProgressResponse;
188+
const unit = String(json.weight_unit ?? '').toLowerCase();
189+
if (unit === 'kg' || unit === 'lb' || unit === 'lbs') return unit;
190+
return null;
191+
} catch {
192+
return null;
193+
}
194+
};
195+
196+
export const lyfatGetExerciseWeightUnits = async (
197+
apiKey: string,
198+
exerciseIds: (string | number)[],
199+
): Promise<Map<string, string>> => {
200+
const unitMap = new Map<string, string>();
201+
const uniqueIds = [...new Set(exerciseIds.map(String))];
202+
203+
const results = await Promise.allSettled(
204+
uniqueIds.map((id) => lyfatGetExerciseWeightUnit(apiKey, id)),
205+
);
206+
207+
uniqueIds.forEach((id, i) => {
208+
const result = results[i];
209+
if (result.status === 'fulfilled' && result.value) {
210+
unitMap.set(id, result.value);
211+
}
212+
});
213+
214+
return unitMap;
215+
};
216+
167217
export const lyfatGetAllWorkoutSummaries = async (apiKey: string): Promise<LyfatGetWorkoutSummaryResponse['workouts']> => {
168218
const allSummaries: LyfatGetWorkoutSummaryResponse['workouts'] = [];
169219
let page = 1;

backend/src/mapLyfataWorkoutsToWorkoutSets.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,35 @@ const toNumber = (v: unknown, fallback = 0): number => {
3636
return Number.isFinite(n) ? n : fallback;
3737
};
3838

39-
const normalizeSetType = (value: unknown): string => {
40-
const s = String(value ?? '').toLowerCase();
39+
const LBS_TO_KG = 0.45359237;
4140

42-
if (s === '0') return 'normal';
43-
if (s === '1') return 'warmup';
44-
if (s === '2') return 'right';
45-
if (s === '3') return 'left';
41+
const LYFTA_SET_TYPE_MAP: Record<string, string> = {
42+
'0': 'normal',
43+
'1': 'warmup',
44+
'2': 'right',
45+
'3': 'left',
46+
'4': 'failure',
47+
'5': 'dropset',
48+
'6': 'negative',
49+
'7': 'partial',
50+
'8': 'myoreps',
51+
'9': 'feederset',
52+
'10': 'topset',
53+
'11': 'backoff',
54+
};
4655

47-
return 'normal';
56+
const normalizeSetType = (value: unknown): string => {
57+
const s = String(value ?? '');
58+
return LYFTA_SET_TYPE_MAP[s] || 'normal';
4859
};
4960

5061
export const mapLyfataWorkoutsToWorkoutSets = (
5162
workouts: LyfatGetWorkoutsResponse['workouts'],
52-
summaries: LyfatGetWorkoutSummaryResponse['workouts'] = []
63+
summaries: LyfatGetWorkoutSummaryResponse['workouts'] = [],
64+
weightUnitMap: Map<string, string> = new Map(),
5365
): WorkoutSetDTO[] => {
5466
const out: WorkoutSetDTO[] = [];
55-
67+
5668
// Create a map of workout ID to duration for quick lookup
5769
const durationMap = new Map<number, number>();
5870
for (const summary of summaries) {
@@ -65,27 +77,34 @@ export const mapLyfataWorkoutsToWorkoutSets = (
6577
for (const w of workouts) {
6678
const title = String(w.title ?? 'Workout');
6779
const start_time = parseDate(w.workout_perform_date);
68-
80+
6981
// Calculate end_time based on duration from summary data
7082
const durationMinutes = durationMap.get(w.id) ?? 0;
7183
let end_time = start_time;
72-
84+
7385
if (durationMinutes > 0 && w.workout_perform_date) {
7486
const startDate = new Date(w.workout_perform_date);
7587
if (!isNaN(startDate.getTime())) {
7688
end_time = new Date(startDate.getTime() + durationMinutes * 60 * 1000).toISOString();
7789
}
7890
}
79-
91+
8092
const description = '';
8193

8294
for (const [exerciseIndex, ex] of (w.exercises ?? []).entries()) {
8395
const exercise_title = String(ex.excercise_name ?? '').trim();
8496
const exercise_notes = '';
8597
const superset_id = '';
98+
const exerciseWeightUnit = weightUnitMap.get(String(ex.exercise_id));
8699

87100
const setsForExercise = [...(ex.sets ?? [])].reverse();
88101
setsForExercise.forEach((s, setIdx) => {
102+
let weight = toNumber(s.weight, 0);
103+
104+
if (weight > 0 && exerciseWeightUnit === 'lb') {
105+
weight = weight * LBS_TO_KG;
106+
}
107+
89108
out.push({
90109
title,
91110
start_time,
@@ -97,7 +116,7 @@ export const mapLyfataWorkoutsToWorkoutSets = (
97116
exercise_notes,
98117
set_index: (ex.sets?.length ?? 1) - 1 - setIdx,
99118
set_type: normalizeSetType(s.set_type_id),
100-
weight_kg: toNumber(s.weight, 0),
119+
weight_kg: weight,
101120
reps: toNumber(s.reps, 0),
102121
distance_km: toNumber(s.distance, 0),
103122
duration_seconds: toNumber(s.duration, ex.exercise_rest_time ?? 0),

backend/src/routes/lyftaRoutes.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import express from 'express';
2-
import { lyfatGetAllWorkouts, lyfatGetAllWorkoutSummaries, lyfatValidateApiKey } from '../lyfta';
2+
import { lyfatGetAllWorkouts, lyfatGetAllWorkoutSummaries, lyfatValidateApiKey, lyfatGetExerciseWeightUnits } from '../lyfta';
33
import { mapLyfataWorkoutsToWorkoutSets } from '../mapLyfataWorkoutsToWorkoutSets';
44
import { getClientIP, getCountryFromIP } from '../geoLocation';
55

@@ -44,7 +44,16 @@ export const createLyftaRouter = (opts: {
4444
lyfatGetAllWorkouts(apiKey),
4545
lyfatGetAllWorkoutSummaries(apiKey),
4646
]);
47-
const sets = mapLyfataWorkoutsToWorkoutSets(workouts, summaries);
47+
48+
const exerciseIds = new Set<string>();
49+
for (const w of workouts) {
50+
for (const ex of (w.exercises ?? [])) {
51+
exerciseIds.add(String(ex.exercise_id));
52+
}
53+
}
54+
55+
const weightUnitMap = await lyfatGetExerciseWeightUnits(apiKey, [...exerciseIds]);
56+
const sets = mapLyfataWorkoutsToWorkoutSets(workouts, summaries, weightUnitMap);
4857
return { workouts, sets };
4958
});
5059

frontend/app/startup/startupAutoLoadHevy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
import { identifyPersonalRecords } from '../../utils/analysis/core';
1010
import {
1111
clearHevyAuthToken,
12-
clearHevyProApiKey,
1312
clearHevyRefreshToken,
1413
getHevyRefreshToken,
1514
saveHevyAuthToken,
@@ -19,6 +18,7 @@ import {
1918
saveSetupComplete,
2019
} from '../../utils/storage/dataSourceStorage';
2120
import {
21+
clearHevyProApiKey,
2222
getHevyPassword,
2323
getHevyUsernameOrEmail,
2424
saveHevyPassword,

frontend/app/startup/startupAutoLoadLyfta.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { WorkoutSet } from '../../types';
22
import { lyfatBackendGetSets } from '../../utils/api/lyfataBackend';
33
import { identifyPersonalRecords } from '../../utils/analysis/core';
4-
import { clearLyfataApiKey, saveSetupComplete } from '../../utils/storage/dataSourceStorage';
4+
import { saveSetupComplete } from '../../utils/storage/dataSourceStorage';
5+
import { clearLyftaApiKey } from '../../utils/storage/hevyCredentialsStorage';
56
import { hydrateBackendWorkoutSetsWithSource } from '../auth/hydrateBackendWorkoutSets';
67
import { getLyfatErrorMessage } from '../ui/appErrorMessages';
78
import type { StartupAutoLoadParams } from './startupAutoLoadTypes';
@@ -42,7 +43,7 @@ export const loadLyftaFromApiKey = (
4243
deps.setCsvImportError(null);
4344
})
4445
.catch((err) => {
45-
clearLyfataApiKey();
46+
clearLyftaApiKey();
4647
if (shouldResetOnError) {
4748
saveSetupComplete(false);
4849
deps.setLyfatLoginError(getLyfatErrorMessage(err));

frontend/app/startup/useStartupAutoLoad.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import {
55
getCombinedDataSources,
66
getDataSourceChoice,
77
getHevyAuthToken,
8-
getHevyProApiKey,
98
getLastCsvPlatform,
109
getLastLoginMethod,
11-
getLyfataApiKey,
1210
getSetupComplete,
1311
saveSetupComplete,
1412
type DataSourceChoice,
1513
type LoginMethod,
1614
} from '../../utils/storage/dataSourceStorage';
15+
import {
16+
getHevyProApiKey,
17+
getLyftaApiKey,
18+
} from '../../utils/storage/hevyCredentialsStorage';
1719
import { getHevyUsernameOrEmail, getHevyPassword } from '../../utils/storage/hevyCredentialsStorage';
1820

1921
import { loadCsvAuto } from './startupAutoLoadCsv';
@@ -62,7 +64,7 @@ export const useStartupAutoLoad = (params: StartupAutoLoadParams): void => {
6264
storedChoice,
6365
hasHevyToken: Boolean(getHevyAuthToken()),
6466
hasHevyProApiKey: Boolean(getHevyProApiKey()),
65-
hasLyftaApiKey: Boolean(getLyfataApiKey()),
67+
hasLyftaApiKey: Boolean(getLyftaApiKey()),
6668
hasCsvData: Boolean(getCSVData()),
6769
});
6870

@@ -106,7 +108,7 @@ export const useStartupAutoLoad = (params: StartupAutoLoadParams): void => {
106108

107109
// Platform: Lyfta
108110
if (platform === 'lyfta') {
109-
const lyftaApiKey = getLyfataApiKey();
111+
const lyftaApiKey = getLyftaApiKey();
110112

111113
// Try API key first (preferred for power users)
112114
if ((method === 'apiKey' || !method) && lyftaApiKey) {

frontend/app/state/clearCacheAndRestart.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,23 @@ import {
1212
import {
1313
clearDataSourceChoice,
1414
clearHevyAuthToken,
15-
clearHevyProApiKey,
1615
clearLastCsvPlatform,
1716
clearLastLoginMethod,
18-
clearLyfataApiKey,
1917
clearCombinedDataSources,
2018
clearSetupComplete,
2119
} from '../../utils/storage/dataSourceStorage';
20+
import {
21+
clearHevyProApiKey,
22+
clearLyftaApiKey,
23+
} from '../../utils/storage/hevyCredentialsStorage';
2224

2325
export const clearCacheAndRestart = (): void => {
2426
trackEvent('cache_clear', {});
2527
resetUser();
2628
clearCSVData();
2729
clearHevyAuthToken();
2830
clearHevyProApiKey();
29-
clearLyfataApiKey();
31+
clearLyftaApiKey();
3032
clearDataSourceChoice();
3133
clearLastCsvPlatform();
3234
clearLastLoginMethod();

frontend/components/app/OnboardingLoginSteps.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from 'react';
22
import type { OnboardingFlow } from '../../app/onboarding/types';
33
import { HevyLoginModal } from '../modals/auth/HevyLoginModal';
4-
import { LyfataLoginModal } from '../modals/auth/LyfataLoginModal';
5-
import { getHevyAuthToken, getHevyProApiKey, getLyfataApiKey } from '../../utils/storage/dataSourceStorage';
4+
import { LyftaLoginModal } from '../modals/auth/LyftaLoginModal';
5+
import { getHevyAuthToken } from '../../utils/storage/dataSourceStorage';
6+
import { getHevyProApiKey, getLyftaApiKey } from '../../utils/storage/hevyCredentialsStorage';
67
import { getPreferencesConfirmed } from '../../utils/storage/localStorage';
78

89
interface HevyLoginStepProps {
@@ -76,13 +77,13 @@ export const LyftaLoginStep: React.FC<LyftaLoginStepProps> = ({
7677
onOpenAddSourcePicker,
7778
backToCombinePicker = false,
7879
}) => (
79-
<LyfataLoginModal
80+
<LyftaLoginModal
8081
intent={intent}
8182
errorMessage={lyfatLoginError}
8283
isLoading={isAnalyzing}
8384
onLogin={onLyfatLogin}
8485
loginLabel={intent === 'initial' ? 'Continue' : 'Login with Lyfta'}
85-
hasSavedSession={Boolean(getLyfataApiKey()) && getPreferencesConfirmed()}
86+
hasSavedSession={Boolean(getLyftaApiKey()) && getPreferencesConfirmed()}
8687
onSyncSaved={onLyfatSyncSaved}
8788
onClearCache={onClearCacheAndRestart}
8889
onImportCsv={() => onSetOnboarding({ intent, step: 'lyfta_csv', platform: 'lyfta', backStep: 'lyfta_login' })}

frontend/components/dashboard/hypertrophy/HypertrophyBarCard.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const FactorProgressBar: React.FC<{
6767

6868
interface HypertrophyBarCardProps {
6969
hypertrophyData: MuscleHypertrophyData[];
70+
hypertrophyData30d?: MuscleHypertrophyData[];
7071
selectedMuscleId?: string | null;
7172
onMuscleClick?: (muscleId: string) => void;
7273
hypertrophyPeriod: '7d' | '30d';
@@ -93,6 +94,7 @@ const HypertrophySortSelect: React.FC<{
9394

9495
export const HypertrophyBarCard: React.FC<HypertrophyBarCardProps> = ({
9596
hypertrophyData,
97+
hypertrophyData30d,
9698
selectedMuscleId,
9799
onMuscleClick,
98100
hypertrophyPeriod,
@@ -106,6 +108,15 @@ export const HypertrophyBarCard: React.FC<HypertrophyBarCardProps> = ({
106108
return { avgScore, bestMuscle: hypertrophyData[0], count: hypertrophyData.length };
107109
}, [hypertrophyData]);
108110

111+
const prevPositionMap = useMemo(() => {
112+
if (!hypertrophyData30d || hypertrophyPeriod !== '7d') return null;
113+
const map = new Map<string, number>();
114+
hypertrophyData30d.forEach((m, idx) => {
115+
map.set(m.muscleId, idx + 1);
116+
});
117+
return map;
118+
}, [hypertrophyData30d, hypertrophyPeriod]);
119+
109120
const handleMouseEnter = (e: React.MouseEvent, m: MuscleHypertrophyData) => {
110121
const raw = m.score.raw;
111122
const volW = Math.round(m.score.volumeScore * FACTOR_WEIGHTS.volumeScore);
@@ -199,9 +210,14 @@ export const HypertrophyBarCard: React.FC<HypertrophyBarCardProps> = ({
199210
</div>
200211
))}
201212
</div>
202-
{hypertrophyData.map((m) => {
213+
{hypertrophyData.map((m, idx) => {
203214
const isSelected = m.muscleId === selectedMuscleId;
204215
const rating = getScoreRating(m.score.totalScore);
216+
const currentPos = idx + 1;
217+
const prevPos = prevPositionMap?.get(m.muscleId);
218+
const movedUp = prevPos !== undefined && currentPos < prevPos;
219+
const movedDown = prevPos !== undefined && currentPos > prevPos;
220+
const isNew = prevPos === undefined;
205221
return (
206222
<div key={m.muscleId}
207223
className="flex items-center gap-2 rounded px-1 py-0.5 -mx-1 group relative cursor-pointer"
@@ -217,10 +233,21 @@ export const HypertrophyBarCard: React.FC<HypertrophyBarCardProps> = ({
217233
<span className={`text-[10px] font-semibold w-[10%] text-right flex-shrink-0 ${isSelected ? 'text-white' : 'text-slate-500'}`}>
218234
{m.score.totalScore}%
219235
</span>
220-
<span className="text-[9px] flex items-center gap-1 w-[20%] lg:w-[12%] flex-shrink-0" style={{ color: rating.color }}>
221-
<span className="truncate">{rating.label}</span>
222-
<TrendingUp className="w-3 h-3" />
223-
</span>
236+
{prevPositionMap ? (
237+
<span
238+
className="text-[9px] flex items-center gap-0.5 w-[15%] lg:w-[12%] flex-shrink-0"
239+
style={{ color: isNew || movedUp ? '#22c55e' : movedDown ? '#ef4444' : '#3b82f6' }}
240+
title={isNew ? 'NEW' : movedUp ? `↑ from #${prevPos}` : movedDown ? `↓ from #${prevPos}` : `= #${currentPos}`}
241+
>
242+
<span className="font-bold">{isNew || movedUp ? '↑' : movedDown ? '↓' : '='}</span>
243+
<span>{isNew ? 'NEW' : (movedUp || movedDown) ? `#${prevPos} → #${currentPos}` : `#${currentPos}`}</span>
244+
</span>
245+
) : (
246+
<span className="text-[9px] flex items-center gap-1 w-[15%] lg:w-[12%] flex-shrink-0" style={{ color: rating.color }}>
247+
<span className="truncate">{rating.label}</span>
248+
<TrendingUp className="w-3 h-3" />
249+
</span>
250+
)}
224251
</div>
225252
);
226253
})}

frontend/components/dashboard/hypertrophy/HypertrophyScatterCard.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,15 @@ export const HypertrophyScatterCard: React.FC<HypertrophyScatterCardProps> = ({
147147
<>
148148
<ResponsiveContainer width="100%" height="100%">
149149
<ReScatterChart margin={{ top: 28, right: 8, bottom: 28, left: 0 }}>
150-
<XAxis type="number" dataKey="progress" domain={[0, 50]}
150+
<XAxis type="number" dataKey="volume" domain={[0, 50]}
151151
tick={{ fill: '#94a3b8', fontSize: 9 }} tickLine={false} axisLine={{ stroke: '#475569' }}
152-
label={{ value: 'Progress', position: 'bottom', offset: 5, fill: '#94a3b8', fontSize: 10, fontWeight: 600 }} />
153-
<YAxis type="number" dataKey="volume" domain={[0, 50]}
152+
label={{ value: 'Volume', position: 'bottom', offset: 5, fill: '#94a3b8', fontSize: 10, fontWeight: 600 }} />
153+
<YAxis type="number" dataKey="progress" domain={[0, 50]}
154154
tick={{ fill: '#94a3b8', fontSize: 9 }} tickLine={false} axisLine={{ stroke: '#475569' }} width={28}
155-
label={{ value: 'Volume', angle: 0, position: 'insideTop', offset: -18, dx: +12, fill: '#94a3b8', fontSize: 10, fontWeight: 600 }} />
155+
label={{ value: 'Progress', angle: 0, position: 'insideTop', offset: -18, dx: +12, fill: '#94a3b8', fontSize: 10, fontWeight: 600 }} />
156156

157-
<ReferenceLine x={PROGRESS_MID} stroke="#475569" strokeDasharray="4 4" strokeWidth={1} />
158-
<ReferenceLine y={VOLUME_MID} stroke="#475569" strokeDasharray="4 4" strokeWidth={1} />
157+
<ReferenceLine x={VOLUME_MID} stroke="#475569" strokeDasharray="4 4" strokeWidth={1} />
158+
<ReferenceLine y={PROGRESS_MID} stroke="#475569" strokeDasharray="4 4" strokeWidth={1} />
159159

160160
<RechartsTooltip content={<CustomScatterTooltip />} />
161161

0 commit comments

Comments
 (0)