@cmichel/healthlog 0.1.0 → 0.2.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 (40) hide show
  1. package/README.md +10 -2
  2. package/dist/cli.js +4 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/dump.js +1 -1
  5. package/dist/commands/dump.js.map +1 -1
  6. package/dist/commands/setup-withings.js +87 -0
  7. package/dist/commands/setup-withings.js.map +1 -0
  8. package/dist/db/body-measurements.js +130 -0
  9. package/dist/db/body-measurements.js.map +1 -0
  10. package/dist/db/provider-state.js +20 -17
  11. package/dist/db/provider-state.js.map +1 -1
  12. package/dist/db/schema.js +28 -4
  13. package/dist/db/schema.js.map +1 -1
  14. package/dist/domain/body-measurement.js +7 -0
  15. package/dist/domain/body-measurement.js.map +1 -0
  16. package/dist/domain/dump.js.map +1 -1
  17. package/dist/domain/provider.js +3 -1
  18. package/dist/domain/provider.js.map +1 -1
  19. package/dist/domain/workout.js.map +1 -1
  20. package/dist/providers/garmin/sync.js +2 -2
  21. package/dist/providers/garmin/sync.js.map +1 -1
  22. package/dist/providers/hevy/sync.js +2 -2
  23. package/dist/providers/hevy/sync.js.map +1 -1
  24. package/dist/providers/withings/client.js +115 -0
  25. package/dist/providers/withings/client.js.map +1 -0
  26. package/dist/providers/withings/normalize.js +76 -0
  27. package/dist/providers/withings/normalize.js.map +1 -0
  28. package/dist/providers/withings/oauth.js +28 -0
  29. package/dist/providers/withings/oauth.js.map +1 -0
  30. package/dist/providers/withings/sync.js +66 -0
  31. package/dist/providers/withings/sync.js.map +1 -0
  32. package/dist/providers/withings/types.js +113 -0
  33. package/dist/providers/withings/types.js.map +1 -0
  34. package/dist/services/dump-service.js +24 -0
  35. package/dist/services/dump-service.js.map +1 -1
  36. package/dist/services/setup-service.js +6 -2
  37. package/dist/services/setup-service.js.map +1 -1
  38. package/dist/services/sync-service.js +18 -6
  39. package/dist/services/sync-service.js.map +1 -1
  40. package/package.json +20 -12
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # healthlog
2
2
 
3
- `healthlog` is a TypeScript CLI that syncs Garmin and Hevy workout data into a local SQLite database and dumps a normalized JSON view for analysis.
3
+ `healthlog` is a TypeScript CLI that syncs Garmin and Hevy workouts plus Withings body measurements and dumps a normalized JSON view for analysis.
4
4
 
5
5
  ## Setup
6
6
 
@@ -31,7 +31,15 @@ healthlog setup hevy
31
31
 
