Skip to content

Commit da3f69d

Browse files
aree6Bridgey30
andcommitted
Fix bugs and add API key support for Hevy Pro login
Co-authored-by: Bridgey30 <[email protected]>
1 parent f263284 commit da3f69d

18 files changed

+1988
-204
lines changed

DEPLOYMENT.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ The backend is required for Hevy login because:
1010
- The Hevy API requires an `x-api-key` header.
1111
- Browsers will block direct calls due to CORS.
1212

13+
Hevy users can authenticate either by:
14+
15+
- Email/username + password (proxied through the backend)
16+
- Hevy Pro API key (from https://hevy.com/settings?developer) pasted directly in the UI (proxied through the backend for data fetch)
17+
1318

1419
## 0) What you will deploy
1520

@@ -153,6 +158,7 @@ Add:
153158
Notes:
154159

155160
- `VITE_BACKEND_URL` must be the public URL of your deployed backend (Render/Railway), not `localhost`.
161+
- `VITE_BACKEND_URL` should be the backend *origin* (no trailing `/api`). The frontend will call `${VITE_BACKEND_URL}/api/...`.
156162
- Example: `https://liftshift-backend.onrender.com`
157163

158164
### 3.2 Trigger a deploy
@@ -170,7 +176,7 @@ After both are deployed:
170176
2. You should see the platform selector
171177
3. Choose:
172178
- Strong (CSV) or
173-
- Hevy (Login or CSV)
179+
- Hevy (Login (email+password or Pro API key) or CSV)
174180
4. After setup, you should see the dashboard
175181

176182
Backend verification (recommended):
@@ -196,6 +202,7 @@ If you ever want to restart onboarding:
196202
- `hevy_username_or_email`
197203
- `hevy_analytics_secret:hevy_password`
198204
- `hevy_auth_token`
205+
- `hevy_pro_api_key`
199206
- `lyfta_api_key`
200207

201208
If your browser is missing WebCrypto/IndexedDB support (or the page isn't a secure context), the app may fall back to storing passwords in Session Storage.
@@ -204,6 +211,8 @@ If your browser is missing WebCrypto/IndexedDB support (or the page isn't a secu
204211
## 5) Notes
205212

206213
- Hevy login is proxied through your backend.
214+
- Credential login stores a Hevy `auth_token` locally and uses it for subsequent syncs.
215+
- Pro API key login stores a Hevy Pro `api-key` locally and uses it for subsequent syncs.
207216
- The app stores the Hevy token in your browser (localStorage).
208217
- If you choose to use Hevy/Lyfta sync, the app may also store your login inputs locally to prefill onboarding (for example: username/email and API keys). Passwords are stored locally and are encrypted when the browser supports WebCrypto + IndexedDB.
209218
- Your workouts are processed client-side into `WorkoutSet[]`.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ By submitting a contribution (code, documentation, or any other material) to thi
108108

109109

110110
1. **Select your platform** (Hevy / Strong)
111-
2. **Hevy**: Choose your **body type** + **weight unit**, then **Continue** to login/sync (or import CSV). / **Strong**: Choose body type + unit, then import CSV
111+
2. **Hevy**: Choose your **body type** + **weight unit**, then **Continue** to login/sync (email+password or Pro API key), or import CSV. / **Strong**: Choose body type + unit, then import CSV
112112
3. **Explore** your analytics across Dashboard, Exercises, and History tabs
113113
4. **Get insights** with real-time feedback and flexible filtering
114114

@@ -189,4 +189,4 @@ If you find this project helpful, you can support it here:
189189

190190
- The only official deployment is https://liftshift.app.
191191
- Any other domain is unofficial. Do not enter credentials into an unofficial deployment.
192-
- LiftShift stores sync credentials locally in your browser (tokens, API keys, and login inputs). Passwords are encrypted at rest when the browser supports WebCrypto + IndexedDB.
192+
- LiftShift stores sync credentials locally in your browser (auth tokens, API keys, and login inputs). Passwords are encrypted at rest when the browser supports WebCrypto + IndexedDB.

backend/src/hevyProApi.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { HevyProWorkout } from './types';
2+
3+
const HEVY_PRO_BASE_URL = 'https://api.hevyapp.com';
4+
5+
const buildHeaders = (apiKey: string): Record<string, string> => {
6+
return {
7+
'content-type': 'application/json',
8+
'api-key': apiKey,
9+
};
10+
};
11+
12+
const parseErrorBody = async (res: Response): Promise<string> => {
13+
try {
14+
const text = await res.text();
15+
return text || `${res.status} ${res.statusText}`;
16+
} catch {
17+
return `${res.status} ${res.statusText}`;
18+
}
19+
};
20+
21+
export interface HevyProWorkoutsPageResponse {
22+
page: number;
23+
page_count: number;
24+
workouts: HevyProWorkout[];
25+
}
26+
27+
export const hevyProGetWorkoutsPage = async (
28+
apiKey: string,
29+
opts: { page: number; pageSize?: number }
30+
): Promise<HevyProWorkoutsPageResponse> => {
31+
const pageSize = Math.min(Math.max(opts.pageSize ?? 10, 1), 10);
32+
const params = new URLSearchParams({
33+
page: String(Math.max(opts.page, 1)),
34+
pageSize: String(pageSize),
35+
});
36+
37+
const res = await fetch(`${HEVY_PRO_BASE_URL}/v1/workouts?${params.toString()}`, {
38+
method: 'GET',
39+
headers: buildHeaders(apiKey),
40+
});
41+
42+
if (!res.ok) {
43+
const msg = await parseErrorBody(res);
44+
const err = new Error(msg);
45+
(err as any).statusCode = res.status;
46+
throw err;
47+
}
48+
49+
return (await res.json()) as HevyProWorkoutsPageResponse;
50+
};
51+
52+
export const hevyProValidateApiKey = async (apiKey: string): Promise<boolean> => {
53+
try {
54+
await hevyProGetWorkoutsPage(apiKey, { page: 1, pageSize: 1 });
55+
return true;
56+
} catch (err) {
57+
const status = (err as any)?.statusCode;
58+
if (status === 401 || status === 403) return false;
59+
return false;
60+
}
61+
};
62+
63+
export const hevyProGetAllWorkouts = async (apiKey: string): Promise<HevyProWorkout[]> => {
64+
const out: HevyProWorkout[] = [];
65+
let page = 1;
66+
let pageCount = 1;
67+
68+
while (page <= pageCount) {
69+
const resp = await hevyProGetWorkoutsPage(apiKey, { page, pageSize: 10 });
70+
pageCount = Number(resp.page_count ?? 1) || 1;
71+
out.push(...(resp.workouts ?? []));
72+
page += 1;
73+
}
74+
75+
return out;
76+
};

backend/src/index.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import express from 'express';
33
import cors from 'cors';
44
import rateLimit from 'express-rate-limit';
55
import { hevyGetAccount, hevyGetWorkoutsPaged, hevyLogin, hevyValidateAuthToken } from './hevyApi';
6-
import { lyfatGetAllWorkouts, lyfatValidateApiKey } from './lyfta';
6+
import { hevyProGetAllWorkouts, hevyProValidateApiKey } from './hevyProApi';
7+
import { lyfatGetAllWorkouts, lyfatGetAllWorkoutSummaries, lyfatValidateApiKey } from './lyfta';
78
import { mapHevyWorkoutsToWorkoutSets } from './mapToWorkoutSets';
9+
import { mapHevyProWorkoutsToWorkoutSets } from './mapHevyProWorkoutsToWorkoutSets';
810
import { mapLyfataWorkoutsToWorkoutSets } from './mapLyfataWorkoutsToWorkoutSets';
911

1012
const PORT = Number(process.env.PORT ?? 5000);
@@ -102,6 +104,34 @@ app.post('/api/hevy/login', loginLimiter, async (req, res) => {
102104
}
103105
});
104106

107+
// Hevy Pro API key endpoints
108+
app.post('/api/hevy/api-key/validate', loginLimiter, async (req, res) => {
109+
const apiKey = String(req.body?.apiKey ?? '').trim();
110+
if (!apiKey) return res.status(400).json({ error: 'Missing apiKey' });
111+
112+
try {
113+
const valid = await hevyProValidateApiKey(apiKey);
114+
res.json({ valid });
115+
} catch (err) {
116+
const status = (err as any).statusCode ?? 500;
117+
res.status(status).json({ error: (err as Error).message || 'Validate failed' });
118+
}
119+
});
120+
121+
app.post('/api/hevy/api-key/sets', async (req, res) => {
122+
const apiKey = String(req.body?.apiKey ?? '').trim();
123+
if (!apiKey) return res.status(400).json({ error: 'Missing apiKey' });
124+
125+
try {
126+
const workouts = await hevyProGetAllWorkouts(apiKey);
127+
const sets = mapHevyProWorkoutsToWorkoutSets(workouts);
128+
res.json({ sets, meta: { workouts: workouts.length } });
129+
} catch (err) {
130+
const status = (err as any).statusCode ?? 500;
131+
res.status(status).json({ error: (err as Error).message || 'Failed to fetch sets' });
132+
}
133+
});
134+
105135
app.post('/api/hevy/validate', async (req, res) => {
106136
const authToken = String(req.body?.auth_token ?? '').trim();
107137
if (!authToken) return res.status(400).json({ error: 'Missing auth_token' });
@@ -202,8 +232,13 @@ app.post('/api/lyfta/sets', async (req, res) => {
202232
if (!apiKey) return res.status(400).json({ error: 'Missing apiKey' });
203233

204234
try {
205-
const workouts = await lyfatGetAllWorkouts(apiKey);
206-
const sets = mapLyfataWorkoutsToWorkoutSets(workouts);
235+
// Fetch both workout details and summaries in parallel
236+
const [workouts, summaries] = await Promise.all([
237+
lyfatGetAllWorkouts(apiKey),
238+
lyfatGetAllWorkoutSummaries(apiKey)
239+
]);
240+
241+
const sets = mapLyfataWorkoutsToWorkoutSets(workouts, summaries);
207242
res.json({ sets, meta: { workouts: workouts.length } });
208243
} catch (err) {
209244
const status = (err as any).statusCode ?? 500;

backend/src/lyfta.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ export interface LyfatGetWorkoutsResponse {
5151
}>;
5252
}
5353

54+
export interface LyfatGetWorkoutSummaryResponse {
55+
status: boolean;
56+
count: number;
57+
total_records: number;
58+
total_pages: number;
59+
current_page: number;
60+
limit: number;
61+
workouts: Array<{
62+
id: string;
63+
title: string;
64+
description: string | null;
65+
workout_duration: string; // e.g. "01:06:25"
66+
total_volume: string;
67+
workout_perform_date: string;
68+
}>;
69+
}
70+
5471
export const lyfatGetWorkouts = async (
5572
apiKey: string,
5673
opts: { limit?: number; page?: number } = {}
@@ -111,3 +128,47 @@ export const lyfatGetAllWorkouts = async (apiKey: string): Promise<LyfatGetWorko
111128

112129
return allWorkouts;
113130
};
131+
132+
export const lyfatGetWorkoutSummaries = async (
133+
apiKey: string,
134+
opts: { limit?: number; page?: number } = {}
135+
): Promise<LyfatGetWorkoutSummaryResponse> => {
136+
const { limit = 1000, page = 1 } = opts;
137+
const params = new URLSearchParams({
138+
limit: String(Math.min(limit, 1000)), // Cap at 1000 for summary endpoint
139+
page: String(page),
140+
});
141+
142+
const res = await fetch(`${LYFTA_BASE_URL}/api/v1/workouts/summary?${params.toString()}`, {
143+
method: 'GET',
144+
headers: buildHeaders(apiKey),
145+
});
146+
147+
if (!res.ok) {
148+
const msg = await parseErrorBody(res);
149+
const err = new Error(msg);
150+
(err as any).statusCode = res.status;
151+
throw err;
152+
}
153+
154+
return (await res.json()) as LyfatGetWorkoutSummaryResponse;
155+
};
156+
157+
export const lyfatGetAllWorkoutSummaries = async (apiKey: string): Promise<LyfatGetWorkoutSummaryResponse['workouts']> => {
158+
const allSummaries: LyfatGetWorkoutSummaryResponse['workouts'] = [];
159+
let page = 1;
160+
let hasMore = true;
161+
162+
while (hasMore) {
163+
const response = await lyfatGetWorkoutSummaries(apiKey, { limit: 1000, page });
164+
allSummaries.push(...response.workouts);
165+
166+
if (page >= response.total_pages) {
167+
hasMore = false;
168+
} else {
169+
page++;
170+
}
171+
}
172+
173+
return allSummaries;
174+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { format, isValid, parseISO } from 'date-fns';
2+
import type { HevyProWorkout, WorkoutSetDTO } from './types';
3+
4+
const DATE_FORMAT_HEVY = 'd MMM yyyy, HH:mm';
5+
6+
const formatIsoDate = (iso: string | undefined): string => {
7+
if (!iso) return '';
8+
try {
9+
const d = parseISO(iso);
10+
return isValid(d) ? format(d, DATE_FORMAT_HEVY) : '';
11+
} catch {
12+
return '';
13+
}
14+
};
15+
16+
const toNumber = (v: unknown, fallback = 0): number => {
17+
if (typeof v === 'number' && Number.isFinite(v)) return v;
18+
const n = Number(v);
19+
return Number.isFinite(n) ? n : fallback;
20+
};
21+
22+
export const mapHevyProWorkoutsToWorkoutSets = (workouts: HevyProWorkout[]): WorkoutSetDTO[] => {
23+
const out: WorkoutSetDTO[] = [];
24+
25+
for (const w of workouts) {
26+
const title = String(w.title ?? 'Workout');
27+
const start_time = formatIsoDate(w.start_time);
28+
const end_time = formatIsoDate(w.end_time);
29+
const description = String(w.description ?? '');
30+
31+
for (const ex of w.exercises ?? []) {
32+
const exercise_title = String(ex.title ?? '').trim();
33+
const exercise_notes = String(ex.notes ?? '');
34+
const superset_id = String(ex.supersets_id ?? '');
35+
36+
for (const s of ex.sets ?? []) {
37+
const distanceMeters = s.distance_meters == null ? 0 : toNumber(s.distance_meters, 0);
38+
out.push({
39+
title,
40+
start_time,
41+
end_time,
42+
description,
43+
exercise_title,
44+
superset_id,
45+
exercise_notes,
46+
set_index: toNumber(s.index, 0),
47+
set_type: String(s.type ?? 'normal'),
48+
weight_kg: s.weight_kg == null ? 0 : toNumber(s.weight_kg, 0),
49+
reps: s.reps == null ? 0 : toNumber(s.reps, 0),
50+
distance_km: distanceMeters > 0 ? distanceMeters / 1000 : 0,
51+
duration_seconds: s.duration_seconds == null ? 0 : toNumber(s.duration_seconds, 0),
52+
rpe: s.rpe == null ? null : toNumber(s.rpe, 0),
53+
});
54+
}
55+
}
56+
}
57+
58+
return out;
59+
};

backend/src/mapLyfataWorkoutsToWorkoutSets.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { format } from 'date-fns';
22
import type { WorkoutSetDTO } from './types';
3-
import type { LyfatGetWorkoutsResponse } from './lyfta';
3+
import type { LyfatGetWorkoutsResponse, LyfatGetWorkoutSummaryResponse } from './lyfta';
44

55
const DATE_FORMAT_LYFTA = 'd MMM yyyy, HH:mm';
66

@@ -15,19 +15,54 @@ const parseDate = (dateStr: string | undefined): string => {
1515
}
1616
};
1717

18+
const parseDurationToMinutes = (durationStr: string | undefined): number => {
19+
if (!durationStr) return 0;
20+
try {
21+
const parts = durationStr.split(':');
22+
if (parts.length !== 3) return 0;
23+
24+
const hours = parseInt(parts[0], 10);
25+
const minutes = parseInt(parts[1], 10);
26+
const seconds = parseInt(parts[2], 10);
27+
28+
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return 0;
29+
30+
return hours * 60 + minutes + Math.round(seconds / 60);
31+
} catch {
32+
return 0;
33+
}
34+
};
35+
1836
const toNumber = (v: unknown, fallback = 0): number => {
1937
if (typeof v === 'number' && Number.isFinite(v)) return v;
2038
const n = Number(v);
2139
return Number.isFinite(n) ? n : fallback;
2240
};
2341

24-
export const mapLyfataWorkoutsToWorkoutSets = (workouts: LyfatGetWorkoutsResponse['workouts']): WorkoutSetDTO[] => {
42+
export const mapLyfataWorkoutsToWorkoutSets = (
43+
workouts: LyfatGetWorkoutsResponse['workouts'],
44+
summaries: LyfatGetWorkoutSummaryResponse['workouts'] = []
45+
): WorkoutSetDTO[] => {
2546
const out: WorkoutSetDTO[] = [];
47+
48+
// Create a map of workout ID to duration for quick lookup
49+
const durationMap = new Map<number, number>();
50+
for (const summary of summaries) {
51+
const workoutId = parseInt(summary.id, 10);
52+
const duration = parseDurationToMinutes(summary.workout_duration);
53+
durationMap.set(workoutId, duration);
54+
}
2655

2756
for (const w of workouts) {
2857
const title = String(w.title ?? 'Workout');
2958
const start_time = parseDate(w.workout_perform_date);
30-
const end_time = start_time; // Lyfta doesn't provide end time
59+
60+
// Calculate end_time based on duration from summary data
61+
const durationMinutes = durationMap.get(w.id) ?? 0;
62+
const end_time = durationMinutes > 0
63+
? format(new Date(new Date(w.workout_perform_date).getTime() + durationMinutes * 60 * 1000), DATE_FORMAT_LYFTA)
64+
: start_time; // Fallback to start_time if no duration available
65+
3166
const description = '';
3267

3368
for (const ex of w.exercises ?? []) {

0 commit comments

Comments
 (0)