@cmichel/healthlog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +40 -0
  2. package/dist/cli.js +49 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/commands/dump.js +26 -0
  5. package/dist/commands/dump.js.map +1 -0
  6. package/dist/commands/setup-garmin.js +30 -0
  7. package/dist/commands/setup-garmin.js.map +1 -0
  8. package/dist/commands/setup-hevy.js +27 -0
  9. package/dist/commands/setup-hevy.js.map +1 -0
  10. package/dist/config/database-path.js +34 -0
  11. package/dist/config/database-path.js.map +1 -0
  12. package/dist/db/database.js +20 -0
  13. package/dist/db/database.js.map +1 -0
  14. package/dist/db/endurance-metrics.js +43 -0
  15. package/dist/db/endurance-metrics.js.map +1 -0
  16. package/dist/db/provider-state.js +48 -0
  17. package/dist/db/provider-state.js.map +1 -0
  18. package/dist/db/schema.js +53 -0
  19. package/dist/db/schema.js.map +1 -0
  20. package/dist/db/strength-metrics.js +14 -0
  21. package/dist/db/strength-metrics.js.map +1 -0
  22. package/dist/db/workouts.js +172 -0
  23. package/dist/db/workouts.js.map +1 -0
  24. package/dist/domain/dump.js +2 -0
  25. package/dist/domain/dump.js.map +1 -0
  26. package/dist/domain/provider.js +2 -0
  27. package/dist/domain/provider.js.map +1 -0
  28. package/dist/domain/workout.js +23 -0
  29. package/dist/domain/workout.js.map +1 -0
  30. package/dist/providers/garmin/client.js +38 -0
  31. package/dist/providers/garmin/client.js.map +1 -0
  32. package/dist/providers/garmin/normalize.js +145 -0
  33. package/dist/providers/garmin/normalize.js.map +1 -0
  34. package/dist/providers/garmin/source.js +49 -0
  35. package/dist/providers/garmin/source.js.map +1 -0
  36. package/dist/providers/garmin/sync.js +72 -0
  37. package/dist/providers/garmin/sync.js.map +1 -0
  38. package/dist/providers/garmin/types.js +129 -0
  39. package/dist/providers/garmin/types.js.map +1 -0
  40. package/dist/providers/hevy/client.js +42 -0
  41. package/dist/providers/hevy/client.js.map +1 -0
  42. package/dist/providers/hevy/normalize.js +49 -0
  43. package/dist/providers/hevy/normalize.js.map +1 -0
  44. package/dist/providers/hevy/source.js +6 -0
  45. package/dist/providers/hevy/source.js.map +1 -0
  46. package/dist/providers/hevy/sync.js +88 -0
  47. package/dist/providers/hevy/sync.js.map +1 -0
  48. package/dist/providers/hevy/types.js +73 -0
  49. package/dist/providers/hevy/types.js.map +1 -0
  50. package/dist/services/dump-service.js +71 -0
  51. package/dist/services/dump-service.js.map +1 -0
  52. package/dist/services/setup-service.js +11 -0
  53. package/dist/services/setup-service.js.map +1 -0
  54. package/dist/services/sync-service.js +32 -0
  55. package/dist/services/sync-service.js.map +1 -0
  56. package/dist/utils/dates.js +35 -0
  57. package/dist/utils/dates.js.map +1 -0
  58. package/dist/utils/logger.js +28 -0
  59. package/dist/utils/logger.js.map +1 -0
  60. package/dist/utils/parse.js +38 -0
  61. package/dist/utils/parse.js.map +1 -0
  62. package/dist/utils/running.js +16 -0
  63. package/dist/utils/running.js.map +1 -0
  64. package/package.json +45 -0