32
32
  Get the Hevy API key from [Hevy developer settings](https://hevy.com/settings?developer). This requires Hevy Pro.
33
33
 
34
- Dump workouts:
34
+ Configure Withings:
35
+
36
+ ```sh
37
+ healthlog setup withings
38
+ ```
39
+
40
+ Create a Withings app in the [Withings developer dashboard](https://developer.withings.com/dashboard/) and register `http://localhost:8088/callback` as the redirect URI while the app is in integration mode. The setup command prints an authorization URL; open it in any browser, then paste the redirected URL or code back into the CLI.
41
+
42
+ Dump data:
35
43
 
36
44
  ```sh
37
45
  # First sync may take a while as it syncs all activities.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from "commander";
3
3
  import { createDumpCommand } from "./commands/dump.js";
4
4
  import { createSetupGarminCommand } from "./commands/setup-garmin.js";
5
5
  import { createSetupHevyCommand } from "./commands/setup-hevy.js";
6
+ import { createSetupWithingsCommand } from "./commands/setup-withings.js";
6
7
  import { resolveDatabasePath } from "./config/database-path.js";
7
8
  import { isVerbose, isVerboseArgv, logger, setLoggerVerbose, } from "./utils/logger.js";
8
9
  process.setSourceMapsEnabled(true);
@@ -10,7 +11,7 @@ export function createProgram() {
10
11
  const program = new Command();
11
12
  program
12
13
  .name("healthlog")
13
- .description("Sync Garmin and Hevy workout data into SQLite and dump normalized JSON")
14
+ .description("Sync health data into SQLite and dump normalized JSON")
14
15
  .configureHelp({ showGlobalOptions: true })
15
16
  .option("--db <path>", "Override SQLite database path")
16
17
  .option("--verbose", "Enable verbose debug logging")
@@ -25,7 +26,8 @@ export function createProgram() {
25
26
  .description("Set up provider credentials")
26
27
  .configureHelp({ showGlobalOptions: true })
27
28
  .addCommand(createSetupGarminCommand(getDatabasePath))
28
- .addCommand(createSetupHevyCommand(getDatabasePath));
29
+ .addCommand(createSetupHevyCommand(getDatabasePath))
30
+ .addCommand(createSetupWithingsCommand(getDatabasePath));
29
31
  program.addCommand(setup);
30
32
  program.addCommand(createDumpCommand(getDatabasePath));
31
33
  return program;
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EACL,SAAS,EACT,aAAa,EACb,MAAM,EACN,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC;AAOnC,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,OAAO;SACJ,IAAI,CAAC,WAAW,CAAC;SACjB,WAAW,CACV,wEAAwE,CACzE;SACA,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,aAAa,EAAE,+BAA+B,CAAC;SACtD,MAAM,CAAC,WAAW,EAAE,8BAA8B,CAAC;SACnD,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE;QACtB,gBAAgB,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,EAAiB,CAAC,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;IAEL,MAAM,eAAe,GAAG,GAAG,EAAE;QAC3B,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,EAAiB,CAAC;QAC9C,OAAO,mBAAmB,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC;IACtD,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC;SAC/B,WAAW,CAAC,6BAA6B,CAAC;SAC1C,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,UAAU,CAAC,wBAAwB,CAAC,eAAe,CAAC,CAAC;SACrD,UAAU,CAAC,sBAAsB,CAAC,eAAe,CAAC,CAAC,CAAC;IAEvD,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;IAC1B,OAAO,CAAC,UAAU,CAAC,iBAAiB,CAAC,eAAe,CAAC,CAAC,CAAC;IAEvD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,MAAM,aAAa,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5C,gBAAgB,CAAC,OAAO,CAAC,CAAC;QAC1B,MAAM,CAAC,KAAK,CACV,KAAK,YAAY,KAAK,IAAI,OAAO;YAC/B,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC;YAChC,CAAC,CAAC,KAAK,YAAY,KAAK;gBACtB,CAAC,CAAC,KAAK,CAAC,OAAO;gBACf,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CACpB,CAAC;QACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACvB,CAAC;AACH,CAAC;AAED,MAAM,IAAI,EAAE,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { Command } from \"commander\";\nimport { createDumpCommand } from \"./commands/dump.js\";\nimport { createSetupGarminCommand } from \"./commands/setup-garmin.js\";\nimport { createSetupHevyCommand } from \"./commands/setup-hevy.js\";\nimport { resolveDatabasePath } from \"./config/database-path.js\";\nimport {\n isVerbose,\n isVerboseArgv,\n logger,\n setLoggerVerbose,\n} from \"./utils/logger.js\";\n\nprocess.setSourceMapsEnabled(true);\n\ntype GlobalOptions = {\n db?: string;\n verbose?: boolean;\n};\n\nexport function createProgram(): Command {\n const program = new Command();\n\n program\n .name(\"healthlog\")\n .description(\n \"Sync Garmin and Hevy workout data into SQLite and dump normalized JSON\",\n )\n .configureHelp({ showGlobalOptions: true })\n .option(\"--db <path>\", \"Override SQLite database path\")\n .option(\"--verbose\", \"Enable verbose debug logging\")\n .hook(\"preAction\", () => {\n setLoggerVerbose(isVerbose(program.opts<GlobalOptions>()));\n });\n\n const getDatabasePath = () => {\n const options = program.opts<GlobalOptions>();\n return resolveDatabasePath({ cliPath: options.db });\n };\n\n const setup = new Command(\"setup\")\n .description(\"Set up provider credentials\")\n .configureHelp({ showGlobalOptions: true })\n .addCommand(createSetupGarminCommand(getDatabasePath))\n .addCommand(createSetupHevyCommand(getDatabasePath));\n\n program.addCommand(setup);\n program.addCommand(createDumpCommand(getDatabasePath));\n\n return program;\n}\n\nasync function main(): Promise<void> {\n try {\n await createProgram().parseAsync(process.argv);\n } catch (error) {\n const verbose = isVerboseArgv(process.argv);\n setLoggerVerbose(verbose);\n logger.error(\n error instanceof Error && verbose\n ? (error.stack ?? error.message)\n : error instanceof Error\n ? error.message\n : String(error),\n );\n process.exitCode = 1;\n }\n}\n\nawait main();\n"]}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EACL,SAAS,EACT,aAAa,EACb,MAAM,EACN,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC;AAOnC,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,OAAO;SACJ,IAAI,CAAC,WAAW,CAAC;SACjB,WAAW,CAAC,uDAAuD,CAAC;SACpE,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,aAAa,EAAE,+BAA+B,CAAC;SACtD,MAAM,CAAC,WAAW,EAAE,8BAA8B,CAAC;SACnD,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE;QACtB,gBAAgB,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,EAAiB,CAAC,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;IAEL,MAAM,eAAe,GAAG,GAAG,EAAE;QAC3B,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,EAAiB,CAAC;QAC9C,OAAO,mBAAmB,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC;IACtD,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC;SAC/B,WAAW,CAAC,6BAA6B,CAAC;SAC1C,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,UAAU,CAAC,wBAAwB,CAAC,eAAe,CAAC,CAAC;SACrD,UAAU,CAAC,sBAAsB,CAAC,eAAe,CAAC,CAAC;SACnD,UAAU,CAAC,0BAA0B,CAAC,eAAe,CAAC,CAAC,CAAC;IAE3D,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;IAC1B,OAAO,CAAC,UAAU,CAAC,iBAAiB,CAAC,eAAe,CAAC,CAAC,CAAC;IAEvD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,MAAM,aAAa,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5C,gBAAgB,CAAC,OAAO,CAAC,CAAC;QAC1B,MAAM,CAAC,KAAK,CACV,KAAK,YAAY,KAAK,IAAI,OAAO;YAC/B,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC;YAChC,CAAC,CAAC,KAAK,YAAY,KAAK;gBACtB,CAAC,CAAC,KAAK,CAAC,OAAO;gBACf,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CACpB,CAAC;QACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACvB,CAAC;AACH,CAAC;AAED,MAAM,IAAI,EAAE,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { Command } from \"commander\";\nimport { createDumpCommand } from \"./commands/dump.js\";\nimport { createSetupGarminCommand } from \"./commands/setup-garmin.js\";\nimport { createSetupHevyCommand } from \"./commands/setup-hevy.js\";\nimport { createSetupWithingsCommand } from \"./commands/setup-withings.js\";\nimport { resolveDatabasePath } from \"./config/database-path.js\";\nimport {\n isVerbose,\n isVerboseArgv,\n logger,\n setLoggerVerbose,\n} from \"./utils/logger.js\";\n\nprocess.setSourceMapsEnabled(true);\n\ntype GlobalOptions = {\n db?: string;\n verbose?: boolean;\n};\n\nexport function createProgram(): Command {\n const program = new Command();\n\n program\n .name(\"healthlog\")\n .description(\"Sync health data into SQLite and dump normalized JSON\")\n .configureHelp({ showGlobalOptions: true })\n .option(\"--db <path>\", \"Override SQLite database path\")\n .option(\"--verbose\", \"Enable verbose debug logging\")\n .hook(\"preAction\", () => {\n setLoggerVerbose(isVerbose(program.opts<GlobalOptions>()));\n });\n\n const getDatabasePath = () => {\n const options = program.opts<GlobalOptions>();\n return resolveDatabasePath({ cliPath: options.db });\n };\n\n const setup = new Command(\"setup\")\n .description(\"Set up provider credentials\")\n .configureHelp({ showGlobalOptions: true })\n .addCommand(createSetupGarminCommand(getDatabasePath))\n .addCommand(createSetupHevyCommand(getDatabasePath))\n .addCommand(createSetupWithingsCommand(getDatabasePath));\n\n program.addCommand(setup);\n program.addCommand(createDumpCommand(getDatabasePath));\n\n return program;\n}\n\nasync function main(): Promise<void> {\n try {\n await createProgram().parseAsync(process.argv);\n } catch (error) {\n const verbose = isVerboseArgv(process.argv);\n setLoggerVerbose(verbose);\n logger.error(\n error instanceof Error && verbose\n ? (error.stack ?? error.message)\n : error instanceof Error\n ? error.message\n : String(error),\n );\n process.exitCode = 1;\n }\n}\n\nawait main();\n"]}
@@ -5,7 +5,7 @@ import { syncConfiguredProviders } from "../services/sync-service.js";
5
5
  import { parseDateRange } from "../utils/dates.js";
6
6
  export function createDumpCommand(getDatabasePath) {
7
7
  return new Command("dump")
8
- .description("Sync configured providers and dump normalized workout JSON")
8
+ .description("Sync configured providers and dump normalized JSON")
9
9
  .configureHelp({ showGlobalOptions: true })
10
10
  .option("--from <date>", "Start date, YYYY-MM-DD")
11
11
  .option("--to <date>", "End date, YYYY-MM-DD")
@@ -1 +1 @@
1
- {"version":3,"file":"dump.js","sourceRoot":"","sources":["../../src/commands/dump.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAQnD,MAAM,UAAU,iBAAiB,CAAC,eAA6B;IAC7D,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;SACvB,WAAW,CAAC,4DAA4D,CAAC;SACzE,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,eAAe,EAAE,wBAAwB,CAAC;SACjD,MAAM,CAAC,aAAa,EAAE,sBAAsB,CAAC;SAC7C,MAAM,CAAC,UAAU,EAAE,0BAA0B,CAAC;SAC9C,MAAM,CAAC,KAAK,EAAE,OAAoB,EAAE,EAAE;QACrC,MAAM,KAAK,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;QACtC,MAAM,EAAE,GAAG,YAAY,CAAC,eAAe,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC;YACH,MAAM,uBAAuB,CAAC,EAAE,CAAC,CAAC;YAClC,MAAM,IAAI,GAAG,iBAAiB,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;YAC1C,OAAO,CAAC,GAAG,CACT,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CACtE,CAAC;QACJ,CAAC;gBAAS,CAAC;YACT,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC","sourcesContent":["import { Command } from \"commander\";\nimport { openDatabase } from \"../db/database.js\";\nimport { buildDumpDocument } from \"../services/dump-service.js\";\nimport { syncConfiguredProviders } from \"../services/sync-service.js\";\nimport { parseDateRange } from \"../utils/dates.js\";\n\ntype DumpOptions = {\n from?: string;\n to?: string;\n pretty?: boolean;\n};\n\nexport function createDumpCommand(getDatabasePath: () => string): Command {\n return new Command(\"dump\")\n .description(\"Sync configured providers and dump normalized workout JSON\")\n .configureHelp({ showGlobalOptions: true })\n .option(\"--from <date>\", \"Start date, YYYY-MM-DD\")\n .option(\"--to <date>\", \"End date, YYYY-MM-DD\")\n .option(\"--pretty\", \"Pretty-print JSON output\")\n .action(async (options: DumpOptions) => {\n const range = parseDateRange(options);\n const db = openDatabase(getDatabasePath());\n try {\n await syncConfiguredProviders(db);\n const dump = buildDumpDocument(db, range);\n console.log(\n options.pretty ? JSON.stringify(dump, null, 2) : JSON.stringify(dump),\n );\n } finally {\n db.close();\n }\n });\n}\n"]}
1
+ {"version":3,"file":"dump.js","sourceRoot":"","sources":["../../src/commands/dump.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAQnD,MAAM,UAAU,iBAAiB,CAAC,eAA6B;IAC7D,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;SACvB,WAAW,CAAC,oDAAoD,CAAC;SACjE,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,eAAe,EAAE,wBAAwB,CAAC;SACjD,MAAM,CAAC,aAAa,EAAE,sBAAsB,CAAC;SAC7C,MAAM,CAAC,UAAU,EAAE,0BAA0B,CAAC;SAC9C,MAAM,CAAC,KAAK,EAAE,OAAoB,EAAE,EAAE;QACrC,MAAM,KAAK,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;QACtC,MAAM,EAAE,GAAG,YAAY,CAAC,eAAe,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC;YACH,MAAM,uBAAuB,CAAC,EAAE,CAAC,CAAC;YAClC,MAAM,IAAI,GAAG,iBAAiB,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;YAC1C,OAAO,CAAC,GAAG,CACT,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CACtE,CAAC;QACJ,CAAC;gBAAS,CAAC;YACT,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC","sourcesContent":["import { Command } from \"commander\";\nimport { openDatabase } from \"../db/database.js\";\nimport { buildDumpDocument } from \"../services/dump-service.js\";\nimport { syncConfiguredProviders } from \"../services/sync-service.js\";\nimport { parseDateRange } from \"../utils/dates.js\";\n\ntype DumpOptions = {\n from?: string;\n to?: string;\n pretty?: boolean;\n};\n\nexport function createDumpCommand(getDatabasePath: () => string): Command {\n return new Command(\"dump\")\n .description(\"Sync configured providers and dump normalized JSON\")\n .configureHelp({ showGlobalOptions: true })\n .option(\"--from <date>\", \"Start date, YYYY-MM-DD\")\n .option(\"--to <date>\", \"End date, YYYY-MM-DD\")\n .option(\"--pretty\", \"Pretty-print JSON output\")\n .action(async (options: DumpOptions) => {\n const range = parseDateRange(options);\n const db = openDatabase(getDatabasePath());\n try {\n await syncConfiguredProviders(db);\n const dump = buildDumpDocument(db, range);\n console.log(\n options.pretty ? JSON.stringify(dump, null, 2) : JSON.stringify(dump),\n );\n } finally {\n db.close();\n }\n });\n}\n"]}
@@ -0,0 +1,87 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { input, password } from "@inquirer/prompts";
3
+ import { Command } from "commander";
4
+ import { openDatabase } from "../db/database.js";
5
+ import { buildWithingsAuthorizationUrl, exchangeWithingsAuthorizationCode, } from "../providers/withings/client.js";
6
+ import { withingsDefaultRedirectUri } from "../providers/withings/types.js";
7
+ import { storeWithingsCredentials } from "../services/setup-service.js";
8
+ import { logger } from "../utils/logger.js";
9
+ export function createSetupWithingsCommand(getDatabasePath) {
10
+ return new Command("withings")
11
+ .description("Set up Withings OAuth credentials")
12
+ .configureHelp({ showGlobalOptions: true })
13
+ .action(async () => {
14
+ const clientId = await input({
15
+ message: "Withings client id:",
16
+ validate: (value) => value.trim() === "" ? "Withings client id is required" : true,
17
+ });
18
+ const clientSecret = await password({
19
+ message: "Withings client secret:",
20
+ mask: "*",
21
+ validate: (value) => value.trim() === "" ? "Withings client secret is required" : true,
22
+ });
23
+ const redirectUri = await input({
24
+ message: "Withings redirect URI:",
25
+ default: withingsDefaultRedirectUri,
26
+ validate: (value) => value.trim() === "" ? "Withings redirect URI is required" : true,
27
+ });
28
+ const state = randomBytes(24).toString("hex");
29
+ const authorizationUrl = buildWithingsAuthorizationUrl({
30
+ clientId: clientId.trim(),
31
+ redirectUri: redirectUri.trim(),
32
+ state,
33
+ });
34
+ logger.info("Open this URL in a browser and approve access:");
35
+ logger.info(authorizationUrl);
36
+ logger.info("The authorization code is only valid briefly.");
37
+ const authorizationInput = await input({
38
+ message: "Paste redirected URL or code:",
39
+ validate: (value) => value.trim() === ""
40
+ ? "Withings redirected URL or code is required"
41
+ : true,
42
+ });
43
+ const code = parseWithingsAuthorizationCode(authorizationInput, state);
44
+ const db = openDatabase(getDatabasePath());
45
+ try {
46
+ const credentials = await exchangeWithingsAuthorizationCode({
47
+ clientId: clientId.trim(),
48
+ clientSecret: clientSecret.trim(),
49
+ redirectUri: redirectUri.trim(),
50
+ code,
51
+ });
52
+ storeWithingsCredentials(db, credentials);
53
+ }
54
+ finally {
55
+ db.close();
56
+ }
57
+ logger.success("Withings setup complete.");
58
+ });
59
+ }
60
+ function parseWithingsAuthorizationCode(value, expectedState) {
61
+ const trimmedValue = value.trim();
62
+ if (trimmedValue === "") {
63
+ throw new Error("Withings authorization code or redirected URL is required");
64
+ }
65
+ const url = parseUrl(trimmedValue);
66
+ if (url === null) {
67
+ return trimmedValue;
68
+ }
69
+ const code = url.searchParams.get("code");
70
+ if (code === null || code.length === 0) {
71
+ throw new Error("Withings redirected URL did not contain a code parameter");
72
+ }
73
+ const returnedState = url.searchParams.get("state");
74
+ if (returnedState !== expectedState) {
75
+ throw new Error("Withings redirected URL state did not match setup state");
76
+ }
77
+ return code;
78
+ }
79
+ function parseUrl(value) {
80
+ try {
81
+ return new URL(value);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ //# sourceMappingURL=setup-withings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup-withings.js","sourceRoot":"","sources":["../../src/commands/setup-withings.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { input, password } from \"@inquirer/prompts\";\nimport { Command } from \"commander\";\nimport { openDatabase } from \"../db/database.js\";\nimport {\n buildWithingsAuthorizationUrl,\n exchangeWithingsAuthorizationCode,\n} from \"../providers/withings/client.js\";\nimport { withingsDefaultRedirectUri } from \"../providers/withings/types.js\";\nimport { storeWithingsCredentials } from \"../services/setup-service.js\";\nimport { logger } from \"../utils/logger.js\";\n\nexport function createSetupWithingsCommand(\n getDatabasePath: () => string,\n): Command {\n return new Command(\"withings\")\n .description(\"Set up Withings OAuth credentials\")\n .configureHelp({ showGlobalOptions: true })\n .action(async () => {\n const clientId = await input({\n message: \"Withings client id:\",\n validate: (value) =>\n value.trim() === \"\" ? \"Withings client id is required\" : true,\n });\n const clientSecret = await password({\n message: \"Withings client secret:\",\n mask: \"*\",\n validate: (value) =>\n value.trim() === \"\" ? \"Withings client secret is required\" : true,\n });\n const redirectUri = await input({\n message: \"Withings redirect URI:\",\n default: withingsDefaultRedirectUri,\n validate: (value) =>\n value.trim() === \"\" ? \"Withings redirect URI is required\" : true,\n });\n\n const state = randomBytes(24).toString(\"hex\");\n const authorizationUrl = buildWithingsAuthorizationUrl({\n clientId: clientId.trim(),\n redirectUri: redirectUri.trim(),\n state,\n });\n\n logger.info(\"Open this URL in a browser and approve access:\");\n logger.info(authorizationUrl);\n logger.info(\"The authorization code is only valid briefly.\");\n\n const authorizationInput = await input({\n message: \"Paste redirected URL or code:\",\n validate: (value) =>\n value.trim() === \"\"\n ? \"Withings redirected URL or code is required\"\n : true,\n });\n const code = parseWithingsAuthorizationCode(authorizationInput, state);\n const db = openDatabase(getDatabasePath());\n try {\n const credentials = await exchangeWithingsAuthorizationCode({\n clientId: clientId.trim(),\n clientSecret: clientSecret.trim(),\n redirectUri: redirectUri.trim(),\n code,\n });\n storeWithingsCredentials(db, credentials);\n } finally {\n db.close();\n }\n\n logger.success(\"Withings setup complete.\");\n });\n}\n\nfunction parseWithingsAuthorizationCode(\n value: string,\n expectedState: string,\n): string {\n const trimmedValue = value.trim();\n if (trimmedValue === \"\") {\n throw new Error(\n \"Withings authorization code or redirected URL is required\",\n );\n }\n\n const url = parseUrl(trimmedValue);\n if (url === null) {\n return trimmedValue;\n }\n\n const code = url.searchParams.get(\"code\");\n if (code === null || code.length === 0) {\n throw new Error(\"Withings redirected URL did not contain a code parameter\");\n }\n\n const returnedState = url.searchParams.get(\"state\");\n if (returnedState !== expectedState) {\n throw new Error(\"Withings redirected URL state did not match setup state\");\n }\n\n return code;\n}\n\nfunction parseUrl(value: string): URL | null {\n try {\n return new URL(value);\n } catch {\n return null;\n }\n}\n"]}
@@ -0,0 +1,130 @@
1
+ export function bodyMeasurementExists(db, id) {
2
+ const row = db
3
+ .prepare("SELECT 1 FROM body_measurements WHERE id = ?")
4
+ .get(id);
5
+ return Boolean(row);
6
+ }
7
+ export function upsertBodyMeasurement(db, measurement) {
8
+ // SQLite handles the upsert below; this preflight query is only for the
9
+ // accurate "new body measurements" sync count.
10
+ const inserted = !bodyMeasurementExists(db, measurement.id);
11
+ db.prepare(`
12
+ INSERT INTO body_measurements (
13
+ id,
14
+ provider,
15
+ provider_id,
16
+ measured_at_ms,
17
+ weight_kg,
18
+ fat_mass_kg,
19
+ muscle_mass_kg,
20
+ bone_mass_kg,
21
+ water_mass_kg,
22
+ fat_free_mass_kg,
23
+ heart_rate_bpm,
24
+ vascular_age_years,
25
+ visceral_fat_index,
26
+ basal_metabolic_rate_kcal_per_day,
27
+ pulse_wave_velocity_meters_per_second,
28
+ source_json,
29
+ provider_extras_json
30
+ )
31
+ VALUES (
32
+ @id,
33
+ @provider,
34
+ @providerId,
35
+ @measuredAtMs,
36
+ @weightKg,
37
+ @fatMassKg,
38
+ @muscleMassKg,
39
+ @boneMassKg,
40
+ @waterMassKg,
41
+ @fatFreeMassKg,
42
+ @heartRateBpm,
43
+ @vascularAgeYears,
44
+ @visceralFatIndex,
45
+ @basalMetabolicRateKcalPerDay,
46
+ @pulseWaveVelocityMetersPerSecond,
47
+ @sourceJson,
48
+ @providerExtrasJson
49
+ )
50
+ ON CONFLICT(id) DO UPDATE SET
51
+ provider = excluded.provider,
52
+ provider_id = excluded.provider_id,
53
+ measured_at_ms = excluded.measured_at_ms,
54
+ weight_kg = excluded.weight_kg,
55
+ fat_mass_kg = excluded.fat_mass_kg,
56
+ muscle_mass_kg = excluded.muscle_mass_kg,
57
+ bone_mass_kg = excluded.bone_mass_kg,
58
+ water_mass_kg = excluded.water_mass_kg,
59
+ fat_free_mass_kg = excluded.fat_free_mass_kg,
60
+ heart_rate_bpm = excluded.heart_rate_bpm,
61
+ vascular_age_years = excluded.vascular_age_years,
62
+ visceral_fat_index = excluded.visceral_fat_index,
63
+ basal_metabolic_rate_kcal_per_day = excluded.basal_metabolic_rate_kcal_per_day,
64
+ pulse_wave_velocity_meters_per_second = excluded.pulse_wave_velocity_meters_per_second,
65
+ source_json = excluded.source_json,
66
+ provider_extras_json = excluded.provider_extras_json
67
+ `).run(measurement);
68
+ return inserted;
69
+ }
70
+ export function getBodyMeasurements(db, range) {
71
+ const conditions = [];
72
+ const params = {};
73
+ if (range.startedAtFromMs !== null) {
74
+ conditions.push("measured_at_ms >= @startedAtFromMs");
75
+ params.startedAtFromMs = range.startedAtFromMs;
76
+ }
77
+ if (range.startedAtBeforeMs !== null) {
78
+ conditions.push("measured_at_ms < @startedAtBeforeMs");
79
+ params.startedAtBeforeMs = range.startedAtBeforeMs;
80
+ }
81
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
82
+ const rows = db
83
+ .prepare(`
84
+ SELECT
85
+ id,
86
+ provider,
87
+ provider_id,
88
+ measured_at_ms,
89
+ weight_kg,
90
+ fat_mass_kg,
91
+ muscle_mass_kg,
92
+ bone_mass_kg,
93
+ water_mass_kg,
94
+ fat_free_mass_kg,
95
+ heart_rate_bpm,
96
+ vascular_age_years,
97
+ visceral_fat_index,
98
+ basal_metabolic_rate_kcal_per_day,
99
+ pulse_wave_velocity_meters_per_second,
100
+ source_json,
101
+ provider_extras_json
102
+ FROM body_measurements
103
+ ${whereClause}
104
+ ORDER BY measured_at_ms ASC, id ASC
105
+ `)
106
+ .all(params);
107
+ return rows.map(mapBodyMeasurementDbRow);
108
+ }
109
+ function mapBodyMeasurementDbRow(row) {
110
+ return {
111
+ id: row.id,
112
+ provider: row.provider,
113
+ providerId: row.provider_id,
114
+ measuredAtMs: row.measured_at_ms,
115
+ weightKg: row.weight_kg,
116
+ fatMassKg: row.fat_mass_kg,
117
+ muscleMassKg: row.muscle_mass_kg,
118
+ boneMassKg: row.bone_mass_kg,
119
+ waterMassKg: row.water_mass_kg,
120
+ fatFreeMassKg: row.fat_free_mass_kg,
121
+ heartRateBpm: row.heart_rate_bpm,
122
+ vascularAgeYears: row.vascular_age_years,
123
+ visceralFatIndex: row.visceral_fat_index,
124
+ basalMetabolicRateKcalPerDay: row.basal_metabolic_rate_kcal_per_day,
125
+ pulseWaveVelocityMetersPerSecond: row.pulse_wave_velocity_meters_per_second,
126
+ sourceJson: row.source_json,
127
+ providerExtrasJson: row.provider_extras_json,
128
+ };
129
+ }
130
+ //# sourceMappingURL=body-measurements.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body-measurements.js","sourceRoot":"","sources":["../../src/db/body-measurements.ts"],"names":[],"mappings":"AAuBA,MAAM,UAAU,qBAAqB,CACnC,EAAqB,EACrB,EAAU;IAEV,MAAM,GAAG,GAAG,EAAE;SACX,OAAO,CAAC,8CAA8C,CAAC;SACvD,GAAG,CAAC,EAAE,CAAgC,CAAC;IAC1C,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC;AACtB,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,EAAqB,EACrB,WAA4B;IAE5B,wEAAwE;IACxE,+CAA+C;IAC/C,MAAM,QAAQ,GAAG,CAAC,qBAAqB,CAAC,EAAE,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC;IAE5D,EAAE,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDV,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAEpB,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,mBAAmB,CACjC,EAAqB,EACrB,KAA2E;IAE3E,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,MAAM,MAAM,GAA2B,EAAE,CAAC;IAE1C,IAAI,KAAK,CAAC,eAAe,KAAK,IAAI,EAAE,CAAC;QACnC,UAAU,CAAC,IAAI,CAAC,oCAAoC,CAAC,CAAC;QACtD,MAAM,CAAC,eAAe,GAAG,KAAK,CAAC,eAAe,CAAC;IACjD,CAAC;IACD,IAAI,KAAK,CAAC,iBAAiB,KAAK,IAAI,EAAE,CAAC;QACrC,UAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;QACvD,MAAM,CAAC,iBAAiB,GAAG,KAAK,CAAC,iBAAiB,CAAC;IACrD,CAAC;IAED,MAAM,WAAW,GACf,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACnE,MAAM,IAAI,GAAG,EAAE;SACZ,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;QAoBL,WAAW;;KAEd,CAAC;SACD,GAAG,CAAC,MAAM,CAA2B,CAAC;IAEzC,OAAO,IAAI,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,uBAAuB,CAAC,GAAyB;IACxD,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,EAAE;QACV,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,UAAU,EAAE,GAAG,CAAC,WAAW;QAC3B,YAAY,EAAE,GAAG,CAAC,cAAc;QAChC,QAAQ,EAAE,GAAG,CAAC,SAAS;QACvB,SAAS,EAAE,GAAG,CAAC,WAAW;QAC1B,YAAY,EAAE,GAAG,CAAC,cAAc;QAChC,UAAU,EAAE,GAAG,CAAC,YAAY;QAC5B,WAAW,EAAE,GAAG,CAAC,aAAa;QAC9B,aAAa,EAAE,GAAG,CAAC,gBAAgB;QACnC,YAAY,EAAE,GAAG,CAAC,cAAc;QAChC,gBAAgB,EAAE,GAAG,CAAC,kBAAkB;QACxC,gBAAgB,EAAE,GAAG,CAAC,kBAAkB;QACxC,4BAA4B,EAAE,GAAG,CAAC,iCAAiC;QACnE,gCAAgC,EAAE,GAAG,CAAC,qCAAqC;QAC3E,UAAU,EAAE,GAAG,CAAC,WAAW;QAC3B,kBAAkB,EAAE,GAAG,CAAC,oBAAoB;KAC7C,CAAC;AACJ,CAAC","sourcesContent":["import type { BodyMeasurement } from \"../domain/body-measurement.js\";\nimport type { HealthlogDatabase } from \"./database.js\";\n\ntype BodyMeasurementDbRow = {\n id: string;\n provider: BodyMeasurement[\"provider\"];\n provider_id: string;\n measured_at_ms: number;\n weight_kg: number | null;\n fat_mass_kg: number | null;\n muscle_mass_kg: number | null;\n bone_mass_kg: number | null;\n water_mass_kg: number | null;\n fat_free_mass_kg: number | null;\n heart_rate_bpm: number | null;\n vascular_age_years: number | null;\n visceral_fat_index: number | null;\n basal_metabolic_rate_kcal_per_day: number | null;\n pulse_wave_velocity_meters_per_second: number | null;\n source_json: string;\n provider_extras_json: string;\n};\n\nexport function bodyMeasurementExists(\n db: HealthlogDatabase,\n id: string,\n): boolean {\n const row = db\n .prepare(\"SELECT 1 FROM body_measurements WHERE id = ?\")\n .get(id) as { \"1\": number } | undefined;\n return Boolean(row);\n}\n\nexport function upsertBodyMeasurement(\n db: HealthlogDatabase,\n measurement: BodyMeasurement,\n): boolean {\n // SQLite handles the upsert below; this preflight query is only for the\n // accurate \"new body measurements\" sync count.\n const inserted = !bodyMeasurementExists(db, measurement.id);\n\n db.prepare(`\n INSERT INTO body_measurements (\n id,\n provider,\n provider_id,\n measured_at_ms,\n weight_kg,\n fat_mass_kg,\n muscle_mass_kg,\n bone_mass_kg,\n water_mass_kg,\n fat_free_mass_kg,\n heart_rate_bpm,\n vascular_age_years,\n visceral_fat_index,\n basal_metabolic_rate_kcal_per_day,\n pulse_wave_velocity_meters_per_second,\n source_json,\n provider_extras_json\n )\n VALUES (\n @id,\n @provider,\n @providerId,\n @measuredAtMs,\n @weightKg,\n @fatMassKg,\n @muscleMassKg,\n @boneMassKg,\n @waterMassKg,\n @fatFreeMassKg,\n @heartRateBpm,\n @vascularAgeYears,\n @visceralFatIndex,\n @basalMetabolicRateKcalPerDay,\n @pulseWaveVelocityMetersPerSecond,\n @sourceJson,\n @providerExtrasJson\n )\n ON CONFLICT(id) DO UPDATE SET\n provider = excluded.provider,\n provider_id = excluded.provider_id,\n measured_at_ms = excluded.measured_at_ms,\n weight_kg = excluded.weight_kg,\n fat_mass_kg = excluded.fat_mass_kg,\n muscle_mass_kg = excluded.muscle_mass_kg,\n bone_mass_kg = excluded.bone_mass_kg,\n water_mass_kg = excluded.water_mass_kg,\n fat_free_mass_kg = excluded.fat_free_mass_kg,\n heart_rate_bpm = excluded.heart_rate_bpm,\n vascular_age_years = excluded.vascular_age_years,\n visceral_fat_index = excluded.visceral_fat_index,\n basal_metabolic_rate_kcal_per_day = excluded.basal_metabolic_rate_kcal_per_day,\n pulse_wave_velocity_meters_per_second = excluded.pulse_wave_velocity_meters_per_second,\n source_json = excluded.source_json,\n provider_extras_json = excluded.provider_extras_json\n `).run(measurement);\n\n return inserted;\n}\n\nexport function getBodyMeasurements(\n db: HealthlogDatabase,\n range: { startedAtFromMs: number | null; startedAtBeforeMs: number | null },\n): BodyMeasurement[] {\n const conditions: string[] = [];\n const params: Record<string, number> = {};\n\n if (range.startedAtFromMs !== null) {\n conditions.push(\"measured_at_ms >= @startedAtFromMs\");\n params.startedAtFromMs = range.startedAtFromMs;\n }\n if (range.startedAtBeforeMs !== null) {\n conditions.push(\"measured_at_ms < @startedAtBeforeMs\");\n params.startedAtBeforeMs = range.startedAtBeforeMs;\n }\n\n const whereClause =\n conditions.length > 0 ? `WHERE ${conditions.join(\" AND \")}` : \"\";\n const rows = db\n .prepare(`\n SELECT\n id,\n provider,\n provider_id,\n measured_at_ms,\n weight_kg,\n fat_mass_kg,\n muscle_mass_kg,\n bone_mass_kg,\n water_mass_kg,\n fat_free_mass_kg,\n heart_rate_bpm,\n vascular_age_years,\n visceral_fat_index,\n basal_metabolic_rate_kcal_per_day,\n pulse_wave_velocity_meters_per_second,\n source_json,\n provider_extras_json\n FROM body_measurements\n ${whereClause}\n ORDER BY measured_at_ms ASC, id ASC\n `)\n .all(params) as BodyMeasurementDbRow[];\n\n return rows.map(mapBodyMeasurementDbRow);\n}\n\nfunction mapBodyMeasurementDbRow(row: BodyMeasurementDbRow): BodyMeasurement {\n return {\n id: row.id,\n provider: row.provider,\n providerId: row.provider_id,\n measuredAtMs: row.measured_at_ms,\n weightKg: row.weight_kg,\n fatMassKg: row.fat_mass_kg,\n muscleMassKg: row.muscle_mass_kg,\n boneMassKg: row.bone_mass_kg,\n waterMassKg: row.water_mass_kg,\n fatFreeMassKg: row.fat_free_mass_kg,\n heartRateBpm: row.heart_rate_bpm,\n vascularAgeYears: row.vascular_age_years,\n visceralFatIndex: row.visceral_fat_index,\n basalMetabolicRateKcalPerDay: row.basal_metabolic_rate_kcal_per_day,\n pulseWaveVelocityMetersPerSecond: row.pulse_wave_velocity_meters_per_second,\n sourceJson: row.source_json,\n providerExtrasJson: row.provider_extras_json,\n };\n}\n"]}
@@ -1,48 +1,51 @@
1
1
  export function getProviderState(db, provider) {
2
2
  const row = db
3
- .prepare("SELECT provider, credentials_json, cursor_json, last_synced_at_ms, updated_at_ms FROM provider_state WHERE provider = ?")
3
+ .prepare("SELECT provider, credentials_json, cursor_json FROM provider_state WHERE provider = ?")
4
4
  .get(provider);
5
5
  return row ? mapProviderDbRow(row) : null;
6
6
  }
7
- export function upsertProviderState(db, provider, credentialsJson, cursorJson, lastSyncedAtMs) {
8
- const updatedAtMs = Date.now();
7
+ export function upsertProviderState(db, provider, credentialsJson, cursorJson) {
9
8
  db.prepare(`
10
- INSERT INTO provider_state (provider, credentials_json, cursor_json, last_synced_at_ms, updated_at_ms)
11
- VALUES (@provider, @credentialsJson, @cursorJson, @lastSyncedAtMs, @updatedAtMs)
9
+ INSERT INTO provider_state (provider, credentials_json, cursor_json)
10
+ VALUES (@provider, @credentialsJson, @cursorJson)
12
11
  ON CONFLICT(provider) DO UPDATE SET
13
12
  credentials_json = excluded.credentials_json,
14
- cursor_json = excluded.cursor_json,
15
- last_synced_at_ms = excluded.last_synced_at_ms,
16
- updated_at_ms = excluded.updated_at_ms
13
+ cursor_json = excluded.cursor_json
17
14
  `).run({
18
15
  provider,
19
16
  credentialsJson,
20
17
  cursorJson,
21
- lastSyncedAtMs,
22
- updatedAtMs,
23
18
  });
24
19
  }
25
- export function updateProviderCursor(db, provider, cursorJson, lastSyncedAtMs) {
20
+ export function updateProviderCursor(db, provider, cursorJson) {
26
21
  const result = db
27
22
  .prepare(`
28
23
  UPDATE provider_state
29
- SET cursor_json = @cursorJson,
30
- last_synced_at_ms = @lastSyncedAtMs,
31
- updated_at_ms = @updatedAtMs
24
+ SET cursor_json = @cursorJson
32
25
  WHERE provider = @provider
33
26
  `)
34
- .run({ provider, cursorJson, lastSyncedAtMs, updatedAtMs: Date.now() });
27
+ .run({ provider, cursorJson });
35
28
  if (result.changes !== 1) {
36
29
  throw new Error(`Cannot update ${provider} cursor because provider is not configured`);
37
30
  }
38
31
  }
32
+ export function updateProviderCredentials(db, provider, credentialsJson) {
33
+ const result = db
34
+ .prepare(`
35
+ UPDATE provider_state
36
+ SET credentials_json = @credentialsJson
37
+ WHERE provider = @provider
38
+ `)
39
+ .run({ provider, credentialsJson });
40
+ if (result.changes !== 1) {
41
+ throw new Error(`Cannot update ${provider} credentials because provider is not configured`);
42
+ }
43
+ }
39
44
  function mapProviderDbRow(row) {
40
45
  return {
41
46
  provider: row.provider,
42
47
  credentialsJson: row.credentials_json,
43
48
  cursorJson: row.cursor_json,
44
- lastSyncedAtMs: row.last_synced_at_ms,
45
- updatedAtMs: row.updated_at_ms,
46
49
  };
47
50
  }
48
51
  //# sourceMappingURL=provider-state.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"provider-state.js","sourceRoot":"","sources":["../../src/db/provider-state.ts"],"names":[],"mappings":"AAmBA,MAAM,UAAU,gBAAgB,CAC9B,EAAqB,EACrB,QAAkB;IAElB,MAAM,GAAG,GAAG,EAAE;SACX,OAAO,CACN,yHAAyH,CAC1H;SACA,GAAG,CAAC,QAAQ,CAA8B,CAAC;IAE9C,OAAO,GAAG,CAAC,CAAC,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,mBAAmB,CACjC,EAAqB,EACrB,QAAkB,EAClB,eAAuB,EACvB,UAAkB,EAClB,cAA6B;IAE7B,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC/B,EAAE,CAAC,OAAO,CAAC;;;;;;;;GAQV,CAAC,CAAC,GAAG,CAAC;QACL,QAAQ;QACR,eAAe;QACf,UAAU;QACV,cAAc;QACd,WAAW;KACZ,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,EAAqB,EACrB,QAAkB,EAClB,UAAkB,EAClB,cAAsB;IAEtB,MAAM,MAAM,GAAG,EAAE;SACd,OAAO,CAAC;;;;;;KAMR,CAAC;SACD,GAAG,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IAE1E,IAAI,MAAM,CAAC,OAAO,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,iBAAiB,QAAQ,4CAA4C,CACtE,CAAC;IACJ,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAkB;IAC1C,OAAO;QACL,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,eAAe,EAAE,GAAG,CAAC,gBAAgB;QACrC,UAAU,EAAE,GAAG,CAAC,WAAW;QAC3B,cAAc,EAAE,GAAG,CAAC,iBAAiB;QACrC,WAAW,EAAE,GAAG,CAAC,aAAa;KAC/B,CAAC;AACJ,CAAC","sourcesContent":["import type { Provider } from \"../domain/provider.js\";\nimport type { HealthlogDatabase } from \"./database.js\";\n\nexport type ProviderState = {\n provider: Provider;\n credentialsJson: string;\n cursorJson: string;\n lastSyncedAtMs: number | null;\n updatedAtMs: number;\n};\n\ntype ProviderDbRow = {\n provider: Provider;\n credentials_json: string;\n cursor_json: string;\n last_synced_at_ms: number | null;\n updated_at_ms: number;\n};\n\nexport function getProviderState(\n db: HealthlogDatabase,\n provider: Provider,\n): ProviderState | null {\n const row = db\n .prepare(\n \"SELECT provider, credentials_json, cursor_json, last_synced_at_ms, updated_at_ms FROM provider_state WHERE provider = ?\",\n )\n .get(provider) as ProviderDbRow | undefined;\n\n return row ? mapProviderDbRow(row) : null;\n}\n\nexport function upsertProviderState(\n db: HealthlogDatabase,\n provider: Provider,\n credentialsJson: string,\n cursorJson: string,\n lastSyncedAtMs: number | null,\n): void {\n const updatedAtMs = Date.now();\n db.prepare(`\n INSERT INTO provider_state (provider, credentials_json, cursor_json, last_synced_at_ms, updated_at_ms)\n VALUES (@provider, @credentialsJson, @cursorJson, @lastSyncedAtMs, @updatedAtMs)\n ON CONFLICT(provider) DO UPDATE SET\n credentials_json = excluded.credentials_json,\n cursor_json = excluded.cursor_json,\n last_synced_at_ms = excluded.last_synced_at_ms,\n updated_at_ms = excluded.updated_at_ms\n `).run({\n provider,\n credentialsJson,\n cursorJson,\n lastSyncedAtMs,\n updatedAtMs,\n });\n}\n\nexport function updateProviderCursor(\n db: HealthlogDatabase,\n provider: Provider,\n cursorJson: string,\n lastSyncedAtMs: number,\n): void {\n const result = db\n .prepare(`\n UPDATE provider_state\n SET cursor_json = @cursorJson,\n last_synced_at_ms = @lastSyncedAtMs,\n updated_at_ms = @updatedAtMs\n WHERE provider = @provider\n `)\n .run({ provider, cursorJson, lastSyncedAtMs, updatedAtMs: Date.now() });\n\n if (result.changes !== 1) {\n throw new Error(\n `Cannot update ${provider} cursor because provider is not configured`,\n );\n }\n}\n\nfunction mapProviderDbRow(row: ProviderDbRow): ProviderState {\n return {\n provider: row.provider,\n credentialsJson: row.credentials_json,\n cursorJson: row.cursor_json,\n lastSyncedAtMs: row.last_synced_at_ms,\n updatedAtMs: row.updated_at_ms,\n };\n}\n"]}
1
+ {"version":3,"file":"provider-state.js","sourceRoot":"","sources":["../../src/db/provider-state.ts"],"names":[],"mappings":"AAeA,MAAM,UAAU,gBAAgB,CAC9B,EAAqB,EACrB,QAAkB;IAElB,MAAM,GAAG,GAAG,EAAE;SACX,OAAO,CACN,uFAAuF,CACxF;SACA,GAAG,CAAC,QAAQ,CAA8B,CAAC;IAE9C,OAAO,GAAG,CAAC,CAAC,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,mBAAmB,CACjC,EAAqB,EACrB,QAAkB,EAClB,eAAuB,EACvB,UAAkB;IAElB,EAAE,CAAC,OAAO,CAAC;;;;;;GAMV,CAAC,CAAC,GAAG,CAAC;QACL,QAAQ;QACR,eAAe;QACf,UAAU;KACX,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,EAAqB,EACrB,QAAkB,EAClB,UAAkB;IAElB,MAAM,MAAM,GAAG,EAAE;SACd,OAAO,CAAC;;;;KAIR,CAAC;SACD,GAAG,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,CAAC;IAEjC,IAAI,MAAM,CAAC,OAAO,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,iBAAiB,QAAQ,4CAA4C,CACtE,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,yBAAyB,CACvC,EAAqB,EACrB,QAAkB,EAClB,eAAuB;IAEvB,MAAM,MAAM,GAAG,EAAE;SACd,OAAO,CAAC;;;;KAIR,CAAC;SACD,GAAG,CAAC,EAAE,QAAQ,EAAE,eAAe,EAAE,CAAC,CAAC;IAEtC,IAAI,MAAM,CAAC,OAAO,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,iBAAiB,QAAQ,iDAAiD,CAC3E,CAAC;IACJ,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAkB;IAC1C,OAAO;QACL,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,eAAe,EAAE,GAAG,CAAC,gBAAgB;QACrC,UAAU,EAAE,GAAG,CAAC,WAAW;KAC5B,CAAC;AACJ,CAAC","sourcesContent":["import type { Provider } from \"../domain/provider.js\";\nimport type { HealthlogDatabase } from \"./database.js\";\n\nexport type ProviderState = {\n provider: Provider;\n credentialsJson: string;\n cursorJson: string;\n};\n\ntype ProviderDbRow = {\n provider: Provider;\n credentials_json: string;\n cursor_json: string;\n};\n\nexport function getProviderState(\n db: HealthlogDatabase,\n provider: Provider,\n): ProviderState | null {\n const row = db\n .prepare(\n \"SELECT provider, credentials_json, cursor_json FROM provider_state WHERE provider = ?\",\n )\n .get(provider) as ProviderDbRow | undefined;\n\n return row ? mapProviderDbRow(row) : null;\n}\n\nexport function upsertProviderState(\n db: HealthlogDatabase,\n provider: Provider,\n credentialsJson: string,\n cursorJson: string,\n): void {\n db.prepare(`\n INSERT INTO provider_state (provider, credentials_json, cursor_json)\n VALUES (@provider, @credentialsJson, @cursorJson)\n ON CONFLICT(provider) DO UPDATE SET\n credentials_json = excluded.credentials_json,\n cursor_json = excluded.cursor_json\n `).run({\n provider,\n credentialsJson,\n cursorJson,\n });\n}\n\nexport function updateProviderCursor(\n db: HealthlogDatabase,\n provider: Provider,\n cursorJson: string,\n): void {\n const result = db\n .prepare(`\n UPDATE provider_state\n SET cursor_json = @cursorJson\n WHERE provider = @provider\n `)\n .run({ provider, cursorJson });\n\n if (result.changes !== 1) {\n throw new Error(\n `Cannot update ${provider} cursor because provider is not configured`,\n );\n }\n}\n\nexport function updateProviderCredentials(\n db: HealthlogDatabase,\n provider: Provider,\n credentialsJson: string,\n): void {\n const result = db\n .prepare(`\n UPDATE provider_state\n SET credentials_json = @credentialsJson\n WHERE provider = @provider\n `)\n .run({ provider, credentialsJson });\n\n if (result.changes !== 1) {\n throw new Error(\n `Cannot update ${provider} credentials because provider is not configured`,\n );\n }\n}\n\nfunction mapProviderDbRow(row: ProviderDbRow): ProviderState {\n return {\n provider: row.provider,\n credentialsJson: row.credentials_json,\n cursorJson: row.cursor_json,\n };\n}\n"]}
package/dist/db/schema.js CHANGED
@@ -1,11 +1,9 @@
1
1
  export function initializeSchema(db) {
2
2
  db.exec(`
3
3
  CREATE TABLE IF NOT EXISTS provider_state (
4
- provider TEXT PRIMARY KEY CHECK (provider IN ('garmin', 'hevy')),
4
+ provider TEXT PRIMARY KEY CHECK (provider IN ('garmin', 'hevy', 'withings')),
5
5
  credentials_json TEXT NOT NULL,
6
- cursor_json TEXT NOT NULL,
7
- last_synced_at_ms INTEGER,
8
- updated_at_ms INTEGER NOT NULL
6
+ cursor_json TEXT NOT NULL
9
7
  );
10
8
 
11
9
  CREATE TABLE IF NOT EXISTS workouts (
@@ -48,6 +46,32 @@ export function initializeSchema(db) {
48
46
  workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,
49
47
  exercises_json TEXT NOT NULL
50
48
  );
49
+
50
+ CREATE TABLE IF NOT EXISTS body_measurements (
51
+ id TEXT PRIMARY KEY,
52
+ provider TEXT NOT NULL CHECK (provider IN ('withings')),
53
+ provider_id TEXT NOT NULL,
54
+ measured_at_ms INTEGER NOT NULL,
55
+ weight_kg REAL,
56
+ fat_mass_kg REAL,
57
+ muscle_mass_kg REAL,
58
+ bone_mass_kg REAL,
59
+ water_mass_kg REAL,
60
+ fat_free_mass_kg REAL,
61
+ heart_rate_bpm REAL,
62
+ vascular_age_years REAL,
63
+ visceral_fat_index REAL,
64
+ basal_metabolic_rate_kcal_per_day REAL,
65
+ pulse_wave_velocity_meters_per_second REAL,
66
+ source_json TEXT NOT NULL,
67
+ provider_extras_json TEXT NOT NULL
68
+ );
69
+
70
+ CREATE UNIQUE INDEX IF NOT EXISTS body_measurements_provider_provider_id_idx
71
+ ON body_measurements (provider, provider_id);
72
+
73
+ CREATE INDEX IF NOT EXISTS body_measurements_measured_at_id_idx
74
+ ON body_measurements (measured_at_ms, id);
51
75
  `);
52
76
  }
53
77
  //# sourceMappingURL=schema.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,gBAAgB,CAAC,EAAqB;IACpD,EAAE,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDP,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type { HealthlogDatabase } from \"./database.js\";\n\nexport function initializeSchema(db: HealthlogDatabase): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS provider_state (\n provider TEXT PRIMARY KEY CHECK (provider IN ('garmin', 'hevy')),\n credentials_json TEXT NOT NULL,\n cursor_json TEXT NOT NULL,\n last_synced_at_ms INTEGER,\n updated_at_ms INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS workouts (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL CHECK (provider IN ('garmin', 'hevy')),\n provider_id TEXT NOT NULL,\n type TEXT NOT NULL CHECK (type IN ('endurance', 'strength')),\n sport TEXT NOT NULL,\n title TEXT NOT NULL,\n started_at_ms INTEGER NOT NULL,\n ended_at_ms INTEGER,\n source_json TEXT NOT NULL,\n provider_extras_json TEXT\n );\n\n CREATE UNIQUE INDEX IF NOT EXISTS workouts_provider_provider_id_idx\n ON workouts (provider, provider_id);\n\n CREATE INDEX IF NOT EXISTS workouts_started_at_id_idx\n ON workouts (started_at_ms, id);\n\n CREATE TABLE IF NOT EXISTS endurance_metrics (\n workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,\n duration_seconds REAL NOT NULL,\n distance_meters REAL NOT NULL,\n elevation_gain_meters REAL NOT NULL,\n elevation_loss_meters REAL NOT NULL,\n start_location_json TEXT,\n calories REAL NOT NULL,\n avg_hr REAL NOT NULL,\n max_hr REAL NOT NULL,\n avg_running_cadence_spm REAL NOT NULL,\n avg_stride_length_cm REAL NOT NULL,\n avg_pace_min_per_km TEXT NOT NULL,\n fastest_pace_min_per_km TEXT NOT NULL,\n activity_metrics_json TEXT NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS strength_metrics (\n workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,\n exercises_json TEXT NOT NULL\n );\n `);\n}\n"]}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,gBAAgB,CAAC,EAAqB;IACpD,EAAE,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEP,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type { HealthlogDatabase } from \"./database.js\";\n\nexport function initializeSchema(db: HealthlogDatabase): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS provider_state (\n provider TEXT PRIMARY KEY CHECK (provider IN ('garmin', 'hevy', 'withings')),\n credentials_json TEXT NOT NULL,\n cursor_json TEXT NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS workouts (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL CHECK (provider IN ('garmin', 'hevy')),\n provider_id TEXT NOT NULL,\n type TEXT NOT NULL CHECK (type IN ('endurance', 'strength')),\n sport TEXT NOT NULL,\n title TEXT NOT NULL,\n started_at_ms INTEGER NOT NULL,\n ended_at_ms INTEGER,\n source_json TEXT NOT NULL,\n provider_extras_json TEXT\n );\n\n CREATE UNIQUE INDEX IF NOT EXISTS workouts_provider_provider_id_idx\n ON workouts (provider, provider_id);\n\n CREATE INDEX IF NOT EXISTS workouts_started_at_id_idx\n ON workouts (started_at_ms, id);\n\n CREATE TABLE IF NOT EXISTS endurance_metrics (\n workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,\n duration_seconds REAL NOT NULL,\n distance_meters REAL NOT NULL,\n elevation_gain_meters REAL NOT NULL,\n elevation_loss_meters REAL NOT NULL,\n start_location_json TEXT,\n calories REAL NOT NULL,\n avg_hr REAL NOT NULL,\n max_hr REAL NOT NULL,\n avg_running_cadence_spm REAL NOT NULL,\n avg_stride_length_cm REAL NOT NULL,\n avg_pace_min_per_km TEXT NOT NULL,\n fastest_pace_min_per_km TEXT NOT NULL,\n activity_metrics_json TEXT NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS strength_metrics (\n workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,\n exercises_json TEXT NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS body_measurements (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL CHECK (provider IN ('withings')),\n provider_id TEXT NOT NULL,\n measured_at_ms INTEGER NOT NULL,\n weight_kg REAL,\n fat_mass_kg REAL,\n muscle_mass_kg REAL,\n bone_mass_kg REAL,\n water_mass_kg REAL,\n fat_free_mass_kg REAL,\n heart_rate_bpm REAL,\n vascular_age_years REAL,\n visceral_fat_index REAL,\n basal_metabolic_rate_kcal_per_day REAL,\n pulse_wave_velocity_meters_per_second REAL,\n source_json TEXT NOT NULL,\n provider_extras_json TEXT NOT NULL\n );\n\n CREATE UNIQUE INDEX IF NOT EXISTS body_measurements_provider_provider_id_idx\n ON body_measurements (provider, provider_id);\n\n CREATE INDEX IF NOT EXISTS body_measurements_measured_at_id_idx\n ON body_measurements (measured_at_ms, id);\n `);\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ export const BodyMeasurementProviderExtrasSchema = z
3
+ .object({
4
+ deviceId: z.string().nullable(),
5
+ })
6
+ .strict();
7
+ //# sourceMappingURL=body-measurement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body-measurement.js","sourceRoot":"","sources":["../../src/domain/body-measurement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,CAAC,MAAM,mCAAmC,GAAG,CAAC;KACjD,MAAM,CAAC;IACN,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAChC,CAAC;KACD,MAAM,EAAE,CAAC","sourcesContent":["import { z } from \"zod\";\nimport type { BodyMeasurementProvider } from \"./provider.js\";\n\nexport const BodyMeasurementProviderExtrasSchema = z\n .object({\n deviceId: z.string().nullable(),\n })\n .strict();\n\nexport type BodyMeasurementProviderExtras = z.infer<\n typeof BodyMeasurementProviderExtrasSchema\n>;\n\nexport type BodyMeasurement = {\n id: string;\n provider: BodyMeasurementProvider;\n providerId: string;\n measuredAtMs: number;\n weightKg: number | null;\n fatMassKg: number | null;\n muscleMassKg: number | null;\n boneMassKg: number | null;\n waterMassKg: number | null;\n fatFreeMassKg: number | null;\n heartRateBpm: number | null;\n vascularAgeYears: number | null;\n visceralFatIndex: number | null;\n basalMetabolicRateKcalPerDay: number | null;\n pulseWaveVelocityMetersPerSecond: number | null;\n sourceJson: string;\n providerExtrasJson: string;\n};\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"dump.js","sourceRoot":"","sources":["../../src/domain/dump.ts"],"names":[],"mappings":"","sourcesContent":["import type { Provider } from \"./provider.js\";\nimport type {\n ActivityMetric,\n StartLocation,\n StrengthExercise,\n} from \"./workout.js\";\n\nexport type DumpRange = {\n from: string | null;\n to: string | null;\n};\n\nexport type DumpDocument = {\n generatedAt: string;\n range: DumpRange;\n workouts: DumpWorkout[];\n};\n\nexport type DumpWorkout = DumpEnduranceWorkout | DumpStrengthWorkout;\n\ntype DumpWorkoutBase = {\n id: string;\n provider: Provider;\n providerId: string;\n type: \"endurance\" | \"strength\";\n sport: string;\n title: string;\n startedAt: string;\n endedAt: string | null;\n providerExtras: unknown | null;\n};\n\nexport type DumpEnduranceWorkout = DumpWorkoutBase & {\n type: \"endurance\";\n durationSeconds: number;\n distanceMeters: number;\n elevationGainMeters: number;\n elevationLossMeters: number;\n startLocation: StartLocation | null;\n calories: number;\n averageHeartRate: number;\n maxHeartRate: number;\n averageRunningCadenceStepsPerMinute: number;\n averageStrideLengthCentimeters: number;\n averagePaceMinutesPerKilometer: string;\n fastestPaceMinutesPerKilometer: string;\n activityMetrics: ActivityMetric[];\n};\n\nexport type DumpStrengthWorkout = DumpWorkoutBase & {\n type: \"strength\";\n exercises: DumpStrengthExercise[];\n};\n\nexport type DumpStrengthExercise = StrengthExercise;\n"]}
1
+ {"version":3,"file":"dump.js","sourceRoot":"","sources":["../../src/domain/dump.ts"],"names":[],"mappings":"","sourcesContent":["import type { BodyMeasurementProviderExtras } from \"./body-measurement.js\";\nimport type { BodyMeasurementProvider, WorkoutProvider } from \"./provider.js\";\nimport type {\n ActivityMetric,\n StartLocation,\n StrengthExercise,\n} from \"./workout.js\";\n\nexport type DumpRange = {\n from: string | null;\n to: string | null;\n};\n\nexport type DumpDocument = {\n generatedAt: string;\n range: DumpRange;\n workouts: DumpWorkout[];\n bodyMeasurements: DumpBodyMeasurement[];\n};\n\nexport type DumpWorkout = DumpEnduranceWorkout | DumpStrengthWorkout;\n\ntype DumpWorkoutBase = {\n id: string;\n provider: WorkoutProvider;\n providerId: string;\n type: \"endurance\" | \"strength\";\n sport: string;\n title: string;\n startedAt: string;\n endedAt: string | null;\n providerExtras: unknown | null;\n};\n\nexport type DumpEnduranceWorkout = DumpWorkoutBase & {\n type: \"endurance\";\n durationSeconds: number;\n distanceMeters: number;\n elevationGainMeters: number;\n elevationLossMeters: number;\n startLocation: StartLocation | null;\n calories: number;\n averageHeartRate: number;\n maxHeartRate: number;\n averageRunningCadenceStepsPerMinute: number;\n averageStrideLengthCentimeters: number;\n averagePaceMinutesPerKilometer: string;\n fastestPaceMinutesPerKilometer: string;\n activityMetrics: ActivityMetric[];\n};\n\nexport type DumpStrengthWorkout = DumpWorkoutBase & {\n type: \"strength\";\n exercises: DumpStrengthExercise[];\n};\n\nexport type DumpStrengthExercise = StrengthExercise;\n\nexport type DumpBodyMeasurement = {\n id: string;\n provider: BodyMeasurementProvider;\n providerId: string;\n measuredAt: string;\n weightKg: number | null;\n fatMassKg: number | null;\n muscleMassKg: number | null;\n boneMassKg: number | null;\n waterMassKg: number | null;\n fatFreeMassKg: number | null;\n heartRateBpm: number | null;\n vascularAgeYears: number | null;\n visceralFatIndex: number | null;\n basalMetabolicRateKcalPerDay: number | null;\n pulseWaveVelocityMetersPerSecond: number | null;\n providerExtras: BodyMeasurementProviderExtras;\n};\n"]}
@@ -1,2 +1,4 @@
1
- export const providers = ["garmin", "hevy"];
1
+ export const providers = ["garmin", "hevy", "withings"];
2
+ export const workoutProviders = ["garmin", "hevy"];
3
+ export const bodyMeasurementProviders = ["withings"];
2
4
  //# sourceMappingURL=provider.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"provider.js","sourceRoot":"","sources":["../../src/domain/provider.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAU,CAAC","sourcesContent":["export const providers = [\"garmin\", \"hevy\"] as const;\n\nexport type Provider = (typeof providers)[number];\n\nexport type ProviderSyncResult = {\n newWorkoutCount: number;\n};\n"]}
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../../src/domain/provider.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAU,CAAC;AAEjE,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAU,CAAC;AAE5D,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,UAAU,CAAU,CAAC","sourcesContent":["export const providers = [\"garmin\", \"hevy\", \"withings\"] as const;\n\nexport const workoutProviders = [\"garmin\", \"hevy\"] as const;\n\nexport const bodyMeasurementProviders = [\"withings\"] as const;\n\nexport type Provider = (typeof providers)[number];\n\nexport type WorkoutProvider = (typeof workoutProviders)[number];\n\nexport type BodyMeasurementProvider = (typeof bodyMeasurementProviders)[number];\n\nexport type ProviderSyncResult = {\n newCount: number;\n};\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"workout.js","sourceRoot":"","sources":["../../src/domain/workout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAkBxB,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC;IAC1C,CAAC,CAAC,MAAM,EAAE;IACV,CAAC,CAAC,MAAM,EAAE;IACV,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAClB,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAC;AAInE,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;AAqBrE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC;KAC/B,MAAM,CAAC;IACN,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE;CAC5B,CAAC;KACD,MAAM,EAAE,CAAC;AAEZ,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC;KACpC,MAAM,CAAC;IACN,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC;CACjC,CAAC;KACD,MAAM,EAAE,CAAC;AAEZ,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAC","sourcesContent":["import { z } from \"zod\";\nimport type { Provider } from \"./provider.js\";\n\nexport type WorkoutType = \"endurance\" | \"strength\";\n\nexport type Workout = {\n id: string;\n provider: Provider;\n providerId: string;\n type: WorkoutType;\n sport: string;\n title: string;\n startedAtMs: number;\n endedAtMs: number | null;\n sourceJson: string;\n providerExtrasJson: string | null;\n};\n\nexport const ActivityMetricSchema = z.tuple([\n z.number(),\n z.number(),\n z.string().min(1),\n]);\n\nexport const ActivityMetricsSchema = z.array(ActivityMetricSchema);\n\nexport type ActivityMetric = z.infer<typeof ActivityMetricSchema>;\n\nexport const StartLocationSchema = z.tuple([z.number(), z.number()]);\n\nexport type StartLocation = z.infer<typeof StartLocationSchema>;\n\nexport type EnduranceMetricsRow = {\n workoutId: string;\n durationSeconds: number;\n distanceMeters: number;\n elevationGainMeters: number;\n elevationLossMeters: number;\n startLocation: StartLocation | null;\n calories: number;\n averageHeartRate: number;\n maxHeartRate: number;\n averageRunningCadenceStepsPerMinute: number;\n averageStrideLengthCentimeters: number;\n averagePaceMinutesPerKilometer: string;\n fastestPaceMinutesPerKilometer: string;\n activityMetricsJson: string;\n};\n\nexport const StrengthSetSchema = z\n .object({\n weightKg: z.number(),\n reps: z.number(),\n durationSeconds: z.number(),\n })\n .strict();\n\nexport const StrengthExerciseSchema = z\n .object({\n title: z.string().min(1),\n sets: z.array(StrengthSetSchema),\n })\n .strict();\n\nexport const StrengthExercisesSchema = z.array(StrengthExerciseSchema);\n\nexport type StrengthSet = z.infer<typeof StrengthSetSchema>;\n\nexport type StrengthExercise = z.infer<typeof StrengthExerciseSchema>;\n\nexport type StrengthMetricsRow = {\n workoutId: string;\n exercisesJson: string;\n};\n\nexport type WorkoutWithMetrics =\n | {\n type: \"endurance\";\n workout: Workout & { type: \"endurance\" };\n enduranceMetrics: EnduranceMetricsRow;\n }\n | {\n type: \"strength\";\n workout: Workout & { type: \"strength\" };\n strengthMetrics: StrengthMetricsRow;\n };\n"]}
1
+ {"version":3,"file":"workout.js","sourceRoot":"","sources":["../../src/domain/workout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAkBxB,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC;IAC1C,CAAC,CAAC,MAAM,EAAE;IACV,CAAC,CAAC,MAAM,EAAE;IACV,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAClB,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAC;AAInE,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;AAqBrE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC;KAC/B,MAAM,CAAC;IACN,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE;CAC5B,CAAC;KACD,MAAM,EAAE,CAAC;AAEZ,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC;KACpC,MAAM,CAAC;IACN,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACxB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC;CACjC,CAAC;KACD,MAAM,EAAE,CAAC;AAEZ,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAC","sourcesContent":["import { z } from \"zod\";\nimport type { WorkoutProvider } from \"./provider.js\";\n\nexport type WorkoutType = \"endurance\" | \"strength\";\n\nexport type Workout = {\n id: string;\n provider: WorkoutProvider;\n providerId: string;\n type: WorkoutType;\n sport: string;\n title: string;\n startedAtMs: number;\n endedAtMs: number | null;\n sourceJson: string;\n providerExtrasJson: string | null;\n};\n\nexport const ActivityMetricSchema = z.tuple([\n z.number(),\n z.number(),\n z.string().min(1),\n]);\n\nexport const ActivityMetricsSchema = z.array(ActivityMetricSchema);\n\nexport type ActivityMetric = z.infer<typeof ActivityMetricSchema>;\n\nexport const StartLocationSchema = z.tuple([z.number(), z.number()]);\n\nexport type StartLocation = z.infer<typeof StartLocationSchema>;\n\nexport type EnduranceMetricsRow = {\n workoutId: string;\n durationSeconds: number;\n distanceMeters: number;\n elevationGainMeters: number;\n elevationLossMeters: number;\n startLocation: StartLocation | null;\n calories: number;\n averageHeartRate: number;\n maxHeartRate: number;\n averageRunningCadenceStepsPerMinute: number;\n averageStrideLengthCentimeters: number;\n averagePaceMinutesPerKilometer: string;\n fastestPaceMinutesPerKilometer: string;\n activityMetricsJson: string;\n};\n\nexport const StrengthSetSchema = z\n .object({\n weightKg: z.number(),\n reps: z.number(),\n durationSeconds: z.number(),\n })\n .strict();\n\nexport const StrengthExerciseSchema = z\n .object({\n title: z.string().min(1),\n sets: z.array(StrengthSetSchema),\n })\n .strict();\n\nexport const StrengthExercisesSchema = z.array(StrengthExerciseSchema);\n\nexport type StrengthSet = z.infer<typeof StrengthSetSchema>;\n\nexport type StrengthExercise = z.infer<typeof StrengthExerciseSchema>;\n\nexport type StrengthMetricsRow = {\n workoutId: string;\n exercisesJson: string;\n};\n\nexport type WorkoutWithMetrics =\n | {\n type: \"endurance\";\n workout: Workout & { type: \"endurance\" };\n enduranceMetrics: EnduranceMetricsRow;\n }\n | {\n type: \"strength\";\n workout: Workout & { type: \"strength\" };\n strengthMetrics: StrengthMetricsRow;\n };\n"]}
@@ -38,10 +38,10 @@ export async function syncGarmin(db, state) {
38
38
  updateProviderCursor(db, "garmin", stringifyJson({
39
39
  version: 1,
40
40
  highestSyncedActivityId,
41
- }), Date.now());
41
+ }));
42
42
  });
