@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
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # healthlog
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.
4
+
5
+ ## Setup
6
+
7
+ Install the CLI:
8
+
9
+ ```sh
10
+ pnpm add -g @cmichel/healthlog
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```sh
16
+ pnpm install
17
+ pnpm build
18
+ ```
19
+
20
+ Configure Garmin:
21
+
22
+ ```sh
23
+ healthlog setup garmin
24
+ ```
25
+
26
+ Configure Hevy:
27
+
28
+ ```sh
29
+ healthlog setup hevy
30
+ ```
31
+
32
+ Get the Hevy API key from [Hevy developer settings](https://hevy.com/settings?developer). This requires Hevy Pro.
33
+
34
+ Dump workouts:
35
+
36
+ ```sh
37
+ # First sync may take a while as it syncs all activities.
38
+ healthlog dump --verbose --pretty
39
+ healthlog dump --from 2026-01-01 --to 2026-06-27
40
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { createDumpCommand } from "./commands/dump.js";
4
+ import { createSetupGarminCommand } from "./commands/setup-garmin.js";
5
+ import { createSetupHevyCommand } from "./commands/setup-hevy.js";
6
+ import { resolveDatabasePath } from "./config/database-path.js";
7
+ import { isVerbose, isVerboseArgv, logger, setLoggerVerbose, } from "./utils/logger.js";
8
+ process.setSourceMapsEnabled(true);
9
+ export function createProgram() {
10
+ const program = new Command();
11
+ program
12
+ .name("healthlog")
13
+ .description("Sync Garmin and Hevy workout data into SQLite and dump normalized JSON")
14
+ .configureHelp({ showGlobalOptions: true })
15
+ .option("--db <path>", "Override SQLite database path")
16
+ .option("--verbose", "Enable verbose debug logging")
17
+ .hook("preAction", () => {
18
+ setLoggerVerbose(isVerbose(program.opts()));
19
+ });
20
+ const getDatabasePath = () => {
21
+ const options = program.opts();
22
+ return resolveDatabasePath({ cliPath: options.db });
23
+ };
24
+ const setup = new Command("setup")
25
+ .description("Set up provider credentials")
26
+ .configureHelp({ showGlobalOptions: true })
27
+ .addCommand(createSetupGarminCommand(getDatabasePath))
28
+ .addCommand(createSetupHevyCommand(getDatabasePath));
29
+ program.addCommand(setup);
30
+ program.addCommand(createDumpCommand(getDatabasePath));
31
+ return program;
32
+ }
33
+ async function main() {
34
+ try {
35
+ await createProgram().parseAsync(process.argv);
36
+ }
37
+ catch (error) {
38
+ const verbose = isVerboseArgv(process.argv);
39
+ setLoggerVerbose(verbose);
40
+ logger.error(error instanceof Error && verbose
41
+ ? (error.stack ?? error.message)
42
+ : error instanceof Error
43
+ ? error.message
44
+ : String(error));
45
+ process.exitCode = 1;
46
+ }
47
+ }
48
+ await main();
49
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,26 @@
1
+ import { Command } from "commander";
2
+ import { openDatabase } from "../db/database.js";
3
+ import { buildDumpDocument } from "../services/dump-service.js";
4
+ import { syncConfiguredProviders } from "../services/sync-service.js";
5
+ import { parseDateRange } from "../utils/dates.js";
6
+ export function createDumpCommand(getDatabasePath) {
7
+ return new Command("dump")
8
+ .description("Sync configured providers and dump normalized workout JSON")
9
+ .configureHelp({ showGlobalOptions: true })
10
+ .option("--from <date>", "Start date, YYYY-MM-DD")
11
+ .option("--to <date>", "End date, YYYY-MM-DD")
12
+ .option("--pretty", "Pretty-print JSON output")
13
+ .action(async (options) => {
14
+ const range = parseDateRange(options);
15
+ const db = openDatabase(getDatabasePath());
16
+ try {
17
+ await syncConfiguredProviders(db);
18
+ const dump = buildDumpDocument(db, range);
19
+ console.log(options.pretty ? JSON.stringify(dump, null, 2) : JSON.stringify(dump));
20
+ }
21
+ finally {
22
+ db.close();
23
+ }
24
+ });
25
+ }
26
+ //# sourceMappingURL=dump.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,30 @@
1
+ import { input, password } from "@inquirer/prompts";
2
+ import { Command } from "commander";
3
+ import { openDatabase } from "../db/database.js";
4
+ import { GarminClient } from "../providers/garmin/client.js";
5
+ import { storeGarminCredentials } from "../services/setup-service.js";
6
+ export function createSetupGarminCommand(getDatabasePath) {
7
+ return new Command("garmin")
8
+ .description("Set up Garmin Connect credentials")
9
+ .configureHelp({ showGlobalOptions: true })
10
+ .action(async () => {
11
+ const username = await input({
12
+ message: "Garmin username:",
13
+ validate: (value) => value.trim() === "" ? "Garmin username is required" : true,
14
+ });
15
+ const garminPassword = await password({
16
+ message: "Garmin password:",
17
+ mask: "*",
18
+ validate: (value) => value.trim() === "" ? "Garmin password is required" : true,
19
+ });
20
+ const tokens = await GarminClient.login(username, garminPassword);
21
+ const db = openDatabase(getDatabasePath());
22
+ try {
23
+ storeGarminCredentials(db, tokens);
24
+ }
25
+ finally {
26
+ db.close();
27
+ }
28
+ });
29
+ }
30
+ //# sourceMappingURL=setup-garmin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup-garmin.js","sourceRoot":"","sources":["../../src/commands/setup-garmin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAC7D,OAAO,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAEtE,MAAM,UAAU,wBAAwB,CACtC,eAA6B;IAE7B,OAAO,IAAI,OAAO,CAAC,QAAQ,CAAC;SACzB,WAAW,CAAC,mCAAmC,CAAC;SAChD,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC;YAC3B,OAAO,EAAE,kBAAkB;YAC3B,QAAQ,EAAE,CAAC,KAAK,EAAE,EAAE,CAClB,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,6BAA6B,CAAC,CAAC,CAAC,IAAI;SAC7D,CAAC,CAAC;QACH,MAAM,cAAc,GAAG,MAAM,QAAQ,CAAC;YACpC,OAAO,EAAE,kBAAkB;YAC3B,IAAI,EAAE,GAAG;YACT,QAAQ,EAAE,CAAC,KAAK,EAAE,EAAE,CAClB,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,6BAA6B,CAAC,CAAC,CAAC,IAAI;SAC7D,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,KAAK,CAAC,QAAQ,EAAE,cAAc,CAAC,CAAC;QAClE,MAAM,EAAE,GAAG,YAAY,CAAC,eAAe,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC;YACH,sBAAsB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QACrC,CAAC;gBAAS,CAAC;YACT,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC","sourcesContent":["import { input, password } from \"@inquirer/prompts\";\nimport { Command } from \"commander\";\nimport { openDatabase } from \"../db/database.js\";\nimport { GarminClient } from \"../providers/garmin/client.js\";\nimport { storeGarminCredentials } from \"../services/setup-service.js\";\n\nexport function createSetupGarminCommand(\n getDatabasePath: () => string,\n): Command {\n return new Command(\"garmin\")\n .description(\"Set up Garmin Connect credentials\")\n .configureHelp({ showGlobalOptions: true })\n .action(async () => {\n const username = await input({\n message: \"Garmin username:\",\n validate: (value) =>\n value.trim() === \"\" ? \"Garmin username is required\" : true,\n });\n const garminPassword = await password({\n message: \"Garmin password:\",\n mask: \"*\",\n validate: (value) =>\n value.trim() === \"\" ? \"Garmin password is required\" : true,\n });\n\n const tokens = await GarminClient.login(username, garminPassword);\n const db = openDatabase(getDatabasePath());\n try {\n storeGarminCredentials(db, tokens);\n } finally {\n db.close();\n }\n });\n}\n"]}
@@ -0,0 +1,27 @@
1
+ import { password } from "@inquirer/prompts";
2
+ import { Command } from "commander";
3
+ import { openDatabase } from "../db/database.js";
4
+ import { HevyClient } from "../providers/hevy/client.js";
5
+ import { storeHevyCredentials } from "../services/setup-service.js";
6
+ export function createSetupHevyCommand(getDatabasePath) {
7
+ return new Command("hevy")
8
+ .description("Set up Hevy API credentials")
9
+ .configureHelp({ showGlobalOptions: true })
10
+ .action(async () => {
11
+ const apiKey = await password({
12
+ message: "Hevy API key:",
13
+ mask: "*",
14
+ validate: (value) => value.trim() === "" ? "Hevy API key is required" : true,
15
+ });
16
+ const trimmedApiKey = apiKey.trim();
17
+ await HevyClient.verifyApiKey(trimmedApiKey);
18
+ const db = openDatabase(getDatabasePath());
19
+ try {
20
+ storeHevyCredentials(db, trimmedApiKey);
21
+ }
22
+ finally {
23
+ db.close();
24
+ }
25
+ });
26
+ }
27
+ //# sourceMappingURL=setup-hevy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup-hevy.js","sourceRoot":"","sources":["../../src/commands/setup-hevy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAEpE,MAAM,UAAU,sBAAsB,CAAC,eAA6B;IAClE,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;SACvB,WAAW,CAAC,6BAA6B,CAAC;SAC1C,aAAa,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC;SAC1C,MAAM,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC;YAC5B,OAAO,EAAE,eAAe;YACxB,IAAI,EAAE,GAAG;YACT,QAAQ,EAAE,CAAC,KAAK,EAAE,EAAE,CAClB,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,0BAA0B,CAAC,CAAC,CAAC,IAAI;SAC1D,CAAC,CAAC;QACH,MAAM,aAAa,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;QAEpC,MAAM,UAAU,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;QAE7C,MAAM,EAAE,GAAG,YAAY,CAAC,eAAe,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC;YACH,oBAAoB,CAAC,EAAE,EAAE,aAAa,CAAC,CAAC;QAC1C,CAAC;gBAAS,CAAC;YACT,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC","sourcesContent":["import { password } from \"@inquirer/prompts\";\nimport { Command } from \"commander\";\nimport { openDatabase } from \"../db/database.js\";\nimport { HevyClient } from \"../providers/hevy/client.js\";\nimport { storeHevyCredentials } from \"../services/setup-service.js\";\n\nexport function createSetupHevyCommand(getDatabasePath: () => string): Command {\n return new Command(\"hevy\")\n .description(\"Set up Hevy API credentials\")\n .configureHelp({ showGlobalOptions: true })\n .action(async () => {\n const apiKey = await password({\n message: \"Hevy API key:\",\n mask: \"*\",\n validate: (value) =>\n value.trim() === \"\" ? \"Hevy API key is required\" : true,\n });\n const trimmedApiKey = apiKey.trim();\n\n await HevyClient.verifyApiKey(trimmedApiKey);\n\n const db = openDatabase(getDatabasePath());\n try {\n storeHevyCredentials(db, trimmedApiKey);\n } finally {\n db.close();\n }\n });\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function resolveDatabasePath(options) {
4
+ if (options.cliPath && options.cliPath.trim() !== "") {
5
+ return path.resolve(options.cliPath);
6
+ }
7
+ const envPath = process.env.HEALTHLOG_DB_PATH;
8
+ if (envPath && envPath.trim() !== "") {
9
+ return path.resolve(envPath);
10
+ }
11
+ return platformDefaultDatabasePath();
12
+ }
13
+ function platformDefaultDatabasePath() {
14
+ const home = os.homedir();
15
+ if (process.platform === "darwin") {
16
+ return path.join(home, "Library", "Application Support", "healthlog", "db.sqlite3");
17
+ }
18
+ if (process.platform === "linux") {
19
+ const xdgDataHome = process.env.XDG_DATA_HOME;
20
+ const dataHome = xdgDataHome && xdgDataHome.trim() !== ""
21
+ ? xdgDataHome
22
+ : path.join(home, ".local", "share");
23
+ return path.join(dataHome, "healthlog", "db.sqlite3");
24
+ }
25
+ if (process.platform === "win32") {
26
+ const localAppData = process.env.LOCALAPPDATA;
27
+ const dataHome = localAppData && localAppData.trim() !== ""
28
+ ? localAppData
29
+ : path.join(home, "AppData", "Local");
30
+ return path.join(dataHome, "healthlog", "db.sqlite3");
31
+ }
32
+ return path.join(home, ".healthlog", "db.sqlite3");
33
+ }
34
+ //# sourceMappingURL=database-path.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database-path.js","sourceRoot":"","sources":["../../src/config/database-path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAM7B,MAAM,UAAU,mBAAmB,CAAC,OAA4B;IAC9D,IAAI,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACrD,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;IAC9C,IAAI,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACrC,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAED,OAAO,2BAA2B,EAAE,CAAC;AACvC,CAAC;AAED,SAAS,2BAA2B;IAClC,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC;IAE1B,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAClC,OAAO,IAAI,CAAC,IAAI,CACd,IAAI,EACJ,SAAS,EACT,qBAAqB,EACrB,WAAW,EACX,YAAY,CACb,CAAC;IACJ,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QACjC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;QAC9C,MAAM,QAAQ,GACZ,WAAW,IAAI,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE;YACtC,CAAC,CAAC,WAAW;YACb,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;QACzC,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;IACxD,CAAC;IAED,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QACjC,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QAC9C,MAAM,QAAQ,GACZ,YAAY,IAAI,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE;YACxC,CAAC,CAAC,YAAY;YACd,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;QAC1C,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;IACxD,CAAC;IAED,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,YAAY,EAAE,YAAY,CAAC,CAAC;AACrD,CAAC","sourcesContent":["import os from \"node:os\";\nimport path from \"node:path\";\n\nexport type DatabasePathOptions = {\n cliPath?: string | undefined;\n};\n\nexport function resolveDatabasePath(options: DatabasePathOptions): string {\n if (options.cliPath && options.cliPath.trim() !== \"\") {\n return path.resolve(options.cliPath);\n }\n\n const envPath = process.env.HEALTHLOG_DB_PATH;\n if (envPath && envPath.trim() !== \"\") {\n return path.resolve(envPath);\n }\n\n return platformDefaultDatabasePath();\n}\n\nfunction platformDefaultDatabasePath(): string {\n const home = os.homedir();\n\n if (process.platform === \"darwin\") {\n return path.join(\n home,\n \"Library\",\n \"Application Support\",\n \"healthlog\",\n \"db.sqlite3\",\n );\n }\n\n if (process.platform === \"linux\") {\n const xdgDataHome = process.env.XDG_DATA_HOME;\n const dataHome =\n xdgDataHome && xdgDataHome.trim() !== \"\"\n ? xdgDataHome\n : path.join(home, \".local\", \"share\");\n return path.join(dataHome, \"healthlog\", \"db.sqlite3\");\n }\n\n if (process.platform === \"win32\") {\n const localAppData = process.env.LOCALAPPDATA;\n const dataHome =\n localAppData && localAppData.trim() !== \"\"\n ? localAppData\n : path.join(home, \"AppData\", \"Local\");\n return path.join(dataHome, \"healthlog\", \"db.sqlite3\");\n }\n\n return path.join(home, \".healthlog\", \"db.sqlite3\");\n}\n"]}
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { initializeSchema } from "./schema.js";
5
+ export function openDatabase(databasePath) {
6
+ const directory = path.dirname(databasePath);
7
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
8
+ const db = new Database(databasePath);
9
+ try {
10
+ fs.chmodSync(directory, 0o700);
11
+ fs.chmodSync(databasePath, 0o600);
12
+ }
13
+ catch {
14
+ // Best effort only; not every filesystem honors POSIX permissions.
15
+ }
16
+ db.pragma("foreign_keys = ON");
17
+ initializeSchema(db);
18
+ return db;
19
+ }
20
+ //# sourceMappingURL=database.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAI/C,MAAM,UAAU,YAAY,CAAC,YAAoB;IAC/C,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7C,EAAE,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAE1D,MAAM,EAAE,GAAG,IAAI,QAAQ,CAAC,YAAY,CAAC,CAAC;IACtC,IAAI,CAAC;QACH,EAAE,CAAC,SAAS,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAC/B,EAAE,CAAC,SAAS,CAAC,YAAY,EAAE,KAAK,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,mEAAmE;IACrE,CAAC;IAED,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;IAC/B,gBAAgB,CAAC,EAAE,CAAC,CAAC;IACrB,OAAO,EAAE,CAAC;AACZ,CAAC","sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport { initializeSchema } from \"./schema.js\";\n\nexport type HealthlogDatabase = Database.Database;\n\nexport function openDatabase(databasePath: string): HealthlogDatabase {\n const directory = path.dirname(databasePath);\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n\n const db = new Database(databasePath);\n try {\n fs.chmodSync(directory, 0o700);\n fs.chmodSync(databasePath, 0o600);\n } catch {\n // Best effort only; not every filesystem honors POSIX permissions.\n }\n\n db.pragma(\"foreign_keys = ON\");\n initializeSchema(db);\n return db;\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import { stringifyJson } from "../utils/parse.js";
2
+ export function insertEnduranceMetrics(db, metrics) {
3
+ db.prepare(`
4
+ INSERT INTO endurance_metrics (
5
+ workout_id,
6
+ duration_seconds,
7
+ distance_meters,
8
+ elevation_gain_meters,
9
+ elevation_loss_meters,
10
+ start_location_json,
11
+ calories,
12
+ avg_hr,
13
+ max_hr,
14
+ avg_running_cadence_spm,
15
+ avg_stride_length_cm,
16
+ avg_pace_min_per_km,
17
+ fastest_pace_min_per_km,
18
+ activity_metrics_json
19
+ )
20
+ VALUES (
21
+ @workoutId,
22
+ @durationSeconds,
23
+ @distanceMeters,
24
+ @elevationGainMeters,
25
+ @elevationLossMeters,
26
+ @startLocationJson,
27
+ @calories,
28
+ @averageHeartRate,
29
+ @maxHeartRate,
30
+ @averageRunningCadenceStepsPerMinute,
31
+ @averageStrideLengthCentimeters,
32
+ @averagePaceMinutesPerKilometer,
33
+ @fastestPaceMinutesPerKilometer,
34
+ @activityMetricsJson
35
+ )
36
+ `).run({
37
+ ...metrics,
38
+ startLocationJson: metrics.startLocation === null
39
+ ? null
40
+ : stringifyJson(metrics.startLocation),
41
+ });
42
+ }
43
+ //# sourceMappingURL=endurance-metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"endurance-metrics.js","sourceRoot":"","sources":["../../src/db/endurance-metrics.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGlD,MAAM,UAAU,sBAAsB,CACpC,EAAqB,EACrB,OAA4B;IAE5B,EAAE,CAAC,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCV,CAAC,CAAC,GAAG,CAAC;QACL,GAAG,OAAO;QACV,iBAAiB,EACf,OAAO,CAAC,aAAa,KAAK,IAAI;YAC5B,CAAC,CAAC,IAAI;YACN,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,aAAa,CAAC;KAC3C,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type { EnduranceMetricsRow } from \"../domain/workout.js\";\nimport { stringifyJson } from \"../utils/parse.js\";\nimport type { HealthlogDatabase } from \"./database.js\";\n\nexport function insertEnduranceMetrics(\n db: HealthlogDatabase,\n metrics: EnduranceMetricsRow,\n): void {\n db.prepare(`\n INSERT INTO endurance_metrics (\n workout_id,\n duration_seconds,\n distance_meters,\n elevation_gain_meters,\n elevation_loss_meters,\n start_location_json,\n calories,\n avg_hr,\n max_hr,\n avg_running_cadence_spm,\n avg_stride_length_cm,\n avg_pace_min_per_km,\n fastest_pace_min_per_km,\n activity_metrics_json\n )\n VALUES (\n @workoutId,\n @durationSeconds,\n @distanceMeters,\n @elevationGainMeters,\n @elevationLossMeters,\n @startLocationJson,\n @calories,\n @averageHeartRate,\n @maxHeartRate,\n @averageRunningCadenceStepsPerMinute,\n @averageStrideLengthCentimeters,\n @averagePaceMinutesPerKilometer,\n @fastestPaceMinutesPerKilometer,\n @activityMetricsJson\n )\n `).run({\n ...metrics,\n startLocationJson:\n metrics.startLocation === null\n ? null\n : stringifyJson(metrics.startLocation),\n });\n}\n"]}
@@ -0,0 +1,48 @@
1
+ export function getProviderState(db, provider) {
2
+ const row = db
3
+ .prepare("SELECT provider, credentials_json, cursor_json, last_synced_at_ms, updated_at_ms FROM provider_state WHERE provider = ?")
4
+ .get(provider);
5
+ return row ? mapProviderDbRow(row) : null;
6
+ }
7
+ export function upsertProviderState(db, provider, credentialsJson, cursorJson, lastSyncedAtMs) {
8
+ const updatedAtMs = Date.now();
9
+ 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)
12
+ ON CONFLICT(provider) DO UPDATE SET
13
+ 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
17
+ `).run({
18
+ provider,
19
+ credentialsJson,
20
+ cursorJson,
21
+ lastSyncedAtMs,
22
+ updatedAtMs,
23
+ });
24
+ }
25
+ export function updateProviderCursor(db, provider, cursorJson, lastSyncedAtMs) {
26
+ const result = db
27
+ .prepare(`
28
+ UPDATE provider_state
29
+ SET cursor_json = @cursorJson,
30
+ last_synced_at_ms = @lastSyncedAtMs,
31
+ updated_at_ms = @updatedAtMs
32
+ WHERE provider = @provider
33
+ `)
34
+ .run({ provider, cursorJson, lastSyncedAtMs, updatedAtMs: Date.now() });
35
+ if (result.changes !== 1) {
36
+ throw new Error(`Cannot update ${provider} cursor because provider is not configured`);
37
+ }
38
+ }
39
+ function mapProviderDbRow(row) {
40
+ return {
41
+ provider: row.provider,
42
+ credentialsJson: row.credentials_json,
43
+ cursorJson: row.cursor_json,
44
+ lastSyncedAtMs: row.last_synced_at_ms,
45
+ updatedAtMs: row.updated_at_ms,
46
+ };
47
+ }
48
+ //# sourceMappingURL=provider-state.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,53 @@
1
+ export function initializeSchema(db) {
2
+ db.exec(`
3
+ CREATE TABLE IF NOT EXISTS provider_state (
4
+ provider TEXT PRIMARY KEY CHECK (provider IN ('garmin', 'hevy')),
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
9
+ );
10
+
11
+ CREATE TABLE IF NOT EXISTS workouts (
12
+ id TEXT PRIMARY KEY,
13
+ provider TEXT NOT NULL CHECK (provider IN ('garmin', 'hevy')),
14
+ provider_id TEXT NOT NULL,
15
+ type TEXT NOT NULL CHECK (type IN ('endurance', 'strength')),
16
+ sport TEXT NOT NULL,
17
+ title TEXT NOT NULL,
18
+ started_at_ms INTEGER NOT NULL,
19
+ ended_at_ms INTEGER,
20
+ source_json TEXT NOT NULL,
21
+ provider_extras_json TEXT
22
+ );
23
+
24
+ CREATE UNIQUE INDEX IF NOT EXISTS workouts_provider_provider_id_idx
25
+ ON workouts (provider, provider_id);
26
+
27
+ CREATE INDEX IF NOT EXISTS workouts_started_at_id_idx
28
+ ON workouts (started_at_ms, id);
29
+
30
+ CREATE TABLE IF NOT EXISTS endurance_metrics (
31
+ workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,
32
+ duration_seconds REAL NOT NULL,
33
+ distance_meters REAL NOT NULL,
34
+ elevation_gain_meters REAL NOT NULL,
35
+ elevation_loss_meters REAL NOT NULL,
36
+ start_location_json TEXT,
37
+ calories REAL NOT NULL,
38
+ avg_hr REAL NOT NULL,
39
+ max_hr REAL NOT NULL,
40
+ avg_running_cadence_spm REAL NOT NULL,
41
+ avg_stride_length_cm REAL NOT NULL,
42
+ avg_pace_min_per_km TEXT NOT NULL,
43
+ fastest_pace_min_per_km TEXT NOT NULL,
44
+ activity_metrics_json TEXT NOT NULL
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS strength_metrics (
48
+ workout_id TEXT PRIMARY KEY REFERENCES workouts(id) ON DELETE CASCADE,
49
+ exercises_json TEXT NOT NULL
50
+ );
51
+ `);
52
+ }
53
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,14 @@
1
+ export function insertStrengthMetrics(db, metrics) {
2
+ const statement = db.prepare(`
3
+ INSERT INTO strength_metrics (
4
+ workout_id,
5
+ exercises_json
6
+ )
7
+ VALUES (
8
+ @workoutId,
9
+ @exercisesJson
10
+ )
11
+ `);
12
+ statement.run(metrics);
13
+ }
14
+ //# sourceMappingURL=strength-metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strength-metrics.js","sourceRoot":"","sources":["../../src/db/strength-metrics.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,qBAAqB,CACnC,EAAqB,EACrB,OAA2B;IAE3B,MAAM,SAAS,GAAG,EAAE,CAAC,OAAO,CAAC;;;;;;;;;GAS5B,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;AACzB,CAAC","sourcesContent":["import type { StrengthMetricsRow } from \"../domain/workout.js\";\nimport type { HealthlogDatabase } from \"./database.js\";\n\nexport function insertStrengthMetrics(\n db: HealthlogDatabase,\n metrics: StrengthMetricsRow,\n): void {\n const statement = db.prepare(`\n INSERT INTO strength_metrics (\n workout_id,\n exercises_json\n )\n VALUES (\n @workoutId,\n @exercisesJson\n )\n `);\n\n statement.run(metrics);\n}\n"]}
@@ -0,0 +1,172 @@
1
+ import { StartLocationSchema } from "../domain/workout.js";
2
+ import { parseJson, parseSchema } from "../utils/parse.js";
3
+ import { insertEnduranceMetrics } from "./endurance-metrics.js";
4
+ import { insertStrengthMetrics } from "./strength-metrics.js";
5
+ export function workoutExists(db, id) {
6
+ const row = db.prepare("SELECT 1 FROM workouts WHERE id = ?").get(id);
7
+ return Boolean(row);
8
+ }
9
+ export function upsertNormalizedWorkout(db, workoutWithMetrics) {
10
+ const inserted = !workoutExists(db, workoutWithMetrics.workout.id);
11
+ db.prepare(`
12
+ INSERT INTO workouts (
13
+ id,
14
+ provider,
15
+ provider_id,
16
+ type,
17
+ sport,
18
+ title,
19
+ started_at_ms,
20
+ ended_at_ms,
21
+ source_json,
22
+ provider_extras_json
23
+ )
24
+ VALUES (
25
+ @id,
26
+ @provider,
27
+ @providerId,
28
+ @type,
29
+ @sport,
30
+ @title,
31
+ @startedAtMs,
32
+ @endedAtMs,
33
+ @sourceJson,
34
+ @providerExtrasJson
35
+ )
36
+ ON CONFLICT(id) DO UPDATE SET
37
+ provider = excluded.provider,
38
+ provider_id = excluded.provider_id,
39
+ type = excluded.type,
40
+ sport = excluded.sport,
41
+ title = excluded.title,
42
+ started_at_ms = excluded.started_at_ms,
43
+ ended_at_ms = excluded.ended_at_ms,
44
+ source_json = excluded.source_json,
45
+ provider_extras_json = excluded.provider_extras_json
46
+ `).run(workoutWithMetrics.workout);
47
+ db.prepare("DELETE FROM endurance_metrics WHERE workout_id = ?").run(workoutWithMetrics.workout.id);
48
+ db.prepare("DELETE FROM strength_metrics WHERE workout_id = ?").run(workoutWithMetrics.workout.id);
49
+ if (workoutWithMetrics.type === "endurance") {
50
+ insertEnduranceMetrics(db, workoutWithMetrics.enduranceMetrics);
51
+ }
52
+ else {
53
+ insertStrengthMetrics(db, workoutWithMetrics.strengthMetrics);
54
+ }
55
+ return inserted;
56
+ }
57
+ export function getWorkoutsWithMetrics(db, range) {
58
+ const conditions = [];
59
+ const params = {};
60
+ if (range.startedAtFromMs !== null) {
61
+ conditions.push("w.started_at_ms >= @startedAtFromMs");
62
+ params.startedAtFromMs = range.startedAtFromMs;
63
+ }
64
+ if (range.startedAtBeforeMs !== null) {
65
+ conditions.push("w.started_at_ms < @startedAtBeforeMs");
66
+ params.startedAtBeforeMs = range.startedAtBeforeMs;
67
+ }
68
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
69
+ const rows = db
70
+ .prepare(`
71
+ SELECT
72
+ w.id,
73
+ w.provider,
74
+ w.provider_id,
75
+ w.type,
76
+ w.sport,
77
+ w.title,
78
+ w.started_at_ms,
79
+ w.ended_at_ms,
80
+ w.source_json,
81
+ w.provider_extras_json,
82
+ em.workout_id AS em_workout_id,
83
+ em.duration_seconds AS em_duration_seconds,
84
+ em.distance_meters AS em_distance_meters,
85
+ em.elevation_gain_meters AS em_elevation_gain_meters,
86
+ em.elevation_loss_meters AS em_elevation_loss_meters,
87
+ em.start_location_json AS em_start_location_json,
88
+ em.calories AS em_calories,
89
+ em.avg_hr AS em_avg_hr,
90
+ em.max_hr AS em_max_hr,
91
+ em.avg_running_cadence_spm AS em_avg_running_cadence_spm,
92
+ em.avg_stride_length_cm AS em_avg_stride_length_cm,
93
+ em.avg_pace_min_per_km AS em_avg_pace_min_per_km,
94
+ em.fastest_pace_min_per_km AS em_fastest_pace_min_per_km,
95
+ em.activity_metrics_json AS em_activity_metrics_json,
96
+ sm.workout_id AS sm_workout_id,
97
+ sm.exercises_json AS sm_exercises_json
98
+ FROM workouts w
99
+ LEFT JOIN endurance_metrics em ON em.workout_id = w.id
100
+ LEFT JOIN strength_metrics sm ON sm.workout_id = w.id
101
+ ${whereClause}
102
+ ORDER BY w.started_at_ms ASC, w.id ASC
103
+ `)
104
+ .all(params);
105
+ return rows.map(mapWorkoutWithMetrics);
106
+ }
107
+ function mapWorkoutWithMetrics(row) {
108
+ if (row.type === "endurance") {
109
+ return {
110
+ type: "endurance",
111
+ workout: { ...mapWorkout(row), type: "endurance" },
112
+ enduranceMetrics: mapEnduranceMetrics(row),
113
+ };
114
+ }
115
+ return {
116
+ type: "strength",
117
+ workout: { ...mapWorkout(row), type: "strength" },
118
+ strengthMetrics: mapStrengthMetrics(row),
119
+ };
120
+ }
121
+ function mapWorkout(row) {
122
+ return {
123
+ id: row.id,
124
+ provider: row.provider,
125
+ providerId: row.provider_id,
126
+ type: row.type,
127
+ sport: row.sport,
128
+ title: row.title,
129
+ startedAtMs: row.started_at_ms,
130
+ endedAtMs: row.ended_at_ms,
131
+ sourceJson: row.source_json,
132
+ providerExtrasJson: row.provider_extras_json,
133
+ };
134
+ }
135
+ function mapEnduranceMetrics(row) {
136
+ const workoutId = requireJoinedValue(row.em_workout_id, "em.workout_id");
137
+ return {
138
+ workoutId,
139
+ durationSeconds: requireJoinedValue(row.em_duration_seconds, "em.duration_seconds"),
140
+ distanceMeters: requireJoinedValue(row.em_distance_meters, "em.distance_meters"),
141
+ elevationGainMeters: requireJoinedValue(row.em_elevation_gain_meters, "em.elevation_gain_meters"),
142
+ elevationLossMeters: requireJoinedValue(row.em_elevation_loss_meters, "em.elevation_loss_meters"),
143
+ startLocation: parseStartLocation(row.em_start_location_json, workoutId),
144
+ calories: requireJoinedValue(row.em_calories, "em.calories"),
145
+ averageHeartRate: requireJoinedValue(row.em_avg_hr, "em.avg_hr"),
146
+ maxHeartRate: requireJoinedValue(row.em_max_hr, "em.max_hr"),
147
+ averageRunningCadenceStepsPerMinute: requireJoinedValue(row.em_avg_running_cadence_spm, "em.avg_running_cadence_spm"),
148
+ averageStrideLengthCentimeters: requireJoinedValue(row.em_avg_stride_length_cm, "em.avg_stride_length_cm"),
149
+ averagePaceMinutesPerKilometer: requireJoinedValue(row.em_avg_pace_min_per_km, "em.avg_pace_min_per_km"),
150
+ fastestPaceMinutesPerKilometer: requireJoinedValue(row.em_fastest_pace_min_per_km, "em.fastest_pace_min_per_km"),
151
+ activityMetricsJson: requireJoinedValue(row.em_activity_metrics_json, "em.activity_metrics_json"),
152
+ };
153
+ }
154
+ function parseStartLocation(startLocationJson, workoutId) {
155
+ if (startLocationJson === null) {
156
+ return null;
157
+ }
158
+ return parseSchema(StartLocationSchema, parseJson(startLocationJson, `start_location_json for ${workoutId}`), `start location for workout ${workoutId}`);
159
+ }
160
+ function mapStrengthMetrics(row) {
161
+ return {
162
+ workoutId: requireJoinedValue(row.sm_workout_id, "sm.workout_id"),
163
+ exercisesJson: requireJoinedValue(row.sm_exercises_json, "sm.exercises_json"),
164
+ };
165
+ }
166
+ function requireJoinedValue(value, column) {
167
+ if (value === null) {
168
+ throw new Error(`Missing joined column ${column}`);
169
+ }
170
+ return value;
171
+ }
172
+ //# sourceMappingURL=workouts.js.map