@@ -0,0 +1,42 @@
1
+ import { parseSchema } from "../../utils/parse.js";
2
+ import { HevyApiPaginatedWorkoutEventsSchema, HevyApiUserInfoResponseSchema, } from "./types.js";
3
+ const hevyApiBaseUrl = "https://api.hevyapp.com";
4
+ // Hevy caps workout event pages at 10 items.
5
+ const hevyPageSize = 10;
6
+ export class HevyClient {
7
+ #apiKey;
8
+ constructor(credentials) {
9
+ this.#apiKey = credentials.apiKey;
10
+ }
11
+ static fromCredentials(credentials) {
12
+ return new HevyClient(credentials);
13
+ }
14
+ static async verifyApiKey(apiKey) {
15
+ const client = new HevyClient({ apiKey });
16
+ await client.getUserInfo();
17
+ }
18
+ async getWorkoutEvents(since, page) {
19
+ const url = new URL("/v1/workouts/events", hevyApiBaseUrl);
20
+ url.searchParams.set("since", since);
21
+ url.searchParams.set("page", String(page));
22
+ url.searchParams.set("pageSize", String(hevyPageSize));
23
+ return parseSchema(HevyApiPaginatedWorkoutEventsSchema, await this.getJson(url), `Hevy workout events page ${page} response`);
24
+ }
25
+ async getUserInfo() {
26
+ const url = new URL("/v1/user/info", hevyApiBaseUrl);
27
+ parseSchema(HevyApiUserInfoResponseSchema, await this.getJson(url), "Hevy user info response");
28
+ }
29
+ async getJson(url) {
30
+ const response = await fetch(url, {
31
+ headers: {
32
+ "api-key": this.#apiKey,
33
+ },
34
+ });
35
+ if (!response.ok) {
36
+ const body = await response.text();
37
+ throw new Error(`Hevy API request failed: ${response.status} ${response.statusText}${body.length > 0 ? `: ${body}` : ""}`);
38
+ }
39
+ return (await response.json());
40
+ }
41
+ }
42
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/providers/hevy/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAKnD,OAAO,EACL,mCAAmC,EACnC,6BAA6B,GAC9B,MAAM,YAAY,CAAC;AAEpB,MAAM,cAAc,GAAG,yBAAyB,CAAC;AACjD,6CAA6C;AAC7C,MAAM,YAAY,GAAG,EAAE,CAAC;AAExB,MAAM,OAAO,UAAU;IACZ,OAAO,CAAS;IAEzB,YAAoB,WAA4B;QAC9C,IAAI,CAAC,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC;IACpC,CAAC;IAED,MAAM,CAAC,eAAe,CAAC,WAA4B;QACjD,OAAO,IAAI,UAAU,CAAC,WAAW,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,MAAc;QACtC,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;QAC1C,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;IAC7B,CAAC;IAED,KAAK,CAAC,gBAAgB,CACpB,KAAa,EACb,IAAY;QAEZ,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,qBAAqB,EAAE,cAAc,CAAC,CAAC;QAC3D,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACrC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3C,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC;QAEvD,OAAO,WAAW,CAChB,mCAAmC,EACnC,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EACvB,4BAA4B,IAAI,WAAW,CAC5C,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,WAAW;QACvB,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,eAAe,EAAE,cAAc,CAAC,CAAC;QACrD,WAAW,CACT,6BAA6B,EAC7B,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EACvB,yBAAyB,CAC1B,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,OAAO,CAAC,GAAQ;QAC5B,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAChC,OAAO,EAAE;gBACP,SAAS,EAAE,IAAI,CAAC,OAAO;aACxB;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CACb,4BAA4B,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,UAAU,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAC1G,CAAC;QACJ,CAAC;QAED,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAY,CAAC;IAC5C,CAAC;CACF","sourcesContent":["import { parseSchema } from \"../../utils/parse.js\";\nimport type {\n HevyApiPaginatedWorkoutEvents,\n HevyCredentials,\n} from \"./types.js\";\nimport {\n HevyApiPaginatedWorkoutEventsSchema,\n HevyApiUserInfoResponseSchema,\n} from \"./types.js\";\n\nconst hevyApiBaseUrl = \"https://api.hevyapp.com\";\n// Hevy caps workout event pages at 10 items.\nconst hevyPageSize = 10;\n\nexport class HevyClient {\n readonly #apiKey: string;\n\n private constructor(credentials: HevyCredentials) {\n this.#apiKey = credentials.apiKey;\n }\n\n static fromCredentials(credentials: HevyCredentials): HevyClient {\n return new HevyClient(credentials);\n }\n\n static async verifyApiKey(apiKey: string): Promise<void> {\n const client = new HevyClient({ apiKey });\n await client.getUserInfo();\n }\n\n async getWorkoutEvents(\n since: string,\n page: number,\n ): Promise<HevyApiPaginatedWorkoutEvents> {\n const url = new URL(\"/v1/workouts/events\", hevyApiBaseUrl);\n url.searchParams.set(\"since\", since);\n url.searchParams.set(\"page\", String(page));\n url.searchParams.set(\"pageSize\", String(hevyPageSize));\n\n return parseSchema(\n HevyApiPaginatedWorkoutEventsSchema,\n await this.getJson(url),\n `Hevy workout events page ${page} response`,\n );\n }\n\n private async getUserInfo(): Promise<void> {\n const url = new URL(\"/v1/user/info\", hevyApiBaseUrl);\n parseSchema(\n HevyApiUserInfoResponseSchema,\n await this.getJson(url),\n \"Hevy user info response\",\n );\n }\n\n private async getJson(url: URL): Promise<unknown> {\n const response = await fetch(url, {\n headers: {\n \"api-key\": this.#apiKey,\n },\n });\n\n if (!response.ok) {\n const body = await response.text();\n throw new Error(\n `Hevy API request failed: ${response.status} ${response.statusText}${body.length > 0 ? `: ${body}` : \"\"}`,\n );\n }\n\n return (await response.json()) as unknown;\n }\n}\n"]}
@@ -0,0 +1,49 @@
1
+ import { StrengthExercisesSchema } from "../../domain/workout.js";
2
+ import { metricNumber, parseSchema, stringifyJson } from "../../utils/parse.js";
3
+ export function normalizeHevyWorkoutSource(source) {
4
+ const workout = source.workout;
5
+ const workoutId = `hevy:${workout.id}`;
6
+ return {
7
+ type: "strength",
8
+ workout: {
9
+ id: workoutId,
10
+ provider: "hevy",
11
+ providerId: workout.id,
12
+ type: "strength",
13
+ sport: "strength_training",
14
+ title: workout.title,
15
+ startedAtMs: parseHevyTimestamp(workout.start_time, "start_time"),
16
+ endedAtMs: workout.end_time === null
17
+ ? null
18
+ : parseHevyTimestamp(workout.end_time, "end_time"),
19
+ sourceJson: stringifyJson(source),
20
+ providerExtrasJson: null,
21
+ },
22
+ strengthMetrics: {
23
+ workoutId,
24
+ exercisesJson: stringifyJson(parseSchema(StrengthExercisesSchema, normalizeHevyExercises(source), "Hevy strength metrics")),
25
+ },
26
+ };
27
+ }
28
+ function normalizeHevyExercises(source) {
29
+ return [...source.workout.exercises]
30
+ .sort((left, right) => left.index - right.index)
31
+ .map((exercise) => ({
32
+ title: exercise.title,
33
+ sets: [...exercise.sets]
34
+ .sort((left, right) => left.index - right.index)
35
+ .map((set) => ({
36
+ weightKg: metricNumber(set.weight_kg),
37
+ reps: metricNumber(set.reps),
38
+ durationSeconds: metricNumber(set.duration_seconds),
39
+ })),
40
+ }));
41
+ }
42
+ function parseHevyTimestamp(value, label) {
43
+ const ms = Date.parse(value);
44
+ if (!Number.isFinite(ms)) {
45
+ throw new Error(`Invalid Hevy ${label} timestamp "${value}"`);
46
+ }
47
+ return ms;
48
+ }
49
+ //# sourceMappingURL=normalize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.js","sourceRoot":"","sources":["../../../src/providers/hevy/normalize.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAGhF,MAAM,UAAU,0BAA0B,CACxC,MAAyB;IAEzB,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IAC/B,MAAM,SAAS,GAAG,QAAQ,OAAO,CAAC,EAAE,EAAE,CAAC;IAEvC,OAAO;QACL,IAAI,EAAE,UAAU;QAChB,OAAO,EAAE;YACP,EAAE,EAAE,SAAS;YACb,QAAQ,EAAE,MAAM;YAChB,UAAU,EAAE,OAAO,CAAC,EAAE;YACtB,IAAI,EAAE,UAAU;YAChB,KAAK,EAAE,mBAAmB;YAC1B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,WAAW,EAAE,kBAAkB,CAAC,OAAO,CAAC,UAAU,EAAE,YAAY,CAAC;YACjE,SAAS,EACP,OAAO,CAAC,QAAQ,KAAK,IAAI;gBACvB,CAAC,CAAC,IAAI;gBACN,CAAC,CAAC,kBAAkB,CAAC,OAAO,CAAC,QAAQ,EAAE,UAAU,CAAC;YACtD,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC;YACjC,kBAAkB,EAAE,IAAI;SACzB;QACD,eAAe,EAAE;YACf,SAAS;YACT,aAAa,EAAE,aAAa,CAC1B,WAAW,CACT,uBAAuB,EACvB,sBAAsB,CAAC,MAAM,CAAC,EAC9B,uBAAuB,CACxB,CACF;SACF;KACF,CAAC;AACJ,CAAC;AAED,SAAS,sBAAsB,CAAC,MAAyB;IACvD,OAAO,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC;SACjC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;SAC/C,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAClB,KAAK,EAAE,QAAQ,CAAC,KAAK;QACrB,IAAI,EAAE,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC;aACrB,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;aAC/C,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YACb,QAAQ,EAAE,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC;YACrC,IAAI,EAAE,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC;YAC5B,eAAe,EAAE,YAAY,CAAC,GAAG,CAAC,gBAAgB,CAAC;SACpD,CAAC,CAAC;KACN,CAAC,CAAC,CAAC;AACR,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAa,EAAE,KAAa;IACtD,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC7B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,gBAAgB,KAAK,eAAe,KAAK,GAAG,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC","sourcesContent":["import type {\n StrengthExercise,\n WorkoutWithMetrics,\n} from \"../../domain/workout.js\";\nimport { StrengthExercisesSchema } from \"../../domain/workout.js\";\nimport { metricNumber, parseSchema, stringifyJson } from \"../../utils/parse.js\";\nimport type { HevyWorkoutSource } from \"./types.js\";\n\nexport function normalizeHevyWorkoutSource(\n source: HevyWorkoutSource,\n): WorkoutWithMetrics {\n const workout = source.workout;\n const workoutId = `hevy:${workout.id}`;\n\n return {\n type: \"strength\",\n workout: {\n id: workoutId,\n provider: \"hevy\",\n providerId: workout.id,\n type: \"strength\",\n sport: \"strength_training\",\n title: workout.title,\n startedAtMs: parseHevyTimestamp(workout.start_time, \"start_time\"),\n endedAtMs:\n workout.end_time === null\n ? null\n : parseHevyTimestamp(workout.end_time, \"end_time\"),\n sourceJson: stringifyJson(source),\n providerExtrasJson: null,\n },\n strengthMetrics: {\n workoutId,\n exercisesJson: stringifyJson(\n parseSchema(\n StrengthExercisesSchema,\n normalizeHevyExercises(source),\n \"Hevy strength metrics\",\n ),\n ),\n },\n };\n}\n\nfunction normalizeHevyExercises(source: HevyWorkoutSource): StrengthExercise[] {\n return [...source.workout.exercises]\n .sort((left, right) => left.index - right.index)\n .map((exercise) => ({\n title: exercise.title,\n sets: [...exercise.sets]\n .sort((left, right) => left.index - right.index)\n .map((set) => ({\n weightKg: metricNumber(set.weight_kg),\n reps: metricNumber(set.reps),\n durationSeconds: metricNumber(set.duration_seconds),\n })),\n }));\n}\n\nfunction parseHevyTimestamp(value: string, label: string): number {\n const ms = Date.parse(value);\n if (!Number.isFinite(ms)) {\n throw new Error(`Invalid Hevy ${label} timestamp \"${value}\"`);\n }\n return ms;\n}\n"]}
@@ -0,0 +1,6 @@
1
+ import { parseSchema } from "../../utils/parse.js";
2
+ import { HevyApiWorkoutSourceSchema } from "./types.js";
3
+ export function buildHevyWorkoutSource(workout) {
4
+ return parseSchema(HevyApiWorkoutSourceSchema, { workout }, "Hevy workout source");
5
+ }
6
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.js","sourceRoot":"","sources":["../../../src/providers/hevy/source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEnD,OAAO,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAExD,MAAM,UAAU,sBAAsB,CACpC,OAAuB;IAEvB,OAAO,WAAW,CAChB,0BAA0B,EAC1B,EAAE,OAAO,EAAE,EACX,qBAAqB,CACtB,CAAC;AACJ,CAAC","sourcesContent":["import { parseSchema } from \"../../utils/parse.js\";\nimport type { HevyApiWorkout, HevyWorkoutSource } from \"./types.js\";\nimport { HevyApiWorkoutSourceSchema } from \"./types.js\";\n\nexport function buildHevyWorkoutSource(\n workout: HevyApiWorkout,\n): HevyWorkoutSource {\n return parseSchema(\n HevyApiWorkoutSourceSchema,\n { workout },\n \"Hevy workout source\",\n );\n}\n"]}
@@ -0,0 +1,88 @@
1
+ import { updateProviderCursor, } from "../../db/provider-state.js";
2
+ import { upsertNormalizedWorkout } from "../../db/workouts.js";
3
+ import { parseJson, parseSchema, stringifyJson } from "../../utils/parse.js";
4
+ import { HevyClient } from "./client.js";
5
+ import { normalizeHevyWorkoutSource } from "./normalize.js";
6
+ import { buildHevyWorkoutSource } from "./source.js";
7
+ import { HevyCredentialsSchema, HevyCursorSchema } from "./types.js";
8
+ export async function syncHevy(db, state) {
9
+ const credentials = parseHevyCredentials(state.credentialsJson);
10
+ const cursor = parseHevyCursor(state.cursorJson);
11
+ const client = HevyClient.fromCredentials(credentials);
12
+ const events = sortEventsByTimestampAsc(await getEventsToSync(client, cursor.since));
13
+ let newWorkoutCount = 0;
14
+ let nextSince = cursor.since;
15
+ const transaction = db.transaction(() => {
16
+ for (const event of events) {
17
+ nextSince = maxIsoTimestamp(nextSince, eventTimestamp(event));
18
+ if (event.type === "deleted") {
19
+ continue;
20
+ }
21
+ const source = buildHevyWorkoutSource(event.workout);
22
+ const rows = normalizeHevyWorkoutSource(source);
23
+ if (upsertNormalizedWorkout(db, rows)) {
24
+ newWorkoutCount += 1;
25
+ }
26
+ }
27
+ updateProviderCursor(db, "hevy", stringifyJson({
28
+ version: 1,
29
+ since: nextSince,
30
+ }), Date.now());
31
+ });
32
+ transaction();
33
+ return { newWorkoutCount };
34
+ }
35
+ async function getEventsToSync(client, since) {
36
+ const events = [];
37
+ let page = 1;
38
+ while (true) {
39
+ const response = await client.getWorkoutEvents(since, page);
40
+ events.push(...response.events);
41
+ if (response.page >= response.page_count) {
42
+ return events;
43
+ }
44
+ page += 1;
45
+ }
46
+ }
47
+ function sortEventsByTimestampAsc(events) {
48
+ // Hevy returns events newest-first; writing oldest-first keeps the newest
49
+ // duplicate workout event as the final local version.
50
+ return [...events].sort((left, right) => eventSortTimestamp(left) - eventSortTimestamp(right));
51
+ }
52
+ function eventSortTimestamp(event) {
53
+ const timestamp = eventTimestamp(event);
54
+ if (timestamp === null) {
55
+ return Number.NEGATIVE_INFINITY;
56
+ }
57
+ return parseIsoTimestamp(timestamp);
58
+ }
59
+ function eventTimestamp(event) {
60
+ if (event.type === "updated") {
61
+ return event.workout.updated_at;
62
+ }
63
+ return event.deleted_at ?? null;
64
+ }
65
+ function maxIsoTimestamp(left, right) {
66
+ if (right === null) {
67
+ return left;
68
+ }
69
+ const leftMs = parseIsoTimestamp(left);
70
+ const rightMs = parseIsoTimestamp(right);
71
+ return rightMs > leftMs ? right : left;
72
+ }
73
+ function parseIsoTimestamp(value) {
74
+ const ms = Date.parse(value);
75
+ if (!Number.isFinite(ms)) {
76
+ throw new Error(`Invalid Hevy cursor timestamp "${value}"`);
77
+ }
78
+ return ms;
79
+ }
80
+ function parseHevyCredentials(json) {
81
+ return parseSchema(HevyCredentialsSchema, parseJson(json, "hevy credentials_json"), "Hevy credentials");
82
+ }
83
+ function parseHevyCursor(json) {
84
+ const cursor = parseSchema(HevyCursorSchema, parseJson(json, "hevy cursor_json"), "Hevy cursor");
85
+ parseIsoTimestamp(cursor.since);
86
+ return cursor;
87
+ }
88
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/providers/hevy/sync.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HealthlogDatabase } from \"../../db/database.js\";\nimport {\n type ProviderState,\n updateProviderCursor,\n} from \"../../db/provider-state.js\";\nimport { upsertNormalizedWorkout } from \"../../db/workouts.js\";\nimport type { ProviderSyncResult } from \"../../domain/provider.js\";\nimport { parseJson, parseSchema, stringifyJson } from \"../../utils/parse.js\";\nimport { HevyClient } from \"./client.js\";\nimport { normalizeHevyWorkoutSource } from \"./normalize.js\";\nimport { buildHevyWorkoutSource } from \"./source.js\";\nimport type {\n HevyApiWorkoutEvent,\n HevyCredentials,\n HevyCursor,\n} from \"./types.js\";\nimport { HevyCredentialsSchema, HevyCursorSchema } from \"./types.js\";\n\nexport async function syncHevy(\n db: HealthlogDatabase,\n state: ProviderState,\n): Promise<ProviderSyncResult> {\n const credentials = parseHevyCredentials(state.credentialsJson);\n const cursor = parseHevyCursor(state.cursorJson);\n const client = HevyClient.fromCredentials(credentials);\n const events = sortEventsByTimestampAsc(\n await getEventsToSync(client, cursor.since),\n );\n\n let newWorkoutCount = 0;\n let nextSince = cursor.since;\n\n const transaction = db.transaction(() => {\n for (const event of events) {\n nextSince = maxIsoTimestamp(nextSince, eventTimestamp(event));\n\n if (event.type === \"deleted\") {\n continue;\n }\n\n const source = buildHevyWorkoutSource(event.workout);\n const rows = normalizeHevyWorkoutSource(source);\n if (upsertNormalizedWorkout(db, rows)) {\n newWorkoutCount += 1;\n }\n }\n\n updateProviderCursor(\n db,\n \"hevy\",\n stringifyJson({\n version: 1,\n since: nextSince,\n } satisfies HevyCursor),\n Date.now(),\n );\n });\n\n transaction();\n return { newWorkoutCount };\n}\n\nasync function getEventsToSync(\n client: HevyClient,\n since: string,\n): Promise<HevyApiWorkoutEvent[]> {\n const events: HevyApiWorkoutEvent[] = [];\n let page = 1;\n\n while (true) {\n const response = await client.getWorkoutEvents(since, page);\n events.push(...response.events);\n\n if (response.page >= response.page_count) {\n return events;\n }\n\n page += 1;\n }\n}\n\nfunction sortEventsByTimestampAsc(\n events: HevyApiWorkoutEvent[],\n): HevyApiWorkoutEvent[] {\n // Hevy returns events newest-first; writing oldest-first keeps the newest\n // duplicate workout event as the final local version.\n return [...events].sort(\n (left, right) => eventSortTimestamp(left) - eventSortTimestamp(right),\n );\n}\n\nfunction eventSortTimestamp(event: HevyApiWorkoutEvent): number {\n const timestamp = eventTimestamp(event);\n if (timestamp === null) {\n return Number.NEGATIVE_INFINITY;\n }\n\n return parseIsoTimestamp(timestamp);\n}\n\nfunction eventTimestamp(event: HevyApiWorkoutEvent): string | null {\n if (event.type === \"updated\") {\n return event.workout.updated_at;\n }\n\n return event.deleted_at ?? null;\n}\n\nfunction maxIsoTimestamp(left: string, right: string | null): string {\n if (right === null) {\n return left;\n }\n\n const leftMs = parseIsoTimestamp(left);\n const rightMs = parseIsoTimestamp(right);\n return rightMs > leftMs ? right : left;\n}\n\nfunction parseIsoTimestamp(value: string): number {\n const ms = Date.parse(value);\n if (!Number.isFinite(ms)) {\n throw new Error(`Invalid Hevy cursor timestamp \"${value}\"`);\n }\n return ms;\n}\n\nfunction parseHevyCredentials(json: string): HevyCredentials {\n return parseSchema(\n HevyCredentialsSchema,\n parseJson(json, \"hevy credentials_json\"),\n \"Hevy credentials\",\n );\n}\n\nfunction parseHevyCursor(json: string): HevyCursor {\n const cursor = parseSchema(\n HevyCursorSchema,\n parseJson(json, \"hevy cursor_json\"),\n \"Hevy cursor\",\n );\n parseIsoTimestamp(cursor.since);\n return cursor;\n}\n"]}
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+ const finiteNumber = z.number();
3
+ const nullableFiniteNumber = finiteNumber.nullable();
4
+ export const HevyCredentialsSchema = z
5
+ .object({
6
+ apiKey: z.string().min(1),
7
+ })
8
+ .strict();
9
+ export const HevyCursorSchema = z
10
+ .object({
11
+ version: z.literal(1),
12
+ since: z.string().min(1),
13
+ })
14
+ .strict();
15
+ export const initialHevyCursor = {
16
+ version: 1,
17
+ since: "1970-01-01T00:00:00Z",
18
+ };
19
+ export const HevyApiUserInfoResponseSchema = z.looseObject({
20
+ data: z.looseObject({}),
21
+ });
22
+ export const HevyApiSetSchema = z.looseObject({
23
+ index: finiteNumber,
24
+ type: z.string().min(1),
25
+ weight_kg: nullableFiniteNumber,
26
+ reps: nullableFiniteNumber,
27
+ duration_seconds: nullableFiniteNumber,
28
+ rpe: nullableFiniteNumber,
29
+ custom_metric: nullableFiniteNumber,
30
+ });
31
+ export const HevyApiExerciseSchema = z.looseObject({
32
+ index: finiteNumber,
33
+ title: z.string().min(1),
34
+ notes: z.string().nullable(),
35
+ exercise_template_id: z.string().nullish(),
36
+ supersets_id: nullableFiniteNumber.optional(),
37
+ sets: z.array(HevyApiSetSchema),
38
+ });
39
+ export const HevyApiWorkoutSchema = z.looseObject({
40
+ id: z.string().min(1),
41
+ title: z.string().min(1),
42
+ routine_id: z.string().nullish(),
43
+ description: z.string().nullable(),
44
+ start_time: z.string().min(1),
45
+ end_time: z.string().nullable(),
46
+ updated_at: z.string().min(1),
47
+ created_at: z.string().min(1),
48
+ exercises: z.array(HevyApiExerciseSchema),
49
+ });
50
+ export const HevyApiWorkoutSourceSchema = z
51
+ .object({
52
+ workout: HevyApiWorkoutSchema,
53
+ })
54
+ .strict();
55
+ export const HevyApiUpdatedWorkoutEventSchema = z.looseObject({
56
+ type: z.literal("updated"),
57
+ workout: HevyApiWorkoutSchema,
58
+ });
59
+ export const HevyApiDeletedWorkoutEventSchema = z.looseObject({
60
+ type: z.literal("deleted"),
61
+ id: z.string().min(1),
62
+ deleted_at: z.string().nullish(),
63
+ });
64
+ export const HevyApiWorkoutEventSchema = z.discriminatedUnion("type", [
65
+ HevyApiUpdatedWorkoutEventSchema,
66
+ HevyApiDeletedWorkoutEventSchema,
67
+ ]);
68
+ export const HevyApiPaginatedWorkoutEventsSchema = z.looseObject({
69
+ page: finiteNumber,
70
+ page_count: finiteNumber,
71
+ events: z.array(HevyApiWorkoutEventSchema),
72
+ });
73
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/providers/hevy/types.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\";\n\nconst finiteNumber = z.number();\nconst nullableFiniteNumber = finiteNumber.nullable();\n\nexport const HevyCredentialsSchema = z\n .object({\n apiKey: z.string().min(1),\n })\n .strict();\n\nexport type HevyCredentials = z.infer<typeof HevyCredentialsSchema>;\n\nexport const HevyCursorSchema = z\n .object({\n version: z.literal(1),\n since: z.string().min(1),\n })\n .strict();\n\nexport type HevyCursor = z.infer<typeof HevyCursorSchema>;\n\nexport const initialHevyCursor: HevyCursor = {\n version: 1,\n since: \"1970-01-01T00:00:00Z\",\n};\n\nexport const HevyApiUserInfoResponseSchema = z.looseObject({\n data: z.looseObject({}),\n});\n\nexport type HevyApiUserInfoResponse = z.infer<\n typeof HevyApiUserInfoResponseSchema\n>;\n\nexport const HevyApiSetSchema = z.looseObject({\n index: finiteNumber,\n type: z.string().min(1),\n weight_kg: nullableFiniteNumber,\n reps: nullableFiniteNumber,\n duration_seconds: nullableFiniteNumber,\n rpe: nullableFiniteNumber,\n custom_metric: nullableFiniteNumber,\n});\n\nexport type HevyApiSet = z.infer<typeof HevyApiSetSchema>;\n\nexport const HevyApiExerciseSchema = z.looseObject({\n index: finiteNumber,\n title: z.string().min(1),\n notes: z.string().nullable(),\n exercise_template_id: z.string().nullish(),\n supersets_id: nullableFiniteNumber.optional(),\n sets: z.array(HevyApiSetSchema),\n});\n\nexport type HevyApiExercise = z.infer<typeof HevyApiExerciseSchema>;\n\nexport const HevyApiWorkoutSchema = z.looseObject({\n id: z.string().min(1),\n title: z.string().min(1),\n routine_id: z.string().nullish(),\n description: z.string().nullable(),\n start_time: z.string().min(1),\n end_time: z.string().nullable(),\n updated_at: z.string().min(1),\n created_at: z.string().min(1),\n exercises: z.array(HevyApiExerciseSchema),\n});\n\nexport type HevyApiWorkout = z.infer<typeof HevyApiWorkoutSchema>;\n\nexport const HevyApiWorkoutSourceSchema = z\n .object({\n workout: HevyApiWorkoutSchema,\n })\n .strict();\n\nexport type HevyWorkoutSource = z.infer<typeof HevyApiWorkoutSourceSchema>;\n\nexport const HevyApiUpdatedWorkoutEventSchema = z.looseObject({\n type: z.literal(\"updated\"),\n workout: HevyApiWorkoutSchema,\n});\n\nexport const HevyApiDeletedWorkoutEventSchema = z.looseObject({\n type: z.literal(\"deleted\"),\n id: z.string().min(1),\n deleted_at: z.string().nullish(),\n});\n\nexport const HevyApiWorkoutEventSchema = z.discriminatedUnion(\"type\", [\n HevyApiUpdatedWorkoutEventSchema,\n HevyApiDeletedWorkoutEventSchema,\n]);\n\nexport type HevyApiWorkoutEvent = z.infer<typeof HevyApiWorkoutEventSchema>;\n\nexport const HevyApiPaginatedWorkoutEventsSchema = z.looseObject({\n page: finiteNumber,\n page_count: finiteNumber,\n events: z.array(HevyApiWorkoutEventSchema),\n});\n\nexport type HevyApiPaginatedWorkoutEvents = z.infer<\n typeof HevyApiPaginatedWorkoutEventsSchema\n>;\n"]}
@@ -0,0 +1,71 @@
1
+ import { getWorkoutsWithMetrics } from "../db/workouts.js";
2
+ import { ActivityMetricsSchema, StrengthExercisesSchema, } from "../domain/workout.js";
3
+ import { parseJson, parseSchema } from "../utils/parse.js";
4
+ export function buildDumpDocument(db, range) {
5
+ const workoutsWithMetrics = getWorkoutsWithMetrics(db, range);
6
+ return {
7
+ generatedAt: new Date().toISOString(),
8
+ range: {
9
+ from: range.from,
10
+ to: range.to,
11
+ },
12
+ workouts: workoutsWithMetrics.map((workoutWithMetrics) => {
13
+ const workout = workoutWithMetrics.workout;
14
+ if (workoutWithMetrics.type === "endurance") {
15
+ const metrics = workoutWithMetrics.enduranceMetrics;
16
+ return {
17
+ id: workout.id,
18
+ provider: workout.provider,
19
+ providerId: workout.providerId,
20
+ type: "endurance",
21
+ sport: workout.sport,
22
+ title: workout.title,
23
+ startedAt: new Date(workout.startedAtMs).toISOString(),
24
+ endedAt: workout.endedAtMs === null
25
+ ? null
26
+ : new Date(workout.endedAtMs).toISOString(),
27
+ providerExtras: parseProviderExtrasJson(workout.providerExtrasJson, workout.id),
28
+ durationSeconds: metrics.durationSeconds,
29
+ distanceMeters: metrics.distanceMeters,
30
+ elevationGainMeters: metrics.elevationGainMeters,
31
+ elevationLossMeters: metrics.elevationLossMeters,
32
+ startLocation: metrics.startLocation,
33
+ calories: metrics.calories,
34
+ averageHeartRate: metrics.averageHeartRate,
35
+ maxHeartRate: metrics.maxHeartRate,
36
+ averageRunningCadenceStepsPerMinute: metrics.averageRunningCadenceStepsPerMinute,
37
+ averageStrideLengthCentimeters: metrics.averageStrideLengthCentimeters,
38
+ averagePaceMinutesPerKilometer: metrics.averagePaceMinutesPerKilometer,
39
+ fastestPaceMinutesPerKilometer: metrics.fastestPaceMinutesPerKilometer,
40
+ activityMetrics: parseActivityMetrics(metrics.activityMetricsJson, workout.id),
41
+ };
42
+ }
43
+ return {
44
+ id: workout.id,
45
+ provider: workout.provider,
46
+ providerId: workout.providerId,
47
+ type: "strength",
48
+ sport: workout.sport,
49
+ title: workout.title,
50
+ startedAt: new Date(workout.startedAtMs).toISOString(),
51
+ endedAt: workout.endedAtMs === null
52
+ ? null
53
+ : new Date(workout.endedAtMs).toISOString(),
54
+ providerExtras: parseProviderExtrasJson(workout.providerExtrasJson, workout.id),
55
+ exercises: parseStrengthExercises(workoutWithMetrics.strengthMetrics.exercisesJson, workout.id),
56
+ };
57
+ }),
58
+ };
59
+ }
60
+ function parseProviderExtrasJson(providerExtrasJson, workoutId) {
61
+ return providerExtrasJson === null
62
+ ? null
63
+ : parseJson(providerExtrasJson, `provider_extras_json for ${workoutId}`);
64
+ }
65
+ function parseStrengthExercises(exercisesJson, workoutId) {
66
+ return parseSchema(StrengthExercisesSchema, parseJson(exercisesJson, `strength metrics for ${workoutId}`), `strength metrics for workout ${workoutId}`);
67
+ }
68
+ function parseActivityMetrics(activityMetricsJson, workoutId) {
69
+ return parseSchema(ActivityMetricsSchema, parseJson(activityMetricsJson, `activity_metrics_json for ${workoutId}`), `activity metrics for workout ${workoutId}`);
70
+ }
71
+ //# sourceMappingURL=dump-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dump-service.js","sourceRoot":"","sources":["../../src/services/dump-service.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HealthlogDatabase } from \"../db/database.js\";\nimport { getWorkoutsWithMetrics } from \"../db/workouts.js\";\nimport type { DumpDocument, DumpWorkout } from \"../domain/dump.js\";\nimport type { ActivityMetric, StrengthExercise } from \"../domain/workout.js\";\nimport {\n ActivityMetricsSchema,\n StrengthExercisesSchema,\n} from \"../domain/workout.js\";\nimport type { DateRange } from \"../utils/dates.js\";\nimport { parseJson, parseSchema } from \"../utils/parse.js\";\n\nexport function buildDumpDocument(\n db: HealthlogDatabase,\n range: DateRange,\n): DumpDocument {\n const workoutsWithMetrics = getWorkoutsWithMetrics(db, range);\n\n return {\n generatedAt: new Date().toISOString(),\n range: {\n from: range.from,\n to: range.to,\n },\n workouts: workoutsWithMetrics.map((workoutWithMetrics): DumpWorkout => {\n const workout = workoutWithMetrics.workout;\n if (workoutWithMetrics.type === \"endurance\") {\n const metrics = workoutWithMetrics.enduranceMetrics;\n return {\n id: workout.id,\n provider: workout.provider,\n providerId: workout.providerId,\n type: \"endurance\",\n sport: workout.sport,\n title: workout.title,\n startedAt: new Date(workout.startedAtMs).toISOString(),\n endedAt:\n workout.endedAtMs === null\n ? null\n : new Date(workout.endedAtMs).toISOString(),\n providerExtras: parseProviderExtrasJson(\n workout.providerExtrasJson,\n workout.id,\n ),\n durationSeconds: metrics.durationSeconds,\n distanceMeters: metrics.distanceMeters,\n elevationGainMeters: metrics.elevationGainMeters,\n elevationLossMeters: metrics.elevationLossMeters,\n startLocation: metrics.startLocation,\n calories: metrics.calories,\n averageHeartRate: metrics.averageHeartRate,\n maxHeartRate: metrics.maxHeartRate,\n averageRunningCadenceStepsPerMinute:\n metrics.averageRunningCadenceStepsPerMinute,\n averageStrideLengthCentimeters:\n metrics.averageStrideLengthCentimeters,\n averagePaceMinutesPerKilometer:\n metrics.averagePaceMinutesPerKilometer,\n fastestPaceMinutesPerKilometer:\n metrics.fastestPaceMinutesPerKilometer,\n activityMetrics: parseActivityMetrics(\n metrics.activityMetricsJson,\n workout.id,\n ),\n };\n }\n\n return {\n id: workout.id,\n provider: workout.provider,\n providerId: workout.providerId,\n type: \"strength\",\n sport: workout.sport,\n title: workout.title,\n startedAt: new Date(workout.startedAtMs).toISOString(),\n endedAt:\n workout.endedAtMs === null\n ? null\n : new Date(workout.endedAtMs).toISOString(),\n providerExtras: parseProviderExtrasJson(\n workout.providerExtrasJson,\n workout.id,\n ),\n exercises: parseStrengthExercises(\n workoutWithMetrics.strengthMetrics.exercisesJson,\n workout.id,\n ),\n };\n }),\n };\n}\n\nfunction parseProviderExtrasJson(\n providerExtrasJson: string | null,\n workoutId: string,\n): unknown | null {\n return providerExtrasJson === null\n ? null\n : parseJson(providerExtrasJson, `provider_extras_json for ${workoutId}`);\n}\n\nfunction parseStrengthExercises(\n exercisesJson: string,\n workoutId: string,\n): StrengthExercise[] {\n return parseSchema(\n StrengthExercisesSchema,\n parseJson(exercisesJson, `strength metrics for ${workoutId}`),\n `strength metrics for workout ${workoutId}`,\n );\n}\n\nfunction parseActivityMetrics(\n activityMetricsJson: string,\n workoutId: string,\n): ActivityMetric[] {\n return parseSchema(\n ActivityMetricsSchema,\n parseJson(activityMetricsJson, `activity_metrics_json for ${workoutId}`),\n `activity metrics for workout ${workoutId}`,\n );\n}\n"]}
@@ -0,0 +1,11 @@
1
+ import { upsertProviderState } from "../db/provider-state.js";
2
+ import { initialGarminCursor } from "../providers/garmin/types.js";
3
+ import { initialHevyCursor } from "../providers/hevy/types.js";
4
+ import { stringifyJson } from "../utils/parse.js";
5
+ export function storeGarminCredentials(db, tokens) {
6
+ upsertProviderState(db, "garmin", stringifyJson(tokens), stringifyJson(initialGarminCursor), null);
7
+ }
8
+ export function storeHevyCredentials(db, apiKey) {
9
+ upsertProviderState(db, "hevy", stringifyJson({ apiKey }), stringifyJson(initialHevyCursor), null);
10
+ }
11
+ //# sourceMappingURL=setup-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup-service.js","sourceRoot":"","sources":["../../src/services/setup-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAE9D,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAElD,MAAM,UAAU,sBAAsB,CACpC,EAAqB,EACrB,MAAoB;IAEpB,mBAAmB,CACjB,EAAE,EACF,QAAQ,EACR,aAAa,CAAC,MAAM,CAAC,EACrB,aAAa,CAAC,mBAAmB,CAAC,EAClC,IAAI,CACL,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,EAAqB,EACrB,MAAc;IAEd,mBAAmB,CACjB,EAAE,EACF,MAAM,EACN,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC,EACzB,aAAa,CAAC,iBAAiB,CAAC,EAChC,IAAI,CACL,CAAC;AACJ,CAAC","sourcesContent":["import type { HealthlogDatabase } from \"../db/database.js\";\nimport { upsertProviderState } from \"../db/provider-state.js\";\nimport type { GarminTokens } from \"../providers/garmin/types.js\";\nimport { initialGarminCursor } from \"../providers/garmin/types.js\";\nimport { initialHevyCursor } from \"../providers/hevy/types.js\";\nimport { stringifyJson } from \"../utils/parse.js\";\n\nexport function storeGarminCredentials(\n db: HealthlogDatabase,\n tokens: GarminTokens,\n): void {\n upsertProviderState(\n db,\n \"garmin\",\n stringifyJson(tokens),\n stringifyJson(initialGarminCursor),\n null,\n );\n}\n\nexport function storeHevyCredentials(\n db: HealthlogDatabase,\n apiKey: string,\n): void {\n upsertProviderState(\n db,\n \"hevy\",\n stringifyJson({ apiKey }),\n stringifyJson(initialHevyCursor),\n null,\n );\n}\n"]}
@@ -0,0 +1,32 @@
1
+ import { getProviderState } from "../db/provider-state.js";
2
+ import { syncGarmin } from "../providers/garmin/sync.js";
3
+ import { syncHevy } from "../providers/hevy/sync.js";
4
+ import { logger } from "../utils/logger.js";
5
+ export async function syncConfiguredProviders(db) {
6
+ let totalNewWorkoutCount = 0;
7
+ let configuredProviderCount = 0;
8
+ const garminState = getProviderState(db, "garmin");
9
+ if (garminState && garminState.credentialsJson.trim() !== "") {
10
+ configuredProviderCount += 1;
11
+ logger.info("Syncing Garmin...");
12
+ const result = await syncGarmin(db, garminState);
13
+ totalNewWorkoutCount += result.newWorkoutCount;
14
+ logger.success(`Garmin sync done: ${formatWorkoutCount(result.newWorkoutCount)}.`);
15
+ }
16
+ const hevyState = getProviderState(db, "hevy");
17
+ if (hevyState && hevyState.credentialsJson.trim() !== "") {
18
+ configuredProviderCount += 1;
19
+ logger.info("Syncing Hevy...");
20
+ const result = await syncHevy(db, hevyState);
21
+ totalNewWorkoutCount += result.newWorkoutCount;
22
+ logger.success(`Hevy sync done: ${formatWorkoutCount(result.newWorkoutCount)}.`);
23
+ }
24
+ if (configuredProviderCount === 0) {
25
+ logger.info("No providers configured; skipping sync.");
26
+ }
27
+ return { newWorkoutCount: totalNewWorkoutCount };
28
+ }
29
+ function formatWorkoutCount(count) {
30
+ return `${count} new ${count === 1 ? "workout" : "workouts"}`;
31
+ }
32
+ //# sourceMappingURL=sync-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-service.js","sourceRoot":"","sources":["../../src/services/sync-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,EAAqB;IAErB,IAAI,oBAAoB,GAAG,CAAC,CAAC;IAC7B,IAAI,uBAAuB,GAAG,CAAC,CAAC;IAEhC,MAAM,WAAW,GAAG,gBAAgB,CAAC,EAAE,EAAE,QAAQ,CAAC,CAAC;IACnD,IAAI,WAAW,IAAI,WAAW,CAAC,eAAe,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC7D,uBAAuB,IAAI,CAAC,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;QACjC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;QACjD,oBAAoB,IAAI,MAAM,CAAC,eAAe,CAAC;QAC/C,MAAM,CAAC,OAAO,CACZ,qBAAqB,kBAAkB,CAAC,MAAM,CAAC,eAAe,CAAC,GAAG,CACnE,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,gBAAgB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;IAC/C,IAAI,SAAS,IAAI,SAAS,CAAC,eAAe,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACzD,uBAAuB,IAAI,CAAC,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QAC/B,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;QAC7C,oBAAoB,IAAI,MAAM,CAAC,eAAe,CAAC;QAC/C,MAAM,CAAC,OAAO,CACZ,mBAAmB,kBAAkB,CAAC,MAAM,CAAC,eAAe,CAAC,GAAG,CACjE,CAAC;IACJ,CAAC;IAED,IAAI,uBAAuB,KAAK,CAAC,EAAE,CAAC;QAClC,MAAM,CAAC,IAAI,CAAC,yCAAyC,CAAC,CAAC;IACzD,CAAC;IAED,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,CAAC;AACnD,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAa;IACvC,OAAO,GAAG,KAAK,QAAQ,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE,CAAC;AAChE,CAAC","sourcesContent":["import type { HealthlogDatabase } from \"../db/database.js\";\nimport { getProviderState } from \"../db/provider-state.js\";\nimport type { ProviderSyncResult } from \"../domain/provider.js\";\nimport { syncGarmin } from \"../providers/garmin/sync.js\";\nimport { syncHevy } from \"../providers/hevy/sync.js\";\nimport { logger } from \"../utils/logger.js\";\n\nexport async function syncConfiguredProviders(\n db: HealthlogDatabase,\n): Promise<ProviderSyncResult> {\n let totalNewWorkoutCount = 0;\n let configuredProviderCount = 0;\n\n const garminState = getProviderState(db, \"garmin\");\n if (garminState && garminState.credentialsJson.trim() !== \"\") {\n configuredProviderCount += 1;\n logger.info(\"Syncing Garmin...\");\n const result = await syncGarmin(db, garminState);\n totalNewWorkoutCount += result.newWorkoutCount;\n logger.success(\n `Garmin sync done: ${formatWorkoutCount(result.newWorkoutCount)}.`,\n );\n }\n\n const hevyState = getProviderState(db, \"hevy\");\n if (hevyState && hevyState.credentialsJson.trim() !== \"\") {\n configuredProviderCount += 1;\n logger.info(\"Syncing Hevy...\");\n const result = await syncHevy(db, hevyState);\n totalNewWorkoutCount += result.newWorkoutCount;\n logger.success(\n `Hevy sync done: ${formatWorkoutCount(result.newWorkoutCount)}.`,\n );\n }\n\n if (configuredProviderCount === 0) {\n logger.info(\"No providers configured; skipping sync.\");\n }\n\n return { newWorkoutCount: totalNewWorkoutCount };\n}\n\nfunction formatWorkoutCount(count: number): string {\n return `${count} new ${count === 1 ? \"workout\" : \"workouts\"}`;\n}\n"]}
@@ -0,0 +1,35 @@
1
+ const datePattern = /^\d{4}-\d{2}-\d{2}$/;
2
+ const oneDayMs = 24 * 60 * 60 * 1000;
3
+ export function parseDateRange(input) {
4
+ const from = input.from ? parseCalendarDate(input.from, "from") : null;
5
+ const to = input.to ? parseCalendarDate(input.to, "to") : null;
6
+ if (from && to && from.utcStartMs > to.utcStartMs) {
7
+ throw new Error("--from must be before or equal to --to");
8
+ }
9
+ // Expand date filters around UTC midnight so local-time workouts near the
10
+ // boundary are included instead of accidentally excluded.
11
+ return {
12
+ from: from?.value ?? null,
13
+ to: to?.value ?? null,
14
+ startedAtFromMs: from ? from.utcStartMs - oneDayMs : null,
15
+ startedAtBeforeMs: to ? to.utcStartMs + 2 * oneDayMs : null,
16
+ };
17
+ }
18
+ function parseCalendarDate(value, label) {
19
+ if (!datePattern.test(value)) {
20
+ throw new Error(`Invalid --${label} date "${value}". Expected YYYY-MM-DD.`);
21
+ }
22
+ const [yearText, monthText, dayText] = value.split("-");
23
+ const year = Number(yearText);
24
+ const month = Number(monthText);
25
+ const day = Number(dayText);
26
+ const utcStartMs = Date.UTC(year, month - 1, day);
27
+ const parsed = new Date(utcStartMs);
28
+ if (parsed.getUTCFullYear() !== year ||
29
+ parsed.getUTCMonth() !== month - 1 ||
30
+ parsed.getUTCDate() !== day) {
31
+ throw new Error(`Invalid --${label} date "${value}". Expected a real calendar date.`);
32
+ }
33
+ return { value, utcStartMs };
34
+ }
35
+ //# sourceMappingURL=dates.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dates.js","sourceRoot":"","sources":["../../src/utils/dates.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,GAAG,qBAAqB,CAAC;AAC1C,MAAM,QAAQ,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAcrC,MAAM,UAAU,cAAc,CAAC,KAAqB;IAClD,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,iBAAiB,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACvE,MAAM,EAAE,GAAG,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAE/D,IAAI,IAAI,IAAI,EAAE,IAAI,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC,UAAU,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;IAC5D,CAAC;IAED,0EAA0E;IAC1E,0DAA0D;IAC1D,OAAO;QACL,IAAI,EAAE,IAAI,EAAE,KAAK,IAAI,IAAI;QACzB,EAAE,EAAE,EAAE,EAAE,KAAK,IAAI,IAAI;QACrB,eAAe,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI;QACzD,iBAAiB,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI;KAC5D,CAAC;AACJ,CAAC;AAED,SAAS,iBAAiB,CACxB,KAAa,EACb,KAAa;IAEb,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,aAAa,KAAK,UAAU,KAAK,yBAAyB,CAAC,CAAC;IAC9E,CAAC;IAED,MAAM,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxD,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC9B,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAChC,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IAC5B,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC;IAEpC,IACE,MAAM,CAAC,cAAc,EAAE,KAAK,IAAI;QAChC,MAAM,CAAC,WAAW,EAAE,KAAK,KAAK,GAAG,CAAC;QAClC,MAAM,CAAC,UAAU,EAAE,KAAK,GAAG,EAC3B,CAAC;QACD,MAAM,IAAI,KAAK,CACb,aAAa,KAAK,UAAU,KAAK,mCAAmC,CACrE,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC;AAC/B,CAAC","sourcesContent":["const datePattern = /^\\d{4}-\\d{2}-\\d{2}$/;\nconst oneDayMs = 24 * 60 * 60 * 1000;\n\nexport type DateRangeInput = {\n from?: string;\n to?: string;\n};\n\nexport type DateRange = {\n from: string | null;\n to: string | null;\n startedAtFromMs: number | null;\n startedAtBeforeMs: number | null;\n};\n\nexport function parseDateRange(input: DateRangeInput): DateRange {\n const from = input.from ? parseCalendarDate(input.from, \"from\") : null;\n const to = input.to ? parseCalendarDate(input.to, \"to\") : null;\n\n if (from && to && from.utcStartMs > to.utcStartMs) {\n throw new Error(\"--from must be before or equal to --to\");\n }\n\n // Expand date filters around UTC midnight so local-time workouts near the\n // boundary are included instead of accidentally excluded.\n return {\n from: from?.value ?? null,\n to: to?.value ?? null,\n startedAtFromMs: from ? from.utcStartMs - oneDayMs : null,\n startedAtBeforeMs: to ? to.utcStartMs + 2 * oneDayMs : null,\n };\n}\n\nfunction parseCalendarDate(\n value: string,\n label: string,\n): { value: string; utcStartMs: number } {\n if (!datePattern.test(value)) {\n throw new Error(`Invalid --${label} date \"${value}\". Expected YYYY-MM-DD.`);\n }\n\n const [yearText, monthText, dayText] = value.split(\"-\");\n const year = Number(yearText);\n const month = Number(monthText);\n const day = Number(dayText);\n const utcStartMs = Date.UTC(year, month - 1, day);\n const parsed = new Date(utcStartMs);\n\n if (\n parsed.getUTCFullYear() !== year ||\n parsed.getUTCMonth() !== month - 1 ||\n parsed.getUTCDate() !== day\n ) {\n throw new Error(\n `Invalid --${label} date \"${value}\". Expected a real calendar date.`,\n );\n }\n\n return { value, utcStartMs };\n}\n"]}
@@ -0,0 +1,28 @@
1
+ import pc from "picocolors";
2
+ let loggerVerbose = false;
3
+ export const logger = {
4
+ info(message) {
5
+ console.error(pc.cyan(message));
6
+ },
7
+ debug(message) {
8
+ if (loggerVerbose) {
9
+ console.error(pc.gray(message));
10
+ }
11
+ },
12
+ success(message) {
13
+ console.error(pc.green(message));
14
+ },
15
+ error(message) {
16
+ console.error(pc.red(message));
17
+ },
18
+ };
19
+ export function setLoggerVerbose(verbose) {
20
+ loggerVerbose = verbose;
21
+ }
22
+ export function isVerbose(options) {
23
+ return !!options.verbose;
24
+ }
25
+ export function isVerboseArgv(argv) {
26
+ return argv.includes("--verbose");
27
+ }
28
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../../src/utils/logger.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAa5B,IAAI,aAAa,GAAG,KAAK,CAAC;AAE1B,MAAM,CAAC,MAAM,MAAM,GAAW;IAC5B,IAAI,CAAC,OAAO;QACV,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;IAClC,CAAC;IACD,KAAK,CAAC,OAAO;QACX,IAAI,aAAa,EAAE,CAAC;YAClB,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;QAClC,CAAC;IACH,CAAC;IACD,OAAO,CAAC,OAAO;QACb,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IACnC,CAAC;IACD,KAAK,CAAC,OAAO;QACX,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;IACjC,CAAC;CACF,CAAC;AAEF,MAAM,UAAU,gBAAgB,CAAC,OAAgB;IAC/C,aAAa,GAAG,OAAO,CAAC;AAC1B,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,OAA4B;IACpD,OAAO,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAc;IAC1C,OAAO,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AACpC,CAAC","sourcesContent":["import pc from \"picocolors\";\n\nexport type Logger = {\n info(message: string): void;\n debug(message: string): void;\n success(message: string): void;\n error(message: string): void;\n};\n\nexport type VerboseOptionSource = {\n verbose?: boolean | undefined;\n};\n\nlet loggerVerbose = false;\n\nexport const logger: Logger = {\n info(message) {\n console.error(pc.cyan(message));\n },\n debug(message) {\n if (loggerVerbose) {\n console.error(pc.gray(message));\n }\n },\n success(message) {\n console.error(pc.green(message));\n },\n error(message) {\n console.error(pc.red(message));\n },\n};\n\nexport function setLoggerVerbose(verbose: boolean): void {\n loggerVerbose = verbose;\n}\n\nexport function isVerbose(options: VerboseOptionSource): boolean {\n return !!options.verbose;\n}\n\nexport function isVerboseArgv(argv: string[]): boolean {\n return argv.includes(\"--verbose\");\n}\n"]}
@@ -0,0 +1,38 @@
1
+ import { ZodError } from "zod";
2
+ export function stringifyJson(value) {
3
+ return JSON.stringify(value);
4
+ }
5
+ export function parseJson(value, label) {
6
+ try {
7
+ return JSON.parse(value);
8
+ }
9
+ catch (error) {
10
+ throw new Error(`Invalid JSON in ${label}: ${error instanceof Error ? error.message : String(error)}`);
11
+ }
12
+ }
13
+ export function parseSchema(schema, value, label) {
14
+ try {
15
+ return schema.parse(value);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof ZodError) {
19
+ const details = error.issues
20
+ .map((issue) => {
21
+ const path = issue.path.length === 0 ? "<root>" : issue.path.join(".");
22
+ return `${path}: ${issue.message}`;
23
+ })
24
+ .join("; ");
25
+ throw new Error(`Invalid ${label}: ${details}`);
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+ export function optionalFiniteNumber(value) {
31
+ return typeof value === "number" && Number.isFinite(value)
32
+ ? value
33
+ : undefined;
34
+ }
35
+ export function metricNumber(value) {
36
+ return optionalFiniteNumber(value) ?? 0;
37
+ }
38
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.js","sourceRoot":"","sources":["../../src/utils/parse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAgB,MAAM,KAAK,CAAC;AAE7C,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;AAC/B,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,KAAa;IACpD,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAY,CAAC;IACtC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,mBAAmB,KAAK,KACtB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CACvD,EAAE,CACH,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CACzB,MAAkB,EAClB,KAAc,EACd,KAAa;IAEb,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC7B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,QAAQ,EAAE,CAAC;YAC9B,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM;iBACzB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;gBACb,MAAM,IAAI,GACR,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBAC5D,OAAO,GAAG,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;YACrC,CAAC,CAAC;iBACD,IAAI,CAAC,IAAI,CAAC,CAAC;YACd,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,KAAK,OAAO,EAAE,CAAC,CAAC;QAClD,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,KAAgC;IAEhC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QACxD,CAAC,CAAC,KAAK;QACP,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAgC;IAC3D,OAAO,oBAAoB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AAC1C,CAAC","sourcesContent":["import { ZodError, type ZodType } from \"zod\";\n\nexport function stringifyJson(value: object): string {\n return JSON.stringify(value);\n}\n\nexport function parseJson(value: string, label: string): unknown {\n try {\n return JSON.parse(value) as unknown;\n } catch (error) {\n throw new Error(\n `Invalid JSON in ${label}: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n}\n\nexport function parseSchema<T>(\n schema: ZodType<T>,\n value: unknown,\n label: string,\n): T {\n try {\n return schema.parse(value);\n } catch (error) {\n if (error instanceof ZodError) {\n const details = error.issues\n .map((issue) => {\n const path =\n issue.path.length === 0 ? \"<root>\" : issue.path.join(\".\");\n return `${path}: ${issue.message}`;\n })\n .join(\"; \");\n throw new Error(`Invalid ${label}: ${details}`);\n }\n throw error;\n }\n}\n\nexport function optionalFiniteNumber(\n value: number | null | undefined,\n): number | undefined {\n return typeof value === \"number\" && Number.isFinite(value)\n ? value\n : undefined;\n}\n\nexport function metricNumber(value: number | null | undefined): number {\n return optionalFiniteNumber(value) ?? 0;\n}\n"]}
@@ -0,0 +1,16 @@
1
+ export function speedToPaceSecondsPerKilometer(metersPerSecond) {
2
+ if (metersPerSecond <= 0) {
3
+ return 0;
4
+ }
5
+ return 1000 / metersPerSecond;
6
+ }
7
+ export function speedToPaceMinutesPerKilometer(metersPerSecond) {
8
+ const secondsPerKilometer = Math.round(speedToPaceSecondsPerKilometer(metersPerSecond));
9
+ if (secondsPerKilometer === 0) {
10
+ return "0:00";
11
+ }
12
+ const minutes = Math.floor(secondsPerKilometer / 60);
13
+ const seconds = secondsPerKilometer % 60;
14
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
15
+ }
16
+ //# sourceMappingURL=running.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"running.js","sourceRoot":"","sources":["../../src/utils/running.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,8BAA8B,CAC5C,eAAuB;IAEvB,IAAI,eAAe,IAAI,CAAC,EAAE,CAAC;QACzB,OAAO,CAAC,CAAC;IACX,CAAC;IAED,OAAO,IAAI,GAAG,eAAe,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,8BAA8B,CAC5C,eAAuB;IAEvB,MAAM,mBAAmB,GAAG,IAAI,CAAC,KAAK,CACpC,8BAA8B,CAAC,eAAe,CAAC,CAChD,CAAC;IACF,IAAI,mBAAmB,KAAK,CAAC,EAAE,CAAC;QAC9B,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,mBAAmB,GAAG,EAAE,CAAC,CAAC;IACrD,MAAM,OAAO,GAAG,mBAAmB,GAAG,EAAE,CAAC;IAEzC,OAAO,GAAG,OAAO,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AAC7D,CAAC","sourcesContent":["export function speedToPaceSecondsPerKilometer(\n metersPerSecond: number,\n): number {\n if (metersPerSecond <= 0) {\n return 0;\n }\n\n return 1000 / metersPerSecond;\n}\n\nexport function speedToPaceMinutesPerKilometer(\n metersPerSecond: number,\n): string {\n const secondsPerKilometer = Math.round(\n speedToPaceSecondsPerKilometer(metersPerSecond),\n );\n if (secondsPerKilometer === 0) {\n return \"0:00\";\n }\n\n const minutes = Math.floor(secondsPerKilometer / 60);\n const seconds = secondsPerKilometer % 60;\n\n return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n"]}