mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'release/v1.46.0' into feat/v146-server
# Conflicts: # src/__tests__/baselines/source-function-lines.json # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
c40d728b47
442 files changed
+33533
-13503
No files matched your search
+3
-3
@@ -53,8 +53,8 @@
|
||||
"mcp:stdio": "npx tsx src/cli/mcp-stdio.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1152.0",
|
||||
"@aws-sdk/lib-storage": "3.1152.0",
|
||||
"@aws-sdk/client-s3": "^3.1153.0",
|
||||
"@aws-sdk/lib-storage": "3.1153.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -66,7 +66,7 @@
|
||||
"@prisma/client": "^7.10.1",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"@tanstack/react-query": "5.104.4",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@tanstack/react-virtual": "^3.14.21",
|
||||
"@types/sax": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Generated
+43
-52
@@ -46,11 +46,11 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1152.0
|
||||
version: 3.1152.0
|
||||
specifier: ^3.1153.0
|
||||
version: 3.1153.0
|
||||
'@aws-sdk/lib-storage':
|
||||
specifier: 3.1152.0
|
||||
version: 3.1152.0(@aws-sdk/client-s3@3.1152.0)
|
||||
specifier: 3.1153.0
|
||||
version: 3.1153.0(@aws-sdk/client-s3@3.1153.0)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
@@ -85,8 +85,8 @@ importers:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.104.4
|
||||
version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
|
||||
specifier: 5.104.5
|
||||
version: 5.104.5(@types/react@19.3.0)(react@19.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.21
|
||||
version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
@@ -303,8 +303,8 @@ packages:
|
||||
resolution: {integrity: sha512-H8BiG/2DnfwydVxD1uDPLa1s1sfhRnVTbD2OwzfTr3WlJzgbIzICDXxBTlTMjbWNNCe4VS9X55fco+JI34L9kw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-s3@3.1152.0':
|
||||
resolution: {integrity: sha512-oI1+ZaM6pSOwZazu4nAb7rL8ug7LSjg31tDoJqRB9MooRqqWCf/qvkIZqne1mRA3xK3slg6K0RNWXaJYHbWLvg==}
|
||||
'@aws-sdk/client-s3@3.1153.0':
|
||||
resolution: {integrity: sha512-h0VScQZPfBApg1Qi4AedsOUSMZ7m758kZIg0JO0B5HtwSzQhB7Tgh6h1JoGEXELgrWADPusYMpMBDmrRAEx2yw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.978.4':
|
||||
@@ -343,11 +343,11 @@ packages:
|
||||
resolution: {integrity: sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/lib-storage@3.1152.0':
|
||||
resolution: {integrity: sha512-UU1fDLKBd8az+4AmNx30ggnA4D4HolUd1MxT/J6ka76qKcckI0IxjAy6FpX/vwiUf2KDNxxN2YX0cfwjMqzWoQ==}
|
||||
'@aws-sdk/lib-storage@3.1153.0':
|
||||
resolution: {integrity: sha512-zshL4tYVwTXfEOQ0iqNsXUPq/0aoZwOHrAsqlBmGcAij1+8qc79rm9LpzBuEV7KWiSMetZ+89n7mSYgcJKFgyA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/client-s3': ^3.1152.0
|
||||
'@aws-sdk/client-s3': ^3.1153.0
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.80':
|
||||
resolution: {integrity: sha512-zX3lXN/9XOUAuGpxgt3wXwZrXhTjQYEgRsqa856PbZWpOtleUWmCMbhlkLfusPuhW24cOC/8JYR8hnRalrrIkQ==}
|
||||
@@ -2856,12 +2856,8 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@smithy/core@3.35.2':
|
||||
resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.36.1':
|
||||
resolution: {integrity: sha512-WC5W1Jrsfxu1y6LeqXMie7N14qmcdafij/Haye4feVlyMMXLGpYxBeOX6Ee1iRJbDzGG5LVman8U1rrt9MYFHA==}
|
||||
'@smithy/core@3.36.0':
|
||||
resolution: {integrity: sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.2':
|
||||
@@ -3008,11 +3004,11 @@ packages:
|
||||
'@tailwindcss/postcss@4.3.3':
|
||||
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
|
||||
|
||||
'@tanstack/query-core@5.104.4':
|
||||
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
|
||||
'@tanstack/query-core@5.104.5':
|
||||
resolution: {integrity: sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==}
|
||||
|
||||
'@tanstack/react-query@5.104.4':
|
||||
resolution: {integrity: sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==}
|
||||
'@tanstack/react-query@5.104.5':
|
||||
resolution: {integrity: sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18 || ^19
|
||||
react: ^18 || ^19
|
||||
@@ -7293,11 +7289,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-s3@3.1152.0':
|
||||
'@aws-sdk/client-s3@3.1153.0':
|
||||
dependencies:
|
||||
'@aws-sdk/checksums': 3.1001.4
|
||||
'@aws-sdk/core': 3.978.4
|
||||
@@ -7305,7 +7301,7 @@ snapshots:
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.80
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.48
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -7316,7 +7312,7 @@ snapshots:
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@aws-sdk/xml-builder': 3.972.41
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/signature-v4': 5.7.4
|
||||
'@smithy/types': 4.19.0
|
||||
bowser: 2.14.1
|
||||
@@ -7326,7 +7322,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -7334,7 +7330,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -7351,7 +7347,7 @@ snapshots:
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.81
|
||||
'@aws-sdk/nested-clients': 3.997.49
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
@@ -7361,7 +7357,7 @@ snapshots:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/nested-clients': 3.997.49
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -7374,7 +7370,7 @@ snapshots:
|
||||
'@aws-sdk/credential-provider-sso': 3.973.19
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.81
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
@@ -7383,7 +7379,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -7393,7 +7389,7 @@ snapshots:
|
||||
'@aws-sdk/nested-clients': 3.997.49
|
||||
'@aws-sdk/token-providers': 3.1153.0
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -7402,14 +7398,14 @@ snapshots:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/nested-clients': 3.997.49
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/lib-storage@3.1152.0(@aws-sdk/client-s3@3.1152.0)':
|
||||
'@aws-sdk/lib-storage@3.1153.0(@aws-sdk/client-s3@3.1153.0)':
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3': 3.1152.0
|
||||
'@smithy/core': 3.35.2
|
||||
'@aws-sdk/client-s3': 3.1153.0
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
buffer: 5.6.0
|
||||
events: 3.3.0
|
||||
@@ -7421,7 +7417,7 @@ snapshots:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.48
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -7430,7 +7426,7 @@ snapshots:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.48
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -7448,7 +7444,7 @@ snapshots:
|
||||
'@aws-sdk/core': 3.978.4
|
||||
'@aws-sdk/nested-clients': 3.997.49
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -9949,37 +9945,32 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@smithy/core@3.35.2':
|
||||
dependencies:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.36.1':
|
||||
'@smithy/core@3.36.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.8.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.12.1':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.7.4':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.1
|
||||
'@smithy/core': 3.36.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -10130,11 +10121,11 @@ snapshots:
|
||||
postcss: 8.5.26
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tanstack/query-core@5.104.4': {}
|
||||
'@tanstack/query-core@5.104.5': {}
|
||||
|
||||
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
|
||||
'@tanstack/react-query@5.104.5(@types/react@19.3.0)(react@19.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.104.4
|
||||
'@tanstack/query-core': 5.104.5
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* true, only that the set did not change without someone editing this file.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
@@ -84,17 +84,25 @@ function backfillFiles(): string[] {
|
||||
const jobs = readdirSync(join(SRC, "lib/jobs"))
|
||||
.filter((f) => /-backfill\.ts$/.test(f))
|
||||
.map((f) => `src/lib/jobs/${f}`);
|
||||
// A provider that carries its own jobs folder (`lib/<provider>/jobs/`).
|
||||
const providerJobs = readdirSync(join(SRC, "lib")).flatMap((dir) => {
|
||||
const jobsDir = join(SRC, "lib", dir, "jobs");
|
||||
if (!existsSync(jobsDir)) return [];
|
||||
return readdirSync(jobsDir)
|
||||
.filter((f) => /(?:^|-)backfill\.ts$/.test(f))
|
||||
.map((f) => `src/lib/${dir}/jobs/${f}`);
|
||||
});
|
||||
const scripts = readdirSync(join(ROOT, "scripts"))
|
||||
.filter((f) => /^backfill-.*\.ts$/.test(f))
|
||||
.map((f) => `scripts/${f}`);
|
||||
return [...jobs, ...scripts].sort();
|
||||
return [...jobs, ...providerJobs, ...scripts].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`.
|
||||
*/
|
||||
const BACKFILL_STAMPS: Record<string, string> = {
|
||||
"src/lib/jobs/fitbit-backfill.ts > fitbitConnection":
|
||||
"src/lib/fitbit/jobs/backfill.ts > fitbitConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/google-health-backfill.ts > googleHealthConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 115,
|
||||
"total": 133899,
|
||||
"count": 106,
|
||||
"total": 122271,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
@@ -64,13 +64,12 @@
|
||||
"lib/export/backup-plan.ts": 853,
|
||||
"lib/export/coach-backup.ts": 1070,
|
||||
"lib/export/profile-backup.ts": 1019,
|
||||
"lib/fitbit/client.ts": 1529,
|
||||
"lib/fitbit/sync-core.ts": 968,
|
||||
"lib/fitbit/client.ts": 1262,
|
||||
"lib/fitbit/sync-core.ts": 951,
|
||||
"lib/gamification/achievements-result.ts": 1094,
|
||||
"lib/gamification/achievements.ts": 913,
|
||||
"lib/google-health/client.ts": 1875,
|
||||
"lib/google-health/mappers.ts": 1295,
|
||||
"lib/google-health/sync-core.ts": 1634,
|
||||
"lib/google-health/sync-core.ts": 1621,
|
||||
"lib/idempotency.ts": 879,
|
||||
"lib/illness/correlation.ts": 1059,
|
||||
"lib/import/health-connect/import.ts": 1689,
|
||||
@@ -84,18 +83,13 @@
|
||||
"lib/insights/mood-aggregates.ts": 1053,
|
||||
"lib/insights/status-cache.ts": 812,
|
||||
"lib/integrations/status.ts": 1445,
|
||||
"lib/jobs/apple-health-import-worker.ts": 801,
|
||||
"lib/jobs/backup-restore.ts": 888,
|
||||
"lib/jobs/coach-nudge.ts": 1089,
|
||||
"lib/jobs/insight-pregenerate.ts": 1266,
|
||||
"lib/jobs/medication-intake-import.ts": 822,
|
||||
"lib/jobs/offhost-backup.ts": 1168,
|
||||
"lib/jobs/backup-restore.ts": 887,
|
||||
"lib/jobs/coach-nudge.ts": 1084,
|
||||
"lib/jobs/insight-pregenerate.ts": 1259,
|
||||
"lib/jobs/medication-intake-import.ts": 819,
|
||||
"lib/jobs/offhost-backup.ts": 1147,
|
||||
"lib/jobs/reaction-line.ts": 824,
|
||||
"lib/jobs/reminder/medication-reminder-check.ts": 818,
|
||||
"lib/jobs/reminder/register-integration-sync.ts": 1051,
|
||||
"lib/jobs/reminder/register-maintenance.ts": 2197,
|
||||
"lib/jobs/reminder/register-rollup.ts": 1156,
|
||||
"lib/jobs/reminder/register-status.ts": 878,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1467,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
@@ -105,18 +99,15 @@
|
||||
"lib/medications/scheduling/recurrence.ts": 1108,
|
||||
"lib/medications/scheduling/slot-upsert.ts": 926,
|
||||
"lib/notifications/senders/apns.ts": 1100,
|
||||
"lib/oura/client.ts": 1360,
|
||||
"lib/oura/sync.ts": 902,
|
||||
"lib/polar/client.ts": 1010,
|
||||
"lib/oura/sync.ts": 900,
|
||||
"lib/rollups/measurement-rollups.ts": 1496,
|
||||
"lib/search/search-records.ts": 996,
|
||||
"lib/sharing/grants.ts": 807,
|
||||
"lib/support/report.ts": 905,
|
||||
"lib/support/report.ts": 903,
|
||||
"lib/telegram-webhook-handlers.ts": 1341,
|
||||
"lib/validations/inbound-documents.ts": 1130,
|
||||
"lib/whoop/client.ts": 917,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 978,
|
||||
"lib/withings/sync.ts": 933,
|
||||
"proxy.ts": 870
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 388,
|
||||
"total": 137367,
|
||||
"count": 380,
|
||||
"total": 133935,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -313,12 +313,11 @@
|
||||
"lib/export/vaccinations-backup.ts :: restoreVaccinationsData": 216,
|
||||
"lib/export/visits-backup.ts :: restoreVisitsData": 261,
|
||||
"lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363,
|
||||
"lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 370,
|
||||
"lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 356,
|
||||
"lib/gamification/achievements-result.ts :: buildAchievementsResult": 586,
|
||||
"lib/google-health/client.ts :: forEachDataPointPage": 288,
|
||||
"lib/google-health/client.ts :: forEachDataPointPage > walk": 223,
|
||||
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 463,
|
||||
"lib/google-health/sync.ts :: runGoogleHealthSyncCycle": 203,
|
||||
"lib/google-health/data-points.ts :: forEachDataPointPage": 288,
|
||||
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
|
||||
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453,
|
||||
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv": 309,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
|
||||
@@ -348,13 +347,6 @@
|
||||
"lib/jobs/reaction-line.ts :: runReactionLine": 337,
|
||||
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 619,
|
||||
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 614,
|
||||
"lib/jobs/reminder/register-integration-sync.ts :: enqueueIntegrationSyncBootDiscovery": 224,
|
||||
"lib/jobs/reminder/register-integration-sync.ts :: registerIntegrationSyncQueues": 320,
|
||||
"lib/jobs/reminder/register-maintenance.ts :: enqueueMaintenanceBootDiscovery": 232,
|
||||
"lib/jobs/reminder/register-maintenance.ts :: registerMaintenanceQueues": 997,
|
||||
"lib/jobs/reminder/register-rollup.ts :: enqueueRollupBootDiscovery": 238,
|
||||
"lib/jobs/reminder/register-rollup.ts :: registerRollupQueues": 616,
|
||||
"lib/jobs/reminder/register-status.ts :: registerStatusQueues": 542,
|
||||
"lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324,
|
||||
"lib/labs/biomarker-merge.ts :: runMerge": 254,
|
||||
"lib/labs/ocr-extract.ts :: runOcrExtraction": 207,
|
||||
@@ -389,7 +381,7 @@
|
||||
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 373,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 499
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 366,
|
||||
"total": 366,
|
||||
"count": 348,
|
||||
"total": 348,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 1,
|
||||
"__tests__/admin-data-wipe-completeness.test.ts": 1,
|
||||
@@ -302,6 +302,7 @@
|
||||
"lib/export/__tests__/intraday-profile-backup.test.ts": 1,
|
||||
"lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts": 1,
|
||||
"lib/fitbit/__tests__/sync-module-graph.test.ts": 1,
|
||||
"lib/fitbit/jobs/__tests__/queues.test.ts": 1,
|
||||
"lib/format/__tests__/bytes.test.ts": 1,
|
||||
"lib/google-health/__tests__/sync-module-graph.test.ts": 1,
|
||||
"lib/http/__tests__/deprecation.test.ts": 1,
|
||||
@@ -313,44 +314,25 @@
|
||||
"lib/jobs/__tests__/ai-job-capability-inventory.test.ts": 1,
|
||||
"lib/jobs/__tests__/apple-health-import-ecg.test.ts": 1,
|
||||
"lib/jobs/__tests__/apple-health-import-export-date.test.ts": 1,
|
||||
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1,
|
||||
"lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1,
|
||||
"lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/cycle-reminder-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/daily-briefing.test.ts": 1,
|
||||
"lib/jobs/__tests__/data-arrival-provider-isolation.test.ts": 1,
|
||||
"lib/jobs/__tests__/data-arrival-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/data-backup-send-policy.test.ts": 1,
|
||||
"lib/jobs/__tests__/drain-cumulative-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/fitbit-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/geo-backfill.test.ts": 1,
|
||||
"lib/jobs/__tests__/geolite2-fetch-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/google-health-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/insight-pregenerate.test.ts": 1,
|
||||
"lib/jobs/__tests__/insight-status-generate.test.ts": 1,
|
||||
"lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/integration-backfill-admission.test.ts": 1,
|
||||
"lib/jobs/__tests__/job-failures-retention.test.ts": 1,
|
||||
"lib/jobs/__tests__/job-outcome-source-inventory.test.ts": 1,
|
||||
"lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/mean-consolidation-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/measurement-reminder-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 1,
|
||||
"lib/jobs/__tests__/nightscout-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/period-narrative-warm.test.ts": 1,
|
||||
"lib/jobs/__tests__/queue-policy-tables.test.ts": 1,
|
||||
"lib/jobs/__tests__/read-side-effect-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/recovery-score-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/sleep-timeline-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/status-cron-candidates.test.ts": 1,
|
||||
"lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/subsystem-surface.test.ts": 1,
|
||||
"lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/whoop-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/withings-ecg-worker.test.ts": 1,
|
||||
"lib/jobs/__tests__/withings-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/workout-insight-queue.test.ts": 1,
|
||||
"lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts": 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2950,
|
||||
"total": 10187,
|
||||
"count": 2915,
|
||||
"total": 9853,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -2062,6 +2062,10 @@
|
||||
"lib/fitbit/__tests__/poll-cohort.test.ts": 1,
|
||||
"lib/fitbit/__tests__/sync-user-fitbit.test.ts": 1,
|
||||
"lib/fitbit/client.ts": 1,
|
||||
"lib/fitbit/jobs/__tests__/backfill.test.ts": 1,
|
||||
"lib/fitbit/jobs/backfill.ts": 1,
|
||||
"lib/fitbit/jobs/oauth-state-cleanup.ts": 1,
|
||||
"lib/fitbit/jobs/sync-handlers.ts": 2,
|
||||
"lib/fitbit/sync.ts": 1,
|
||||
"lib/format-locale.ts": 1,
|
||||
"lib/format.ts": 4,
|
||||
@@ -2077,7 +2081,6 @@
|
||||
"lib/google-health/__tests__/sync-intraday-daily-wide.test.ts": 1,
|
||||
"lib/google-health/__tests__/sync-intraday-overlap.test.ts": 1,
|
||||
"lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts": 1,
|
||||
"lib/google-health/client.ts": 1,
|
||||
"lib/google-health/sync-core.ts": 2,
|
||||
"lib/google-health/sync-metrics.ts": 1,
|
||||
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
|
||||
@@ -2285,59 +2288,33 @@
|
||||
"lib/integrations/metric-freshness.ts": 1,
|
||||
"lib/integrations/status.ts": 19,
|
||||
"lib/intl/__tests__/formatter-cache.test.ts": 1,
|
||||
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 2,
|
||||
"lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1,
|
||||
"lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1,
|
||||
"lib/jobs/__tests__/coach-nudge.test.ts": 3,
|
||||
"lib/jobs/__tests__/coach-reminder-sweep.test.ts": 1,
|
||||
"lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/cycle-reminder-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/cycle-reminder.test.ts": 2,
|
||||
"lib/jobs/__tests__/data-arrival-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/drain-cumulative-queue.test.ts": 4,
|
||||
"lib/jobs/__tests__/feedback-aggregator.test.ts": 2,
|
||||
"lib/jobs/__tests__/fitbit-backfill.test.ts": 1,
|
||||
"lib/jobs/__tests__/fitbit-queues.test.ts": 2,
|
||||
"lib/jobs/__tests__/geo-backfill.test.ts": 3,
|
||||
"lib/jobs/__tests__/google-health-queues.test.ts": 1,
|
||||
"lib/jobs/__tests__/geo-backfill.test.ts": 1,
|
||||
"lib/jobs/__tests__/google-health-sleep-repair.test.ts": 1,
|
||||
"lib/jobs/__tests__/hero-line-json-envelope.test.ts": 1,
|
||||
"lib/jobs/__tests__/insight-pregenerate-retry-enqueue.test.ts": 1,
|
||||
"lib/jobs/__tests__/insight-pregenerate.test.ts": 16,
|
||||
"lib/jobs/__tests__/insight-status-generate.test.ts": 2,
|
||||
"lib/jobs/__tests__/insight-pregenerate.test.ts": 14,
|
||||
"lib/jobs/__tests__/intake-auto-skip.test.ts": 3,
|
||||
"lib/jobs/__tests__/intake-auto-take.test.ts": 1,
|
||||
"lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 6,
|
||||
"lib/jobs/__tests__/job-run-report.test.ts": 1,
|
||||
"lib/jobs/__tests__/mean-consolidation-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-reminder-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-reminder.test.ts": 6,
|
||||
"lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 2,
|
||||
"lib/jobs/__tests__/medication-inventory-expire.test.ts": 1,
|
||||
"lib/jobs/__tests__/medication-low-stock.test.ts": 2,
|
||||
"lib/jobs/__tests__/medication-reminder-check.test.ts": 2,
|
||||
"lib/jobs/__tests__/mood-reminder.test.ts": 2,
|
||||
"lib/jobs/__tests__/nightscout-queues.test.ts": 3,
|
||||
"lib/jobs/__tests__/period-narrative-warm.test.ts": 1,
|
||||
"lib/jobs/__tests__/reaction-line-degradation.test.ts": 2,
|
||||
"lib/jobs/__tests__/recovery-score-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/recovery-score.test.ts": 1,
|
||||
"lib/jobs/__tests__/reminder-phases.test.ts": 1,
|
||||
"lib/jobs/__tests__/reminder-satisfy.test.ts": 3,
|
||||
"lib/jobs/__tests__/report-worker-error.test.ts": 1,
|
||||
"lib/jobs/__tests__/run-job.test.ts": 1,
|
||||
"lib/jobs/__tests__/sleep-timeline-backfill.test.ts": 1,
|
||||
"lib/jobs/__tests__/sleep-timeline-queues.test.ts": 2,
|
||||
"lib/jobs/__tests__/status-cron-candidates.test.ts": 2,
|
||||
"lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1,
|
||||
"lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 2,
|
||||
"lib/jobs/__tests__/whoop-backfill.test.ts": 1,
|
||||
"lib/jobs/__tests__/whoop-queues.test.ts": 2,
|
||||
"lib/jobs/__tests__/whoop-resource-sync.test.ts": 1,
|
||||
"lib/jobs/__tests__/withings-queues.test.ts": 2,
|
||||
"lib/jobs/__tests__/workout-insight-queue.test.ts": 1,
|
||||
"lib/jobs/apple-health-import-worker.ts": 4,
|
||||
"lib/jobs/coach-message-cleanup.ts": 1,
|
||||
"lib/jobs/coach-nudge-ai.ts": 3,
|
||||
@@ -2359,8 +2336,6 @@
|
||||
"lib/jobs/environment-air-quality-history.ts": 1,
|
||||
"lib/jobs/environment-fetch.ts": 2,
|
||||
"lib/jobs/feedback-aggregator.ts": 6,
|
||||
"lib/jobs/fitbit-backfill.ts": 1,
|
||||
"lib/jobs/fitbit-oauth-state-cleanup.ts": 1,
|
||||
"lib/jobs/free-text-encryption-backfill.ts": 8,
|
||||
"lib/jobs/geo-backfill.ts": 4,
|
||||
"lib/jobs/google-health-backfill.ts": 1,
|
||||
@@ -2393,11 +2368,10 @@
|
||||
"lib/jobs/recovery-score.ts": 1,
|
||||
"lib/jobs/reminder-phases.ts": 1,
|
||||
"lib/jobs/reminder-satisfy.ts": 1,
|
||||
"lib/jobs/reminder-worker.ts": 4,
|
||||
"lib/jobs/reminder-worker.ts": 3,
|
||||
"lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts": 1,
|
||||
"lib/jobs/reminder/__tests__/queue-retention.test.ts": 1,
|
||||
"lib/jobs/reminder/cleanup-handlers.ts": 7,
|
||||
"lib/jobs/reminder/fitbit-sync.ts": 2,
|
||||
"lib/jobs/reminder/google-health-sync.ts": 1,
|
||||
"lib/jobs/reminder/insights-handlers.ts": 3,
|
||||
"lib/jobs/reminder/medication-maintenance.ts": 3,
|
||||
@@ -2407,11 +2381,6 @@
|
||||
"lib/jobs/reminder/oura-sync.ts": 1,
|
||||
"lib/jobs/reminder/polar-sync.ts": 2,
|
||||
"lib/jobs/reminder/poll-cohort.ts": 2,
|
||||
"lib/jobs/reminder/register-integration-sync.ts": 63,
|
||||
"lib/jobs/reminder/register-maintenance.ts": 114,
|
||||
"lib/jobs/reminder/register-reminders.ts": 15,
|
||||
"lib/jobs/reminder/register-rollup.ts": 37,
|
||||
"lib/jobs/reminder/register-status.ts": 38,
|
||||
"lib/jobs/reminder/registrar-shared.ts": 2,
|
||||
"lib/jobs/reminder/strava-sync.ts": 1,
|
||||
"lib/jobs/reminder/whoop-sync.ts": 3,
|
||||
@@ -2712,7 +2681,6 @@
|
||||
"lib/openapi/routes/workouts.ts": 4,
|
||||
"lib/optimistic-lock.ts": 1,
|
||||
"lib/oura/__tests__/sync.test.ts": 1,
|
||||
"lib/oura/client.ts": 1,
|
||||
"lib/oura/credentials.ts": 3,
|
||||
"lib/oura/cycle-sync.ts": 1,
|
||||
"lib/oura/response-classifier.ts": 1,
|
||||
@@ -2724,7 +2692,6 @@
|
||||
"lib/personal-records/pr-detection-worker.ts": 6,
|
||||
"lib/personal-records/pr-direction.ts": 26,
|
||||
"lib/polar/__tests__/dedup.test.ts": 1,
|
||||
"lib/polar/client.ts": 1,
|
||||
"lib/polar/credentials.ts": 3,
|
||||
"lib/polar/response-classifier.ts": 1,
|
||||
"lib/polar/sync-workouts.ts": 1,
|
||||
@@ -2826,7 +2793,6 @@
|
||||
"lib/sse/__tests__/create-stream.test.ts": 1,
|
||||
"lib/sse/create-stream.ts": 3,
|
||||
"lib/strava/__tests__/dedup.test.ts": 1,
|
||||
"lib/strava/client.ts": 1,
|
||||
"lib/strava/credentials.ts": 1,
|
||||
"lib/strava/response-classifier.ts": 1,
|
||||
"lib/strava/sync.ts": 1,
|
||||
@@ -2928,7 +2894,6 @@
|
||||
"lib/whoop/__tests__/sync-workout-overlap.test.ts": 1,
|
||||
"lib/whoop/__tests__/sync.test.ts": 1,
|
||||
"lib/whoop/__tests__/webhook-notification.test.ts": 1,
|
||||
"lib/whoop/client.ts": 3,
|
||||
"lib/whoop/connect-ticket.ts": 1,
|
||||
"lib/whoop/oauth-state.ts": 1,
|
||||
"lib/whoop/return-scheme.ts": 1,
|
||||
@@ -2941,13 +2906,13 @@
|
||||
"lib/withings/__tests__/sync-sleep.test.ts": 4,
|
||||
"lib/withings/__tests__/sync-typed-classification.test.ts": 1,
|
||||
"lib/withings/__tests__/sync.test.ts": 1,
|
||||
"lib/withings/client.ts": 5,
|
||||
"lib/withings/client.ts": 3,
|
||||
"lib/withings/oauth-state.ts": 1,
|
||||
"lib/withings/response-classifier.ts": 1,
|
||||
"lib/withings/sync-activity.ts": 7,
|
||||
"lib/withings/sync-ecg.ts": 4,
|
||||
"lib/withings/sync-sleep.ts": 9,
|
||||
"lib/withings/sync.ts": 9,
|
||||
"lib/withings/sync.ts": 6,
|
||||
"lib/withings/webhook-handler.ts": 3,
|
||||
"lib/workouts/list-read.ts": 1,
|
||||
"proxy.ts": 29,
|
||||
|
||||
@@ -88,15 +88,18 @@ describe("an empty environment variable counts as unset", () => {
|
||||
it("T3 — the sweep read the tree and the matcher fires on real source", () => {
|
||||
// An empty offender list proves nothing if the narrowed walk found no
|
||||
// files, or if the matcher no longer fires on what the tree actually
|
||||
// holds. Floor the narrowed set, then put the pre-fix read back into a
|
||||
// real client and require the matcher to find it there.
|
||||
// holds. Floor the narrowed set, then put the pre-fix read back into the
|
||||
// redirect-URI helper every client uses and require the matcher to find it there.
|
||||
const files = sourceFiles();
|
||||
expect(files.length).toBeGreaterThan(2400);
|
||||
expect(files).toContain("lib/withings/client.ts");
|
||||
const real = readFileSync(join(SRC, "lib/withings/client.ts"), "utf8");
|
||||
expect(files).toContain("lib/integrations/oauth-kit/redirect-uri.ts");
|
||||
const real = readFileSync(
|
||||
join(SRC, "lib/integrations/oauth-kit/redirect-uri.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect([...real.matchAll(NULLISH_ENV_FALLBACK)]).toEqual([]);
|
||||
const reverted = real.replace(
|
||||
/envOr\(\s*"WITHINGS_REDIRECT_URI",/,
|
||||
/envOr\(envVar,/,
|
||||
"process.env.WITHINGS_REDIRECT_URI ?? (",
|
||||
);
|
||||
expect(reverted).not.toBe(real);
|
||||
|
||||
@@ -63,8 +63,6 @@ const NO_FLOOR_NEEDED: Readonly<Record<string, string>> = {
|
||||
"named walks, each must positively match the keyset cursor",
|
||||
"components/charts/__tests__/touch-action-guard.test.ts":
|
||||
"named chart wrappers, each must positively declare touch-pan-y",
|
||||
"__tests__/moodlog-removal-guard.test.ts":
|
||||
"absence checks over named paths, columns and keys; no matcher to drift",
|
||||
|
||||
// Unit tests of runtime code whose name ends in "guard". They read no
|
||||
// source and sweep nothing.
|
||||
|
||||
@@ -197,6 +197,8 @@ export interface ModuleImports {
|
||||
value: string[];
|
||||
/** True when every statement is an `export … from` (or an import). */
|
||||
reexportOnly: boolean;
|
||||
/** The value imports evaluated when the module loads: `value` without `import("…")`. */
|
||||
staticValue: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +242,7 @@ export function moduleImports(sf: ts.SourceFile): ModuleImports {
|
||||
}
|
||||
other += 1;
|
||||
}
|
||||
const staticValue = [...value];
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
@@ -252,7 +255,7 @@ export function moduleImports(sf: ts.SourceFile): ModuleImports {
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return { value, reexportOnly: reexports > 0 && other === 0 };
|
||||
return { value, staticValue, reexportOnly: reexports > 0 && other === 0 };
|
||||
}
|
||||
|
||||
export interface ImportGraph {
|
||||
@@ -374,3 +377,40 @@ export function backEdgeCycles(edges: Map<string, string[]>): string[][] {
|
||||
for (const v of [...edges.keys()].sort()) walk(v);
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module under `src/` that loading `entries` evaluates: static value
|
||||
* imports and re-exports, transitively. A dynamic `import("…")` is not
|
||||
* followed, since it loads only when it runs. Paths are relative to `src/`.
|
||||
*/
|
||||
export function staticImportClosure(entries: readonly string[]): Set<string> {
|
||||
const cache = ts.createModuleResolutionCache(
|
||||
process.cwd(),
|
||||
(x) => x,
|
||||
COMPILER_OPTIONS,
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const stack = [...entries];
|
||||
while (stack.length > 0) {
|
||||
const rel = stack.pop()!;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
const abs = join(SRC, rel);
|
||||
for (const spec of moduleImports(parse(rel, readFileSync(abs, "utf8")))
|
||||
.staticValue) {
|
||||
const hit = ts.resolveModuleName(
|
||||
spec,
|
||||
abs,
|
||||
COMPILER_OPTIONS,
|
||||
ts.sys,
|
||||
cache,
|
||||
).resolvedModule;
|
||||
if (!hit || hit.isExternalLibraryImport) continue;
|
||||
if (!hit.resolvedFileName.startsWith(`${SRC}/`)) continue;
|
||||
const target = hit.resolvedFileName.slice(SRC.length + 1);
|
||||
if (target.startsWith("generated/") || target.endsWith(".d.ts")) continue;
|
||||
stack.push(target);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
@@ -11,23 +11,23 @@
|
||||
* `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or
|
||||
* `long`. This guard holds the tree to it:
|
||||
*
|
||||
* - every queue bound with `createAndWork` is in the table, and every table
|
||||
* entry is bound;
|
||||
* - every `boss.send` and every schedule tuple names a queue in the table;
|
||||
* - every queue the worker registry declares (`jobs/registry/`) is in the
|
||||
* table, and every table entry is declared;
|
||||
* - every `boss.send` and every scheduled queue names a queue in the table;
|
||||
* - a `long` queue has an expiry between the default and pg-boss's 24-hour
|
||||
* ceiling, every send and schedule for it carries `expireInSeconds` (or an
|
||||
* identifier the table names as its carrier), it stops on the job's budget
|
||||
* where the table says (`jobBudget` / `jobDeadline`, or a declared
|
||||
* one-shot), and it is exclusive per identity where the table says
|
||||
* (`lockedPass` at the binding, or the named marker);
|
||||
* ceiling, every send for it carries `expireInSeconds` (or an identifier
|
||||
* the table names as its carrier) and its schedule's send options do, it
|
||||
* stops on the job's budget where the table says (`jobBudget` /
|
||||
* `jobDeadline`, or a declared one-shot), and it is exclusive per identity
|
||||
* where the table says (the definition's `lock`, or the named marker);
|
||||
* - a `budgeted` queue stops on the budget and relies on the default expiry;
|
||||
* - a `short` queue's sends set no expiry of their own, which would mean the
|
||||
* job is longer than the table claims.
|
||||
*
|
||||
* Floors fail the guard if a matcher stops matching, and the "plants" cases
|
||||
* run the checks over a planted long queue that lacks each protection in turn
|
||||
* (the mutation proof kept in the suite). Removing `lockedPass` from the
|
||||
* pr-detection binding, or the expiry from its cron tuple, fails the real
|
||||
* (the mutation proof kept in the suite). Removing the `lock` from the
|
||||
* pr-detection definition, or the expiry from its schedule, fails the real
|
||||
* checks below.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -39,19 +39,14 @@ import {
|
||||
DEFAULT_JOB_EXPIRE_SECONDS,
|
||||
QUEUE_RUNTIME,
|
||||
type QueueRuntime,
|
||||
type SourceSite,
|
||||
} from "@/lib/jobs/queue-runtime";
|
||||
import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane";
|
||||
import type { JobDefinition } from "@/lib/jobs/registry/define-job";
|
||||
import { allJobDefinitions } from "@/lib/jobs/registry/index";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const REGISTRARS = [
|
||||
"lib/jobs/reminder/register-integration-sync.ts",
|
||||
"lib/jobs/reminder/register-maintenance.ts",
|
||||
"lib/jobs/reminder/register-reminders.ts",
|
||||
"lib/jobs/reminder/register-rollup.ts",
|
||||
"lib/jobs/reminder/register-status.ts",
|
||||
];
|
||||
const DEFINITIONS = allJobDefinitions();
|
||||
/** pg-boss refuses an expiry of 24 hours or more. */
|
||||
const MAX_EXPIRE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
@@ -120,27 +115,6 @@ interface Site {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */
|
||||
function bindings(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
definitions: Definitions,
|
||||
): Site[] {
|
||||
const out: Site[] = [];
|
||||
for (const file of REGISTRARS) {
|
||||
const text = code(texts.get(file) ?? "");
|
||||
for (const m of text.matchAll(
|
||||
/createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g,
|
||||
)) {
|
||||
const open = text.indexOf("(", m.index);
|
||||
const queue = resolve(definitions, file, m[1]);
|
||||
if (queue) {
|
||||
out.push({ file, queue, text: text.slice(open, closeOf(text, open)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */
|
||||
function sends(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
@@ -162,31 +136,6 @@ function sends(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */
|
||||
function schedules(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
definitions: Definitions,
|
||||
): Site[] {
|
||||
const out: Site[] = [];
|
||||
for (const file of REGISTRARS) {
|
||||
const text = code(texts.get(file) ?? "");
|
||||
const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text);
|
||||
if (!m) continue;
|
||||
const open = m.index + m[0].length - 1;
|
||||
const body = text.slice(open + 1, closeOf(text, open) - 1);
|
||||
for (let i = 0; i < body.length; i += 1) {
|
||||
if (body[i] !== "[") continue;
|
||||
const end = closeOf(body, i);
|
||||
const tuple = body.slice(i, end);
|
||||
const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1];
|
||||
const queue = ident ? resolve(definitions, file, ident) : null;
|
||||
if (queue) out.push({ file, queue, text: tuple });
|
||||
i = end - 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text of `fn` (a function or a const) in `file`. */
|
||||
function bodyOf(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
@@ -201,14 +150,6 @@ function bodyOf(
|
||||
return text.slice(m.index, end < 0 ? undefined : end + 2);
|
||||
}
|
||||
|
||||
function siteText(
|
||||
texts: ReadonlyMap<string, string>,
|
||||
site: SourceSite,
|
||||
binding: Site | undefined,
|
||||
): string {
|
||||
return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site);
|
||||
}
|
||||
|
||||
const STOPS = /\bjob(?:Budget|Deadline)\s*\(/;
|
||||
|
||||
/** Every finding for one queue's declaration against the tree. */
|
||||
@@ -216,13 +157,12 @@ function check(
|
||||
queue: string,
|
||||
entry: QueueRuntime,
|
||||
texts: ReadonlyMap<string, string>,
|
||||
all: { bindings: Site[]; sends: Site[]; schedules: Site[] },
|
||||
all: { definitions: readonly JobDefinition[]; sends: Site[] },
|
||||
): string[] {
|
||||
const problems: string[] = [];
|
||||
const binding = all.bindings.find((b) => b.queue === queue);
|
||||
const carriers = [...all.sends, ...all.schedules].filter(
|
||||
(s) => s.queue === queue,
|
||||
);
|
||||
const definition = all.definitions.find((d) => d.queue === queue);
|
||||
const sends = all.sends.filter((s) => s.queue === queue);
|
||||
const scheduledExpiry = definition?.scheduleOptions?.expireInSeconds;
|
||||
if (entry.runtime === "long") {
|
||||
if (
|
||||
!(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) ||
|
||||
@@ -230,85 +170,86 @@ function check(
|
||||
) {
|
||||
problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`);
|
||||
}
|
||||
for (const site of carriers) {
|
||||
for (const site of sends) {
|
||||
const carried =
|
||||
/\bexpireInSeconds\b/.test(site.text) ||
|
||||
entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text));
|
||||
if (!carried) {
|
||||
problems.push(
|
||||
`${queue}: ${site.file} sends or schedules it without an expiry`,
|
||||
);
|
||||
problems.push(`${queue}: ${site.file} sends it without an expiry`);
|
||||
}
|
||||
}
|
||||
if (definition?.cron && typeof scheduledExpiry !== "number") {
|
||||
problems.push(`${queue}: its schedule carries no expiry`);
|
||||
}
|
||||
if (typeof entry.stop === "object" && "oneShot" in entry.stop) {
|
||||
if (entry.stop.oneShot.length < 20) {
|
||||
problems.push(`${queue}: a one-shot needs its reason`);
|
||||
}
|
||||
} else if (!STOPS.test(siteText(texts, entry.stop, binding))) {
|
||||
} else if (!STOPS.test(bodyOf(texts, entry.stop))) {
|
||||
problems.push(`${queue}: does not stop on the job's budget`);
|
||||
}
|
||||
if (entry.exclusive === "lockedPass") {
|
||||
if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) {
|
||||
problems.push(`${queue}: binding holds no lockedPass`);
|
||||
if (!definition?.lock) {
|
||||
problems.push(`${queue}: definition holds no lock`);
|
||||
}
|
||||
} else if (
|
||||
!siteText(texts, entry.exclusive.at, binding).includes(
|
||||
entry.exclusive.marker,
|
||||
)
|
||||
!bodyOf(texts, entry.exclusive.at).includes(entry.exclusive.marker)
|
||||
) {
|
||||
problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`);
|
||||
}
|
||||
} else {
|
||||
if (entry.runtime === "budgeted") {
|
||||
if (!STOPS.test(siteText(texts, entry.budget, binding))) {
|
||||
if (!STOPS.test(bodyOf(texts, entry.budget))) {
|
||||
problems.push(`${queue}: does not stop on the job's budget`);
|
||||
}
|
||||
}
|
||||
for (const site of carriers) {
|
||||
for (const site of sends) {
|
||||
if (/\bexpireInSeconds\b/.test(site.text)) {
|
||||
problems.push(
|
||||
`${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scheduledExpiry !== undefined) {
|
||||
problems.push(
|
||||
`${queue}: its schedule sets an expiry on a queue declared ${entry.runtime}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function scan(texts: ReadonlyMap<string, string>) {
|
||||
const definitions = definitionsOf(texts);
|
||||
return {
|
||||
bindings: bindings(texts, definitions),
|
||||
sends: sends(texts, definitions),
|
||||
schedules: schedules(texts, definitions),
|
||||
};
|
||||
return { sends: sends(texts, definitionsOf(texts)) };
|
||||
}
|
||||
|
||||
describe("every queue declares its runtime", () => {
|
||||
const all = scan(source);
|
||||
const bound = new Set(all.bindings.map((b) => b.queue));
|
||||
const all = { definitions: DEFINITIONS, ...scan(source) };
|
||||
const declared = new Set(DEFINITIONS.map((d) => d.queue));
|
||||
|
||||
it("finds the bindings, sends and schedules it is meant to police", () => {
|
||||
// Pinned below the counts on 2026-10-01 (112 / 64 / 74).
|
||||
expect(all.bindings.length).toBeGreaterThanOrEqual(105);
|
||||
it("finds the declarations, sends and schedules it is meant to police", () => {
|
||||
// Pinned below the counts on 2026-10-23 (122 / 64 / 78).
|
||||
expect(DEFINITIONS.length).toBeGreaterThanOrEqual(115);
|
||||
expect(all.sends.length).toBeGreaterThanOrEqual(50);
|
||||
expect(all.schedules.length).toBeGreaterThanOrEqual(70);
|
||||
expect(DEFINITIONS.filter((d) => d.cron).length).toBeGreaterThanOrEqual(70);
|
||||
expect(
|
||||
Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length,
|
||||
).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("every bound queue is in the table, and every entry is bound", () => {
|
||||
expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]);
|
||||
it("every declared queue is in the table, and every entry is declared", () => {
|
||||
expect([...declared].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
Object.keys(QUEUE_RUNTIME)
|
||||
.filter((q) => !bound.has(q))
|
||||
.filter((q) => !declared.has(q))
|
||||
.sort(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every send and schedule names a queue in the table", () => {
|
||||
const unknown = [...all.sends, ...all.schedules]
|
||||
it("every send names a queue in the table", () => {
|
||||
const unknown = all.sends
|
||||
.filter((s) => !(s.queue in QUEUE_RUNTIME))
|
||||
.map((s) => `${s.file} → ${s.queue}`);
|
||||
expect(unknown).toEqual([]);
|
||||
@@ -322,51 +263,56 @@ describe("every queue declares its runtime", () => {
|
||||
});
|
||||
|
||||
it("plants: a long queue missing any protection is caught", () => {
|
||||
const registrar = REGISTRARS[1];
|
||||
const planted = (binding: string, schedule: string) =>
|
||||
const file = "lib/jobs/planted.ts";
|
||||
const texts = (handler: string) =>
|
||||
new Map([
|
||||
...source,
|
||||
[
|
||||
registrar,
|
||||
`const PLANT_QUEUE = "planted-long";\n` +
|
||||
`const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` +
|
||||
`async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`,
|
||||
],
|
||||
[file, `export async function plant(jobs) {\n ${handler}\n}\n`],
|
||||
]);
|
||||
const entry: QueueRuntime = {
|
||||
runtime: "long",
|
||||
expireInSeconds: 3600,
|
||||
expiryVia: [],
|
||||
stop: "binding",
|
||||
stop: { file, fn: "plant" },
|
||||
exclusive: "lockedPass",
|
||||
why: "planted",
|
||||
};
|
||||
const findings = (binding: string, schedule: string) => {
|
||||
const texts = planted(binding, schedule);
|
||||
return check("planted-long", entry, texts, scan(texts));
|
||||
const planted = (overrides: Partial<JobDefinition>): JobDefinition => ({
|
||||
queue: "planted-long",
|
||||
cron: "0 3 * * *",
|
||||
scheduleOptions: { expireInSeconds: 3600 },
|
||||
work: { localConcurrency: 1 },
|
||||
lock: () => "pass",
|
||||
load: async () => async () => ({ ok: true, did: {} }),
|
||||
...overrides,
|
||||
});
|
||||
const findings = (
|
||||
handler: string,
|
||||
overrides: Partial<JobDefinition> = {},
|
||||
) => {
|
||||
const t = texts(handler);
|
||||
return check("planted-long", entry, t, {
|
||||
definitions: [planted(overrides)],
|
||||
...scan(t),
|
||||
});
|
||||
};
|
||||
const safe = [
|
||||
"lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))",
|
||||
"[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]",
|
||||
] as const;
|
||||
expect(findings(...safe)).toEqual([]);
|
||||
expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([
|
||||
`planted-long: ${registrar} sends or schedules it without an expiry`,
|
||||
const budgeted = "return run(jobBudget(jobs));";
|
||||
expect(findings(budgeted)).toEqual([]);
|
||||
expect(findings(budgeted, { scheduleOptions: { retryLimit: 0 } })).toEqual([
|
||||
"planted-long: its schedule carries no expiry",
|
||||
]);
|
||||
expect(
|
||||
findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]),
|
||||
).toEqual(["planted-long: does not stop on the job's budget"]);
|
||||
expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([
|
||||
"planted-long: binding holds no lockedPass",
|
||||
expect(findings("return run(jobs);")).toEqual([
|
||||
"planted-long: does not stop on the job's budget",
|
||||
]);
|
||||
expect(findings(budgeted, { lock: undefined })).toEqual([
|
||||
"planted-long: definition holds no lock",
|
||||
]);
|
||||
expect(
|
||||
check(
|
||||
"planted-long",
|
||||
{ ...entry, expireInSeconds: 900 },
|
||||
...((): [ReadonlyMap<string, string>, ReturnType<typeof scan>] => {
|
||||
const texts = planted(...safe);
|
||||
return [texts, scan(texts)];
|
||||
})(),
|
||||
texts(budgeted),
|
||||
{ definitions: [planted({})], ...scan(texts(budgeted)) },
|
||||
),
|
||||
).toEqual(["planted-long: expiry 900s out of range"]);
|
||||
});
|
||||
@@ -374,12 +320,13 @@ describe("every queue declares its runtime", () => {
|
||||
|
||||
/**
|
||||
* Queues a file sends to through a variable (`boss.send(queueName, …)`): every
|
||||
* known queue name the file spells out, as a constant or a map value, counts
|
||||
* as sent, because the matcher above cannot tell which one the variable holds.
|
||||
* known queue the file names, as a literal, a map value or an imported
|
||||
* `*_QUEUE` constant, counts as sent, because the matcher above cannot tell which one the variable holds.
|
||||
*/
|
||||
function dynamicallySent(texts: ReadonlyMap<string, string>): Set<string> {
|
||||
const definitions = definitionsOf(texts);
|
||||
const out = new Set<string>();
|
||||
for (const [, raw] of texts) {
|
||||
for (const [file, raw] of texts) {
|
||||
const text = code(raw);
|
||||
if (
|
||||
!/\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*[a-z]/.test(
|
||||
@@ -391,13 +338,20 @@ function dynamicallySent(texts: ReadonlyMap<string, string>): Set<string> {
|
||||
for (const m of text.matchAll(/"([a-z0-9-]+)"/g)) {
|
||||
if (m[1] in QUEUE_RUNTIME) out.add(m[1]);
|
||||
}
|
||||
// A queue named through an imported constant counts the same.
|
||||
for (const m of text.matchAll(/\b([A-Z][A-Z0-9_]*_QUEUE)\b/g)) {
|
||||
const queue = resolve(definitions, file, m[1]);
|
||||
if (queue && queue in QUEUE_RUNTIME) out.add(queue);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("a cron-fed queue polls slowly, a sent one does not", () => {
|
||||
const all = scan(source);
|
||||
const scheduled = new Set(all.schedules.map((s) => s.queue));
|
||||
const scheduled = new Set(
|
||||
DEFINITIONS.filter((d) => d.cron).map((d) => d.queue),
|
||||
);
|
||||
const sent = new Set([
|
||||
...all.sends.map((s) => s.queue),
|
||||
...dynamicallySent(source),
|
||||
@@ -418,26 +372,14 @@ describe("a cron-fed queue polls slowly, a sent one does not", () => {
|
||||
});
|
||||
|
||||
describe("heavy jobs share the lane", () => {
|
||||
const all = scan(source);
|
||||
const laned = all.bindings.filter((b) => /\bheavyLane\s*\(/.test(b.text));
|
||||
const laned = DEFINITIONS.filter((d) => d.lane);
|
||||
|
||||
it("binds every heavy queue in the lane, and nothing else", () => {
|
||||
expect(laned.map((b) => b.queue).sort()).toEqual(
|
||||
it("puts every heavy queue in the lane, and nothing else", () => {
|
||||
expect(laned.map((d) => d.queue).sort()).toEqual(
|
||||
Object.keys(HEAVY_QUEUES).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds a lockedPass outside the lane, so an expiry retry finds the run going", () => {
|
||||
// Outside, a retry pg-boss starts while the expired run still holds the
|
||||
// lane finds the lock and settles; inside, the lane would defer it and
|
||||
// run the job a second time once the first is done.
|
||||
const inverted = laned
|
||||
.filter((b) => /\blockedPass\s*\(/.test(b.text))
|
||||
.filter((b) => b.text.indexOf("lockedPass") > b.text.indexOf("heavyLane"))
|
||||
.map((b) => b.queue);
|
||||
expect(inverted).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares every heavy queue long, so its expiry, stop and exclusivity are proven", () => {
|
||||
// A heavy job is the one most likely to run past pg-boss's default
|
||||
// expiry, and the retry that follows an expiry runs it a second time.
|
||||
@@ -464,41 +406,28 @@ describe("heavy jobs share the lane", () => {
|
||||
// A deferred job returns from active to created. Under `short` (one
|
||||
// queued job per key) and `stately` (one per state) a twin already
|
||||
// queued would refuse it, and the job would fail instead of waiting.
|
||||
const policies = policiesOf(source);
|
||||
expect(policies.size).toBeGreaterThanOrEqual(40);
|
||||
const refused = Object.keys(HEAVY_QUEUES)
|
||||
.filter((queue) => /^(?:short|stately)$/.test(policies.get(queue) ?? ""))
|
||||
.sort();
|
||||
expect(refused).toEqual([]);
|
||||
// The planted case: the same check over a table that declares one.
|
||||
const planted = new Map(source);
|
||||
const file = REGISTRARS[2];
|
||||
planted.set(
|
||||
file,
|
||||
`const DATA_BACKUP_QUEUE = "data-backup";\nconst queuePolicies: QueuePolicyTable = {\n [DATA_BACKUP_QUEUE]: { policy: "short", reason: "x" },\n};\n`,
|
||||
const refusing = (definitions: readonly JobDefinition[]) =>
|
||||
definitions
|
||||
.filter(
|
||||
(d) => d.lane && /^(?:short|stately)$/.test(d.policy?.policy ?? ""),
|
||||
)
|
||||
.map((d) => d.queue)
|
||||
.sort();
|
||||
expect(DEFINITIONS.filter((d) => d.policy).length).toBeGreaterThanOrEqual(
|
||||
40,
|
||||
);
|
||||
expect(policiesOf(planted).get("data-backup")).toBe("short");
|
||||
expect(refusing(DEFINITIONS)).toEqual([]);
|
||||
// The planted case: the same check over a heavy queue declared `short`.
|
||||
expect(
|
||||
refusing([
|
||||
{
|
||||
queue: "data-backup",
|
||||
policy: { policy: "short", reason: "x" },
|
||||
work: { localConcurrency: 1 },
|
||||
lane: true,
|
||||
load: async () => async () => ({ ok: true, did: {} }),
|
||||
},
|
||||
]),
|
||||
).toEqual(["data-backup"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Every `[QUEUE]: { policy: "…" }` in a registrar's `queuePolicies`. */
|
||||
function policiesOf(texts: ReadonlyMap<string, string>): Map<string, string> {
|
||||
const definitions = definitionsOf(texts);
|
||||
const out = new Map<string, string>();
|
||||
for (const file of REGISTRARS) {
|
||||
const text = code(texts.get(file) ?? "");
|
||||
const m = /const\s+queuePolicies\s*:\s*QueuePolicyTable\s*=\s*\{/.exec(
|
||||
text,
|
||||
);
|
||||
if (!m) continue;
|
||||
const open = m.index + m[0].length - 1;
|
||||
const body = text.slice(open, closeOf(text, open));
|
||||
for (const entry of body.matchAll(
|
||||
/\[\s*([A-Z][A-Z0-9_]*)\s*\]\s*:\s*\{\s*policy\s*:\s*"([a-z]+)"/g,
|
||||
)) {
|
||||
const queue = resolve(definitions, file, entry[1]);
|
||||
if (queue) out.set(queue, entry[2]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -39,6 +39,13 @@ const TAIL = "runs afterMeasurementMutation";
|
||||
const TAIL_VIA_PREFIX = "runs afterMeasurementMutation via ";
|
||||
const TAIL_VIA = (file: string) => `${TAIL_VIA_PREFIX}${file}`;
|
||||
|
||||
/**
|
||||
* Runs the provider write step (`integrations/sync-kit/after-write.ts`),
|
||||
* which is `afterMeasurementMutation` plus the arrivals and, where asked, the
|
||||
* dashboard snapshot.
|
||||
*/
|
||||
const PROVIDER_TAIL = "runs afterProviderWrite";
|
||||
|
||||
/**
|
||||
* Every file with a measurement write, and how it keeps the two readers
|
||||
* current. A file listed here must still contain a write (a stale entry
|
||||
@@ -66,17 +73,13 @@ const WRITE_SITES: Record<string, string> = {
|
||||
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
|
||||
"lib/oura/retire-distance.ts": TAIL,
|
||||
// Integration syncs: each collects the (type, day) pairs it touched and
|
||||
// runs both legs itself at the end of the sync.
|
||||
"lib/fitbit/sync-core.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the upsert",
|
||||
"lib/google-health/sync-core.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the upsert",
|
||||
"lib/nightscout/sync.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the sync",
|
||||
// runs the provider write step over them.
|
||||
"lib/fitbit/sync-core.ts": PROVIDER_TAIL,
|
||||
"lib/google-health/sync-core.ts": PROVIDER_TAIL,
|
||||
"lib/nightscout/sync.ts": PROVIDER_TAIL,
|
||||
"lib/withings/sync.ts": PROVIDER_TAIL,
|
||||
"lib/oura/sync-heart-rate.ts":
|
||||
"collects the days each heart-rate page touched; refolds them and re-warms the type once per window",
|
||||
"lib/withings/sync.ts":
|
||||
"refolds the touched days and re-warms the touched types at the end of the sync",
|
||||
"lib/withings/sync-activity.ts":
|
||||
"refolds the touched days (and the day a moved row left) and re-warms the touched types",
|
||||
"lib/withings/sync-ecg.ts":
|
||||
@@ -165,10 +168,15 @@ describe("measurement write tail", () => {
|
||||
});
|
||||
|
||||
it("the files that claim the shared tail call it", () => {
|
||||
const missing = Object.entries(WRITE_SITES)
|
||||
.filter(
|
||||
([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX),
|
||||
)
|
||||
const claims = Object.entries(WRITE_SITES).filter(
|
||||
([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX),
|
||||
);
|
||||
// Both shapes are in use; a set that lost either would check nothing.
|
||||
expect(claims.filter(([, r]) => r === TAIL).length).toBeGreaterThan(5);
|
||||
expect(
|
||||
claims.filter(([, r]) => r.startsWith(TAIL_VIA_PREFIX)).length,
|
||||
).toBeGreaterThan(0);
|
||||
const missing = claims
|
||||
.map(([file, reason]) =>
|
||||
reason.startsWith(TAIL_VIA_PREFIX)
|
||||
? reason.slice(TAIL_VIA_PREFIX.length)
|
||||
@@ -183,6 +191,18 @@ describe("measurement write tail", () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("the files that claim the provider write step call it", () => {
|
||||
const claimed = Object.entries(WRITE_SITES)
|
||||
.filter(([, reason]) => reason === PROVIDER_TAIL)
|
||||
.map(([file]) => file);
|
||||
expect(claimed.length).toBeGreaterThan(0);
|
||||
const missing = claimed.filter(
|
||||
(file) =>
|
||||
!/afterProviderWrite\s*\(/.test(readFileSync(join(SRC, file), "utf8")),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("the syncs that run both legs by hand call both", () => {
|
||||
const handRolled = Object.entries(WRITE_SITES)
|
||||
.filter(([, reason]) => reason.includes("re-warms"))
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { allJobDefinitions } from "@/lib/jobs/registry/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict";
|
||||
import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
@@ -76,12 +77,9 @@ describe("moodLog integration stays removed", () => {
|
||||
});
|
||||
|
||||
it("registers no moodlog queue, cron or handler binding", () => {
|
||||
const registrar = readSource(
|
||||
"src/lib/jobs/reminder/register-integration-sync.ts",
|
||||
"export async function registerIntegrationSyncQueues",
|
||||
);
|
||||
expect(registrar).not.toContain("moodlog");
|
||||
expect(registrar).not.toContain("MoodLog");
|
||||
const queues = allJobDefinitions().map((job) => job.queue);
|
||||
expect(queues.length).toBeGreaterThan(100);
|
||||
expect(queues.filter((queue) => /mood-?log/i.test(queue))).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no moodlog integration key on the status envelope", () => {
|
||||
|
||||
@@ -10,17 +10,23 @@
|
||||
*
|
||||
* A. Each kit provider has `src/lib/<provider>/oauth-descriptor.ts`, and the
|
||||
* descriptor names that provider.
|
||||
* B. A provider client's `refreshAccessToken` is imported by that
|
||||
* provider's descriptor and by nothing else outside tests.
|
||||
* B. A provider's refresh (`<provider>OAuth.refresh(…)`, from the shared
|
||||
* OAuth client factory) is called by that provider's descriptor and by
|
||||
* nothing else outside tests.
|
||||
* C. The refresh lock is taken by the kit and by the AI provider store
|
||||
* (`src/lib/ai/provider.ts`, which is not an integration) only.
|
||||
* D. A token is sealed into a connection row only by a callback route
|
||||
* (the connect-time write). The kit writes ciphertext the descriptor is
|
||||
* handed and never encrypts on its own.
|
||||
* E. A provider integration speaks to a token endpoint only through the
|
||||
* factory (`oauth-kit/client.ts`): no other integration module builds a
|
||||
* `grant_type` form. Sign-in (OIDC), the MCP authorization server and
|
||||
* the Codex device login are not provider integrations.
|
||||
*
|
||||
* Fitbit is the one listed exception: the classic Fitbit Web API is retired
|
||||
* on 2026-10-30 and its integration rides out on its own refresh until then,
|
||||
* rather than being migrated onto a lifecycle it will never use.
|
||||
* Fitbit is the one listed exception to the lifecycle: the classic Fitbit Web
|
||||
* API is retired on 2026-10-30 and its integration rides out on its own
|
||||
* refresh until then, rather than being migrated onto a lifecycle it will
|
||||
* never use. Its wire goes through the factory like every other provider's.
|
||||
*
|
||||
* Every leg asserts a non-empty match set before it compares, so a matcher
|
||||
* that stops matching fails instead of passing.
|
||||
@@ -68,21 +74,18 @@ describe("OAuth token lifecycle guard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("B: a provider's refreshAccessToken is imported only by its descriptor", () => {
|
||||
const importers = files.filter((f) => {
|
||||
const src = read(f);
|
||||
return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test(
|
||||
src,
|
||||
);
|
||||
});
|
||||
expect(importers.length).toBeGreaterThan(0);
|
||||
it("B: a provider's refresh is called only by its descriptor", () => {
|
||||
const callers = files.filter((f) =>
|
||||
/\b[a-z][A-Za-z]*OAuth\.refresh\s*\(/.test(read(f)),
|
||||
);
|
||||
expect(callers.length).toBeGreaterThan(0);
|
||||
const allowed = new Set([
|
||||
...KIT_PROVIDERS.filter((p) => p !== "polar").map(
|
||||
(p) => `src/lib/${p}/oauth-descriptor.ts`,
|
||||
),
|
||||
...RETIRED,
|
||||
]);
|
||||
expect(importers.filter((f) => !allowed.has(f))).toEqual([]);
|
||||
expect(callers.filter((f) => !allowed.has(f))).toEqual([]);
|
||||
});
|
||||
|
||||
it("C: only the kit and the AI provider store take the refresh lock", () => {
|
||||
@@ -111,4 +114,18 @@ describe("OAuth token lifecycle guard", () => {
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("E: provider integrations reach a token endpoint only through the factory", () => {
|
||||
const forms = files.filter((f) => /\bgrant_type\b/.test(read(f)));
|
||||
expect(forms.length).toBeGreaterThan(0);
|
||||
const NOT_INTEGRATIONS = new Set([
|
||||
"src/lib/integrations/oauth-kit/client.ts",
|
||||
"src/lib/auth/oidc.ts",
|
||||
"src/lib/ai/codex-oauth.ts",
|
||||
"src/lib/mcp/oauth/metadata.ts",
|
||||
"src/app/api/mcp/oauth/token/route.ts",
|
||||
"src/app/api/mcp/oauth/register/route.ts",
|
||||
]);
|
||||
expect(forms.filter((f) => !NOT_INTEGRATIONS.has(f)).sort()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ const LIB = join(process.cwd(), "src", "lib");
|
||||
* three outcomes cannot carry.
|
||||
*/
|
||||
const EXCEPTIONS: Readonly<Record<string, { matches: number; why: string }>> = {
|
||||
"google-health/client.ts": {
|
||||
"google-health/data-points.ts": {
|
||||
matches: 1,
|
||||
why: "progress-bounded streaming list walk with slice budget and resume (forEachDataPointPage)",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A provider sync loads none of the AI layer.
|
||||
*
|
||||
* Thirteen sync modules once imported `insights/comprehensive-generate` to
|
||||
* reach one invalidation function it re-exported, so every sync worker
|
||||
* evaluated the whole insight generator. The write tail is
|
||||
* `integrations/sync-kit/after-write.ts` now, and it reaches the status cards
|
||||
* through `insights/status-invalidation`. This guard holds that:
|
||||
*
|
||||
* - no production module in a provider folder (`PROVIDER_DIRS`) imports
|
||||
* anything under `lib/ai/` or the comprehensive generator;
|
||||
* - and loading any of them does not evaluate the comprehensive generator
|
||||
* transitively either.
|
||||
*
|
||||
* The planted case runs the transitive check over a module that does load
|
||||
* it, so a walk that resolves nothing cannot pass.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PROVIDER_DIRS } from "./helpers/provider-dirs";
|
||||
import { requireFloor, walkSourceFiles } from "./helpers/source-files";
|
||||
import {
|
||||
SRC,
|
||||
moduleImports,
|
||||
parse,
|
||||
readSrc,
|
||||
staticImportClosure,
|
||||
} from "./helpers/source-metrics";
|
||||
|
||||
const GENERATOR = "lib/insights/comprehensive-generate.ts";
|
||||
const FORBIDDEN_SPEC = /^@\/lib\/(?:ai\/|insights\/comprehensive-generate$)/;
|
||||
|
||||
function providerModules(): string[] {
|
||||
return PROVIDER_DIRS.flatMap((dir) =>
|
||||
walkSourceFiles(`${SRC}/lib/${dir}`, { floor: 1 })
|
||||
.filter((f) => !f.includes("__tests__") && !/\.test\.tsx?$/.test(f))
|
||||
.map((f) => `lib/${dir}/${f}`),
|
||||
);
|
||||
}
|
||||
|
||||
describe("provider folders and the AI layer", () => {
|
||||
const modules = providerModules();
|
||||
|
||||
it("reads every provider folder", () => {
|
||||
requireFloor("provider modules", modules, 60);
|
||||
});
|
||||
|
||||
it("imports nothing from lib/ai or the comprehensive generator", () => {
|
||||
const offenders = modules.flatMap((rel) =>
|
||||
moduleImports(parse(rel, readSrc(rel)))
|
||||
.value.filter((spec) => FORBIDDEN_SPEC.test(spec))
|
||||
.map((spec) => `${rel} → ${spec}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not load the comprehensive generator transitively", () => {
|
||||
const closure = staticImportClosure(modules);
|
||||
expect(closure.size).toBeGreaterThan(modules.length);
|
||||
expect(closure.has(GENERATOR)).toBe(false);
|
||||
});
|
||||
|
||||
it("catches a module that loads it (planted)", () => {
|
||||
expect(
|
||||
staticImportClosure(["lib/jobs/morning-digest-refresh.ts"]).has(
|
||||
GENERATOR,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,7 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
|
||||
provider: "Polar",
|
||||
mapper: "mapPolarSportType",
|
||||
mapperFile: "lib/polar/sport-map.ts",
|
||||
captureFile: "lib/polar/client.ts",
|
||||
captureFile: "lib/polar/map.ts",
|
||||
captureField: "detailed_sport_info",
|
||||
writeFile: "lib/polar/sync-workouts.ts",
|
||||
metadataKey: "polarSport",
|
||||
@@ -92,7 +92,7 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
|
||||
provider: "Strava",
|
||||
mapper: "mapStravaSportType",
|
||||
mapperFile: "lib/strava/sport-map.ts",
|
||||
captureFile: "lib/strava/client.ts",
|
||||
captureFile: "lib/strava/map.ts",
|
||||
captureField: "rawSportType",
|
||||
writeFile: "lib/strava/sync.ts",
|
||||
metadataKey: "stravaType",
|
||||
|
||||
@@ -32,10 +32,10 @@ import {
|
||||
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
|
||||
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
||||
import {
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
|
||||
@@ -31,10 +31,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { ENCRYPTION_KEY_ROTATE_QUEUE } from "@/lib/jobs/encryption-key-rotate-queue";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_V2_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_KIND,
|
||||
APPLE_HEALTH_IMPORT_PARSER_REVISION,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import { APPLE_HEALTH_IMPORT_V2_QUEUE } from "@/lib/jobs/apple-health-import-queue";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import {
|
||||
createImportJobUnlessBusy,
|
||||
|
||||
@@ -7,15 +7,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchProfile,
|
||||
resolveFitbitUserId,
|
||||
FITBIT_OAUTH_SCOPE,
|
||||
} from "@/lib/fitbit/client";
|
||||
import { fetchProfile, resolveFitbitUserId } from "@/lib/fitbit/client";
|
||||
import { fitbitOAuth } from "@/lib/fitbit/oauth";
|
||||
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
|
||||
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
||||
import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state";
|
||||
import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill";
|
||||
import { FITBIT_BACKFILL_QUEUE } from "@/lib/fitbit/jobs/queues";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
@@ -150,7 +147,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return ERR("nocreds");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
|
||||
const tokens = await fitbitOAuth.exchange(code, creds, {
|
||||
codeVerifier: consumed.codeVerifier,
|
||||
});
|
||||
|
||||
// The initial code exchange MUST return a refresh token. If Fitbit ever
|
||||
// omits it, refuse to persist — a stored empty refresh token silently bricks
|
||||
|
||||
@@ -27,10 +27,15 @@ vi.mock("@/lib/fitbit/credentials", () => ({
|
||||
getUserFitbitCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fitbit/client", () => ({
|
||||
getAuthorizationUrl: vi.fn(
|
||||
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
|
||||
),
|
||||
vi.mock("@/lib/fitbit/oauth", () => ({
|
||||
fitbitOAuth: {
|
||||
authorizationUrl: vi.fn(
|
||||
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/oauth-kit/pkce", () => ({
|
||||
generatePkcePair: vi.fn(() => ({
|
||||
verifier: "verifier-stub",
|
||||
challenge: "challenge-stub",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client";
|
||||
import { fitbitOAuth } from "@/lib/fitbit/oauth";
|
||||
import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce";
|
||||
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
||||
import {
|
||||
FITBIT_OAUTH_STATE_COOKIE,
|
||||
@@ -94,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const url = getAuthorizationUrl(nonce, creds, pkce.challenge);
|
||||
const url = fitbitOAuth.authorizationUrl(nonce, creds, pkce.challenge);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
|
||||
|
||||
@@ -10,14 +10,14 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchProfile,
|
||||
resolveGoogleHealthUserId,
|
||||
getGoogleHealthScopeString,
|
||||
} from "@/lib/google-health/client";
|
||||
import { googleHealthOAuth } from "@/lib/google-health/oauth";
|
||||
import { getGoogleHealthScopeString } from "@/lib/google-health/oauth";
|
||||
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
|
||||
import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state";
|
||||
import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill";
|
||||
import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill-queue";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
@@ -162,14 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return ERR("nocreds");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, consumed.codeVerifier, creds);
|
||||
const tokens = await googleHealthOAuth.exchange(code, creds, {
|
||||
codeVerifier: consumed.codeVerifier,
|
||||
});
|
||||
|
||||
// The initial code exchange MUST return a refresh token (the connect route
|
||||
// forces `prompt=consent` + `access_type=offline`). If Google ever omits
|
||||
// it, refuse to persist — a stored empty refresh token silently bricks
|
||||
// every future token refresh (the refresh POST then 400s and the
|
||||
// connection dies the moment the access token expires). Error out cleanly
|
||||
// so the user re-consents rather than landing a dead connection.
|
||||
// The exchange must return a refresh token (the connect route forces
|
||||
// `prompt=consent` and `access_type=offline`). A stored empty one would
|
||||
// fail every later refresh, so the person re-consents instead of landing
|
||||
// a connection that dies with its first access token.
|
||||
if (!tokens.refresh_token) {
|
||||
getEvent()?.addWarning(
|
||||
`google-health callback: token response carried no refresh_token for user ${recordUserId}`,
|
||||
|
||||
@@ -30,12 +30,12 @@ import { emitSignal } from "@/lib/logging/signal";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_V2_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_KIND,
|
||||
APPLE_HEALTH_IMPORT_PARSER_REVISION,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import { APPLE_HEALTH_IMPORT_V2_QUEUE } from "@/lib/jobs/apple-health-import-queue";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import {
|
||||
createImportJobUnlessBusy,
|
||||
|
||||
@@ -28,10 +28,10 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
HEALTH_CONNECT_IMPORT_KIND,
|
||||
HEALTH_CONNECT_IMPORT_PARSER_REVISION,
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
HEALTH_CONNECT_IMPORT_SEND_OPTIONS,
|
||||
type HealthConnectImportPayload,
|
||||
} from "@/lib/jobs/health-connect-import-worker";
|
||||
import { HEALTH_CONNECT_IMPORT_QUEUE } from "@/lib/jobs/health-connect-import-queue";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import {
|
||||
createImportJobUnlessBusy,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/google-health/sync-core";
|
||||
import { fetchDailyRollUp } from "@/lib/google-health/client";
|
||||
import {
|
||||
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE,
|
||||
GOOGLE_HEALTH_API_BASE,
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
fetchDailyRollUp,
|
||||
fetchDataPoints,
|
||||
} from "@/lib/google-health/client";
|
||||
} from "@/lib/google-health/data-points";
|
||||
import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/request";
|
||||
import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers";
|
||||
import { GoogleHealthApiError } from "@/lib/google-health/response-classifier";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from "@/lib/withings/plan-features";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { hasActivityScope } from "@/lib/withings/oauth";
|
||||
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import {
|
||||
googleHealthBackfillEndedList,
|
||||
|
||||
@@ -63,10 +63,8 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS } from "@/lib/jobs/medication-intake-import";
|
||||
import { MEDICATION_INTAKE_IMPORT_QUEUE } from "@/lib/jobs/medication-intake-import-queue";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
|
||||
@@ -74,13 +74,15 @@ const {
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock }));
|
||||
|
||||
vi.mock("@/lib/oura/client", async (importOriginal) => ({
|
||||
isAcceptedOuraIssuer: (
|
||||
await importOriginal<typeof import("@/lib/oura/client")>()
|
||||
).isAcceptedOuraIssuer,
|
||||
exchangeCode: exchangeMock,
|
||||
vi.mock("@/lib/oura/client", () => ({
|
||||
fetchOuraAccountId: vi.fn(async () => "oura-account-1"),
|
||||
}));
|
||||
vi.mock("@/lib/oura/oauth", async (importOriginal) => ({
|
||||
isAcceptedOuraIssuer: (
|
||||
await importOriginal<typeof import("@/lib/oura/oauth")>()
|
||||
).isAcceptedOuraIssuer,
|
||||
exchangeOuraCode: exchangeMock,
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
@@ -6,11 +6,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchOuraAccountId,
|
||||
isAcceptedOuraIssuer,
|
||||
} from "@/lib/oura/client";
|
||||
import { fetchOuraAccountId } from "@/lib/oura/client";
|
||||
import { exchangeOuraCode, isAcceptedOuraIssuer } from "@/lib/oura/oauth";
|
||||
import {
|
||||
ALREADY_LINKED_REASON,
|
||||
accountLinkedElsewhere,
|
||||
@@ -27,10 +24,8 @@ import { markReconnected } from "@/lib/integrations/status";
|
||||
import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
|
||||
import {
|
||||
OURA_BACKFILL_QUEUE,
|
||||
type OuraBackfillPayload,
|
||||
} from "@/lib/jobs/oura-backfill";
|
||||
import { type OuraBackfillPayload } from "@/lib/jobs/oura-backfill";
|
||||
import { OURA_BACKFILL_QUEUE } from "@/lib/jobs/oura-backfill-queue";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
@@ -139,7 +134,7 @@ async function completeCallback(
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds, iss);
|
||||
const tokens = await exchangeOuraCode(code, creds, iss);
|
||||
|
||||
// One Oura account, one record: a guardian signed in to their own Oura
|
||||
// account must not attach it to the profile they manage.
|
||||
|
||||
@@ -76,9 +76,11 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
|
||||
vi.mock("@/lib/polar/client", () => ({
|
||||
exchangeCode: exchangeCodeMock,
|
||||
registerUser: registerUserMock,
|
||||
}));
|
||||
vi.mock("@/lib/polar/oauth", () => ({
|
||||
polarOAuth: { exchange: exchangeCodeMock },
|
||||
}));
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, registerUser } from "@/lib/polar/client";
|
||||
import { registerUser } from "@/lib/polar/client";
|
||||
import { polarOAuth } from "@/lib/polar/oauth";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
@@ -115,7 +116,7 @@ async function completeCallback(
|
||||
const creds = await getPolarClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await polarOAuth.exchange(code, creds);
|
||||
// Guard against an off-spec token body without `x_user_id`: persisting the
|
||||
// string "undefined" as the member id would break every later data read
|
||||
// (`/v3/users/undefined/...`) with no clear cause.
|
||||
|
||||
@@ -83,7 +83,9 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc(${s})` }));
|
||||
vi.mock("@/lib/strava/client", () => ({ exchangeCode: exchangeCodeMock }));
|
||||
vi.mock("@/lib/strava/oauth", () => ({
|
||||
stravaOAuth: { exchange: exchangeCodeMock },
|
||||
}));
|
||||
vi.mock("@/lib/strava/credentials", () => ({
|
||||
getStravaClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode } from "@/lib/strava/client";
|
||||
import { stravaOAuth } from "@/lib/strava/oauth";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill";
|
||||
import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill-queue";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
@@ -114,7 +114,7 @@ async function completeCallback(
|
||||
const creds = await getStravaClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await stravaOAuth.exchange(code, creds);
|
||||
|
||||
const athleteId =
|
||||
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
|
||||
|
||||
@@ -52,15 +52,19 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
|
||||
|
||||
vi.mock("@/lib/whoop/client", () => ({
|
||||
WHOOP_OAUTH_SCOPE: "read:recovery",
|
||||
exchangeCode: vi.fn(async () => ({
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
expires_in: 3600,
|
||||
scope: "read:recovery",
|
||||
})),
|
||||
fetchProfile: vi.fn(async () => ({ user_id: 42 })),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/oauth", () => ({
|
||||
WHOOP_OAUTH_SCOPE: "read:recovery",
|
||||
whoopOAuth: {
|
||||
exchange: vi.fn(async () => ({
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
expires_in: 3600,
|
||||
scope: "read:recovery",
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/whoop/credentials", () => ({
|
||||
getUserWhoopCredentials: vi.fn(async () => ({
|
||||
|
||||
@@ -6,15 +6,12 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchProfile,
|
||||
WHOOP_OAUTH_SCOPE,
|
||||
} from "@/lib/whoop/client";
|
||||
import { fetchProfile } from "@/lib/whoop/client";
|
||||
import { WHOOP_OAUTH_SCOPE, whoopOAuth } from "@/lib/whoop/oauth";
|
||||
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
|
||||
import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state";
|
||||
import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme";
|
||||
import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill";
|
||||
import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill-queue";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
@@ -205,7 +202,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return outcomeRedirect(returnScheme, "error", "nocreds");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await whoopOAuth.exchange(code, creds);
|
||||
const profile = await fetchProfile(tokens.access_token);
|
||||
const whoopUserId = String(profile.user_id);
|
||||
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
@@ -81,8 +81,8 @@ vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({
|
||||
exchangeCode: vi.fn(),
|
||||
vi.mock("@/lib/withings/oauth", () => ({
|
||||
withingsOAuth: { exchange: vi.fn() },
|
||||
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
|
||||
}));
|
||||
|
||||
@@ -157,8 +157,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
} as never);
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
vi.mocked(exchangeCode).mockResolvedValue({
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
vi.mocked(withingsOAuth.exchange).mockResolvedValue({
|
||||
userid: "wuid",
|
||||
access_token: "atok",
|
||||
refresh_token: "rtok",
|
||||
@@ -221,8 +221,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
} as never);
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
vi.mocked(exchangeCode).mockResolvedValue({
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
vi.mocked(withingsOAuth.exchange).mockResolvedValue({
|
||||
userid: "wuid",
|
||||
access_token: "atok",
|
||||
refresh_token: "rtok",
|
||||
@@ -300,7 +300,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
expiredRow as never,
|
||||
);
|
||||
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
@@ -310,7 +310,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
"withings=error&reason=expired",
|
||||
);
|
||||
// Single atomic round-trip consumed the row; no token exchange.
|
||||
expect(exchangeCode).not.toHaveBeenCalled();
|
||||
expect(withingsOAuth.exchange).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
crossUserRow as never,
|
||||
);
|
||||
|
||||
const { exchangeCode } = await import("@/lib/withings/client");
|
||||
const { withingsOAuth } = await import("@/lib/withings/oauth");
|
||||
|
||||
const req = callbackRequest(NONCE, NONCE);
|
||||
const res = await GET(req);
|
||||
@@ -334,7 +334,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => {
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=cross_user",
|
||||
);
|
||||
expect(exchangeCode).not.toHaveBeenCalled();
|
||||
expect(withingsOAuth.exchange).not.toHaveBeenCalled();
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
meta: { reason: "cross_user" },
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
|
||||
import { WITHINGS_OAUTH_SCOPE, withingsOAuth } from "@/lib/withings/oauth";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
|
||||
import { setupWebhook } from "@/lib/withings/sync";
|
||||
@@ -209,7 +209,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
const tokens = await withingsOAuth.exchange(code, creds);
|
||||
|
||||
// One Withings account, one record. A guardian signed in to their own
|
||||
// Health Mate account must not attach it to the profile they manage:
|
||||
|
||||
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { hasActivityScope } from "@/lib/withings/oauth";
|
||||
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
|
||||
import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor";
|
||||
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
|
||||
|
||||
@@ -4,13 +4,9 @@ import {
|
||||
FITBIT_API_BASE,
|
||||
FITBIT_RETRY_AFTER_WAIT_CAP_S,
|
||||
FITBIT_FIELD_MAP,
|
||||
FITBIT_OAUTH_SCOPE,
|
||||
exchangeCode,
|
||||
fetchSleepRange,
|
||||
fetchSpo2Range,
|
||||
fetchWeightRange,
|
||||
generatePkcePair,
|
||||
getAuthorizationUrl,
|
||||
mapActiveCalories,
|
||||
mapBodyFat,
|
||||
mapDistance,
|
||||
@@ -29,9 +25,11 @@ import {
|
||||
parseVo2Max,
|
||||
readActivityList,
|
||||
readSleepSessions,
|
||||
refreshAccessToken,
|
||||
resolveFitbitUserId,
|
||||
} from "../client";
|
||||
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
|
||||
import { fitbitOAuth } from "../oauth";
|
||||
import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce";
|
||||
|
||||
const CREDS = { clientId: "cid", clientSecret: "csecret" };
|
||||
|
||||
@@ -89,7 +87,7 @@ describe("generatePkcePair", () => {
|
||||
|
||||
describe("getAuthorizationUrl", () => {
|
||||
it("builds the classic Fitbit authorize URL with PKCE S256", () => {
|
||||
const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz");
|
||||
const url = fitbitOAuth.authorizationUrl("nonce123", CREDS, "chal-xyz");
|
||||
expect(url).toContain("www.fitbit.com/oauth2/authorize");
|
||||
expect(url).toContain("response_type=code");
|
||||
expect(url).toContain("client_id=cid");
|
||||
@@ -118,7 +116,7 @@ describe("getAuthorizationUrl", () => {
|
||||
});
|
||||
|
||||
it("carries the configured app origin in the redirect_uri", () => {
|
||||
const url = getAuthorizationUrl("nonce123", CREDS, "c");
|
||||
const url = fitbitOAuth.authorizationUrl("nonce123", CREDS, "c");
|
||||
const redirect = new URL(url).searchParams.get("redirect_uri");
|
||||
expect(redirect).toBe("https://app.example.test/api/fitbit/callback");
|
||||
});
|
||||
@@ -133,12 +131,14 @@ describe("redirect_uri allowlist", () => {
|
||||
|
||||
it("rejects a non-https app origin", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test";
|
||||
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/);
|
||||
expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow(
|
||||
/must be https/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows http on localhost (dev)", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
const url = getAuthorizationUrl("n", CREDS, "c");
|
||||
const url = fitbitOAuth.authorizationUrl("n", CREDS, "c");
|
||||
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
|
||||
"http://localhost:3000/api/fitbit/callback",
|
||||
);
|
||||
@@ -146,7 +146,7 @@ describe("redirect_uri allowlist", () => {
|
||||
|
||||
it("rejects an explicit redirect that targets the wrong path", () => {
|
||||
process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil";
|
||||
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
|
||||
expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow(
|
||||
/must target \/api\/fitbit\/callback/,
|
||||
);
|
||||
});
|
||||
@@ -155,7 +155,7 @@ describe("redirect_uri allowlist", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test";
|
||||
process.env.FITBIT_REDIRECT_URI =
|
||||
"https://attacker.example.test/api/fitbit/callback";
|
||||
expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(
|
||||
expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow(
|
||||
/does not match/,
|
||||
);
|
||||
});
|
||||
@@ -175,7 +175,9 @@ describe("token exchange + refresh", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
const tok = await exchangeCode("code", "verifier-123", CREDS);
|
||||
const tok = await fitbitOAuth.exchange("code", CREDS, {
|
||||
codeVerifier: "verifier-123",
|
||||
});
|
||||
expect(tok.access_token).toBe("at");
|
||||
expect(tok.refresh_token).toBe("rt");
|
||||
expect(tok.expires_in).toBe(28800);
|
||||
@@ -202,7 +204,7 @@ describe("token exchange + refresh", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
const tok = await refreshAccessToken("rt1", CREDS);
|
||||
const tok = await fitbitOAuth.refresh("rt1", CREDS);
|
||||
expect(tok.access_token).toBe("at2");
|
||||
// Classic Fitbit rotates: a fresh refresh token is returned every time.
|
||||
expect(tok.refresh_token).toBe("rt2");
|
||||
@@ -220,7 +222,9 @@ describe("token exchange + refresh", () => {
|
||||
installFetchMock([
|
||||
{ status: 401, body: { errors: [{ errorType: "invalid_grant" }] } },
|
||||
]);
|
||||
await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({
|
||||
await expect(
|
||||
fitbitOAuth.exchange("bad", CREDS, { codeVerifier: "v" }),
|
||||
).rejects.toMatchObject({
|
||||
name: "FitbitApiError",
|
||||
classification: "reauth_required",
|
||||
});
|
||||
@@ -231,7 +235,9 @@ describe("token exchange + refresh", () => {
|
||||
// TypeError. It must surface as a classified transient integration error.
|
||||
it("classifies a 2xx with an empty body instead of casting null", async () => {
|
||||
installFetchMock([{ status: 200, body: null }]);
|
||||
await expect(exchangeCode("code", "v", CREDS)).rejects.toMatchObject({
|
||||
await expect(
|
||||
fitbitOAuth.exchange("code", CREDS, { codeVerifier: "v" }),
|
||||
).rejects.toMatchObject({
|
||||
name: "FitbitApiError",
|
||||
classification: "transient",
|
||||
reason: "empty_token_body",
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => ({ rowsUpserted: 0, durationMs: 0 })),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/client";
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
|
||||
|
||||
import { FITBIT_OAUTH_SCOPE } from "../client";
|
||||
import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth";
|
||||
|
||||
/**
|
||||
* Guard against the class of bug reported in #396: the setup docs drifting away
|
||||
|
||||
@@ -60,7 +60,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
@@ -70,7 +70,9 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserFitbitCredentials: getUserFitbitCredentialsMock,
|
||||
}));
|
||||
vi.mock("../client", () => ({ refreshAccessToken: refreshAccessTokenMock }));
|
||||
vi.mock("../oauth", () => ({
|
||||
fitbitOAuth: { refresh: refreshAccessTokenMock },
|
||||
}));
|
||||
|
||||
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
|
||||
vi.mock("../sync-activity", () => ({ syncUserActivity: resourceFake }));
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
@@ -44,7 +44,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserFitbitCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
|
||||
vi.mock("../oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { eventStorage } from "@/lib/logging/context";
|
||||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
|
||||
@@ -60,7 +60,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
@@ -57,7 +57,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserFitbitCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
|
||||
vi.mock("../oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { upsertFitbitMeasurements } from "../sync-core";
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: (...a: unknown[]) => recomputeUserRollups(...a),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) =>
|
||||
rows,
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeBucketsForKeys: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => ({ rowsUpserted: 0, durationMs: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
@@ -55,11 +56,14 @@ vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
|
||||
}));
|
||||
|
||||
vi.mock("../client", async (orig) => {
|
||||
const actual = await orig<typeof import("../client")>();
|
||||
vi.mock("../oauth", async (orig) => {
|
||||
const actual = await orig<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a),
|
||||
fitbitOAuth: {
|
||||
...actual.fitbitOAuth,
|
||||
refresh: (...a: unknown[]) => refreshAccessTokenMock(...a),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -36,19 +36,15 @@
|
||||
* self-serve Google path exists, the classic API is the only viable transport
|
||||
* for self-hosters; the card keeps its experimental badge.
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { FitbitApiError, classifyFitbitResponse } from "./response-classifier";
|
||||
import { envValue } from "@/lib/env";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
|
||||
/** Classic Fitbit Web API base. */
|
||||
export const FITBIT_API_BASE = "https://api.fitbit.com";
|
||||
const FITBIT_OAUTH_AUTH_URL = "https://www.fitbit.com/oauth2/authorize";
|
||||
const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token";
|
||||
|
||||
/**
|
||||
* Fitbit returns body weight / distance in metric (kg / km) only when an
|
||||
@@ -58,269 +54,6 @@ const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token";
|
||||
*/
|
||||
const FITBIT_METRIC_LOCALE = "en_GB";
|
||||
|
||||
export interface FitbitCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OAuth `redirect_uri`, then assert it against an allowlist
|
||||
* (defence-in-depth; carried over from the prior transport).
|
||||
*
|
||||
* The value is operator-controlled config (`FITBIT_REDIRECT_URI`, else derived
|
||||
* from `NEXT_PUBLIC_APP_URL`), not user input, and Fitbit's registered-redirect
|
||||
* check is the real backstop. But a misconfigured or `Host`-coerced
|
||||
* `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting a forwarded
|
||||
* Host) would otherwise send the authorization code's landing URL off-origin.
|
||||
* Pin the target so a malformed origin fails fast at the handshake:
|
||||
* - must be an absolute, parseable URL,
|
||||
* - must be https (the one exception is a localhost/loopback dev host),
|
||||
* - must land on the fixed `/api/fitbit/callback` path,
|
||||
* - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it.
|
||||
*/
|
||||
export function getFitbitRedirectUri(): string {
|
||||
// An empty or blank value counts as unset: the compose whitelist
|
||||
// materialises an unset var as an empty string, which must fall through to
|
||||
// the derived URI rather than read as "not configured".
|
||||
const explicit = envValue("FITBIT_REDIRECT_URI");
|
||||
const appUrl = envValue("NEXT_PUBLIC_APP_URL");
|
||||
const raw =
|
||||
explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined);
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
"Fitbit redirect_uri is not configured — set FITBIT_REDIRECT_URI or NEXT_PUBLIC_APP_URL",
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Fitbit redirect_uri is not an absolute URL: ${raw}`);
|
||||
}
|
||||
|
||||
const isLoopback =
|
||||
parsed.hostname === "localhost" ||
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "[::1]" ||
|
||||
parsed.hostname === "::1";
|
||||
if (
|
||||
parsed.protocol !== "https:" &&
|
||||
!(parsed.protocol === "http:" && isLoopback)
|
||||
) {
|
||||
throw new Error(
|
||||
`Fitbit redirect_uri must be https (or http on localhost): ${parsed.origin}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.pathname !== "/api/fitbit/callback") {
|
||||
throw new Error(
|
||||
`Fitbit redirect_uri must target /api/fitbit/callback, got ${parsed.pathname}`,
|
||||
);
|
||||
}
|
||||
|
||||
// When an explicit FITBIT_REDIRECT_URI is set alongside NEXT_PUBLIC_APP_URL,
|
||||
// require them to share an origin so the pinned value can't drift to an
|
||||
// unexpected host relative to the app's own base URL.
|
||||
if (explicit && appUrl) {
|
||||
let appOrigin: string;
|
||||
try {
|
||||
appOrigin = new URL(appUrl).origin;
|
||||
} catch {
|
||||
throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`);
|
||||
}
|
||||
if (parsed.origin !== appOrigin) {
|
||||
throw new Error(
|
||||
`Fitbit redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classic Fitbit OAuth scopes HealthLog requests (space-separated on the wire).
|
||||
* Each is independently self-serve grantable at the consent screen and the
|
||||
* `temperature` set is omitted on purpose (see the skin-temperature note in
|
||||
* `mapping.md` — the classic skin-temp reading is a baseline DELTA, not an
|
||||
* absolute reading, so it has no honest canonical slot here). The launch bundle
|
||||
* covers every metric the v1.20.0 mappers extract.
|
||||
*/
|
||||
export const FITBIT_OAUTH_SCOPE = [
|
||||
"activity",
|
||||
"cardio_fitness",
|
||||
"heartrate",
|
||||
"oxygen_saturation",
|
||||
"profile",
|
||||
"respiratory_rate",
|
||||
"sleep",
|
||||
"weight",
|
||||
].join(" ");
|
||||
|
||||
// ─── PKCE ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Fitbit recommends PKCE (S256). The verifier is a high-entropy random string;
|
||||
// the challenge is BASE64URL(SHA256(verifier)). The verifier is stashed on the
|
||||
// OAuth-state row at connect and presented on the token exchange at callback.
|
||||
|
||||
export interface FitbitPkcePair {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars,
|
||||
* comfortably inside Fitbit's 43–128 char verifier range and well past the
|
||||
* 256-bit entropy floor.
|
||||
*/
|
||||
export function generatePkcePair(): FitbitPkcePair {
|
||||
const verifier = randomBytes(64).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Fitbit authorization URL (a browser redirect, not a fetch). `state`
|
||||
* is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the
|
||||
* S256 PKCE challenge whose verifier the callback presents on token exchange.
|
||||
*/
|
||||
export function getAuthorizationUrl(
|
||||
state: string,
|
||||
creds: FitbitCredentials,
|
||||
codeChallenge: string,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: creds.clientId,
|
||||
redirect_uri: getFitbitRedirectUri(),
|
||||
scope: FITBIT_OAUTH_SCOPE,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
return `${FITBIT_OAUTH_AUTH_URL}?${params}`;
|
||||
}
|
||||
|
||||
export interface FitbitTokenResponse {
|
||||
access_token: string;
|
||||
/**
|
||||
* Classic Fitbit refresh tokens ROTATE (one-time use) — every token response
|
||||
* (initial exchange AND every refresh) carries a fresh `refresh_token` that
|
||||
* MUST replace the stored one. Typed optional only to guard a malformed
|
||||
* response defensively; the callers treat an absent value as a hard error /
|
||||
* keep-existing fallback.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/** Basic-auth header carrying the confidential client credentials. */
|
||||
function basicAuthHeader(creds: FitbitCredentials): string {
|
||||
const raw = `${creds.clientId}:${creds.clientSecret}`;
|
||||
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
|
||||
}
|
||||
|
||||
async function postToken(
|
||||
params: URLSearchParams,
|
||||
creds: FitbitCredentials,
|
||||
verb: string,
|
||||
): Promise<FitbitTokenResponse> {
|
||||
const start = performance.now();
|
||||
const res = await safeFetch(FITBIT_OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: basicAuthHeader(creds),
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const json = await res.json().catch(() => null);
|
||||
const verdict = classifyFitbitResponse(res.status);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "fitbit",
|
||||
method: verb,
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new FitbitApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError:
|
||||
typeof json?.errors?.[0]?.errorType === "string"
|
||||
? json.errors[0].errorType
|
||||
: typeof json?.error === "string"
|
||||
? json.error
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
// A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204)
|
||||
// leaves `json` null. Casting it straight to the token type handed callers a
|
||||
// null they then dereferenced, surfacing an unclassified TypeError instead of
|
||||
// a handled integration error. Narrow explicitly and classify as transient.
|
||||
if (json === null || typeof json !== "object") {
|
||||
throw new FitbitApiError({
|
||||
verb,
|
||||
classification: "transient",
|
||||
httpStatus: res.status,
|
||||
reason: "empty_token_body",
|
||||
});
|
||||
}
|
||||
return json as FitbitTokenResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for the initial token pair, presenting the PKCE
|
||||
* verifier. `redirect_uri` must exactly match the one sent to the authorize
|
||||
* endpoint.
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
creds: FitbitCredentials,
|
||||
): Promise<FitbitTokenResponse> {
|
||||
return postToken(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: creds.clientId,
|
||||
redirect_uri: getFitbitRedirectUri(),
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
creds,
|
||||
"exchangeCode",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an expired access token. Classic Fitbit ROTATES the refresh token:
|
||||
* the response carries a fresh `access_token` AND a fresh `refresh_token`; the
|
||||
* caller MUST persist the new refresh token (the old one is now invalid). The
|
||||
* grant's scope is preserved, so no `scope` param is re-sent.
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
creds: FitbitCredentials,
|
||||
): Promise<FitbitTokenResponse> {
|
||||
return postToken(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
creds,
|
||||
"refreshAccessToken",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Fitbit profile carries the stable external user id (`encodedId`) used as
|
||||
* the connection's `fitbitUserId`. A single GET, not a paginated collection.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import type { FitbitCredentials } from "./client";
|
||||
import type { FitbitCredentials } from "./oauth";
|
||||
|
||||
/**
|
||||
* Fetch and decrypt the user's Fitbit API credentials.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
import {
|
||||
enqueueBootTimeFitbitBackfill,
|
||||
runFitbitBackfillForUser,
|
||||
} from "../fitbit-backfill";
|
||||
} from "@/lib/fitbit/jobs/backfill";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* pg-boss queue registration for the Fitbit sync layer.
|
||||
*
|
||||
* Reads the worker registry instead of booting the worker: every Fitbit queue
|
||||
* is declared in the integration-sync group with its cron and handler, the
|
||||
* full-history queues forward to the shared admission lane, and the boot
|
||||
* discovery offers the backfill and the sleep repair again. This is the fast
|
||||
* guard for the dead-queue class (a queue declared but never registered,
|
||||
* which silently never drains).
|
||||
*
|
||||
* Fitbit is poll-only at launch (no webhook — Pub/Sub deferred), so a single
|
||||
* hourly `fitbit-sync` queue drives the per-user `syncUserFitbit` driver across
|
||||
* every connection, alongside the backfill + the daily OAuth-state sweep.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FITBIT_BACKFILL_QUEUE,
|
||||
FITBIT_OAUTH_STATE_CLEANUP_CRON,
|
||||
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
FITBIT_SLEEP_REPAIR_QUEUE,
|
||||
FITBIT_SYNC_CRON,
|
||||
FITBIT_SYNC_QUEUE,
|
||||
} from "@/lib/fitbit/jobs/queues";
|
||||
import { INTEGRATION_BACKFILL_ADMISSION_QUEUE } from "@/lib/jobs/integration-backfill-admission";
|
||||
import {
|
||||
handleFitbitOAuthStateCleanup,
|
||||
handleFitbitSync,
|
||||
} from "@/lib/fitbit/jobs/sync-handlers";
|
||||
import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import {
|
||||
discoveryOf,
|
||||
groupOf,
|
||||
jobOf,
|
||||
loadedHandler,
|
||||
recordingBoss,
|
||||
} from "@/lib/jobs/registry/__tests__/registry-helpers";
|
||||
|
||||
const handlerSource = readFileSync(
|
||||
join(__dirname, "..", "sync-handlers.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const FITBIT_QUEUES = [
|
||||
FITBIT_SYNC_QUEUE,
|
||||
FITBIT_BACKFILL_QUEUE,
|
||||
FITBIT_SLEEP_REPAIR_QUEUE,
|
||||
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
] as const;
|
||||
|
||||
/** The admission sends the source queue's handler makes for one job. */
|
||||
async function forwardOneJob(queue: string) {
|
||||
const { boss, sends } = recordingBoss();
|
||||
const handler = await jobOf(queue).load({ boss });
|
||||
await handler([
|
||||
{
|
||||
id: "j1",
|
||||
name: queue,
|
||||
data: { userId: "u1" },
|
||||
expireInSeconds: 60,
|
||||
heartbeatSeconds: null,
|
||||
retryCount: 0,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
]);
|
||||
return sends;
|
||||
}
|
||||
|
||||
describe("Fitbit sync queues", () => {
|
||||
it("declares the Fitbit sync + oauth-state-cleanup queue constants", () => {
|
||||
expect(FITBIT_SYNC_QUEUE).toBe("fitbit-sync");
|
||||
expect(FITBIT_OAUTH_STATE_CLEANUP_QUEUE).toBe("fitbit-oauth-state-cleanup");
|
||||
});
|
||||
|
||||
it("declares EVERY Fitbit queue in the integration-sync group", () => {
|
||||
for (const q of FITBIT_QUEUES) {
|
||||
expect(groupOf(q).name).toBe("integration_sync");
|
||||
}
|
||||
});
|
||||
|
||||
it("schedules the hourly poll cron + the daily oauth-state sweep", () => {
|
||||
expect(jobOf(FITBIT_SYNC_QUEUE).cron).toBe(FITBIT_SYNC_CRON);
|
||||
expect(jobOf(FITBIT_SYNC_QUEUE).scheduleOptions).toBeUndefined();
|
||||
const sweep = jobOf(FITBIT_OAUTH_STATE_CLEANUP_QUEUE);
|
||||
expect(sweep.cron).toBe(FITBIT_OAUTH_STATE_CLEANUP_CRON);
|
||||
expect(sweep.scheduleOptions).toBe(cronIsTheRetry);
|
||||
});
|
||||
|
||||
it("binds a handler for every Fitbit queue", async () => {
|
||||
expect(await loadedHandler(FITBIT_SYNC_QUEUE)).toBe(handleFitbitSync);
|
||||
expect(await loadedHandler(FITBIT_OAUTH_STATE_CLEANUP_QUEUE)).toBe(
|
||||
handleFitbitOAuthStateCleanup,
|
||||
);
|
||||
for (const [queue, kind] of [
|
||||
[FITBIT_BACKFILL_QUEUE, "fitbit-backfill"],
|
||||
[FITBIT_SLEEP_REPAIR_QUEUE, "fitbit-sleep-repair"],
|
||||
] as const) {
|
||||
const sends = await forwardOneJob(queue);
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(sends[0]!.queue).toBe(INTEGRATION_BACKFILL_ADMISSION_QUEUE);
|
||||
expect(sends[0]!.data).toMatchObject({ kind, data: { userId: "u1" } });
|
||||
}
|
||||
});
|
||||
|
||||
it("wires the self-converging backfill and the one-shot sleep-repair boot discovery", () => {
|
||||
const passes = discoveryOf("integration_sync");
|
||||
expect(passes).toContain("fitbit-backfill");
|
||||
expect(passes).toContain("fitbit-sleep-repair");
|
||||
});
|
||||
|
||||
it("imports the Fitbit sync driver into the handler module", () => {
|
||||
expect(handlerSource).toMatch(/from\s*["']@\/lib\/fitbit\/sync["']/);
|
||||
});
|
||||
});
|
||||
+9
-3
@@ -50,7 +50,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
@@ -59,7 +59,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
||||
vi.mock("@/lib/fitbit/credentials", () => ({
|
||||
getUserFitbitCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/fitbit/client", () => ({ refreshAccessToken: vi.fn() }));
|
||||
vi.mock("@/lib/fitbit/oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fitbit/oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
FITBIT_BACKFILL_DAYS,
|
||||
@@ -70,7 +76,7 @@ import {
|
||||
import {
|
||||
enqueueBootTimeFitbitSleepRepair,
|
||||
runFitbitSleepRepairForUser,
|
||||
} from "../fitbit-sleep-repair";
|
||||
} from "@/lib/fitbit/jobs/sleep-repair";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
beforeEach(() => {
|
||||
+1
-1
@@ -21,7 +21,7 @@ vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
import { handleFitbitSync } from "../reminder/fitbit-sync";
|
||||
import { handleFitbitSync } from "@/lib/fitbit/jobs/sync-handlers";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
describe("handleFitbitSync", () => {
|
||||
@@ -21,15 +21,7 @@ import {
|
||||
} from "@/lib/integrations/page-walk";
|
||||
import { syncUserFitbit } from "@/lib/fitbit/sync";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
|
||||
|
||||
/**
|
||||
* Serial concurrency — a backfill walks years of history for one account and is
|
||||
* rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding
|
||||
* the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const FITBIT_BACKFILL_CONCURRENCY = 1;
|
||||
import { FITBIT_BACKFILL_QUEUE } from "./queues";
|
||||
|
||||
export interface FitbitBackfillPayload {
|
||||
userId: string;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Every queue the Fitbit integration runs, as one block of the worker
|
||||
* registry.
|
||||
*
|
||||
* Google turns the classic Fitbit Web API off on 2026-10-30
|
||||
* (`lib/fitbit/retirement.ts`). Until the integration is removed it keeps
|
||||
* syncing; removing it then is this folder plus the one line in
|
||||
* `jobs/registry/integration-sync.ts` that spreads these definitions in.
|
||||
*/
|
||||
import { bootStaggerSecondsFor } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import {
|
||||
defineJob,
|
||||
type BootDiscovery,
|
||||
type JobDefinition,
|
||||
} from "@/lib/jobs/registry/define-job";
|
||||
|
||||
import {
|
||||
FITBIT_BACKFILL_CONCURRENCY,
|
||||
FITBIT_BACKFILL_QUEUE,
|
||||
FITBIT_OAUTH_STATE_CLEANUP_CRON,
|
||||
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
FITBIT_SLEEP_REPAIR_CONCURRENCY,
|
||||
FITBIT_SLEEP_REPAIR_QUEUE,
|
||||
FITBIT_SYNC_CRON,
|
||||
FITBIT_SYNC_QUEUE,
|
||||
} from "./queues";
|
||||
|
||||
export const FITBIT_JOBS: readonly JobDefinition[] = [
|
||||
// The cron walks every connection; serial so a backfill-heavy tick never
|
||||
// crowds the request pool.
|
||||
defineJob({
|
||||
queue: FITBIT_SYNC_QUEUE,
|
||||
cron: FITBIT_SYNC_CRON,
|
||||
work: { localConcurrency: 1 },
|
||||
load: async () => (await import("./sync-handlers")).handleFitbitSync,
|
||||
}),
|
||||
// The source queue keeps the per-connection singleton and forwards
|
||||
// admitted work to the shared drain.
|
||||
defineJob({
|
||||
queue: FITBIT_BACKFILL_QUEUE,
|
||||
policy: {
|
||||
policy: "exclusive",
|
||||
reason:
|
||||
"Per-connection full-history backfill; boot-discovery driven and self-converging.",
|
||||
},
|
||||
work: { localConcurrency: FITBIT_BACKFILL_CONCURRENCY },
|
||||
load: async ({ boss }) =>
|
||||
(
|
||||
await import("@/lib/jobs/integration-backfill-forward")
|
||||
).forwardToBackfillAdmission(boss, "fitbit-backfill"),
|
||||
}),
|
||||
// One-shot sleep duplicate repair: a bounded sleep re-read per connection,
|
||||
// forwarded to the shared drain.
|
||||
defineJob({
|
||||
queue: FITBIT_SLEEP_REPAIR_QUEUE,
|
||||
policy: {
|
||||
policy: "exclusive",
|
||||
reason:
|
||||
"Per-connection one-shot sleep re-read; discovery drops the connection once it is marked repaired.",
|
||||
},
|
||||
work: { localConcurrency: FITBIT_SLEEP_REPAIR_CONCURRENCY },
|
||||
load: async ({ boss }) =>
|
||||
(
|
||||
await import("@/lib/jobs/integration-backfill-forward")
|
||||
).forwardToBackfillAdmission(boss, "fitbit-sleep-repair"),
|
||||
}),
|
||||
defineJob({
|
||||
queue: FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
cron: FITBIT_OAUTH_STATE_CLEANUP_CRON,
|
||||
scheduleOptions: cronIsTheRetry,
|
||||
work: { localConcurrency: 1 },
|
||||
load: async () =>
|
||||
(await import("./sync-handlers")).handleFitbitOAuthStateCleanup,
|
||||
}),
|
||||
];
|
||||
|
||||
/** The Fitbit backfill and sleep repair, offered again at every boot. */
|
||||
export const FITBIT_BOOT_DISCOVERY: readonly BootDiscovery[] = [
|
||||
{
|
||||
name: "fitbit-backfill",
|
||||
run: async () =>
|
||||
(await import("./backfill")).enqueueBootTimeFitbitBackfill(
|
||||
bootStaggerSecondsFor("fitbit-backfill"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "fitbit-sleep-repair",
|
||||
run: async () =>
|
||||
(await import("./sleep-repair")).enqueueBootTimeFitbitSleepRepair(
|
||||
bootStaggerSecondsFor("fitbit-sleep-repair"),
|
||||
),
|
||||
},
|
||||
];
|
||||
File renamed without changes.
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The queue facts of the Fitbit jobs: names and concurrency. They live apart
|
||||
* from the handlers so the worker registry and every sender can read them
|
||||
* without loading the Fitbit sync.
|
||||
*/
|
||||
export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair";
|
||||
|
||||
/**
|
||||
* Serial concurrency — a repair walks a year of sleep history for one account
|
||||
* in 30-day requests against the tight per-user budget; concurrency-1 keeps it
|
||||
* from crowding the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const FITBIT_SLEEP_REPAIR_CONCURRENCY = 1;
|
||||
|
||||
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
|
||||
|
||||
/**
|
||||
* Serial concurrency — a backfill walks years of history for one account and is
|
||||
* rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding
|
||||
* the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const FITBIT_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
/** Hourly Fitbit poll (no webhook). */
|
||||
export const FITBIT_SYNC_QUEUE = "fitbit-sync";
|
||||
export const FITBIT_SYNC_CRON = "8 * * * *";
|
||||
|
||||
/** Daily sweep of the Fitbit OAuth state ledger. */
|
||||
export const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup";
|
||||
export const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *";
|
||||
@@ -46,15 +46,7 @@ import {
|
||||
} from "@/lib/fitbit/sync-core";
|
||||
import { syncUserSleep } from "@/lib/fitbit/sync-sleep";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair";
|
||||
|
||||
/**
|
||||
* Serial concurrency — a repair walks a year of sleep history for one account
|
||||
* in 30-day requests against the tight per-user budget; concurrency-1 keeps it
|
||||
* from crowding the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const FITBIT_SLEEP_REPAIR_CONCURRENCY = 1;
|
||||
import { FITBIT_SLEEP_REPAIR_QUEUE } from "./queues";
|
||||
|
||||
export interface FitbitSleepRepairPayload {
|
||||
userId: string;
|
||||
@@ -11,8 +11,8 @@ import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { runFitbitPollCohort } from "@/lib/fitbit/sync";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
import { cleanupExpiredFitbitOAuthStates } from "./oauth-state-cleanup";
|
||||
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
|
||||
import { isFitbitRetired } from "@/lib/fitbit/retirement";
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The Fitbit Web API OAuth client: consent URL, code exchange and refresh,
|
||||
* over the shared factory (`integrations/oauth-kit/client.ts`).
|
||||
*
|
||||
* S256 PKCE with the confidential client's secret by HTTP Basic beside it.
|
||||
* Refresh tokens rotate (one-time use): every answer carries a new refresh
|
||||
* token that replaces the stored one. A refusal names itself in
|
||||
* `errors[0].errorType`, or in an RFC 6749 `error`.
|
||||
*/
|
||||
import {
|
||||
defineOAuthClient,
|
||||
oauthErrorCode,
|
||||
type OAuthClientCredentials,
|
||||
} from "@/lib/integrations/oauth-kit/client";
|
||||
import { checkedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri";
|
||||
|
||||
import { FitbitApiError } from "./response-classifier";
|
||||
|
||||
export type FitbitCredentials = OAuthClientCredentials;
|
||||
|
||||
/**
|
||||
* The classic scopes HealthLog requests, space-separated on the wire. Each is
|
||||
* self-serve grantable; `temperature` is left out on purpose (the classic
|
||||
* skin-temperature reading is a delta, not an absolute value).
|
||||
*/
|
||||
export const FITBIT_OAUTH_SCOPE = [
|
||||
"activity",
|
||||
"cardio_fitness",
|
||||
"heartrate",
|
||||
"oxygen_saturation",
|
||||
"profile",
|
||||
"respiratory_rate",
|
||||
"sleep",
|
||||
"weight",
|
||||
].join(" ");
|
||||
|
||||
export interface FitbitTokenResponse {
|
||||
access_token: string;
|
||||
/**
|
||||
* Rotates on every answer and must replace the stored one. Optional only to
|
||||
* guard a malformed answer.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
function fitbitErrorType(body: unknown): string | undefined {
|
||||
if (body !== null && typeof body === "object") {
|
||||
const first = (body as { errors?: Array<{ errorType?: unknown }> })
|
||||
.errors?.[0];
|
||||
if (typeof first?.errorType === "string") return first.errorType;
|
||||
}
|
||||
return oauthErrorCode(body);
|
||||
}
|
||||
|
||||
export const fitbitOAuth = defineOAuthClient<FitbitTokenResponse>({
|
||||
service: "fitbit",
|
||||
authorizeUrl: "https://www.fitbit.com/oauth2/authorize",
|
||||
tokenUrl: "https://api.fitbit.com/oauth2/token",
|
||||
redirectUri: () =>
|
||||
checkedRedirectUri("Fitbit", "FITBIT_REDIRECT_URI", "/api/fitbit/callback"),
|
||||
authorizeParams: () => ({ scope: FITBIT_OAUTH_SCOPE }),
|
||||
pkce: true,
|
||||
clientAuth: { kind: "basic", idInForm: { exchange: true, refresh: false } },
|
||||
refresh: true,
|
||||
upstreamError: fitbitErrorType,
|
||||
error: (failure) =>
|
||||
new FitbitApiError({
|
||||
verb: failure.verb,
|
||||
classification: failure.classification,
|
||||
httpStatus: failure.status,
|
||||
reason: failure.reason,
|
||||
upstreamError: failure.upstreamError,
|
||||
}),
|
||||
});
|
||||
@@ -10,17 +10,14 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
|
||||
import { recordSyncFailure } from "@/lib/integrations/status";
|
||||
import type { FailureKind } from "@/lib/integrations/status";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForKeys,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { refreshAccessToken } from "./client";
|
||||
import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups";
|
||||
import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write";
|
||||
import { fitbitOAuth } from "./oauth";
|
||||
import { isFitbitRetired } from "./retirement";
|
||||
import { getUserFitbitCredentials } from "./credentials";
|
||||
import { FitbitApiError, classifyFitbitError } from "./response-classifier";
|
||||
@@ -115,7 +112,7 @@ export async function getValidToken(
|
||||
return null;
|
||||
}
|
||||
|
||||
const newTokens = await refreshAccessToken(refreshToken, creds);
|
||||
const newTokens = await fitbitOAuth.refresh(refreshToken, creds);
|
||||
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
|
||||
|
||||
// Classic Fitbit ROTATES refresh tokens: the refresh response carries a
|
||||
@@ -898,23 +895,9 @@ export async function upsertFitbitMeasurements(
|
||||
return { imported, touched, inserted: insertedRows };
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = collapseToTypeDayKeys(touched);
|
||||
await recomputeBucketsForKeys(userId, keys);
|
||||
invalidateStatusInsightsForTypes(
|
||||
userId,
|
||||
keys.map((k) => k.type),
|
||||
{ source: "sync" },
|
||||
).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`fitbit: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`fitbit: rollup recompute failed for ${userId}: ${err}`,
|
||||
);
|
||||
}
|
||||
// The arrivals went out per chunk above; the shared tail folds, marks the
|
||||
// caches and re-warms the status cards.
|
||||
await afterProviderWrite({ userId, provider: "fitbit", touched });
|
||||
|
||||
return { imported, touched, inserted: insertedRows };
|
||||
}
|
||||
|
||||
+5
-18
@@ -2,10 +2,9 @@
|
||||
import pLimit from "p-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { isReauthRequired, recordSyncSuccess } from "@/lib/integrations/status";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { afterProviderBackfill } from "@/lib/integrations/sync-kit/after-write";
|
||||
import { syncUserActivity } from "./sync-activity";
|
||||
import {
|
||||
hardFailStorage,
|
||||
@@ -127,22 +126,10 @@ export async function syncUserFitbit(
|
||||
// + 3 queue sends). Best-effort: a populator hiccup never fails the backfill.
|
||||
if (opts.fullSync && deferTracker.keys.size > 0) {
|
||||
try {
|
||||
const days = Array.from(deferTracker.keys.values());
|
||||
const types = Array.from(new Set(days.map((k) => k.type)));
|
||||
const sorted = days
|
||||
.map((k) => k.measuredAt.getTime())
|
||||
.sort((a, b) => a - b);
|
||||
const from = new Date(sorted[0]!);
|
||||
// The keys are UTC day-starts; extend `to` past the last touched day so
|
||||
// the aggregator's `< to` upper bound covers it.
|
||||
const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000);
|
||||
await recomputeUserRollups(userId, { types, from, to });
|
||||
invalidateStatusInsightsForTypes(userId, types, {
|
||||
source: "sync",
|
||||
}).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`fitbit: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
await afterProviderBackfill({
|
||||
userId,
|
||||
provider: "fitbit",
|
||||
keys: Array.from(deferTracker.keys.values()),
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
[
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-oxygen-saturation/dataPoints/spo2-0001",
|
||||
"dailyOxygenSaturation": {
|
||||
"date": { "year": 2026, "month": 6, "day": 1 },
|
||||
"averagePercentage": 96.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-oxygen-saturation/dataPoints/spo2-0002",
|
||||
"dailyOxygenSaturation": {
|
||||
"date": "2026-06-02T00:00:00",
|
||||
"averagePercentage": "97"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-heart-rate-variability/dataPoints/hrv-0001",
|
||||
"dailyHeartRateVariability": {
|
||||
"date": { "year": 2026, "month": 6, "day": 1 },
|
||||
"averageHeartRateVariabilityMilliseconds": 42.123456
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-heart-rate-variability/dataPoints/hrv-0002",
|
||||
"dailyHeartRateVariability": {
|
||||
"date": { "year": 2026, "month": 12, "day": 31 },
|
||||
"averageHeartRateVariabilityMilliseconds": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-resting-heart-rate/dataPoints/rhr-0001",
|
||||
"dailyRestingHeartRate": {
|
||||
"date": "2026-06-03",
|
||||
"beatsPerMinute": "55"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-resting-heart-rate/dataPoints/rhr-0002",
|
||||
"dailyRestingHeartRate": {
|
||||
"date": { "year": 2026, "month": "6", "day": 4 },
|
||||
"beatsPerMinute": 57
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-respiratory-rate/dataPoints/resp-0001",
|
||||
"dailyRespiratoryRate": {
|
||||
"date": { "year": 2026, "month": 3, "day": 29 },
|
||||
"breathsPerMinute": 14.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-respiratory-rate/dataPoints/resp-0002",
|
||||
"dailyRespiratoryRate": {
|
||||
"date": { "year": 2026, "month": 3, "day": 30 },
|
||||
"dailyRespiratoryRateBpm": 15.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-sleep-temperature-derivations/dataPoints/temp-0001",
|
||||
"dailySleepTemperatureDerivations": {
|
||||
"date": { "year": 2026, "month": 6, "day": 5 },
|
||||
"nightlyTemperatureCelsius": 34.42,
|
||||
"baselineTemperatureCelsius": 34.1,
|
||||
"relativeNightlyStddev30dCelsius": 0.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-sleep-temperature-derivations/dataPoints/temp-0002",
|
||||
"dailySleepTemperatureDerivations": {
|
||||
"date": "garbage",
|
||||
"nightlyTemperatureCelsius": 34.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0001",
|
||||
"dailyVo2Max": {
|
||||
"date": { "year": 2026, "month": 6, "day": 6 },
|
||||
"vo2Max": 47.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0002",
|
||||
"dailyVo2Max": {
|
||||
"vo2Max": "45"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0003",
|
||||
"dailyVo2Max": {
|
||||
"date": { "year": 2026, "month": 6, "day": 7 },
|
||||
"vo2Max": null
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0001",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-02T17:00:00Z",
|
||||
"endTime": "2026-06-02T17:42:30Z",
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 2 },
|
||||
"time": { "hours": 19 }
|
||||
}
|
||||
},
|
||||
"exerciseType": "RUNNING",
|
||||
"metricsSummary": {
|
||||
"caloriesKcal": 412.6,
|
||||
"distanceMillimeters": "7012345",
|
||||
"averageHeartRateBeatsPerMinute": "151"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0002",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-03T07:00:00",
|
||||
"endTime": "2026-06-03T08:00:00"
|
||||
},
|
||||
"exerciseType": "strength training",
|
||||
"metricsSummary": {
|
||||
"caloriesKcal": "250",
|
||||
"averageHeartRateBeatsPerMinute": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-04T06:30:00+02:00",
|
||||
"endTime": "2026-06-04T07:15:00+02:00"
|
||||
},
|
||||
"exerciseType": " KITESURFING ",
|
||||
"metricsSummary": {
|
||||
"distanceMillimeters": -100,
|
||||
"averageHeartRateBeatsPerMinute": "128.6"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0004",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-05T12:00:00Z",
|
||||
"endTime": "2026-06-05T12:30:00Z"
|
||||
},
|
||||
"exerciseType": " "
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0005",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-06T12:00:00Z",
|
||||
"endTime": "2026-06-06T12:00:00Z"
|
||||
},
|
||||
"exerciseType": "WALKING"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0006",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-07T12:00:00Z"
|
||||
},
|
||||
"exerciseType": "WALKING"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0007",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-08T18:00:00Z",
|
||||
"endTime": "2026-06-08T19:05:00Z"
|
||||
},
|
||||
"exerciseType": "Mountain-Biking",
|
||||
"metricsSummary": {
|
||||
"caloriesKcal": 0,
|
||||
"distanceMillimeters": "0",
|
||||
"averageHeartRateBeatsPerMinute": "x"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/exercise/dataPoints/exercise-0008",
|
||||
"exercise": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-09T20:00:00Z",
|
||||
"endTime": "2026-06-09T20:20:00Z"
|
||||
},
|
||||
"exerciseType": "STAIR_CLIMBER"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 1 },
|
||||
"time": {}
|
||||
},
|
||||
"civilEndTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 1 },
|
||||
"time": { "hours": 23, "minutes": 59, "seconds": 59 }
|
||||
},
|
||||
"steps": { "countSum": "8432" }
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 2 },
|
||||
"time": {}
|
||||
},
|
||||
"steps": { "countSum": "0" }
|
||||
},
|
||||
{
|
||||
"civilStartTime": "2026-06-03T00:00:00",
|
||||
"steps": { "countSum": 10250 }
|
||||
},
|
||||
{
|
||||
"civilEndTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 4 }
|
||||
},
|
||||
"steps": { "countSum": "5000" }
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 1 },
|
||||
"time": {}
|
||||
},
|
||||
"distance": { "millimetersSum": "6240512" }
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 2 },
|
||||
"time": {}
|
||||
},
|
||||
"distance": { "millimetersSum": "-1" }
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 1 },
|
||||
"time": {}
|
||||
},
|
||||
"activeEnergyBurned": { "kcalSum": 512.75 }
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 6, "day": 2 },
|
||||
"time": {}
|
||||
},
|
||||
"activeEnergyBurned": {}
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 12, "day": 31 },
|
||||
"time": {}
|
||||
},
|
||||
"floors": { "countSum": "12" }
|
||||
},
|
||||
{
|
||||
"civilStartTime": {
|
||||
"date": { "year": 2026, "month": 1, "day": 1 },
|
||||
"time": {}
|
||||
},
|
||||
"floors": { "countSum": "" }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
[
|
||||
{
|
||||
"name": "users/me/dataTypes/weight/dataPoints/weight-0001",
|
||||
"weight": {
|
||||
"sampleTime": { "physicalTime": "2026-06-02T06:15:00Z" },
|
||||
"weightGrams": 78400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/weight/dataPoints/weight-0002",
|
||||
"weight": {
|
||||
"sampleTime": { "physicalTime": "2026-06-03T07:30:00+02:00" },
|
||||
"weightGrams": "78250.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/weight/dataPoints/weight-0003",
|
||||
"weight": {
|
||||
"sampleTime": { "physicalTime": "2026-06-04T23:45:00" },
|
||||
"weightGrams": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/weight/dataPoints/weight-0004",
|
||||
"weight": {
|
||||
"sampleTime": { "physicalTime": "2026-06-04T23:45:00" },
|
||||
"weightGrams": 77900
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/body-fat/dataPoints/body-fat-0001",
|
||||
"bodyFat": {
|
||||
"sampleTime": { "physicalTime": "2026-06-02T06:15:00.000Z" },
|
||||
"percentage": 21.4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/body-fat/dataPoints/body-fat-0002",
|
||||
"bodyFat": {
|
||||
"percentage": "20.9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0001",
|
||||
"heartRate": {
|
||||
"sampleTime": { "physicalTime": "2026-06-02T12:00:30-04:00" },
|
||||
"beatsPerMinute": "64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0002",
|
||||
"heartRate": {
|
||||
"sampleTime": { "physicalTime": "2026-06-02 00:10" },
|
||||
"beatsPerMinute": " "
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0003",
|
||||
"heartRate": {
|
||||
"sampleTime": { "physicalTime": "not-a-time" },
|
||||
"beatsPerMinute": 58
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/blood-glucose/dataPoints/blood-glucose-0001",
|
||||
"bloodGlucose": {
|
||||
"sampleTime": { "physicalTime": "2026-06-05T08:00:00Z" },
|
||||
"bloodGlucoseMilligramsPerDeciliter": 96.333333
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/blood-glucose/dataPoints/blood-glucose-0002",
|
||||
"bloodGlucose": {
|
||||
"sampleTime": { "physicalTime": "2026-06-05T09:00:00Z" },
|
||||
"bloodGlucoseMilligramsPerDeciliter": -5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/core-body-temperature/dataPoints/core-temp-0001",
|
||||
"coreBodyTemperature": {
|
||||
"sampleTime": { "physicalTime": "2026-06-06T03:00:00" },
|
||||
"temperatureCelsius": 36.75
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/height/dataPoints/height-0001",
|
||||
"height": {
|
||||
"sampleTime": { "physicalTime": "2026-01-15T10:00:00Z" },
|
||||
"heightMillimeters": "1805"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/height/dataPoints/height-0002",
|
||||
"height": {
|
||||
"heightMillimeters": 1798
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/height/dataPoints/height-0003",
|
||||
"height": {
|
||||
"sampleTime": { "physicalTime": "2026-01-16T10:00:00Z" },
|
||||
"heightMillimeters": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
[
|
||||
{
|
||||
"name": "users/me/dataTypes/sleep/dataPoints/sleep-0001",
|
||||
"sleep": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-01T22:30:00Z",
|
||||
"endTime": "2026-06-02T06:10:00Z"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"startTime": "2026-06-01T22:30:00Z",
|
||||
"startUtcOffset": "7200s",
|
||||
"endTime": "2026-06-01T22:45:00Z",
|
||||
"endUtcOffset": "7200s",
|
||||
"type": "AWAKE"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-01T22:45:00Z",
|
||||
"endTime": "2026-06-02T00:15:00Z",
|
||||
"type": "LIGHT"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-02T00:15:00Z",
|
||||
"endTime": "2026-06-02T01:20:30Z",
|
||||
"type": "DEEP"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-02T01:20:30Z",
|
||||
"endTime": "2026-06-02T02:05:00Z",
|
||||
"type": "REM"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-02T02:05:00Z",
|
||||
"endTime": "2026-06-02T06:10:00Z",
|
||||
"type": "LIGHT"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/sleep/dataPoints/sleep-0002",
|
||||
"sleep": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-02T23:50:00",
|
||||
"endTime": "2026-06-03T07:00:00"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"startTime": "2026-06-02T23:50:00",
|
||||
"endTime": "2026-06-03T00:20:00",
|
||||
"type": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-03T00:20:00",
|
||||
"endTime": "2026-06-03T00:25:00",
|
||||
"type": "RESTLESS"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-03T00:25:00",
|
||||
"endTime": "2026-06-03T07:00:00",
|
||||
"type": "asleep"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"sleep": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-04T22:00:00+02:00",
|
||||
"endTime": "2026-06-05T05:30:00+02:00"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"startTime": "2026-06-04T22:00:00+02:00",
|
||||
"endTime": "2026-06-05T01:00:00+02:00",
|
||||
"type": "DEEP"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-05T01:00:00+02:00",
|
||||
"endTime": "2026-06-05T05:30:00+02:00",
|
||||
"type": "SLEEP_STAGE_TYPE_UNSPECIFIED"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "",
|
||||
"sleep": {
|
||||
"interval": {
|
||||
"startTime": "2026-06-06T23:00:00Z"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"startTime": "2026-06-06T23:00:00Z",
|
||||
"endTime": "2026-06-06T23:00:00Z",
|
||||
"type": "LIGHT"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-07T01:00:00Z",
|
||||
"endTime": "2026-06-07T00:30:00Z",
|
||||
"type": "REM"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-07T01:00:00Z",
|
||||
"type": "DEEP"
|
||||
},
|
||||
{
|
||||
"endTime": "2026-06-07T02:00:00Z",
|
||||
"type": "DEEP"
|
||||
},
|
||||
null,
|
||||
"LIGHT",
|
||||
{
|
||||
"startTime": "2026-06-07T02:00:00Z",
|
||||
"endTime": "2026-06-07T02:30:00Z"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-07T02:00:00Z",
|
||||
"endTime": "2026-06-07T02:30:00Z",
|
||||
"type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"sleep": {
|
||||
"stages": "not-a-list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users/me/dataTypes/sleep/dataPoints/sleep-0006",
|
||||
"sleep": {
|
||||
"stages": [
|
||||
{
|
||||
"startTime": "2026-06-08T00:00:00Z",
|
||||
"endTime": "2026-06-08T00:10:00Z",
|
||||
"type": "NAP_STAGE_ALPHA"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T00:10:00Z",
|
||||
"endTime": "2026-06-08T00:20:00Z",
|
||||
"type": "NAP_STAGE_BETA"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T00:20:00Z",
|
||||
"endTime": "2026-06-08T00:30:00Z",
|
||||
"type": "NAP_STAGE_GAMMA"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T00:30:00Z",
|
||||
"endTime": "2026-06-08T00:40:00Z",
|
||||
"type": "NAP_STAGE_ALPHA"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T00:40:00Z",
|
||||
"endTime": "2026-06-08T00:50:00Z",
|
||||
"type": "NAP_STAGE_DELTA"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T00:50:00Z",
|
||||
"endTime": "2026-06-08T01:00:00Z",
|
||||
"type": "NAP_STAGE_EPSILON"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T01:00:00Z",
|
||||
"endTime": "2026-06-08T01:10:00Z",
|
||||
"type": "NAP_STAGE_ZETA_WITH_A_LABEL_MUCH_LONGER_THAN_FORTY_CHARACTERS"
|
||||
},
|
||||
{
|
||||
"startTime": "2026-06-08T01:10:00Z",
|
||||
"endTime": "2026-06-08T01:40:00Z",
|
||||
"type": "light"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,604 @@
|
||||
{
|
||||
"resolveGoogleHealthUserId": [
|
||||
{ "profile": { "name": "users/1234567890" }, "userId": "1234567890" },
|
||||
{ "profile": { "name": "users/", "id": "id-0002" }, "userId": "id-0002" },
|
||||
{ "profile": { "name": "plain-0003" }, "userId": "plain-0003" },
|
||||
{ "profile": { "id": "id-0004" }, "userId": "id-0004" },
|
||||
{ "profile": {}, "userId": "me" }
|
||||
],
|
||||
"formatCivilBound": [
|
||||
{
|
||||
"instant": "2026-06-01T21:59:59.000Z",
|
||||
"tz": null,
|
||||
"bound": "2026-06-01T21:59:59"
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T21:59:59.000Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"bound": "2026-06-01T23:59:59"
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T21:59:59.000Z",
|
||||
"tz": "America/New_York",
|
||||
"bound": "2026-06-01T17:59:59"
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T22:00:00.000Z",
|
||||
"tz": null,
|
||||
"bound": "2026-06-01T22:00:00"
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T22:00:00.000Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"bound": "2026-06-02T00:00:00"
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T22:00:00.000Z",
|
||||
"tz": "America/New_York",
|
||||
"bound": "2026-06-01T18:00:00"
|
||||
},
|
||||
{
|
||||
"instant": "2026-03-29T00:30:00.000Z",
|
||||
"tz": null,
|
||||
"bound": "2026-03-29T00:30:00"
|
||||
},
|
||||
{
|
||||
"instant": "2026-03-29T00:30:00.000Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"bound": "2026-03-29T01:30:00"
|
||||
},
|
||||
{
|
||||
"instant": "2026-03-29T00:30:00.000Z",
|
||||
"tz": "America/New_York",
|
||||
"bound": "2026-03-28T20:30:00"
|
||||
},
|
||||
{
|
||||
"instant": "2026-12-31T23:30:15.500Z",
|
||||
"tz": null,
|
||||
"bound": "2026-12-31T23:30:15"
|
||||
},
|
||||
{
|
||||
"instant": "2026-12-31T23:30:15.500Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"bound": "2027-01-01T00:30:15"
|
||||
},
|
||||
{
|
||||
"instant": "2026-12-31T23:30:15.500Z",
|
||||
"tz": "America/New_York",
|
||||
"bound": "2026-12-31T18:30:15"
|
||||
}
|
||||
],
|
||||
"civilDateInTz": [
|
||||
{
|
||||
"instant": "2026-06-01T21:59:59.000Z",
|
||||
"tz": null,
|
||||
"date": { "year": 2026, "month": 6, "day": 1 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T21:59:59.000Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"date": { "year": 2026, "month": 6, "day": 1 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T21:59:59.000Z",
|
||||
"tz": "America/New_York",
|
||||
"date": { "year": 2026, "month": 6, "day": 1 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T22:00:00.000Z",
|
||||
"tz": null,
|
||||
"date": { "year": 2026, "month": 6, "day": 1 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T22:00:00.000Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"date": { "year": 2026, "month": 6, "day": 2 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-06-01T22:00:00.000Z",
|
||||
"tz": "America/New_York",
|
||||
"date": { "year": 2026, "month": 6, "day": 1 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-03-29T00:30:00.000Z",
|
||||
"tz": null,
|
||||
"date": { "year": 2026, "month": 3, "day": 29 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-03-29T00:30:00.000Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"date": { "year": 2026, "month": 3, "day": 29 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-03-29T00:30:00.000Z",
|
||||
"tz": "America/New_York",
|
||||
"date": { "year": 2026, "month": 3, "day": 28 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-12-31T23:30:15.500Z",
|
||||
"tz": null,
|
||||
"date": { "year": 2026, "month": 12, "day": 31 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-12-31T23:30:15.500Z",
|
||||
"tz": "Europe/Berlin",
|
||||
"date": { "year": 2027, "month": 1, "day": 1 }
|
||||
},
|
||||
{
|
||||
"instant": "2026-12-31T23:30:15.500Z",
|
||||
"tz": "America/New_York",
|
||||
"date": { "year": 2026, "month": 12, "day": 31 }
|
||||
}
|
||||
],
|
||||
"incrementalFilter": [
|
||||
{
|
||||
"dataType": "weight",
|
||||
"style": "camel",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "weight.sample_time.physical_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "weight",
|
||||
"style": "camel",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "weight.sample_time.physical_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "weight",
|
||||
"style": "camel",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "weight.sample_time.physical_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "weight",
|
||||
"style": "snake",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "weight.sample_time.physical_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "weight",
|
||||
"style": "snake",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "weight.sample_time.physical_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "weight",
|
||||
"style": "snake",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "weight.sample_time.physical_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "daily-heart-rate-variability",
|
||||
"style": "camel",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "dailyHeartRateVariability.date",
|
||||
"bound": "2026-06-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "daily-heart-rate-variability",
|
||||
"style": "camel",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "dailyHeartRateVariability.date",
|
||||
"bound": "2026-06-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "daily-heart-rate-variability",
|
||||
"style": "camel",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "dailyHeartRateVariability.date",
|
||||
"bound": "2026-06-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "daily-heart-rate-variability",
|
||||
"style": "snake",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "daily_heart_rate_variability.date",
|
||||
"bound": "2026-06-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "daily-heart-rate-variability",
|
||||
"style": "snake",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "daily_heart_rate_variability.date",
|
||||
"bound": "2026-06-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "daily-heart-rate-variability",
|
||||
"style": "snake",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "daily_heart_rate_variability.date",
|
||||
"bound": "2026-06-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "sleep",
|
||||
"style": "camel",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "sleep.interval.end_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "sleep",
|
||||
"style": "camel",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "sleep.interval.end_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "sleep",
|
||||
"style": "camel",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "sleep.interval.end_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "sleep",
|
||||
"style": "snake",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "sleep.interval.end_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "sleep",
|
||||
"style": "snake",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "sleep.interval.end_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "sleep",
|
||||
"style": "snake",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "sleep.interval.end_time",
|
||||
"bound": "2026-06-01T22:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "exercise",
|
||||
"style": "camel",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "exercise.interval.civil_start_time",
|
||||
"bound": "2026-06-01T22:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "exercise",
|
||||
"style": "camel",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "exercise.interval.civil_start_time",
|
||||
"bound": "2026-06-02T00:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "exercise",
|
||||
"style": "camel",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "exercise.interval.civil_start_time",
|
||||
"bound": "2026-06-01T18:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "exercise",
|
||||
"style": "snake",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"field": "exercise.interval.civil_start_time",
|
||||
"bound": "2026-06-01T22:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "exercise",
|
||||
"style": "snake",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"field": "exercise.interval.civil_start_time",
|
||||
"bound": "2026-06-02T00:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "exercise",
|
||||
"style": "snake",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"field": "exercise.interval.civil_start_time",
|
||||
"bound": "2026-06-01T18:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "steps",
|
||||
"style": "camel",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "steps",
|
||||
"style": "camel",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "steps",
|
||||
"style": "camel",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "steps",
|
||||
"style": "snake",
|
||||
"tz": null,
|
||||
"filter": {
|
||||
"threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "steps",
|
||||
"style": "snake",
|
||||
"tz": "Europe/Berlin",
|
||||
"filter": {
|
||||
"threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataType": "steps",
|
||||
"style": "snake",
|
||||
"tz": "America/New_York",
|
||||
"filter": {
|
||||
"threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list"
|
||||
}
|
||||
}
|
||||
],
|
||||
"incrementalFilterDefaultStyle": {
|
||||
"field": "dailyRestingHeartRate.date",
|
||||
"bound": "2026-06-01"
|
||||
},
|
||||
"chunkCivilRange": [
|
||||
{
|
||||
"start": { "year": 2026, "month": 1, "day": 1 },
|
||||
"end": { "year": 2026, "month": 7, "day": 1 },
|
||||
"maxDays": null,
|
||||
"chunks": [
|
||||
{
|
||||
"start": { "year": 2026, "month": 1, "day": 1 },
|
||||
"end": { "year": 2026, "month": 4, "day": 1 }
|
||||
},
|
||||
{
|
||||
"start": { "year": 2026, "month": 4, "day": 1 },
|
||||
"end": { "year": 2026, "month": 6, "day": 30 }
|
||||
},
|
||||
{
|
||||
"start": { "year": 2026, "month": 6, "day": 30 },
|
||||
"end": { "year": 2026, "month": 7, "day": 1 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"start": { "year": 2026, "month": 2, "day": 20 },
|
||||
"end": { "year": 2026, "month": 3, "day": 10 },
|
||||
"maxDays": 14,
|
||||
"chunks": [
|
||||
{
|
||||
"start": { "year": 2026, "month": 2, "day": 20 },
|
||||
"end": { "year": 2026, "month": 3, "day": 6 }
|
||||
},
|
||||
{
|
||||
"start": { "year": 2026, "month": 3, "day": 6 },
|
||||
"end": { "year": 2026, "month": 3, "day": 10 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"start": { "year": 2026, "month": 6, "day": 1 },
|
||||
"end": { "year": 2026, "month": 6, "day": 1 },
|
||||
"maxDays": null,
|
||||
"chunks": []
|
||||
},
|
||||
{
|
||||
"start": { "year": 2026, "month": 6, "day": 2 },
|
||||
"end": { "year": 2026, "month": 6, "day": 1 },
|
||||
"maxDays": null,
|
||||
"chunks": []
|
||||
}
|
||||
],
|
||||
"buildDailyRollUpBody": [
|
||||
{
|
||||
"chunk": {
|
||||
"start": { "year": 2026, "month": 1, "day": 1 },
|
||||
"end": { "year": 2026, "month": 4, "day": 1 }
|
||||
},
|
||||
"pageToken": null,
|
||||
"body": {
|
||||
"range": {
|
||||
"start": {
|
||||
"date": { "year": 2026, "month": 1, "day": 1 },
|
||||
"time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 }
|
||||
},
|
||||
"end": {
|
||||
"date": { "year": 2026, "month": 3, "day": 31 },
|
||||
"time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 }
|
||||
}
|
||||
},
|
||||
"windowSizeDays": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"chunk": {
|
||||
"start": { "year": 2026, "month": 2, "day": 20 },
|
||||
"end": { "year": 2026, "month": 3, "day": 1 }
|
||||
},
|
||||
"pageToken": "page-token-0002",
|
||||
"body": {
|
||||
"range": {
|
||||
"start": {
|
||||
"date": { "year": 2026, "month": 2, "day": 20 },
|
||||
"time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 }
|
||||
},
|
||||
"end": {
|
||||
"date": { "year": 2026, "month": 2, "day": 28 },
|
||||
"time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 }
|
||||
}
|
||||
},
|
||||
"windowSizeDays": 1,
|
||||
"pageToken": "page-token-0002"
|
||||
}
|
||||
},
|
||||
{
|
||||
"chunk": {
|
||||
"start": { "year": 2026, "month": 12, "day": 31 },
|
||||
"end": { "year": 2027, "month": 1, "day": 1 }
|
||||
},
|
||||
"pageToken": "",
|
||||
"body": {
|
||||
"range": {
|
||||
"start": {
|
||||
"date": { "year": 2026, "month": 12, "day": 31 },
|
||||
"time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 }
|
||||
},
|
||||
"end": {
|
||||
"date": { "year": 2026, "month": 12, "day": 31 },
|
||||
"time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 }
|
||||
}
|
||||
},
|
||||
"windowSizeDays": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"extractGoogleApiErrorDetail": [
|
||||
{
|
||||
"body": {
|
||||
"error": {
|
||||
"code": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"message": "Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?filter=weight.date",
|
||||
"details": [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.BadRequest",
|
||||
"fieldViolations": [
|
||||
{
|
||||
"field": "filter",
|
||||
"description": "Unknown field weight.date"
|
||||
},
|
||||
{ "field": "page_size" },
|
||||
{ "description": "Authorization: Bearer abc.def.ghi rejected" },
|
||||
{ "field": "fourth", "description": "dropped past the third" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"detail": "INVALID_ARGUMENT: Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?[REDACTED] [filter: Unknown field weight.date; page_size; Authorization: Bearer [REDACTED] rejected]"
|
||||
},
|
||||
{
|
||||
"body": { "error": { "status": "RESOURCE_EXHAUSTED" } },
|
||||
"detail": "RESOURCE_EXHAUSTED"
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"error": {
|
||||
"message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
}
|
||||
},
|
||||
"detail": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"error": { "details": [null, { "fieldViolations": [{}, 5] }] }
|
||||
},
|
||||
"detail": null
|
||||
},
|
||||
{ "body": { "error": "invalid_grant" }, "detail": null },
|
||||
{ "body": { "error": {} }, "detail": null },
|
||||
{ "body": null, "detail": null },
|
||||
{ "body": "plain text", "detail": null }
|
||||
],
|
||||
"refusesEdgeFilter": [
|
||||
{ "detail": null, "refuses": true },
|
||||
{ "detail": "INVALID_ARGUMENT", "refuses": true },
|
||||
{ "detail": "INVALID_ARGUMENT: Invalid filter", "refuses": true },
|
||||
{ "detail": "INVALID_ARGUMENT: Invalid page token", "refuses": false },
|
||||
{ "detail": "INVALID_ARGUMENT: page_size out of range", "refuses": false },
|
||||
{ "detail": "INVALID_ARGUMENT: pageSize too large", "refuses": false },
|
||||
{ "detail": "UNAUTHENTICATED: access token expired", "refuses": false },
|
||||
{ "detail": "FAILED_PRECONDITION: something else", "refuses": false },
|
||||
{ "detail": "Bad filter and bad token", "refuses": true }
|
||||
],
|
||||
"googleHealthRetryDelayMs": [
|
||||
{ "headers": { "retry-after": "30" }, "body": null, "delayMs": 30000 },
|
||||
{ "headers": { "retry-after": "1.5" }, "body": null, "delayMs": 1500 },
|
||||
{
|
||||
"headers": { "retry-after": "Wed, 01 Jul 2026 09:00:45 GMT" },
|
||||
"body": null,
|
||||
"delayMs": 45000
|
||||
},
|
||||
{ "headers": { "retry-after": "-3" }, "body": null, "delayMs": null },
|
||||
{
|
||||
"headers": {},
|
||||
"body": {
|
||||
"error": {
|
||||
"details": [
|
||||
{ "@type": "type.googleapis.com/google.rpc.ErrorInfo" },
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
"retryDelay": "12.5s"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"delayMs": 12500
|
||||
},
|
||||
{
|
||||
"headers": {},
|
||||
"body": { "error": { "details": [{ "retryDelay": "soon" }] } },
|
||||
"delayMs": null
|
||||
},
|
||||
{ "headers": {}, "body": null, "delayMs": null }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"key": "dailyOxygenSaturation",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "OXYGEN_SATURATION",
|
||||
"value": 96.5,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-01T12:00:00.000Z",
|
||||
"fieldTag": "2026-06-01:spo2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"key": "dailyOxygenSaturation",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "OXYGEN_SATURATION",
|
||||
"value": 96.5,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-01T10:00:00.000Z",
|
||||
"fieldTag": "2026-06-01:spo2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"key": "dailyOxygenSaturation",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "OXYGEN_SATURATION",
|
||||
"value": 96.5,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-01T16:00:00.000Z",
|
||||
"fieldTag": "2026-06-01:spo2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "dailyOxygenSaturation",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "OXYGEN_SATURATION",
|
||||
"value": 97,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-02T12:00:00.000Z",
|
||||
"fieldTag": "2026-06-02:spo2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "dailyOxygenSaturation",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "OXYGEN_SATURATION",
|
||||
"value": 97,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-02T10:00:00.000Z",
|
||||
"fieldTag": "2026-06-02:spo2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "dailyOxygenSaturation",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "OXYGEN_SATURATION",
|
||||
"value": 97,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-02T16:00:00.000Z",
|
||||
"fieldTag": "2026-06-02:spo2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "dailyHeartRateVariability",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "HRV_RMSSD",
|
||||
"value": 42.12,
|
||||
"unit": "ms",
|
||||
"measuredAt": "2026-06-01T12:00:00.000Z",
|
||||
"fieldTag": "2026-06-01:hrv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "dailyHeartRateVariability",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "HRV_RMSSD",
|
||||
"value": 42.12,
|
||||
"unit": "ms",
|
||||
"measuredAt": "2026-06-01T10:00:00.000Z",
|
||||
"fieldTag": "2026-06-01:hrv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "dailyHeartRateVariability",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "HRV_RMSSD",
|
||||
"value": 42.12,
|
||||
"unit": "ms",
|
||||
"measuredAt": "2026-06-01T16:00:00.000Z",
|
||||
"fieldTag": "2026-06-01:hrv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 3, "key": "dailyHeartRateVariability", "tz": null, "output": [] },
|
||||
{
|
||||
"index": 3,
|
||||
"key": "dailyHeartRateVariability",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"key": "dailyHeartRateVariability",
|
||||
"tz": "America/New_York",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "dailyRestingHeartRate",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "RESTING_HEART_RATE",
|
||||
"value": 55,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-06-03T12:00:00.000Z",
|
||||
"fieldTag": "2026-06-03:rhr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "dailyRestingHeartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "RESTING_HEART_RATE",
|
||||
"value": 55,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-06-03T10:00:00.000Z",
|
||||
"fieldTag": "2026-06-03:rhr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "dailyRestingHeartRate",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "RESTING_HEART_RATE",
|
||||
"value": 55,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-06-03T16:00:00.000Z",
|
||||
"fieldTag": "2026-06-03:rhr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"key": "dailyRestingHeartRate",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "RESTING_HEART_RATE",
|
||||
"value": 57,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:rhr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"key": "dailyRestingHeartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "RESTING_HEART_RATE",
|
||||
"value": 57,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:rhr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"key": "dailyRestingHeartRate",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "RESTING_HEART_RATE",
|
||||
"value": 57,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:rhr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "dailyRespiratoryRate",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "RESPIRATORY_RATE",
|
||||
"value": 14.8,
|
||||
"unit": "breaths/min",
|
||||
"measuredAt": "2026-03-29T12:00:00.000Z",
|
||||
"fieldTag": "2026-03-29:resp_rate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "dailyRespiratoryRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "RESPIRATORY_RATE",
|
||||
"value": 14.8,
|
||||
"unit": "breaths/min",
|
||||
"measuredAt": "2026-03-29T10:00:00.000Z",
|
||||
"fieldTag": "2026-03-29:resp_rate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "dailyRespiratoryRate",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "RESPIRATORY_RATE",
|
||||
"value": 14.8,
|
||||
"unit": "breaths/min",
|
||||
"measuredAt": "2026-03-29T16:00:00.000Z",
|
||||
"fieldTag": "2026-03-29:resp_rate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 7, "key": "dailyRespiratoryRate", "tz": null, "output": [] },
|
||||
{
|
||||
"index": 7,
|
||||
"key": "dailyRespiratoryRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"key": "dailyRespiratoryRate",
|
||||
"tz": "America/New_York",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "dailySleepTemperatureDerivations",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "WRIST_TEMPERATURE",
|
||||
"value": 34.42,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-06-05T12:00:00.000Z",
|
||||
"fieldTag": "2026-06-05:wrist_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "dailySleepTemperatureDerivations",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "WRIST_TEMPERATURE",
|
||||
"value": 34.42,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-06-05T10:00:00.000Z",
|
||||
"fieldTag": "2026-06-05:wrist_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "dailySleepTemperatureDerivations",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "WRIST_TEMPERATURE",
|
||||
"value": 34.42,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-06-05T16:00:00.000Z",
|
||||
"fieldTag": "2026-06-05:wrist_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"key": "dailySleepTemperatureDerivations",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "WRIST_TEMPERATURE",
|
||||
"value": 34.6,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"key": "dailySleepTemperatureDerivations",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "WRIST_TEMPERATURE",
|
||||
"value": 34.6,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"key": "dailySleepTemperatureDerivations",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "WRIST_TEMPERATURE",
|
||||
"value": 34.6,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"key": "dailyVo2Max",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "VO2_MAX",
|
||||
"value": 47.25,
|
||||
"unit": "mL/(kg·min)",
|
||||
"measuredAt": "2026-06-06T12:00:00.000Z",
|
||||
"fieldTag": "vo2_max:2026-06-06",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"key": "dailyVo2Max",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "VO2_MAX",
|
||||
"value": 47.25,
|
||||
"unit": "mL/(kg·min)",
|
||||
"measuredAt": "2026-06-06T10:00:00.000Z",
|
||||
"fieldTag": "vo2_max:2026-06-06",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"key": "dailyVo2Max",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "VO2_MAX",
|
||||
"value": 47.25,
|
||||
"unit": "mL/(kg·min)",
|
||||
"measuredAt": "2026-06-06T16:00:00.000Z",
|
||||
"fieldTag": "vo2_max:2026-06-06",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"key": "dailyVo2Max",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "VO2_MAX",
|
||||
"value": 45,
|
||||
"unit": "mL/(kg·min)",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "vo2_max:1970-01-01T00:00:00.000Z",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"key": "dailyVo2Max",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "VO2_MAX",
|
||||
"value": 45,
|
||||
"unit": "mL/(kg·min)",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "vo2_max:1970-01-01T00:00:00.000Z",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"key": "dailyVo2Max",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "VO2_MAX",
|
||||
"value": 45,
|
||||
"unit": "mL/(kg·min)",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "vo2_max:1970-01-01T00:00:00.000Z",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 12, "key": "dailyVo2Max", "tz": null, "output": [] },
|
||||
{ "index": 12, "key": "dailyVo2Max", "tz": "Europe/Berlin", "output": [] },
|
||||
{ "index": 12, "key": "dailyVo2Max", "tz": "America/New_York", "output": [] }
|
||||
]
|
||||
@@ -0,0 +1,314 @@
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"tz": null,
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001",
|
||||
"sportType": "running",
|
||||
"sportTypeRaw": "RUNNING",
|
||||
"startedAt": "2026-06-02T17:00:00.000Z",
|
||||
"endedAt": "2026-06-02T17:42:30.000Z",
|
||||
"durationSec": 2550,
|
||||
"totalEnergyKcal": 413,
|
||||
"totalDistanceM": 7012.35,
|
||||
"avgHeartRate": 151,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"tz": "Europe/Berlin",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001",
|
||||
"sportType": "running",
|
||||
"sportTypeRaw": "RUNNING",
|
||||
"startedAt": "2026-06-02T17:00:00.000Z",
|
||||
"endedAt": "2026-06-02T17:42:30.000Z",
|
||||
"durationSec": 2550,
|
||||
"totalEnergyKcal": 413,
|
||||
"totalDistanceM": 7012.35,
|
||||
"avgHeartRate": 151,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"tz": "America/New_York",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001",
|
||||
"sportType": "running",
|
||||
"sportTypeRaw": "RUNNING",
|
||||
"startedAt": "2026-06-02T17:00:00.000Z",
|
||||
"endedAt": "2026-06-02T17:42:30.000Z",
|
||||
"durationSec": 2550,
|
||||
"totalEnergyKcal": 413,
|
||||
"totalDistanceM": 7012.35,
|
||||
"avgHeartRate": 151,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"tz": null,
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002",
|
||||
"sportType": "strength",
|
||||
"sportTypeRaw": "strength training",
|
||||
"startedAt": "2026-06-03T07:00:00.000Z",
|
||||
"endedAt": "2026-06-03T08:00:00.000Z",
|
||||
"durationSec": 3600,
|
||||
"totalEnergyKcal": 250,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"tz": "Europe/Berlin",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002",
|
||||
"sportType": "strength",
|
||||
"sportTypeRaw": "strength training",
|
||||
"startedAt": "2026-06-03T05:00:00.000Z",
|
||||
"endedAt": "2026-06-03T06:00:00.000Z",
|
||||
"durationSec": 3600,
|
||||
"totalEnergyKcal": 250,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"tz": "America/New_York",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002",
|
||||
"sportType": "strength",
|
||||
"sportTypeRaw": "strength training",
|
||||
"startedAt": "2026-06-03T11:00:00.000Z",
|
||||
"endedAt": "2026-06-03T12:00:00.000Z",
|
||||
"durationSec": 3600,
|
||||
"totalEnergyKcal": 250,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"tz": null,
|
||||
"workout": {
|
||||
"externalId": "exercise:2026-06-04T04:30:00.000Z",
|
||||
"sportType": "other",
|
||||
"sportTypeRaw": "KITESURFING",
|
||||
"startedAt": "2026-06-04T04:30:00.000Z",
|
||||
"endedAt": "2026-06-04T05:15:00.000Z",
|
||||
"durationSec": 2700,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": 129,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"tz": "Europe/Berlin",
|
||||
"workout": {
|
||||
"externalId": "exercise:2026-06-04T04:30:00.000Z",
|
||||
"sportType": "other",
|
||||
"sportTypeRaw": "KITESURFING",
|
||||
"startedAt": "2026-06-04T04:30:00.000Z",
|
||||
"endedAt": "2026-06-04T05:15:00.000Z",
|
||||
"durationSec": 2700,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": 129,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"tz": "America/New_York",
|
||||
"workout": {
|
||||
"externalId": "exercise:2026-06-04T04:30:00.000Z",
|
||||
"sportType": "other",
|
||||
"sportTypeRaw": "KITESURFING",
|
||||
"startedAt": "2026-06-04T04:30:00.000Z",
|
||||
"endedAt": "2026-06-04T05:15:00.000Z",
|
||||
"durationSec": 2700,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": 129,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"tz": null,
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004",
|
||||
"sportType": "other",
|
||||
"sportTypeRaw": null,
|
||||
"startedAt": "2026-06-05T12:00:00.000Z",
|
||||
"endedAt": "2026-06-05T12:30:00.000Z",
|
||||
"durationSec": 1800,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"tz": "Europe/Berlin",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004",
|
||||
"sportType": "other",
|
||||
"sportTypeRaw": null,
|
||||
"startedAt": "2026-06-05T12:00:00.000Z",
|
||||
"endedAt": "2026-06-05T12:30:00.000Z",
|
||||
"durationSec": 1800,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"tz": "America/New_York",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004",
|
||||
"sportType": "other",
|
||||
"sportTypeRaw": null,
|
||||
"startedAt": "2026-06-05T12:00:00.000Z",
|
||||
"endedAt": "2026-06-05T12:30:00.000Z",
|
||||
"durationSec": 1800,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{ "index": 4, "tz": null, "workout": null },
|
||||
{ "index": 4, "tz": "Europe/Berlin", "workout": null },
|
||||
{ "index": 4, "tz": "America/New_York", "workout": null },
|
||||
{ "index": 5, "tz": null, "workout": null },
|
||||
{ "index": 5, "tz": "Europe/Berlin", "workout": null },
|
||||
{ "index": 5, "tz": "America/New_York", "workout": null },
|
||||
{
|
||||
"index": 6,
|
||||
"tz": null,
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007",
|
||||
"sportType": "cycling",
|
||||
"sportTypeRaw": "Mountain-Biking",
|
||||
"startedAt": "2026-06-08T18:00:00.000Z",
|
||||
"endedAt": "2026-06-08T19:05:00.000Z",
|
||||
"durationSec": 3900,
|
||||
"totalEnergyKcal": 0,
|
||||
"totalDistanceM": 0,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"tz": "Europe/Berlin",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007",
|
||||
"sportType": "cycling",
|
||||
"sportTypeRaw": "Mountain-Biking",
|
||||
"startedAt": "2026-06-08T18:00:00.000Z",
|
||||
"endedAt": "2026-06-08T19:05:00.000Z",
|
||||
"durationSec": 3900,
|
||||
"totalEnergyKcal": 0,
|
||||
"totalDistanceM": 0,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"tz": "America/New_York",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007",
|
||||
"sportType": "cycling",
|
||||
"sportTypeRaw": "Mountain-Biking",
|
||||
"startedAt": "2026-06-08T18:00:00.000Z",
|
||||
"endedAt": "2026-06-08T19:05:00.000Z",
|
||||
"durationSec": 3900,
|
||||
"totalEnergyKcal": 0,
|
||||
"totalDistanceM": 0,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"tz": null,
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008",
|
||||
"sportType": "stairClimber",
|
||||
"sportTypeRaw": "STAIR_CLIMBER",
|
||||
"startedAt": "2026-06-09T20:00:00.000Z",
|
||||
"endedAt": "2026-06-09T20:20:00.000Z",
|
||||
"durationSec": 1200,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"tz": "Europe/Berlin",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008",
|
||||
"sportType": "stairClimber",
|
||||
"sportTypeRaw": "STAIR_CLIMBER",
|
||||
"startedAt": "2026-06-09T20:00:00.000Z",
|
||||
"endedAt": "2026-06-09T20:20:00.000Z",
|
||||
"durationSec": 1200,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"tz": "America/New_York",
|
||||
"workout": {
|
||||
"externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008",
|
||||
"sportType": "stairClimber",
|
||||
"sportTypeRaw": "STAIR_CLIMBER",
|
||||
"startedAt": "2026-06-09T20:00:00.000Z",
|
||||
"endedAt": "2026-06-09T20:20:00.000Z",
|
||||
"durationSec": 1200,
|
||||
"totalEnergyKcal": null,
|
||||
"totalDistanceM": null,
|
||||
"avgHeartRate": null,
|
||||
"maxHeartRate": null,
|
||||
"minHeartRate": null
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"sleepStages": [
|
||||
{ "raw": "AWAKE", "stage": "AWAKE" },
|
||||
{ "raw": "wake", "stage": "AWAKE" },
|
||||
{ "raw": "LIGHT", "stage": "CORE" },
|
||||
{ "raw": " core ", "stage": "CORE" },
|
||||
{ "raw": "Deep", "stage": "DEEP" },
|
||||
{ "raw": "REM", "stage": "REM" },
|
||||
{ "raw": "ASLEEP", "stage": "ASLEEP" },
|
||||
{ "raw": "RESTLESS", "stage": "AWAKE" },
|
||||
{ "raw": "in bed", "stage": "IN_BED" },
|
||||
{ "raw": "IN-BED", "stage": "IN_BED" },
|
||||
{ "raw": "inbed", "stage": "IN_BED" },
|
||||
{ "raw": "SLEEP_STAGE_TYPE_UNSPECIFIED", "stage": null },
|
||||
{ "raw": "", "stage": null },
|
||||
{ "raw": 42, "stage": null },
|
||||
{ "raw": null, "stage": null }
|
||||
],
|
||||
"sportTypes": [
|
||||
{ "raw": "RUNNING", "sportType": "running" },
|
||||
{ "raw": "TREADMILL_RUNNING", "sportType": "running" },
|
||||
{ "raw": "STRENGTH_TRAINING", "sportType": "strength" },
|
||||
{ "raw": "strength training", "sportType": "strength" },
|
||||
{ "raw": "Mountain-Biking", "sportType": "cycling" },
|
||||
{ "raw": "STAIR_CLIMBER", "sportType": "stairClimber" },
|
||||
{ "raw": "STAIRCLIMBER", "sportType": "stairClimber" },
|
||||
{ "raw": "HIGH INTENSITY INTERVAL TRAINING", "sportType": "hiit" },
|
||||
{ "raw": "FOOTBALL", "sportType": "soccer" },
|
||||
{ "raw": "PILATES", "sportType": "mindAndBody" },
|
||||
{ "raw": "OTHER_WORKOUT", "sportType": "other" },
|
||||
{ "raw": "KITESURFING", "sportType": "other" },
|
||||
{ "raw": "", "sportType": "other" },
|
||||
{ "raw": " ", "sportType": "other" },
|
||||
{ "raw": null, "sportType": "other" },
|
||||
{ "raw": 7, "sportType": "other" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"key": "steps",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 8432,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-01T12:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"key": "steps",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 8432,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-01T10:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"key": "steps",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 8432,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-01T16:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "steps",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 0,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-02T12:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-02",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "steps",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 0,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-02T10:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-02",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "steps",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 0,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-02T16:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-02",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "steps",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 10250,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-03T12:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-03",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "steps",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 10250,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-03T10:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-03",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "steps",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVITY_STEPS",
|
||||
"value": 10250,
|
||||
"unit": "steps",
|
||||
"measuredAt": "2026-06-03T16:00:00.000Z",
|
||||
"fieldTag": "steps:2026-06-03",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 3, "key": "steps", "tz": null, "output": [] },
|
||||
{ "index": 3, "key": "steps", "tz": "Europe/Berlin", "output": [] },
|
||||
{ "index": 3, "key": "steps", "tz": "America/New_York", "output": [] },
|
||||
{
|
||||
"index": 4,
|
||||
"key": "distance",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "WALKING_RUNNING_DISTANCE",
|
||||
"value": 6240.51,
|
||||
"unit": "m",
|
||||
"measuredAt": "2026-06-01T12:00:00.000Z",
|
||||
"fieldTag": "distance:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "distance",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "WALKING_RUNNING_DISTANCE",
|
||||
"value": 6240.51,
|
||||
"unit": "m",
|
||||
"measuredAt": "2026-06-01T10:00:00.000Z",
|
||||
"fieldTag": "distance:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "distance",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "WALKING_RUNNING_DISTANCE",
|
||||
"value": 6240.51,
|
||||
"unit": "m",
|
||||
"measuredAt": "2026-06-01T16:00:00.000Z",
|
||||
"fieldTag": "distance:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 5, "key": "distance", "tz": null, "output": [] },
|
||||
{ "index": 5, "key": "distance", "tz": "Europe/Berlin", "output": [] },
|
||||
{ "index": 5, "key": "distance", "tz": "America/New_York", "output": [] },
|
||||
{
|
||||
"index": 6,
|
||||
"key": "activeEnergyBurned",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVE_ENERGY_BURNED",
|
||||
"value": 512.75,
|
||||
"unit": "kcal",
|
||||
"measuredAt": "2026-06-01T12:00:00.000Z",
|
||||
"fieldTag": "active_energy:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "activeEnergyBurned",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVE_ENERGY_BURNED",
|
||||
"value": 512.75,
|
||||
"unit": "kcal",
|
||||
"measuredAt": "2026-06-01T10:00:00.000Z",
|
||||
"fieldTag": "active_energy:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "activeEnergyBurned",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "ACTIVE_ENERGY_BURNED",
|
||||
"value": 512.75,
|
||||
"unit": "kcal",
|
||||
"measuredAt": "2026-06-01T16:00:00.000Z",
|
||||
"fieldTag": "active_energy:2026-06-01",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 7, "key": "activeEnergyBurned", "tz": null, "output": [] },
|
||||
{
|
||||
"index": 7,
|
||||
"key": "activeEnergyBurned",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"key": "activeEnergyBurned",
|
||||
"tz": "America/New_York",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "floors",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "FLIGHTS_CLIMBED",
|
||||
"value": 12,
|
||||
"unit": "flights",
|
||||
"measuredAt": "2026-12-31T12:00:00.000Z",
|
||||
"fieldTag": "floors:2026-12-31",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "floors",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "FLIGHTS_CLIMBED",
|
||||
"value": 12,
|
||||
"unit": "flights",
|
||||
"measuredAt": "2026-12-31T11:00:00.000Z",
|
||||
"fieldTag": "floors:2026-12-31",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "floors",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "FLIGHTS_CLIMBED",
|
||||
"value": 12,
|
||||
"unit": "flights",
|
||||
"measuredAt": "2026-12-31T17:00:00.000Z",
|
||||
"fieldTag": "floors:2026-12-31",
|
||||
"cumulativeDaily": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 9, "key": "floors", "tz": null, "output": [] },
|
||||
{ "index": 9, "key": "floors", "tz": "Europe/Berlin", "output": [] },
|
||||
{ "index": 9, "key": "floors", "tz": "America/New_York", "output": [] }
|
||||
]
|
||||
@@ -0,0 +1,693 @@
|
||||
{
|
||||
"measurements": [
|
||||
{
|
||||
"index": 0,
|
||||
"key": "weight",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 78.4,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-02T06:15:00.000Z",
|
||||
"fieldTag": "2026-06-02T06:15:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"key": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 78.4,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-02T06:15:00.000Z",
|
||||
"fieldTag": "2026-06-02T06:15:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"key": "weight",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 78.4,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-02T06:15:00.000Z",
|
||||
"fieldTag": "2026-06-02T06:15:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "weight",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 78.25,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-03T05:30:00.000Z",
|
||||
"fieldTag": "2026-06-03T05:30:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 78.25,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-03T05:30:00.000Z",
|
||||
"fieldTag": "2026-06-03T05:30:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"key": "weight",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 78.25,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-03T05:30:00.000Z",
|
||||
"fieldTag": "2026-06-03T05:30:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 2, "key": "weight", "tz": null, "output": [] },
|
||||
{ "index": 2, "key": "weight", "tz": "Europe/Berlin", "output": [] },
|
||||
{ "index": 2, "key": "weight", "tz": "America/New_York", "output": [] },
|
||||
{
|
||||
"index": 3,
|
||||
"key": "weight",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 77.9,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-04T23:45:00.000Z",
|
||||
"fieldTag": "2026-06-04T23:45:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"key": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 77.9,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-04T21:45:00.000Z",
|
||||
"fieldTag": "2026-06-04T23:45:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"key": "weight",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 77.9,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-06-05T03:45:00.000Z",
|
||||
"fieldTag": "2026-06-04T23:45:00.000Z:weight"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "bodyFat",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_FAT",
|
||||
"value": 21.4,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-02T06:15:00.000Z",
|
||||
"fieldTag": "2026-06-02T06:15:00.000Z:body_fat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "bodyFat",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_FAT",
|
||||
"value": 21.4,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-02T06:15:00.000Z",
|
||||
"fieldTag": "2026-06-02T06:15:00.000Z:body_fat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"key": "bodyFat",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_FAT",
|
||||
"value": 21.4,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-06-02T06:15:00.000Z",
|
||||
"fieldTag": "2026-06-02T06:15:00.000Z:body_fat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"key": "bodyFat",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_FAT",
|
||||
"value": 20.9,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:body_fat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"key": "bodyFat",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_FAT",
|
||||
"value": 20.9,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:body_fat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"key": "bodyFat",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_FAT",
|
||||
"value": 20.9,
|
||||
"unit": "%",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:body_fat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "heartRate",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "PULSE",
|
||||
"value": 64,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-06-02T16:00:30.000Z",
|
||||
"fieldTag": "2026-06-02T16:00:30.000Z:hr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "heartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "PULSE",
|
||||
"value": 64,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-06-02T16:00:30.000Z",
|
||||
"fieldTag": "2026-06-02T16:00:30.000Z:hr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"key": "heartRate",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "PULSE",
|
||||
"value": 64,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-06-02T16:00:30.000Z",
|
||||
"fieldTag": "2026-06-02T16:00:30.000Z:hr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 7, "key": "heartRate", "tz": null, "output": [] },
|
||||
{ "index": 7, "key": "heartRate", "tz": "Europe/Berlin", "output": [] },
|
||||
{ "index": 7, "key": "heartRate", "tz": "America/New_York", "output": [] },
|
||||
{
|
||||
"index": 8,
|
||||
"key": "heartRate",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "PULSE",
|
||||
"value": 58,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:hr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "heartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "PULSE",
|
||||
"value": 58,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:hr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"key": "heartRate",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "PULSE",
|
||||
"value": 58,
|
||||
"unit": "bpm",
|
||||
"measuredAt": "2026-07-01T09:00:00.000Z",
|
||||
"fieldTag": "1970-01-01T00:00:00.000Z:hr"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"key": "bloodGlucose",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "BLOOD_GLUCOSE",
|
||||
"value": 96.33,
|
||||
"unit": "mg/dL",
|
||||
"measuredAt": "2026-06-05T08:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T08:00:00.000Z:glucose"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"key": "bloodGlucose",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "BLOOD_GLUCOSE",
|
||||
"value": 96.33,
|
||||
"unit": "mg/dL",
|
||||
"measuredAt": "2026-06-05T08:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T08:00:00.000Z:glucose"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"key": "bloodGlucose",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "BLOOD_GLUCOSE",
|
||||
"value": 96.33,
|
||||
"unit": "mg/dL",
|
||||
"measuredAt": "2026-06-05T08:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T08:00:00.000Z:glucose"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "index": 10, "key": "bloodGlucose", "tz": null, "output": [] },
|
||||
{ "index": 10, "key": "bloodGlucose", "tz": "Europe/Berlin", "output": [] },
|
||||
{
|
||||
"index": 10,
|
||||
"key": "bloodGlucose",
|
||||
"tz": "America/New_York",
|
||||
"output": []
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"key": "coreBodyTemperature",
|
||||
"tz": null,
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_TEMPERATURE",
|
||||
"value": 36.75,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-06-06T03:00:00.000Z",
|
||||
"fieldTag": "2026-06-06T03:00:00.000Z:core_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"key": "coreBodyTemperature",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_TEMPERATURE",
|
||||
"value": 36.75,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-06-06T01:00:00.000Z",
|
||||
"fieldTag": "2026-06-06T03:00:00.000Z:core_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"key": "coreBodyTemperature",
|
||||
"tz": "America/New_York",
|
||||
"output": [
|
||||
{
|
||||
"type": "BODY_TEMPERATURE",
|
||||
"value": 36.75,
|
||||
"unit": "celsius",
|
||||
"measuredAt": "2026-06-06T07:00:00.000Z",
|
||||
"fieldTag": "2026-06-06T03:00:00.000Z:core_temp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"key": "height",
|
||||
"tz": null,
|
||||
"output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" }
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"key": "height",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" }
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"key": "height",
|
||||
"tz": "America/New_York",
|
||||
"output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" }
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"key": "height",
|
||||
"tz": null,
|
||||
"output": { "cm": 179.8, "sampledAt": null }
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"key": "height",
|
||||
"tz": "Europe/Berlin",
|
||||
"output": { "cm": 179.8, "sampledAt": null }
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"key": "height",
|
||||
"tz": "America/New_York",
|
||||
"output": { "cm": 179.8, "sampledAt": null }
|
||||
},
|
||||
{ "index": 14, "key": "height", "tz": null, "output": null },
|
||||
{ "index": 14, "key": "height", "tz": "Europe/Berlin", "output": null },
|
||||
{ "index": 14, "key": "height", "tz": "America/New_York", "output": null }
|
||||
],
|
||||
"sampleInstants": [
|
||||
{
|
||||
"index": 0,
|
||||
"dataType": "weight",
|
||||
"tz": null,
|
||||
"instant": "2026-06-02T06:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"dataType": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-02T06:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"dataType": "weight",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-02T06:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"dataType": "weight",
|
||||
"tz": null,
|
||||
"instant": "2026-06-03T05:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"dataType": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-03T05:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"dataType": "weight",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-03T05:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"dataType": "weight",
|
||||
"tz": null,
|
||||
"instant": "2026-06-04T23:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"dataType": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-04T21:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"dataType": "weight",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-05T03:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"dataType": "weight",
|
||||
"tz": null,
|
||||
"instant": "2026-06-04T23:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"dataType": "weight",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-04T21:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"dataType": "weight",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-05T03:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"dataType": "bodyFat",
|
||||
"tz": null,
|
||||
"instant": "2026-06-02T06:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"dataType": "bodyFat",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-02T06:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"dataType": "bodyFat",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-02T06:15:00.000Z"
|
||||
},
|
||||
{ "index": 5, "dataType": "bodyFat", "tz": null, "instant": null },
|
||||
{
|
||||
"index": 5,
|
||||
"dataType": "bodyFat",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": null
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"dataType": "bodyFat",
|
||||
"tz": "America/New_York",
|
||||
"instant": null
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"dataType": "heartRate",
|
||||
"tz": null,
|
||||
"instant": "2026-06-02T16:00:30.000Z"
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"dataType": "heartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-02T16:00:30.000Z"
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"dataType": "heartRate",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-02T16:00:30.000Z"
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"dataType": "heartRate",
|
||||
"tz": null,
|
||||
"instant": "2026-06-02T00:10:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"dataType": "heartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-01T22:10:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"dataType": "heartRate",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-02T04:10:00.000Z"
|
||||
},
|
||||
{ "index": 8, "dataType": "heartRate", "tz": null, "instant": null },
|
||||
{
|
||||
"index": 8,
|
||||
"dataType": "heartRate",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": null
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"dataType": "heartRate",
|
||||
"tz": "America/New_York",
|
||||
"instant": null
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"dataType": "bloodGlucose",
|
||||
"tz": null,
|
||||
"instant": "2026-06-05T08:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"dataType": "bloodGlucose",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-05T08:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"dataType": "bloodGlucose",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-05T08:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"dataType": "bloodGlucose",
|
||||
"tz": null,
|
||||
"instant": "2026-06-05T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"dataType": "bloodGlucose",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-05T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"dataType": "bloodGlucose",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-05T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"dataType": "coreBodyTemperature",
|
||||
"tz": null,
|
||||
"instant": "2026-06-06T03:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"dataType": "coreBodyTemperature",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-06-06T01:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"dataType": "coreBodyTemperature",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-06-06T07:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"dataType": "height",
|
||||
"tz": null,
|
||||
"instant": "2026-01-15T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"dataType": "height",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-01-15T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"dataType": "height",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-01-15T10:00:00.000Z"
|
||||
},
|
||||
{ "index": 13, "dataType": "height", "tz": null, "instant": null },
|
||||
{
|
||||
"index": 13,
|
||||
"dataType": "height",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": null
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"dataType": "height",
|
||||
"tz": "America/New_York",
|
||||
"instant": null
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"dataType": "height",
|
||||
"tz": null,
|
||||
"instant": "2026-01-16T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"dataType": "height",
|
||||
"tz": "Europe/Berlin",
|
||||
"instant": "2026-01-16T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"dataType": "height",
|
||||
"tz": "America/New_York",
|
||||
"instant": "2026-01-16T10:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"sampleInstantOfDailyType": null
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"tz": null,
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001",
|
||||
"windowStart": "2026-06-01T22:30:00.000Z",
|
||||
"windowEnd": "2026-06-02T06:10:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 15,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-01T22:45:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T00:15:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 65.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T01:20:30.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 44.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T02:05:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z",
|
||||
"sleepStage": "REM"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 245,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T06:10:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 15,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-01T22:45:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T00:15:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 65.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T01:20:30.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 44.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T02:05:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z",
|
||||
"sleepStage": "REM"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 245,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T06:10:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"tz": "Europe/Berlin",
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001",
|
||||
"windowStart": "2026-06-01T22:30:00.000Z",
|
||||
"windowEnd": "2026-06-02T06:10:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 15,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-01T22:45:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T00:15:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 65.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T01:20:30.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 44.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T02:05:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z",
|
||||
"sleepStage": "REM"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 245,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T06:10:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 15,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-01T22:45:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T00:15:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 65.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T01:20:30.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 44.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T02:05:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z",
|
||||
"sleepStage": "REM"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 245,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T06:10:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 0,
|
||||
"tz": "America/New_York",
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001",
|
||||
"windowStart": "2026-06-01T22:30:00.000Z",
|
||||
"windowEnd": "2026-06-02T06:10:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 15,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-01T22:45:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T00:15:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 65.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T01:20:30.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 44.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T02:05:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z",
|
||||
"sleepStage": "REM"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 245,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T06:10:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 15,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-01T22:45:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T00:15:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 65.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T01:20:30.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 44.5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T02:05:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z",
|
||||
"sleepStage": "REM"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 245,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T06:10:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"tz": null,
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002",
|
||||
"windowStart": "2026-06-02T23:50:00.000Z",
|
||||
"windowEnd": "2026-06-03T07:00:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T00:20:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T23:50:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T00:25:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:20:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 395,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T07:00:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:25:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T00:20:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T23:50:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T00:25:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:20:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 395,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T07:00:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:25:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"tz": "Europe/Berlin",
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002",
|
||||
"windowStart": "2026-06-02T21:50:00.000Z",
|
||||
"windowEnd": "2026-06-03T05:00:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T22:20:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T21:50:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T22:25:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:20:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 395,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T05:00:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:25:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T22:20:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T21:50:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-02T22:25:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:20:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 395,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T05:00:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:25:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"tz": "America/New_York",
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002",
|
||||
"windowStart": "2026-06-03T03:50:00.000Z",
|
||||
"windowEnd": "2026-06-03T11:00:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T04:20:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T03:50:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T04:25:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:20:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 395,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T11:00:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:25:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T04:20:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T03:50:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 5,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T04:25:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:20:00.000Z",
|
||||
"sleepStage": "AWAKE"
|
||||
},
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 395,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-03T11:00:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:25:00.000Z",
|
||||
"sleepStage": "ASLEEP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"tz": null,
|
||||
"detailed": {
|
||||
"anchor": "2026-06-05T03:30:00.000Z",
|
||||
"windowStart": "2026-06-04T20:00:00.000Z",
|
||||
"windowEnd": "2026-06-04T23:00:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 180,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-04T23:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 1,
|
||||
"unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"]
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 180,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-04T23:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"tz": "Europe/Berlin",
|
||||
"detailed": {
|
||||
"anchor": "2026-06-05T03:30:00.000Z",
|
||||
"windowStart": "2026-06-04T20:00:00.000Z",
|
||||
"windowEnd": "2026-06-04T23:00:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 180,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-04T23:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 1,
|
||||
"unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"]
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 180,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-04T23:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"tz": "America/New_York",
|
||||
"detailed": {
|
||||
"anchor": "2026-06-05T03:30:00.000Z",
|
||||
"windowStart": "2026-06-04T20:00:00.000Z",
|
||||
"windowEnd": "2026-06-04T23:00:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 180,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-04T23:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 1,
|
||||
"unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"]
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 180,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-04T23:00:00.000Z",
|
||||
"fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z",
|
||||
"sleepStage": "DEEP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"tz": null,
|
||||
"detailed": {
|
||||
"anchor": "2026-06-06T23:00:00.000Z",
|
||||
"windowStart": null,
|
||||
"windowEnd": null,
|
||||
"rows": [],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"tz": "Europe/Berlin",
|
||||
"detailed": {
|
||||
"anchor": "2026-06-06T23:00:00.000Z",
|
||||
"windowStart": null,
|
||||
"windowEnd": null,
|
||||
"rows": [],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"tz": "America/New_York",
|
||||
"detailed": {
|
||||
"anchor": "2026-06-06T23:00:00.000Z",
|
||||
"windowStart": null,
|
||||
"windowEnd": null,
|
||||
"rows": [],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"tz": null,
|
||||
"detailed": {
|
||||
"anchor": "1970-01-01T00:00:00.000Z",
|
||||
"windowStart": null,
|
||||
"windowEnd": null,
|
||||
"rows": [],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"tz": "Europe/Berlin",
|
||||
"detailed": {
|
||||
"anchor": "1970-01-01T00:00:00.000Z",
|
||||
"windowStart": null,
|
||||
"windowEnd": null,
|
||||
"rows": [],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"tz": "America/New_York",
|
||||
"detailed": {
|
||||
"anchor": "1970-01-01T00:00:00.000Z",
|
||||
"windowStart": null,
|
||||
"windowEnd": null,
|
||||
"rows": [],
|
||||
"unknownStageSegments": 0,
|
||||
"unknownStageLabels": []
|
||||
},
|
||||
"rows": []
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"tz": null,
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006",
|
||||
"windowStart": "2026-06-08T01:10:00.000Z",
|
||||
"windowEnd": "2026-06-08T01:40:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-08T01:40:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 7,
|
||||
"unknownStageLabels": [
|
||||
"NAP_STAGE_ALPHA",
|
||||
"NAP_STAGE_BETA",
|
||||
"NAP_STAGE_GAMMA",
|
||||
"NAP_STAGE_DELTA",
|
||||
"NAP_STAGE_EPSILON"
|
||||
]
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-08T01:40:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"tz": "Europe/Berlin",
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006",
|
||||
"windowStart": "2026-06-08T01:10:00.000Z",
|
||||
"windowEnd": "2026-06-08T01:40:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-08T01:40:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 7,
|
||||
"unknownStageLabels": [
|
||||
"NAP_STAGE_ALPHA",
|
||||
"NAP_STAGE_BETA",
|
||||
"NAP_STAGE_GAMMA",
|
||||
"NAP_STAGE_DELTA",
|
||||
"NAP_STAGE_EPSILON"
|
||||
]
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-08T01:40:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"tz": "America/New_York",
|
||||
"detailed": {
|
||||
"anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006",
|
||||
"windowStart": "2026-06-08T01:10:00.000Z",
|
||||
"windowEnd": "2026-06-08T01:40:00.000Z",
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-08T01:40:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
],
|
||||
"unknownStageSegments": 7,
|
||||
"unknownStageLabels": [
|
||||
"NAP_STAGE_ALPHA",
|
||||
"NAP_STAGE_BETA",
|
||||
"NAP_STAGE_GAMMA",
|
||||
"NAP_STAGE_DELTA",
|
||||
"NAP_STAGE_EPSILON"
|
||||
]
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 30,
|
||||
"unit": "minutes",
|
||||
"measuredAt": "2026-06-08T01:40:00.000Z",
|
||||
"fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z",
|
||||
"sleepStage": "CORE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -43,12 +43,12 @@ import {
|
||||
googleHealthBackfillRetryPending,
|
||||
readGoogleHealthBackfillProgress,
|
||||
} from "../backfill-progress";
|
||||
import { forEachDataPointPage } from "@/lib/google-health/data-points";
|
||||
import {
|
||||
forEachDataPointPage,
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
noteGoogleHealthDeferred,
|
||||
runWithGoogleHealthClientOutcome,
|
||||
} from "../client";
|
||||
} from "@/lib/google-health/budget";
|
||||
import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const T0 = Date.parse("2026-10-17T12:00:00Z");
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
googleHealthSliceBudget,
|
||||
runWithGoogleHealthClientOutcome,
|
||||
runWithGoogleHealthStop,
|
||||
} from "../client";
|
||||
} from "@/lib/google-health/budget";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -18,7 +18,8 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
return { ...actual, safeFetch: safeFetchMock };
|
||||
});
|
||||
|
||||
import { GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints } from "../client";
|
||||
import { fetchDataPoints } from "@/lib/google-health/data-points";
|
||||
import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers";
|
||||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
import { eventStorage } from "@/lib/logging/context";
|
||||
import { GoogleHealthApiError } from "../response-classifier";
|
||||
|
||||
@@ -32,15 +32,19 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
GOOGLE_HEALTH_SKIP_MS,
|
||||
type GoogleHealthDataPoint,
|
||||
forEachDataPointPage,
|
||||
googleHealthSliceBudget,
|
||||
refusesEdgeFilter,
|
||||
} from "@/lib/google-health/data-points";
|
||||
import {
|
||||
googleHealthSliceBudget,
|
||||
runWithGoogleHealthClientOutcome,
|
||||
runWithGoogleHealthStop,
|
||||
} from "../client";
|
||||
} from "@/lib/google-health/budget";
|
||||
import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
type GoogleHealthDataPoint,
|
||||
} from "@/lib/google-health/mappers";
|
||||
import { GoogleHealthApiError } from "../response-classifier";
|
||||
import { GOOGLE_HEALTH_NO_PROGRESS_PAGES } from "../backfill-bounds";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* What each Google Health mapper makes of a fixed set of payloads, pinned in
|
||||
* `__golden__/map-*.json`.
|
||||
*
|
||||
* The fixtures under `__fixtures__/` are synthetic data points in the
|
||||
* documented v4 wire shape (spot samples, daily summaries, dailyRollUp
|
||||
* windows, sleep sessions, exercise sessions), chosen to walk every branch the
|
||||
* mappers have: int64 values as strings, zeros and negatives, missing or
|
||||
* unparseable anchors, offset-less local times, unknown stage and sport
|
||||
* labels. Every mapper that takes a zone runs once without one and once each
|
||||
* for `Europe/Berlin` and `America/New_York`. The pure request helpers from
|
||||
* `client.ts` run over small input tables in the test itself.
|
||||
*
|
||||
* The goldens were written before the mappers moved out of `client.ts` and
|
||||
* `mappers.ts` into their own modules, so a green run proves the move changed
|
||||
* no output. The clock is pinned because a sample without a parseable anchor
|
||||
* falls back to the current time.
|
||||
*/
|
||||
import { format } from "prettier";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
googleHealthSampleInstant,
|
||||
mapActiveEnergy,
|
||||
mapBloodGlucose,
|
||||
mapBodyFat,
|
||||
mapCoreBodyTemperature,
|
||||
mapDistance,
|
||||
mapFloors,
|
||||
mapGoogleHealthSleepStage,
|
||||
mapGoogleHealthSportType,
|
||||
mapHeartRate,
|
||||
mapHeartRateVariability,
|
||||
mapHeight,
|
||||
mapOxygenSaturation,
|
||||
mapRespiratoryRate,
|
||||
mapRestingHeartRate,
|
||||
mapSleepSession,
|
||||
mapSleepSessionDetailed,
|
||||
mapSteps,
|
||||
mapVo2Max,
|
||||
mapWeight,
|
||||
mapWorkout,
|
||||
mapWristTemperature,
|
||||
type GoogleHealthDataPoint,
|
||||
type GoogleHealthDataType,
|
||||
type GoogleHealthRollupPoint,
|
||||
} from "../mappers";
|
||||
import {
|
||||
buildDailyRollUpBody,
|
||||
chunkCivilRange,
|
||||
civilDateInTz,
|
||||
resolveGoogleHealthUserId,
|
||||
} from "../client";
|
||||
import { refusesEdgeFilter } from "@/lib/google-health/data-points";
|
||||
import {
|
||||
extractGoogleApiErrorDetail,
|
||||
formatCivilBound,
|
||||
googleHealthRetryDelayMs,
|
||||
incrementalFilter,
|
||||
} from "@/lib/google-health/request";
|
||||
import dailySummariesJson from "./__fixtures__/daily-summaries.json";
|
||||
import exerciseJson from "./__fixtures__/exercise.json";
|
||||
import rollupsJson from "./__fixtures__/rollups.json";
|
||||
import samplesJson from "./__fixtures__/samples.json";
|
||||
import sleepJson from "./__fixtures__/sleep.json";
|
||||
|
||||
/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */
|
||||
async function golden(value: unknown): Promise<string> {
|
||||
return format(JSON.stringify(value), { parser: "json" });
|
||||
}
|
||||
|
||||
/** Captures a thrown error as data, so a refusal is part of the golden too. */
|
||||
function outcome<T>(fn: () => T): T | { threw: string } {
|
||||
try {
|
||||
return fn();
|
||||
} catch (err) {
|
||||
return { threw: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
const samples: GoogleHealthDataPoint[] = samplesJson;
|
||||
const dailySummaries: GoogleHealthDataPoint[] = dailySummariesJson;
|
||||
const rollups: GoogleHealthRollupPoint[] = rollupsJson;
|
||||
const sleepSessions: GoogleHealthDataPoint[] = sleepJson;
|
||||
const exercises: GoogleHealthDataPoint[] = exerciseJson;
|
||||
|
||||
const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const;
|
||||
|
||||
type ZonedMapper<P, R> = (point: P, tz?: string) => R;
|
||||
|
||||
/** Each fixture point through the mapper for the union key it carries, in every zone. */
|
||||
function mapByKey<P extends Record<string, unknown>, R>(
|
||||
points: P[],
|
||||
mappers: Record<string, ZonedMapper<P, R>>,
|
||||
): Array<{ index: number; key: string; tz: string | null; output: R }> {
|
||||
const out: Array<{
|
||||
index: number;
|
||||
key: string;
|
||||
tz: string | null;
|
||||
output: R;
|
||||
}> = [];
|
||||
points.forEach((point, index) => {
|
||||
for (const [key, mapper] of Object.entries(mappers)) {
|
||||
if (!(key in point)) continue;
|
||||
for (const tz of ZONES) {
|
||||
out.push({ index, key, tz: tz ?? null, output: mapper(point, tz) });
|
||||
}
|
||||
}
|
||||
});
|
||||
if (out.length !== points.length * ZONES.length) {
|
||||
throw new Error("a fixture point matched no mapper, or more than one");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date("2026-07-01T09:00:00.000Z");
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers({ now: FIXED_NOW, toFake: ["Date"] });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Google Health mapper goldens", () => {
|
||||
it("maps spot samples", async () => {
|
||||
const output = {
|
||||
measurements: mapByKey<GoogleHealthDataPoint, unknown>(samples, {
|
||||
weight: mapWeight,
|
||||
bodyFat: mapBodyFat,
|
||||
heartRate: mapHeartRate,
|
||||
bloodGlucose: mapBloodGlucose,
|
||||
coreBodyTemperature: mapCoreBodyTemperature,
|
||||
height: (point) => mapHeight(point),
|
||||
}),
|
||||
sampleInstants: samples.flatMap((point, index) =>
|
||||
Object.entries(GOOGLE_HEALTH_DATA_TYPES)
|
||||
.filter(([, dt]) => dt.key in point)
|
||||
.flatMap(([name, dt]) =>
|
||||
ZONES.map((tz) => ({
|
||||
index,
|
||||
dataType: name,
|
||||
tz: tz ?? null,
|
||||
instant: googleHealthSampleInstant(point, dt, tz),
|
||||
})),
|
||||
),
|
||||
),
|
||||
sampleInstantOfDailyType: googleHealthSampleInstant(
|
||||
dailySummaries[0]!,
|
||||
GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation,
|
||||
),
|
||||
};
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-samples.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps daily summaries", async () => {
|
||||
const output = mapByKey(dailySummaries, {
|
||||
dailyOxygenSaturation: mapOxygenSaturation,
|
||||
dailyHeartRateVariability: mapHeartRateVariability,
|
||||
dailyRestingHeartRate: mapRestingHeartRate,
|
||||
dailyRespiratoryRate: mapRespiratoryRate,
|
||||
dailySleepTemperatureDerivations: mapWristTemperature,
|
||||
dailyVo2Max: mapVo2Max,
|
||||
});
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-daily-summaries.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps dailyRollUp windows", async () => {
|
||||
const output = mapByKey(rollups, {
|
||||
steps: mapSteps,
|
||||
distance: mapDistance,
|
||||
activeEnergyBurned: mapActiveEnergy,
|
||||
floors: mapFloors,
|
||||
});
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-rollups.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps sleep sessions", async () => {
|
||||
const output = sleepSessions.flatMap((point, index) =>
|
||||
ZONES.map((tz) => ({
|
||||
index,
|
||||
tz: tz ?? null,
|
||||
detailed: mapSleepSessionDetailed(point, tz),
|
||||
rows: mapSleepSession(point, tz),
|
||||
})),
|
||||
);
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-sleep.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps exercise sessions", async () => {
|
||||
const output = exercises.flatMap((point, index) =>
|
||||
ZONES.map((tz) => ({
|
||||
index,
|
||||
tz: tz ?? null,
|
||||
workout: mapWorkout(point, tz),
|
||||
})),
|
||||
);
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-exercise.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves sleep stage and sport labels", async () => {
|
||||
const stageLabels: unknown[] = [
|
||||
"AWAKE",
|
||||
"wake",
|
||||
"LIGHT",
|
||||
" core ",
|
||||
"Deep",
|
||||
"REM",
|
||||
"ASLEEP",
|
||||
"RESTLESS",
|
||||
"in bed",
|
||||
"IN-BED",
|
||||
"inbed",
|
||||
"SLEEP_STAGE_TYPE_UNSPECIFIED",
|
||||
"",
|
||||
42,
|
||||
null,
|
||||
];
|
||||
const sportLabels: unknown[] = [
|
||||
"RUNNING",
|
||||
"TREADMILL_RUNNING",
|
||||
"STRENGTH_TRAINING",
|
||||
"strength training",
|
||||
"Mountain-Biking",
|
||||
"STAIR_CLIMBER",
|
||||
"STAIRCLIMBER",
|
||||
"HIGH INTENSITY INTERVAL TRAINING",
|
||||
"FOOTBALL",
|
||||
"PILATES",
|
||||
"OTHER_WORKOUT",
|
||||
"KITESURFING",
|
||||
"",
|
||||
" ",
|
||||
null,
|
||||
7,
|
||||
];
|
||||
const output = {
|
||||
sleepStages: stageLabels.map((raw) => ({
|
||||
raw,
|
||||
stage: mapGoogleHealthSleepStage(raw),
|
||||
})),
|
||||
sportTypes: sportLabels.map((raw) => ({
|
||||
raw,
|
||||
sportType: mapGoogleHealthSportType(raw),
|
||||
})),
|
||||
};
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-labels.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds request values with the pure client helpers", async () => {
|
||||
const instants = [
|
||||
new Date("2026-06-01T21:59:59.000Z"),
|
||||
new Date("2026-06-01T22:00:00.000Z"),
|
||||
new Date("2026-03-29T00:30:00.000Z"),
|
||||
new Date("2026-12-31T23:30:15.500Z"),
|
||||
];
|
||||
const listTypes: GoogleHealthDataType[] = [
|
||||
GOOGLE_HEALTH_DATA_TYPES.weight,
|
||||
GOOGLE_HEALTH_DATA_TYPES.heartRateVariability,
|
||||
GOOGLE_HEALTH_DATA_TYPES.sleep,
|
||||
GOOGLE_HEALTH_DATA_TYPES.exercise,
|
||||
GOOGLE_HEALTH_DATA_TYPES.steps,
|
||||
];
|
||||
const errorBodies: unknown[] = [
|
||||
{
|
||||
error: {
|
||||
code: 400,
|
||||
status: "INVALID_ARGUMENT",
|
||||
message:
|
||||
"Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?filter=weight.date",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.BadRequest",
|
||||
fieldViolations: [
|
||||
{ field: "filter", description: "Unknown field weight.date" },
|
||||
{ field: "page_size" },
|
||||
{ description: "Authorization: Bearer abc.def.ghi rejected" },
|
||||
{ field: "fourth", description: "dropped past the third" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ error: { status: "RESOURCE_EXHAUSTED" } },
|
||||
{ error: { message: "x".repeat(260) } },
|
||||
{ error: { details: [null, { fieldViolations: [{}, 5] }] } },
|
||||
{ error: "invalid_grant" },
|
||||
{ error: {} },
|
||||
null,
|
||||
"plain text",
|
||||
];
|
||||
const retryCases: Array<{
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
}> = [
|
||||
{ headers: { "retry-after": "30" }, body: null },
|
||||
{ headers: { "retry-after": "1.5" }, body: null },
|
||||
{
|
||||
headers: { "retry-after": "Wed, 01 Jul 2026 09:00:45 GMT" },
|
||||
body: null,
|
||||
},
|
||||
{ headers: { "retry-after": "-3" }, body: null },
|
||||
{
|
||||
headers: {},
|
||||
body: {
|
||||
error: {
|
||||
details: [
|
||||
{ "@type": "type.googleapis.com/google.rpc.ErrorInfo" },
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
retryDelay: "12.5s",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
body: { error: { details: [{ retryDelay: "soon" }] } },
|
||||
},
|
||||
{ headers: {}, body: null },
|
||||
];
|
||||
const output = {
|
||||
resolveGoogleHealthUserId: [
|
||||
{ name: "users/1234567890" },
|
||||
{ name: "users/", id: "id-0002" },
|
||||
{ name: "plain-0003" },
|
||||
{ id: "id-0004" },
|
||||
{},
|
||||
].map((profile) => ({
|
||||
profile,
|
||||
userId: resolveGoogleHealthUserId(profile),
|
||||
})),
|
||||
formatCivilBound: instants.flatMap((instant) =>
|
||||
ZONES.map((tz) => ({
|
||||
instant,
|
||||
tz: tz ?? null,
|
||||
bound: formatCivilBound(instant, tz),
|
||||
})),
|
||||
),
|
||||
civilDateInTz: instants.flatMap((instant) =>
|
||||
ZONES.map((tz) => ({
|
||||
instant,
|
||||
tz: tz ?? null,
|
||||
date: civilDateInTz(instant, tz),
|
||||
})),
|
||||
),
|
||||
incrementalFilter: listTypes.flatMap((dataType) =>
|
||||
(["camel", "snake"] as const).flatMap((style) =>
|
||||
ZONES.map((tz) => ({
|
||||
dataType: dataType.path,
|
||||
style,
|
||||
tz: tz ?? null,
|
||||
filter: outcome(() =>
|
||||
incrementalFilter(dataType, instants[1]!, tz, style),
|
||||
),
|
||||
})),
|
||||
),
|
||||
),
|
||||
incrementalFilterDefaultStyle: incrementalFilter(
|
||||
GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
|
||||
instants[0]!,
|
||||
),
|
||||
chunkCivilRange: [
|
||||
{
|
||||
start: { year: 2026, month: 1, day: 1 },
|
||||
end: { year: 2026, month: 7, day: 1 },
|
||||
},
|
||||
{
|
||||
start: { year: 2026, month: 2, day: 20 },
|
||||
end: { year: 2026, month: 3, day: 10 },
|
||||
maxDays: 14,
|
||||
},
|
||||
{
|
||||
start: { year: 2026, month: 6, day: 1 },
|
||||
end: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
{
|
||||
start: { year: 2026, month: 6, day: 2 },
|
||||
end: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
].map(({ start, end, maxDays }) => ({
|
||||
start,
|
||||
end,
|
||||
maxDays: maxDays ?? null,
|
||||
chunks: chunkCivilRange(start, end, maxDays),
|
||||
})),
|
||||
buildDailyRollUpBody: [
|
||||
{
|
||||
chunk: {
|
||||
start: { year: 2026, month: 1, day: 1 },
|
||||
end: { year: 2026, month: 4, day: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
chunk: {
|
||||
start: { year: 2026, month: 2, day: 20 },
|
||||
end: { year: 2026, month: 3, day: 1 },
|
||||
},
|
||||
pageToken: "page-token-0002",
|
||||
},
|
||||
{
|
||||
chunk: {
|
||||
start: { year: 2026, month: 12, day: 31 },
|
||||
end: { year: 2027, month: 1, day: 1 },
|
||||
},
|
||||
pageToken: "",
|
||||
},
|
||||
].map(({ chunk, pageToken }) => ({
|
||||
chunk,
|
||||
pageToken: pageToken ?? null,
|
||||
body: buildDailyRollUpBody(chunk, pageToken),
|
||||
})),
|
||||
extractGoogleApiErrorDetail: errorBodies.map((body) => ({
|
||||
body,
|
||||
detail: extractGoogleApiErrorDetail(body) ?? null,
|
||||
})),
|
||||
refusesEdgeFilter: [
|
||||
undefined,
|
||||
"INVALID_ARGUMENT",
|
||||
"INVALID_ARGUMENT: Invalid filter",
|
||||
"INVALID_ARGUMENT: Invalid page token",
|
||||
"INVALID_ARGUMENT: page_size out of range",
|
||||
"INVALID_ARGUMENT: pageSize too large",
|
||||
"UNAUTHENTICATED: access token expired",
|
||||
"FAILED_PRECONDITION: something else",
|
||||
"Bad filter and bad token",
|
||||
].map((detail) => ({
|
||||
detail: detail ?? null,
|
||||
refuses: refusesEdgeFilter(detail),
|
||||
})),
|
||||
googleHealthRetryDelayMs: retryCases.map(({ headers, body }) => ({
|
||||
headers,
|
||||
body,
|
||||
delayMs:
|
||||
googleHealthRetryDelayMs(
|
||||
new Response(null, { status: 429, headers }),
|
||||
body,
|
||||
FIXED_NOW.getTime(),
|
||||
) ?? null,
|
||||
})),
|
||||
};
|
||||
await expect(await golden(output)).toMatchFileSnapshot(
|
||||
"./__golden__/map-client-helpers.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,13 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkCivilRange } from "../client";
|
||||
import { mapVo2Max } from "@/lib/google-health/mappers";
|
||||
import {
|
||||
chunkCivilRange,
|
||||
formatCivilBound,
|
||||
incrementalFilter,
|
||||
} from "@/lib/google-health/request";
|
||||
import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
mapActiveEnergy,
|
||||
mapBloodGlucose,
|
||||
@@ -36,11 +39,10 @@ import {
|
||||
mapSleepSession,
|
||||
mapSleepSessionDetailed,
|
||||
mapSteps,
|
||||
mapVo2Max,
|
||||
mapWeight,
|
||||
mapWorkout,
|
||||
mapWristTemperature,
|
||||
} from "../client";
|
||||
} from "@/lib/google-health/mappers";
|
||||
|
||||
describe("mapGoogleHealthSportType", () => {
|
||||
it("keeps Badminton as a first-class workout sport", () => {
|
||||
|
||||
@@ -21,12 +21,12 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
return { ...actual, safeFetch: safeFetchMock };
|
||||
});
|
||||
|
||||
import { fetchDataPoints } from "@/lib/google-health/data-points";
|
||||
import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
GOOGLE_HEALTH_RATE_LIMIT,
|
||||
fetchDataPoints,
|
||||
googleHealthRetryDelayMs,
|
||||
} from "../client";
|
||||
} from "@/lib/google-health/request";
|
||||
import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers";
|
||||
import { collapseNaturalKeyTwins } from "../sync-core";
|
||||
import { GoogleHealthApiError } from "../response-classifier";
|
||||
|
||||
|
||||
@@ -20,15 +20,15 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS,
|
||||
GOOGLE_HEALTH_ROLLUP_RANGE_DAYS,
|
||||
buildDailyRollUpBody,
|
||||
extractGoogleApiErrorDetail,
|
||||
fetchDailyRollUp,
|
||||
runWithGoogleHealthClientOutcome,
|
||||
type GoogleHealthRollupShape,
|
||||
} from "../client";
|
||||
import { extractGoogleApiErrorDetail } from "@/lib/google-health/request";
|
||||
import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget";
|
||||
import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers";
|
||||
import { GoogleHealthApiError } from "../response-classifier";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
GOOGLE_HEALTH_CORE_SCOPES,
|
||||
getGoogleHealthScopeString,
|
||||
resolveGoogleHealthScopes,
|
||||
} from "../client";
|
||||
} from "@/lib/google-health/oauth";
|
||||
|
||||
describe("Google Health scope resolver", () => {
|
||||
it("ships exactly the four core Restricted read scopes", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "../client";
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
|
||||
import {
|
||||
INTEGRATION_DOCS_BASE,
|
||||
integrationDocsHref,
|
||||
|
||||
@@ -16,11 +16,11 @@ const { fetchDailyRollUpMock, fetchDataPointsMock, handleErrorMock } =
|
||||
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchDailyRollUp: fetchDailyRollUpMock,
|
||||
fetchDataPoints: fetchDataPointsMock,
|
||||
};
|
||||
return { ...actual, fetchDailyRollUp: fetchDailyRollUpMock };
|
||||
});
|
||||
vi.mock("../data-points", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../data-points")>();
|
||||
return { ...actual, fetchDataPoints: fetchDataPointsMock };
|
||||
});
|
||||
vi.mock("../sync-core", () => ({
|
||||
getValidToken: vi.fn(async () => ({
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock("@/lib/integrations/status", () => ({
|
||||
recordSyncFailure: vi.fn(async () => {}),
|
||||
recordSyncSuccess: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/measurement-emit", () => ({
|
||||
|
||||
@@ -76,7 +76,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
@@ -85,9 +85,15 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock,
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return { ...actual, refreshAccessToken: refreshAccessTokenMock };
|
||||
vi.mock("../oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
googleHealthOAuth: {
|
||||
...actual.googleHealthOAuth,
|
||||
refresh: refreshAccessTokenMock,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: vi.fn(() => []),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../sync-workout", () => ({ syncUserWorkout: resources.workout }));
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: vi.fn(() => []),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../sync-workout", () => ({ syncUserWorkout: vi.fn(async () => 0) }));
|
||||
|
||||
@@ -46,10 +46,8 @@ vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
|
||||
import {
|
||||
GOOGLE_HEALTH_PAGE_SIZE,
|
||||
runWithGoogleHealthClientOutcome,
|
||||
} from "../client";
|
||||
import { GOOGLE_HEALTH_PAGE_SIZE } from "@/lib/google-health/data-points";
|
||||
import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget";
|
||||
import { syncUserMetrics } from "../sync-metrics";
|
||||
|
||||
const PAGES = 3;
|
||||
|
||||
@@ -22,8 +22,8 @@ const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted(
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
vi.mock("../data-points", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../data-points")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchDataPoints: fetchDataPointsMock,
|
||||
@@ -44,9 +44,12 @@ vi.mock("../client", async (importOriginal) => {
|
||||
if (points.length > 0) await onPage(points);
|
||||
},
|
||||
),
|
||||
mapWeight: mapWeightMock,
|
||||
};
|
||||
});
|
||||
vi.mock("../mappers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../mappers")>();
|
||||
return { ...actual, mapWeight: mapWeightMock };
|
||||
});
|
||||
|
||||
vi.mock("../sync-core", () => ({
|
||||
getValidToken: vi.fn(async () => ({
|
||||
|
||||
@@ -142,7 +142,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: vi.fn(() => []),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../sync-core", () => ({
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
@@ -45,12 +45,18 @@ vi.mock("../client", async () => {
|
||||
const actual = await vi.importActual<typeof import("../client")>("../client");
|
||||
return {
|
||||
...actual,
|
||||
refreshAccessToken: vi.fn(),
|
||||
noteGoogleHealthMapped: vi.fn(),
|
||||
noteGoogleHealthWritten: vi.fn(),
|
||||
noteGoogleHealthOutcomeFailure: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { eventStorage } from "@/lib/logging/context";
|
||||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
|
||||
@@ -26,10 +26,12 @@ const {
|
||||
maybeEnqueueMorningRefreshMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
vi.mock("../data-points", () => ({
|
||||
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100,
|
||||
GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" },
|
||||
fetchDataPoints: fetchDataPointsMock,
|
||||
}));
|
||||
vi.mock("../mappers", () => ({
|
||||
GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" },
|
||||
mapSleepSessionDetailed: mapSleepSessionDetailedMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -26,13 +26,19 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserGoogleHealthCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
|
||||
vi.mock("../oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { replaceStaleGoogleHealthSleep } from "../sync-core";
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: vi.fn(() => []),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../sync-workout", () => ({ syncUserWorkout: resources.workout }));
|
||||
@@ -54,7 +54,7 @@ vi.mock("../sync-sleep", () => ({ syncUserSleep: resources.sleep }));
|
||||
vi.mock("../sync-activity", () => ({ syncUserActivity: resources.activity }));
|
||||
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resources.metrics }));
|
||||
|
||||
import { googleHealthSliceExhausted } from "../client";
|
||||
import { googleHealthSliceExhausted } from "@/lib/google-health/budget";
|
||||
import { syncUserGoogleHealth } from "../sync";
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -39,15 +39,18 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserGoogleHealthCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return { ...actual, refreshAccessToken: vi.fn() };
|
||||
vi.mock("../oauth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../oauth")>();
|
||||
return {
|
||||
...actual,
|
||||
googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { upsertGoogleHealthMeasurements } from "../sync-core";
|
||||
|
||||
@@ -29,10 +29,12 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
vi.mock("../client", () => ({
|
||||
vi.mock("../data-points", () => ({
|
||||
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100,
|
||||
GOOGLE_HEALTH_DATA_TYPES: { exercise: "exercise" },
|
||||
fetchDataPoints,
|
||||
}));
|
||||
vi.mock("../mappers", () => ({
|
||||
GOOGLE_HEALTH_DATA_TYPES: { exercise: "exercise" },
|
||||
mapWorkout,
|
||||
}));
|
||||
vi.mock("../sync-core", () => ({
|
||||
|
||||
Loaded 100 of 442 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user