43
43
  transaction();
44
- return { newWorkoutCount };
44
+ return { newCount: newWorkoutCount };
45
45
  }
46
46
  function formatWorkoutCount(count) {
47
47
  return `${count} new ${count === 1 ? "workout" : "workouts"}`;
@@ -1 +1 @@
1
- {"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/providers/garmin/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 { logger } from \"../../utils/logger.js\";\nimport { parseJson, parseSchema, stringifyJson } from \"../../utils/parse.js\";\nimport { GarminClient } from \"./client.js\";\nimport { normalizeGarminWorkoutSource } from \"./normalize.js\";\nimport { fetchFullGarminWorkout } from \"./source.js\";\nimport type {\n GarminApiActivity,\n GarminCursor,\n GarminTokens,\n GarminWorkoutSource,\n} from \"./types.js\";\nimport { GarminCursorSchema, GarminTokensSchema } from \"./types.js\";\n\nconst batchSize = 100;\n\nexport async function syncGarmin(\n db: HealthlogDatabase,\n state: ProviderState,\n): Promise<ProviderSyncResult> {\n const tokens = parseGarminTokens(state.credentialsJson);\n const cursor = parseGarminCursor(state.cursorJson);\n const client = GarminClient.fromTokens(tokens);\n const newActivities = await getActivitiesToSync(\n client,\n cursor.highestSyncedActivityId,\n );\n const sortedNewActivities = [...newActivities].sort(\n (a, b) => a.activityId - b.activityId,\n );\n if (sortedNewActivities.length > 0) {\n logger.info(\n `Garmin found ${formatWorkoutCount(sortedNewActivities.length)} to sync.`,\n );\n }\n let newWorkoutCount = 0;\n let highestSyncedActivityId = cursor.highestSyncedActivityId;\n\n const fullWorkoutByActivityId = new Map<number, GarminWorkoutSource>();\n for (const [index, activity] of sortedNewActivities.entries()) {\n logger.debug(\n `Garmin fetching ${index + 1}/${sortedNewActivities.length}: ${activity.activityName}`,\n );\n fullWorkoutByActivityId.set(\n activity.activityId,\n await fetchFullGarminWorkout(client, activity),\n );\n }\n\n const transaction = db.transaction(() => {\n for (const activity of sortedNewActivities) {\n const fullWorkout = fullWorkoutByActivityId.get(activity.activityId);\n if (!fullWorkout) {\n throw new Error(\n `Missing full Garmin workout for activity ${activity.activityId}`,\n );\n }\n const rows = normalizeGarminWorkoutSource(fullWorkout);\n if (upsertNormalizedWorkout(db, rows)) {\n newWorkoutCount += 1;\n }\n highestSyncedActivityId = Math.max(\n highestSyncedActivityId,\n activity.activityId,\n );\n }\n\n updateProviderCursor(\n db,\n \"garmin\",\n stringifyJson({\n version: 1,\n highestSyncedActivityId,\n } satisfies GarminCursor),\n Date.now(),\n );\n });\n\n transaction();\n return { newWorkoutCount };\n}\n\nfunction formatWorkoutCount(count: number): string {\n return `${count} new ${count === 1 ? \"workout\" : \"workouts\"}`;\n}\n\nasync function getActivitiesToSync(\n client: GarminClient,\n highestSyncedActivityId: number,\n): Promise<GarminApiActivity[]> {\n const activities: GarminApiActivity[] = [];\n let start = 0;\n\n while (true) {\n const batch = await client.getActivities(start, batchSize);\n const lastSyncedIndex = batch.findIndex(\n (activity) => activity.activityId <= highestSyncedActivityId,\n );\n\n if (lastSyncedIndex !== -1) {\n activities.push(...batch.slice(0, lastSyncedIndex));\n return activities;\n }\n\n activities.push(...batch);\n\n if (batch.length < batchSize) {\n return activities;\n }\n\n start += batchSize;\n }\n}\n\nfunction parseGarminTokens(json: string): GarminTokens {\n return parseSchema(\n GarminTokensSchema,\n parseJson(json, \"garmin credentials_json\"),\n \"Garmin credentials\",\n );\n}\n\nfunction parseGarminCursor(json: string): GarminCursor {\n return parseSchema(\n GarminCursorSchema,\n parseJson(json, \"garmin cursor_json\"),\n \"Garmin cursor\",\n );\n}\n"]}
1
+ {"version":3,"file":"sync.js","sourceRoot":"","sources":["../../../src/providers/garmin/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 { logger } from \"../../utils/logger.js\";\nimport { parseJson, parseSchema, stringifyJson } from \"../../utils/parse.js\";\nimport { GarminClient } from \"./client.js\";\nimport { normalizeGarminWorkoutSource } from \"./normalize.js\";\nimport { fetchFullGarminWorkout } from \"./source.js\";\nimport type {\n GarminApiActivity,\n GarminCursor,\n GarminTokens,\n GarminWorkoutSource,\n} from \"./types.js\";\nimport { GarminCursorSchema, GarminTokensSchema } from \"./types.js\";\n\nconst batchSize = 100;\n\nexport async function syncGarmin(\n db: HealthlogDatabase,\n state: ProviderState,\n): Promise<ProviderSyncResult> {\n const tokens = parseGarminTokens(state.credentialsJson);\n const cursor = parseGarminCursor(state.cursorJson);\n const client = GarminClient.fromTokens(tokens);\n const newActivities = await getActivitiesToSync(\n client,\n cursor.highestSyncedActivityId,\n );\n const sortedNewActivities = [...newActivities].sort(\n (a, b) => a.activityId - b.activityId,\n );\n if (sortedNewActivities.length > 0) {\n logger.info(\n `Garmin found ${formatWorkoutCount(sortedNewActivities.length)} to sync.`,\n );\n }\n let newWorkoutCount = 0;\n let highestSyncedActivityId = cursor.highestSyncedActivityId;\n\n const fullWorkoutByActivityId = new Map<number, GarminWorkoutSource>();\n for (const [index, activity] of sortedNewActivities.entries()) {\n logger.debug(\n `Garmin fetching ${index + 1}/${sortedNewActivities.length}: ${activity.activityName}`,\n );\n fullWorkoutByActivityId.set(\n activity.activityId,\n await fetchFullGarminWorkout(client, activity),\n );\n }\n\n const transaction = db.transaction(() => {\n for (const activity of sortedNewActivities) {\n const fullWorkout = fullWorkoutByActivityId.get(activity.activityId);\n if (!fullWorkout) {\n throw new Error(\n `Missing full Garmin workout for activity ${activity.activityId}`,\n );\n }\n const rows = normalizeGarminWorkoutSource(fullWorkout);\n if (upsertNormalizedWorkout(db, rows)) {\n newWorkoutCount += 1;\n }\n highestSyncedActivityId = Math.max(\n highestSyncedActivityId,\n activity.activityId,\n );\n }\n\n updateProviderCursor(\n db,\n \"garmin\",\n stringifyJson({\n version: 1,\n highestSyncedActivityId,\n } satisfies GarminCursor),\n );\n });\n\n transaction();\n return { newCount: newWorkoutCount };\n}\n\nfunction formatWorkoutCount(count: number): string {\n return `${count} new ${count === 1 ? \"workout\" : \"workouts\"}`;\n}\n\nasync function getActivitiesToSync(\n client: GarminClient,\n highestSyncedActivityId: number,\n): Promise<GarminApiActivity[]> {\n const activities: GarminApiActivity[] = [];\n let start = 0;\n\n while (true) {\n const batch = await client.getActivities(start, batchSize);\n const lastSyncedIndex = batch.findIndex(\n (activity) => activity.activityId <= highestSyncedActivityId,\n );\n\n if (lastSyncedIndex !== -1) {\n activities.push(...batch.slice(0, lastSyncedIndex));\n return activities;\n }\n\n activities.push(...batch);\n\n if (batch.length < batchSize) {\n return activities;\n }\n\n start += batchSize;\n }\n}\n\nfunction parseGarminTokens(json: string): GarminTokens {\n return parseSchema(\n GarminTokensSchema,\n parseJson(json, \"garmin credentials_json\"),\n \"Garmin credentials\",\n );\n}\n\nfunction parseGarminCursor(json: string): GarminCursor {\n return parseSchema(\n GarminCursorSchema,\n parseJson(json, \"garmin cursor_json\"),\n \"Garmin cursor\",\n );\n}\n"]}