Skip to content

Commit 3740415

Browse files
committed
feat(export): track user body measurements history
- Change user_measurements PK to (user_id, run_id) to allow historical tracking. - Implement SQLite migration to schema version 2. - Enhance sync logging with record counts and token state visibility. - Add weight trend query to documentation. - Improve Whoop client debug logging.
1 parent 27ee6ee commit 3740415

7 files changed

Lines changed: 99 additions & 17 deletions

File tree

docs/QUERY_EXAMPLES.sql

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@ SELECT p.user_id,
1919
m.height_meter,
2020
m.weight_kilogram,
2121
m.max_heart_rate,
22-
p.updated_at
22+
m.updated_at
2323
FROM user_profile p
24-
JOIN user_measurements m ON m.user_id = p.user_id
24+
JOIN user_measurements m ON m.user_id = p.user_id AND m.run_id = p.run_id
2525
ORDER BY p.updated_at DESC
2626
LIMIT 1;
2727

28-
-- 4) Sleep score trend (latest 30)
28+
-- 4) Weight and body measurements trend (latest 30 runs)
29+
SELECT r.started_at,
30+
m.weight_kilogram,
31+
m.height_meter,
32+
m.max_heart_rate
33+
FROM user_measurements m
34+
JOIN dump_runs r ON r.id = m.run_id
35+
WHERE r.status = 'success'
36+
ORDER BY r.started_at DESC
37+
LIMIT 30;
38+
39+
-- 5) Sleep score trend (latest 30)
2940
SELECT r.local_date AS day,
3041
r.start_time,
3142
s.sleep_performance_percentage,

docs/SCHEMA.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ Schema source of truth: `src/export/schema.sql`
3333
- columns: `email`, `first_name`, `last_name`, `run_id`, `updated_at`
3434

3535
- `user_measurements`
36-
- one row per user measurements snapshot
37-
- PK: `user_id`
38-
- columns: `height_meter`, `weight_kilogram`, `max_heart_rate`, `run_id`, `updated_at`
36+
- snapshot per user measurements per run
37+
- PK: `(user_id, run_id)`
38+
- columns: `height_meter`, `weight_kilogram`, `max_heart_rate`, `updated_at`
3939

4040
## Sleep tables
4141

src/auth/token-store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface NormalizedToken {
1717
tokenType?: string;
1818
refreshToken?: string;
1919
scope?: string;
20+
expiresIn?: number;
2021
expiresAt?: Date;
2122
}
2223

@@ -171,6 +172,7 @@ export function normalizeToken(token: StoredToken): NormalizedToken {
171172
tokenType: token.token_type,
172173
refreshToken: token.refresh_token,
173174
scope: token.scope,
175+
expiresIn: token.expires_in,
174176
expiresAt: parseExpiresAt(token),
175177
};
176178
}

src/commands/server.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ async function refreshTokenFile(params: {
7272
}
7373

7474
await writeTokenFile(params.credentialsFile, refreshed);
75+
76+
params.logger.info("Token file updated after refresh", {
77+
expiresInSeconds: refreshed.expires_in,
78+
expiresAt: refreshed.expires_at,
79+
});
7580
}
7681

7782
export async function runServerCommand(cli: ServerCliOptions): Promise<void> {
@@ -187,6 +192,15 @@ export async function runServerCommand(cli: ServerCliOptions): Promise<void> {
187192
});
188193
}
189194

195+
logger.info("Starting sync with token state", {
196+
reason,
197+
tokenExpiresInSeconds: token.expiresIn,
198+
tokenExpiresAt: token.expiresAt,
199+
tokenExpired: isTokenExpired(token),
200+
configuredRefreshMinutes: config.server.jwtRefreshMinutes,
201+
output: config.export.output,
202+
});
203+
190204
const client = new WhoopClient({
191205
accessToken: token.accessToken,
192206
userAgent: "whoosh/0.1.0",
@@ -207,7 +221,17 @@ export async function runServerCommand(cli: ServerCliOptions): Promise<void> {
207221
}
208222

209223
markSyncSuccess(healthState);
210-
logger.info("Scheduled sync completed", { reason });
224+
logger.info("Scheduled sync completed", {
225+
reason,
226+
mode: plan.mode,
227+
output: config.export.output,
228+
records: {
229+
sleeps: dump.sleep_collection.records.length,
230+
recoveries: dump.recovery_collection.records.length,
231+
workouts: dump.workout_collection.records.length,
232+
cycles: dump.cycle_collection.records.length,
233+
},
234+
});
211235
} catch (error) {
212236
const errorMessage = error instanceof Error ? error.message : String(error);
213237
markSyncFailure(healthState, errorMessage);

src/export/schema.sql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ CREATE TABLE IF NOT EXISTS user_profile (
3333
FOREIGN KEY (run_id) REFERENCES dump_runs(id)
3434
);
3535

36-
-- User body measurements keyed by user id.
36+
-- User body measurements snapshot per run.
3737
CREATE TABLE IF NOT EXISTS user_measurements (
38-
user_id INTEGER PRIMARY KEY,
38+
user_id INTEGER NOT NULL,
3939
height_meter REAL,
4040
weight_kilogram REAL,
4141
max_heart_rate INTEGER,
4242
run_id INTEGER NOT NULL,
4343
updated_at TEXT NOT NULL,
44+
PRIMARY KEY (user_id, run_id),
4445
FOREIGN KEY (run_id) REFERENCES dump_runs(id)
4546
);
4647

src/export/sqlite.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { AppError } from "../util/errors";
55
import type { Logger } from "../util/logger";
66
import type { CycleRecord, RecoveryRecord, SleepRecord, WhoopDump, WorkoutRecord } from "../whoop/types";
77

8-
const SCHEMA_VERSION = 1;
8+
const SCHEMA_VERSION = 2;
99

1010
export interface SqliteExportOptions {
1111
dbPath: string;
@@ -35,6 +35,32 @@ function applyPragmas(db: Database): void {
3535

3636
function applySchema(db: Database, schemaSql: string): void {
3737
db.exec(schemaSql);
38+
39+
const currentVersionRow = db.query(
40+
"SELECT MAX(version) as version FROM schema_migrations",
41+
).get() as { version: number | null } | null;
42+
const currentVersion = currentVersionRow?.version ?? 0;
43+
44+
if (currentVersion < 2) {
45+
// Migration to version 2: Change user_measurements PK to (user_id, run_id).
46+
// SQLite doesn't support changing PK on an existing table directly.
47+
db.exec(`
48+
CREATE TABLE IF NOT EXISTS user_measurements_new (
49+
user_id INTEGER NOT NULL,
50+
height_meter REAL,
51+
weight_kilogram REAL,
52+
max_heart_rate INTEGER,
53+
run_id INTEGER NOT NULL,
54+
updated_at TEXT NOT NULL,
55+
PRIMARY KEY (user_id, run_id),
56+
FOREIGN KEY (run_id) REFERENCES dump_runs(id)
57+
);
58+
INSERT INTO user_measurements_new SELECT * FROM user_measurements;
59+
DROP TABLE user_measurements;
60+
ALTER TABLE user_measurements_new RENAME TO user_measurements;
61+
`);
62+
}
63+
3864
db.query(
3965
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?) ON CONFLICT(version) DO NOTHING",
4066
).run(SCHEMA_VERSION, new Date().toISOString());
@@ -280,11 +306,10 @@ function upsertUserMeasurements(db: Database, runId: number, dump: WhoopDump): v
280306
db.query(
281307
`INSERT INTO user_measurements (user_id, height_meter, weight_kilogram, max_heart_rate, run_id, updated_at)
282308
VALUES (?, ?, ?, ?, ?, ?)
283-
ON CONFLICT(user_id) DO UPDATE SET
309+
ON CONFLICT(user_id, run_id) DO UPDATE SET
284310
height_meter = excluded.height_meter,
285311
weight_kilogram = excluded.weight_kilogram,
286312
max_heart_rate = excluded.max_heart_rate,
287-
run_id = excluded.run_id,
288313
updated_at = excluded.updated_at`,
289314
).run(
290315
dump.user_data.user_id,

src/whoop/client.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,30 @@ export class WhoopClient {
4747
}
4848

4949
private async getJSON<T>(url: string): Promise<T> {
50-
const response = await fetchWithRetry(url, {
51-
method: "GET",
52-
headers: this.buildHeaders(),
53-
}, {
54-
fetchImpl: this.fetchImpl,
50+
const startedAt = Date.now();
51+
this.logger?.debug("HTTP GET start", { url });
52+
53+
let response: Response;
54+
try {
55+
response = await fetchWithRetry(url, {
56+
method: "GET",
57+
headers: this.buildHeaders(),
58+
}, {
59+
fetchImpl: this.fetchImpl,
60+
});
61+
} catch (error) {
62+
this.logger?.error("HTTP GET failed", {
63+
url,
64+
elapsedMs: Date.now() - startedAt,
65+
error: error instanceof Error ? error.message : String(error),
66+
});
67+
throw error;
68+
}
69+
70+
this.logger?.debug("HTTP GET success", {
71+
url,
72+
status: response.status,
73+
elapsedMs: Date.now() - startedAt,
5574
});
5675

5776
let json: unknown;

0 commit comments

Comments
 (0)