@clipin/convex-wearables 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +616 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +4 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +244 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +555 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/types.d.ts +689 -0
  12. package/dist/client/types.d.ts.map +1 -0
  13. package/dist/client/types.js +112 -0
  14. package/dist/client/types.js.map +1 -0
  15. package/dist/component/_generated/_ignore.d.ts +1 -0
  16. package/dist/component/_generated/_ignore.d.ts.map +1 -0
  17. package/dist/component/_generated/_ignore.js +4 -0
  18. package/dist/component/_generated/_ignore.js.map +1 -0
  19. package/dist/component/_generated/api.d.ts +13 -0
  20. package/dist/component/_generated/api.d.ts.map +1 -0
  21. package/dist/component/_generated/api.js +14 -0
  22. package/dist/component/_generated/api.js.map +1 -0
  23. package/dist/component/_generated/dataModel.d.ts +28 -0
  24. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  25. package/dist/component/_generated/dataModel.js +11 -0
  26. package/dist/component/_generated/dataModel.js.map +1 -0
  27. package/dist/component/_generated/server.d.ts +23 -0
  28. package/dist/component/_generated/server.d.ts.map +1 -0
  29. package/dist/component/_generated/server.js +18 -0
  30. package/dist/component/_generated/server.js.map +1 -0
  31. package/dist/component/backfillJobs.d.ts +121 -0
  32. package/dist/component/backfillJobs.d.ts.map +1 -0
  33. package/dist/component/backfillJobs.js +233 -0
  34. package/dist/component/backfillJobs.js.map +1 -0
  35. package/dist/component/connections.d.ts +159 -0
  36. package/dist/component/connections.d.ts.map +1 -0
  37. package/dist/component/connections.js +288 -0
  38. package/dist/component/connections.js.map +1 -0
  39. package/dist/component/convex.config.d.ts +3 -0
  40. package/dist/component/convex.config.d.ts.map +1 -0
  41. package/dist/component/convex.config.js +6 -0
  42. package/dist/component/convex.config.js.map +1 -0
  43. package/dist/component/dataPoints.d.ts +81 -0
  44. package/dist/component/dataPoints.d.ts.map +1 -0
  45. package/dist/component/dataPoints.js +258 -0
  46. package/dist/component/dataPoints.js.map +1 -0
  47. package/dist/component/dataSources.d.ts +56 -0
  48. package/dist/component/dataSources.d.ts.map +1 -0
  49. package/dist/component/dataSources.js +95 -0
  50. package/dist/component/dataSources.js.map +1 -0
  51. package/dist/component/events.d.ts +203 -0
  52. package/dist/component/events.d.ts.map +1 -0
  53. package/dist/component/events.js +251 -0
  54. package/dist/component/events.js.map +1 -0
  55. package/dist/component/garminBackfill.d.ts +40 -0
  56. package/dist/component/garminBackfill.d.ts.map +1 -0
  57. package/dist/component/garminBackfill.js +296 -0
  58. package/dist/component/garminBackfill.js.map +1 -0
  59. package/dist/component/garminWebhooks.d.ts +17 -0
  60. package/dist/component/garminWebhooks.d.ts.map +1 -0
  61. package/dist/component/garminWebhooks.js +505 -0
  62. package/dist/component/garminWebhooks.js.map +1 -0
  63. package/dist/component/httpHandlers.d.ts +32 -0
  64. package/dist/component/httpHandlers.d.ts.map +1 -0
  65. package/dist/component/httpHandlers.js +131 -0
  66. package/dist/component/httpHandlers.js.map +1 -0
  67. package/dist/component/lifecycle.d.ts +12 -0
  68. package/dist/component/lifecycle.d.ts.map +1 -0
  69. package/dist/component/lifecycle.js +79 -0
  70. package/dist/component/lifecycle.js.map +1 -0
  71. package/dist/component/menstrualCycles.d.ts +98 -0
  72. package/dist/component/menstrualCycles.d.ts.map +1 -0
  73. package/dist/component/menstrualCycles.js +112 -0
  74. package/dist/component/menstrualCycles.js.map +1 -0
  75. package/dist/component/oauthActions.d.ts +52 -0
  76. package/dist/component/oauthActions.d.ts.map +1 -0
  77. package/dist/component/oauthActions.js +208 -0
  78. package/dist/component/oauthActions.js.map +1 -0
  79. package/dist/component/oauthStates.d.ts +47 -0
  80. package/dist/component/oauthStates.d.ts.map +1 -0
  81. package/dist/component/oauthStates.js +77 -0
  82. package/dist/component/oauthStates.js.map +1 -0
  83. package/dist/component/providerSettings.d.ts +15 -0
  84. package/dist/component/providerSettings.d.ts.map +1 -0
  85. package/dist/component/providerSettings.js +57 -0
  86. package/dist/component/providerSettings.js.map +1 -0
  87. package/dist/component/providers/garmin.d.ts +306 -0
  88. package/dist/component/providers/garmin.d.ts.map +1 -0
  89. package/dist/component/providers/garmin.js +675 -0
  90. package/dist/component/providers/garmin.js.map +1 -0
  91. package/dist/component/providers/oauth.d.ts +42 -0
  92. package/dist/component/providers/oauth.d.ts.map +1 -0
  93. package/dist/component/providers/oauth.js +181 -0
  94. package/dist/component/providers/oauth.js.map +1 -0
  95. package/dist/component/providers/polar.d.ts +6 -0
  96. package/dist/component/providers/polar.d.ts.map +1 -0
  97. package/dist/component/providers/polar.js +175 -0
  98. package/dist/component/providers/polar.js.map +1 -0
  99. package/dist/component/providers/registry.d.ts +14 -0
  100. package/dist/component/providers/registry.d.ts.map +1 -0
  101. package/dist/component/providers/registry.js +32 -0
  102. package/dist/component/providers/registry.js.map +1 -0
  103. package/dist/component/providers/strava.d.ts +45 -0
  104. package/dist/component/providers/strava.d.ts.map +1 -0
  105. package/dist/component/providers/strava.js +182 -0
  106. package/dist/component/providers/strava.js.map +1 -0
  107. package/dist/component/providers/suunto.d.ts +5 -0
  108. package/dist/component/providers/suunto.d.ts.map +1 -0
  109. package/dist/component/providers/suunto.js +502 -0
  110. package/dist/component/providers/suunto.js.map +1 -0
  111. package/dist/component/providers/types.d.ts +139 -0
  112. package/dist/component/providers/types.d.ts.map +1 -0
  113. package/dist/component/providers/types.js +5 -0
  114. package/dist/component/providers/types.js.map +1 -0
  115. package/dist/component/providers/whoop.d.ts +4 -0
  116. package/dist/component/providers/whoop.d.ts.map +1 -0
  117. package/dist/component/providers/whoop.js +439 -0
  118. package/dist/component/providers/whoop.js.map +1 -0
  119. package/dist/component/schema.d.ts +429 -0
  120. package/dist/component/schema.d.ts.map +1 -0
  121. package/dist/component/schema.js +282 -0
  122. package/dist/component/schema.js.map +1 -0
  123. package/dist/component/sdkPush.d.ts +143 -0
  124. package/dist/component/sdkPush.d.ts.map +1 -0
  125. package/dist/component/sdkPush.js +338 -0
  126. package/dist/component/sdkPush.js.map +1 -0
  127. package/dist/component/summaries.d.ts +129 -0
  128. package/dist/component/summaries.d.ts.map +1 -0
  129. package/dist/component/summaries.js +129 -0
  130. package/dist/component/summaries.js.map +1 -0
  131. package/dist/component/syncJobs.d.ts +142 -0
  132. package/dist/component/syncJobs.d.ts.map +1 -0
  133. package/dist/component/syncJobs.js +136 -0
  134. package/dist/component/syncJobs.js.map +1 -0
  135. package/dist/component/syncWorkflow.d.ts +99 -0
  136. package/dist/component/syncWorkflow.d.ts.map +1 -0
  137. package/dist/component/syncWorkflow.js +579 -0
  138. package/dist/component/syncWorkflow.js.map +1 -0
  139. package/dist/component/workflowManager.d.ts +3 -0
  140. package/dist/component/workflowManager.d.ts.map +1 -0
  141. package/dist/component/workflowManager.js +17 -0
  142. package/dist/component/workflowManager.js.map +1 -0
  143. package/package.json +84 -0
package/README.md ADDED
@@ -0,0 +1,616 @@
1
+ # @clipin/convex-wearables
2
+
3
+ A [Convex component](https://docs.convex.dev/components) for wearable device integrations. Sync health data from **Garmin, Strava, Whoop, Polar, Suunto, Apple HealthKit, Samsung Health, and Google Health Connect** into your Convex app.
4
+
5
+ Built as a drop-in module: install the component, pass your provider credentials, and start querying workouts, sleep sessions, heart rate, and 88 pre-defined health metrics — all in TypeScript, no backend glue code required.
6
+
7
+ ## Features
8
+
9
+ - **OAuth 2.0 flows** with PKCE support — authorize users, exchange tokens, auto-refresh
10
+ - **Automatic sync** — cron-triggered or on-demand data fetching from provider APIs
11
+ - **Normalized data model** — workouts, sleep, time-series metrics, and daily summaries in a unified schema
12
+ - **40+ workout types** mapped to a unified taxonomy (running, cycling, swimming, yoga, etc.)
13
+ - **88 pre-defined series types** — heart rate, HRV, SpO2, steps, weight, body temperature, and more
14
+ - **Cursor-based pagination** — efficient data access within Convex's scan limits
15
+ - **Deduplication** — events and data points are deduped by external ID and source+timestamp
16
+ - **Precomputed daily summaries** — activity, sleep, recovery, and body composition aggregates
17
+ - **GDPR-ready** — cascading user data deletion in a single call
18
+ - **Webhook + SDK push support** — Garmin webhooks plus normalized mobile SDK ingestion for Apple Health / Google Health Connect
19
+ - **Full TypeScript** — end-to-end type safety from provider API to client query
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @clipin/convex-wearables convex
25
+ ```
26
+
27
+ `convex` is a peer dependency and should be `>= 1.17.0`.
28
+
29
+ ## Quick Start
30
+
31
+ ### 1. Install the component in your Convex app
32
+
33
+ ```ts
34
+ // convex/convex.config.ts
35
+ import { defineApp } from "convex/server";
36
+ import wearables from "@clipin/convex-wearables/convex.config";
37
+
38
+ const app = defineApp();
39
+ app.use(wearables);
40
+
41
+ export default app;
42
+ ```
43
+
44
+ ### 2. Create the client
45
+
46
+ ```ts
47
+ // convex/wearables.ts
48
+ import { WearablesClient, type ProviderName } from "@clipin/convex-wearables";
49
+ import { components } from "./_generated/api";
50
+
51
+ export const wearables = new WearablesClient(components.wearables, {
52
+ providers: {
53
+ strava: {
54
+ clientId: process.env.STRAVA_CLIENT_ID!,
55
+ clientSecret: process.env.STRAVA_CLIENT_SECRET!,
56
+ },
57
+ garmin: {
58
+ clientId: process.env.GARMIN_CLIENT_ID!,
59
+ clientSecret: process.env.GARMIN_CLIENT_SECRET!,
60
+ },
61
+ // Add more providers as needed
62
+ },
63
+ });
64
+ ```
65
+
66
+ ### 3. Use in your queries and mutations
67
+
68
+ ```ts
69
+ // convex/workouts.ts
70
+ import { query, mutation } from "./_generated/server";
71
+ import { v } from "convex/values";
72
+ import { wearables } from "./wearables";
73
+
74
+ // Get a user's recent workouts
75
+ export const listWorkouts = query({
76
+ args: {
77
+ userId: v.string(),
78
+ cursor: v.optional(v.string()),
79
+ },
80
+ handler: async (ctx, args) => {
81
+ return await wearables.getEvents(ctx, {
82
+ userId: args.userId,
83
+ category: "workout",
84
+ limit: 20,
85
+ cursor: args.cursor,
86
+ });
87
+ },
88
+ });
89
+
90
+ // Get heart rate time-series for the last 24 hours
91
+ export const getHeartRate = query({
92
+ args: { userId: v.string() },
93
+ handler: async (ctx, args) => {
94
+ const now = Date.now();
95
+ return await wearables.getTimeSeries(ctx, {
96
+ userId: args.userId,
97
+ seriesType: "heart_rate",
98
+ startDate: now - 24 * 60 * 60 * 1000,
99
+ endDate: now,
100
+ });
101
+ },
102
+ });
103
+
104
+ // Get daily activity summaries for a date range
105
+ export const getWeeklySummary = query({
106
+ args: { userId: v.string() },
107
+ handler: async (ctx, args) => {
108
+ return await wearables.getDailySummaries(ctx, {
109
+ userId: args.userId,
110
+ category: "activity",
111
+ startDate: "2026-03-09",
112
+ endDate: "2026-03-15",
113
+ });
114
+ },
115
+ });
116
+
117
+ // Disconnect a provider
118
+ export const disconnectProvider = mutation({
119
+ args: {
120
+ userId: v.string(),
121
+ provider: v.string(),
122
+ },
123
+ handler: async (ctx, args) => {
124
+ await wearables.disconnect(ctx, {
125
+ userId: args.userId,
126
+ provider: args.provider as ProviderName,
127
+ });
128
+ },
129
+ });
130
+ ```
131
+
132
+ ## API Reference
133
+
134
+ ### `WearablesClient`
135
+
136
+ The main API surface. Instantiate once with your component reference and provider credentials.
137
+
138
+ ```ts
139
+ const wearables = new WearablesClient(components.wearables, config);
140
+ ```
141
+
142
+ #### Connection Management
143
+
144
+ | Method | Description |
145
+ |--------|-------------|
146
+ | `getConnections(ctx, { userId })` | Get all connections for a user (tokens stripped) |
147
+ | `getConnection(ctx, { userId, provider })` | Get a specific provider connection |
148
+ | `getSyncStatus(ctx, { userId })` | Get sync status across all providers |
149
+ | `disconnect(ctx, { userId, provider })` | Disconnect a provider (clears tokens, sets inactive) |
150
+
151
+ #### Events (Workouts & Sleep)
152
+
153
+ | Method | Description |
154
+ |--------|-------------|
155
+ | `getEvents(ctx, { userId, category, startDate?, endDate?, limit?, cursor? })` | Paginated events query |
156
+ | `getEvent(ctx, { eventId })` | Get a single event by ID |
157
+
158
+ The `category` parameter is `"workout"` or `"sleep"`. Results are ordered by start time (newest first). Pagination uses cursor-based tokens returned in `nextCursor`.
159
+
160
+ #### Time Series
161
+
162
+ | Method | Description |
163
+ |--------|-------------|
164
+ | `getTimeSeries(ctx, { userId, seriesType, startDate, endDate, limit? })` | Get time-series data points |
165
+ | `getLatestDataPoint(ctx, { userId, seriesType })` | Get the most recent value for a metric |
166
+ | `getAvailableSeriesTypes(ctx, { userId })` | List which metric types have data |
167
+
168
+ See [Series Types](#series-types) for all 88 supported metrics.
169
+
170
+ #### Daily Summaries
171
+
172
+ | Method | Description |
173
+ |--------|-------------|
174
+ | `getDailySummaries(ctx, { userId, category, startDate, endDate })` | Get daily aggregates |
175
+
176
+ Categories: `"activity"`, `"sleep"`, `"recovery"`, `"body"`.
177
+
178
+ #### Data Sources
179
+
180
+ | Method | Description |
181
+ |--------|-------------|
182
+ | `getOrCreateDataSource(ctx, { userId, provider, deviceModel?, source? })` | Get or create a data source |
183
+
184
+ #### Sync Control
185
+
186
+ | Method | Description |
187
+ |--------|-------------|
188
+ | `createSyncJob(ctx, { userId, provider? })` | Create a sync job record |
189
+ | `getSyncJobs(ctx, { userId, limit? })` | Get recent sync jobs |
190
+ | `syncAllActive(ctx, { syncWindowHours? })` | Trigger a sync across all active connections |
191
+
192
+ #### OAuth
193
+
194
+ | Method | Description |
195
+ |--------|-------------|
196
+ | `generateAuthUrl(ctx, { userId, provider, redirectUri })` | Build an OAuth URL using configured provider credentials |
197
+ | `handleCallback(ctx, { provider, state, code })` | Exchange a callback code and persist the resulting connection |
198
+
199
+ #### Lifecycle
200
+
201
+ | Method | Description |
202
+ |--------|-------------|
203
+ | `deleteAllUserData(ctx, { userId })` | Delete all data for a user (GDPR) |
204
+
205
+ #### Configuration
206
+
207
+ | Method | Description |
208
+ |--------|-------------|
209
+ | `getProviderCredentials(provider)` | Get credentials for a provider |
210
+ | `getConfiguredProviders()` | List all configured providers |
211
+
212
+ ## Data Model
213
+
214
+ ### Tables
215
+
216
+ | Table | Description | Key Indexes |
217
+ |-------|-------------|-------------|
218
+ | `connections` | OAuth tokens + provider link per user | `by_user`, `by_user_provider`, `by_status` |
219
+ | `dataSources` | User + provider + device combinations | `by_user_provider`, `by_user_provider_device`, `by_connection` |
220
+ | `dataPoints` | Time-series health metrics | `by_source_type_time`, `by_type_time` |
221
+ | `events` | Workouts and sleep sessions | `by_user_category_time`, `by_external_id`, `by_source_start_end` |
222
+ | `dailySummaries` | Precomputed daily aggregates | `by_user_category_date`, `by_user_date` |
223
+ | `syncJobs` | Sync workflow tracking | `by_user`, `by_user_provider`, `by_user_status`, `by_status` |
224
+ | `oauthStates` | Temporary OAuth PKCE state | `by_state` |
225
+ | `backfillJobs` | Long-running historical data imports | `by_connection`, `by_status` |
226
+
227
+ ### Deduplication
228
+
229
+ Events are deduplicated at two levels:
230
+
231
+ 1. **By `externalId`** — provider-assigned IDs like `strava-12345` prevent duplicate imports
232
+ 2. **By `dataSourceId` + `startDatetime` + `endDatetime`** — catches duplicates even without external IDs
233
+
234
+ Data points are deduplicated by `dataSourceId` + `seriesType` + `recordedAt`.
235
+
236
+ ## OAuth Flow
237
+
238
+ The component handles the full OAuth 2.0 authorization code flow:
239
+
240
+ ```
241
+ ┌──────────┐ 1. generateAuthUrl ┌─────────────────┐
242
+ │ Your App │ ───────────────────────▶ │ Component │
243
+ │ │ ◀──────────────────────── │ (stores state) │
244
+ │ │ ← authorization URL │ │
245
+ └─────┬─────┘ └─────────────────┘
246
+ │ 2. redirect user to provider
247
+
248
+ ┌──────────┐ 3. user authorizes ┌─────────────────┐
249
+ │ Provider │ ───────────────────────▶ │ Your App │
250
+ │ (Strava) │ ← redirect with code │ /callback │
251
+ └──────────┘ └─────┬───────────┘
252
+ │ 4. handleCallback
253
+
254
+ ┌─────────────────┐
255
+ │ Component │
256
+ │ - exchange code │
257
+ │ - store tokens │
258
+ │ - create conn │
259
+ └─────────────────┘
260
+ ```
261
+
262
+ ### Actions
263
+
264
+ | Action | Description |
265
+ |--------|-------------|
266
+ | `generateAuthUrl` | Build OAuth URL, store state with PKCE |
267
+ | `handleCallback` | Exchange code, fetch user info, create connection |
268
+ | `ensureValidToken` | Internal token refresh helper used by sync actions |
269
+
270
+ ## Sync Workflow
271
+
272
+ The sync workflow runs as a Convex action:
273
+
274
+ 1. **Token validation** — refreshes expired tokens automatically
275
+ 2. **Data fetch** — calls provider API with pagination (e.g., 200 activities per page from Strava)
276
+ 3. **Batch storage** — writes events in batches of 50 to stay within Convex's 1-second mutation timeout
277
+ 4. **Status tracking** — creates sync job records with status, timestamps, and error details
278
+
279
+ ### Cron-based sync
280
+
281
+ Set up a Convex cron to sync all active connections periodically:
282
+
283
+ ```ts
284
+ // convex/crons.ts
285
+ import { cronJobs } from "convex/server";
286
+ import { components } from "./_generated/api";
287
+
288
+ const crons = cronJobs();
289
+
290
+ crons.interval(
291
+ "sync all wearables",
292
+ { minutes: 15 },
293
+ components.wearables.syncWorkflow.syncAllActive,
294
+ {
295
+ clientCredentials: {
296
+ strava: {
297
+ clientId: process.env.STRAVA_CLIENT_ID!,
298
+ clientSecret: process.env.STRAVA_CLIENT_SECRET!,
299
+ },
300
+ },
301
+ syncWindowHours: 24,
302
+ },
303
+ );
304
+
305
+ export default crons;
306
+ ```
307
+
308
+ ## Webhook Support
309
+
310
+ ### Garmin Webhooks
311
+
312
+ Register Garmin routes directly from the package:
313
+
314
+ ```ts
315
+ // convex/http.ts
316
+ import { httpRouter } from "convex/server";
317
+ import { registerRoutes } from "@clipin/convex-wearables";
318
+ import { components } from "./_generated/api";
319
+
320
+ const http = httpRouter();
321
+
322
+ registerRoutes(http, components.wearables, {
323
+ garmin: {
324
+ clientId: process.env.GARMIN_CLIENT_ID,
325
+ clientSecret: process.env.GARMIN_CLIENT_SECRET,
326
+ oauthCallbackPath: "/oauth/garmin/callback",
327
+ successRedirectUrl: process.env.NEXT_PUBLIC_APP_URL,
328
+ webhookPath: "/webhooks/garmin/push",
329
+ healthPath: "/webhooks/garmin/health",
330
+ },
331
+ });
332
+
333
+ export default http;
334
+ ```
335
+
336
+ The Garmin route helper:
337
+
338
+ - handles the Garmin OAuth callback redirect
339
+ - validates the `garmin-client-id` header
340
+ - logs payload summaries and processing errors
341
+ - forwards the payload to `components.wearables.garminWebhooks.processPushPayload`
342
+ - exposes an optional health-check route
343
+
344
+ If you customize `oauthCallbackPath`, the redirect URI used when calling
345
+ `oauthActions.generateAuthUrl` must match that same callback path.
346
+
347
+ ### Strava Webhooks
348
+
349
+ The component provides HTTP handlers for Strava's [webhook events API](https://developers.strava.com/docs/webhooks/):
350
+
351
+ | Endpoint | Handler | Purpose |
352
+ |----------|---------|---------|
353
+ | `GET /webhooks/strava` | `stravaWebhookVerify` | Subscription verification (hub.challenge) |
354
+ | `POST /webhooks/strava` | `stravaWebhookEvent` | Receive activity create/update/delete events |
355
+
356
+ Mount these in your Convex HTTP router:
357
+
358
+ ```ts
359
+ // convex/http.ts
360
+ import { httpRouter } from "convex/server";
361
+ import { stravaWebhookVerify, stravaWebhookEvent } from "@clipin/convex-wearables";
362
+
363
+ const http = httpRouter();
364
+
365
+ http.route({
366
+ path: "/webhooks/strava",
367
+ method: "GET",
368
+ handler: stravaWebhookVerify,
369
+ });
370
+
371
+ http.route({
372
+ path: "/webhooks/strava",
373
+ method: "POST",
374
+ handler: stravaWebhookEvent,
375
+ });
376
+
377
+ export default http;
378
+ ```
379
+
380
+ ### SDK Push (Apple Health / Google Health Connect)
381
+
382
+ For on-device providers, register the normalized SDK sync route explicitly:
383
+
384
+ ```ts
385
+ // convex/http.ts
386
+ import { httpRouter } from "convex/server";
387
+ import { getSdkSyncUrl, registerRoutes } from "@clipin/convex-wearables";
388
+ import { components } from "./_generated/api";
389
+
390
+ const http = httpRouter();
391
+
392
+ const routeConfig = {
393
+ sdk: {
394
+ syncPath: "/sdk/sync",
395
+ authToken: process.env.WEARABLES_SDK_AUTH_TOKEN,
396
+ },
397
+ };
398
+
399
+ registerRoutes(http, components.wearables, routeConfig);
400
+
401
+ const sdkSyncUrl = getSdkSyncUrl(process.env.CONVEX_SITE_URL!, routeConfig);
402
+
403
+ export default http;
404
+ ```
405
+
406
+ Then POST a pre-normalized payload from your mobile app:
407
+
408
+ ```json
409
+ {
410
+ "userId": "user_123",
411
+ "provider": "google",
412
+ "sourceMetadata": {
413
+ "deviceModel": "Pixel Watch 3",
414
+ "source": "health-connect"
415
+ },
416
+ "events": [],
417
+ "dataPoints": [
418
+ {
419
+ "seriesType": "heart_rate",
420
+ "recordedAt": 1773817200000,
421
+ "value": 58
422
+ }
423
+ ],
424
+ "summaries": []
425
+ }
426
+ ```
427
+
428
+ The backend stores the payload using the same `connections`, `dataSources`, `events`, `dataPoints`, and `dailySummaries` tables as the cloud providers.
429
+
430
+ The SDK payload also accepts `device` and `dailySummaries` as compatibility aliases, and normalizes common Health Connect metric names like `hrv_rmssd`.
431
+
432
+ ## Supported Providers
433
+
434
+ | Provider | Integration mode | Current support | Status |
435
+ |----------|------------------|-----------------|--------|
436
+ | Strava | OAuth pull sync + webhook-triggered resync | Workouts, connection lifecycle, sync jobs | Implemented |
437
+ | Garmin | OAuth pull sync + push webhooks + durable backfill | Workouts, sleep, time-series, summaries | Implemented |
438
+ | Apple Health | Normalized SDK push | Workouts, sleep, time-series, summaries from your mobile app | Implemented via SDK |
439
+ | Samsung Health | Normalized SDK push | Workouts, sleep, time-series, summaries from your mobile app | Implemented via SDK |
440
+ | Google Health Connect | Normalized SDK push | Workouts, sleep, time-series, summaries from your mobile app | Implemented via SDK |
441
+ | Whoop | Provider scaffolding | Not yet wired to data sync | Planned |
442
+ | Polar | Provider scaffolding | Not yet wired to data sync | Planned |
443
+ | Suunto | Provider scaffolding | Not yet wired to data sync | Planned |
444
+
445
+ SDK-push providers rely on your app to send normalized payloads. The component stores and queries that data, but it does not yet fetch Apple Health, Samsung Health, or Google Health Connect data directly from vendor APIs.
446
+
447
+ ### Adding a Provider
448
+
449
+ Implement the `ProviderDefinition` interface and register it in the provider registry:
450
+
451
+ ```ts
452
+ // src/component/providers/garmin.ts
453
+ import type { ProviderDefinition } from "./registry";
454
+
455
+ export const garminProvider: ProviderDefinition = {
456
+ oauthConfig(clientId, clientSecret) {
457
+ return {
458
+ endpoints: {
459
+ authorizeUrl: "https://connect.garmin.com/oauthConfirm",
460
+ tokenUrl: "https://connectapi.garmin.com/oauth-service/oauth/token",
461
+ apiBaseUrl: "https://apis.garmin.com",
462
+ },
463
+ clientId,
464
+ clientSecret,
465
+ defaultScope: "",
466
+ usePkce: false,
467
+ authMethod: "body",
468
+ };
469
+ },
470
+ async fetchWorkouts(accessToken, startDate, endDate) {
471
+ // Fetch and normalize activities...
472
+ return [];
473
+ },
474
+ async getUserInfo(accessToken) {
475
+ // Fetch user profile...
476
+ return { providerUserId: null, username: null };
477
+ },
478
+ };
479
+ ```
480
+
481
+ ### Workout Type Taxonomy
482
+
483
+ The component normalizes provider-specific activity types to a unified taxonomy:
484
+
485
+ | Unified Type | Strava Types |
486
+ |---|---|
487
+ | `running` | Run, VirtualRun |
488
+ | `trail_running` | TrailRun |
489
+ | `cycling` | Ride, GravelRide |
490
+ | `mountain_biking` | MountainBikeRide |
491
+ | `indoor_cycling` | VirtualRide |
492
+ | `swimming` | Swim |
493
+ | `hiking` | Hike |
494
+ | `walking` | Walk |
495
+ | `strength_training` | WeightTraining |
496
+ | `yoga` | Yoga |
497
+ | `alpine_skiing` | AlpineSki |
498
+ | `rowing` | Rowing |
499
+ | `kayaking` | Kayaking |
500
+ | `surfing` | Surfing |
501
+ | `rock_climbing` | RockClimbing |
502
+ | `golf` | Golf |
503
+ | `pickleball` | Pickleball |
504
+ | `tennis` | Tennis |
505
+ | `soccer` | Soccer |
506
+ | ... | (40+ types total) |
507
+
508
+ ## Series Types
509
+
510
+ All 88 pre-defined metric types are available via the `SERIES_TYPES` constant:
511
+
512
+ ```ts
513
+ import { SERIES_TYPES } from "@clipin/convex-wearables/types";
514
+
515
+ console.log(SERIES_TYPES.heart_rate);
516
+ // { id: 1, unit: "bpm" }
517
+ ```
518
+
519
+ <details>
520
+ <summary>Full list of series types</summary>
521
+
522
+ **Heart & Cardiovascular**: `heart_rate`, `resting_heart_rate`, `heart_rate_variability_sdnn`, `heart_rate_variability_rmssd`, `heart_rate_recovery_one_minute`, `walking_heart_rate_average`, `recovery_score`
523
+
524
+ **Blood & Respiratory**: `oxygen_saturation`, `blood_glucose`, `blood_pressure_systolic`, `blood_pressure_diastolic`, `respiratory_rate`, `sleeping_breathing_disturbances`, `blood_alcohol_content`, `peripheral_perfusion_index`, `forced_vital_capacity`, `forced_expiratory_volume_1`, `peak_expiratory_flow_rate`
525
+
526
+ **Body Composition**: `height`, `weight`, `body_fat_percentage`, `body_mass_index`, `lean_body_mass`, `body_temperature`, `skin_temperature`, `waist_circumference`, `body_fat_mass`, `skeletal_muscle_mass`
527
+
528
+ **Fitness**: `vo2_max`, `six_minute_walk_test_distance`
529
+
530
+ **Activity — Basic**: `steps`, `energy`, `basal_energy`, `stand_time`, `exercise_time`, `physical_effort`, `flights_climbed`, `average_met`
531
+
532
+ **Activity — Distance**: `distance_walking_running`, `distance_cycling`, `distance_swimming`, `distance_downhill_snow_sports`, `distance_other`
533
+
534
+ **Activity — Walking/Running/Swimming**: `walking_step_length`, `walking_speed`, `running_power`, `running_speed`, `running_stride_length`, `swimming_stroke_count`, `underwater_depth`, and more
535
+
536
+ **Environmental**: `environmental_audio_exposure`, `headphone_audio_exposure`, `time_in_daylight`, `water_temperature`, `uv_exposure`, `weather_temperature`, `weather_humidity`
537
+
538
+ **Garmin-specific**: `garmin_stress_level`, `garmin_skin_temperature`, `garmin_fitness_age`, `garmin_body_battery`
539
+
540
+ </details>
541
+
542
+ ## Testing
543
+
544
+ The package currently has 110 passing tests across the component internals, provider adapters, webhook ingestion, SDK push ingestion, workflow orchestration, and client helpers.
545
+
546
+ ```bash
547
+ # Run all tests
548
+ npm test
549
+
550
+ # Run tests in watch mode
551
+ npm run test:watch
552
+
553
+ # Run a specific test file
554
+ npx vitest run src/component/events.test.ts
555
+ ```
556
+
557
+ Coverage includes:
558
+
559
+ - `convex-test` suites for schema/index behavior, deduplication, data isolation, and sync job lifecycle
560
+ - webhook and ingestion flows for Garmin push payloads and normalized mobile SDK payloads
561
+ - provider adapter normalization for Strava plus additional provider config coverage
562
+ - workflow orchestration and client helpers such as route registration and SDK sync URL generation
563
+
564
+ ## Platform Considerations
565
+
566
+ This component is designed around Convex's platform constraints:
567
+
568
+ | Constraint | Limit | How we handle it |
569
+ |---|---|---|
570
+ | Mutation timeout | 1 second | Batch writes (50 events per mutation) |
571
+ | Document scan limit | 32K per query | Cursor-based pagination, precomputed daily summaries |
572
+ | Action timeout | 10 minutes | Paginated provider API calls, sync-per-connection |
573
+ | Document size | 1 MiB | Flat event schema, sleep stages as embedded array |
574
+
575
+ For high-volume time-series data (e.g., per-second heart rate), consider using [`@convex-dev/aggregate`](https://github.com/get-convex/aggregate) for O(log n) sum/count/avg queries alongside this component.
576
+
577
+ ## Project Structure
578
+
579
+ ```
580
+ convex-wearables/
581
+ ├── src/
582
+ │ ├── client/
583
+ │ │ ├── index.ts # WearablesClient and HTTP route helper exports
584
+ │ │ └── types.ts # Shared types and SERIES_TYPES
585
+ │ └── component/
586
+ │ ├── schema.ts # Convex schema
587
+ │ ├── connections.ts # Connection lifecycle queries and mutations
588
+ │ ├── events.ts # Workout and sleep storage/query APIs
589
+ │ ├── dataPoints.ts # Time-series storage/query APIs
590
+ │ ├── dataSources.ts # Provider/device source tracking
591
+ │ ├── summaries.ts # Daily aggregates
592
+ │ ├── syncJobs.ts # Sync job tracking
593
+ │ ├── syncWorkflow.ts # Durable per-connection sync orchestration
594
+ │ ├── garminWebhooks.ts # Garmin push ingestion
595
+ │ ├── sdkPush.ts # Normalized mobile SDK ingestion
596
+ │ ├── garminBackfill.ts # Garmin historical backfill workflow
597
+ │ ├── httpHandlers.ts # Standalone HTTP action handlers
598
+ │ ├── oauthActions.ts # OAuth URL generation and callback handling
599
+ │ ├── providerSettings.ts # Stored provider credentials
600
+ │ ├── lifecycle.ts # GDPR user data deletion
601
+ │ ├── convex.config.ts # Component config
602
+ │ ├── providers/
603
+ │ │ ├── types.ts # Provider interfaces
604
+ │ │ ├── oauth.ts # Shared OAuth utilities
605
+ │ │ ├── garmin.ts # Garmin adapter and normalization
606
+ │ │ ├── strava.ts # Strava adapter and normalization
607
+ │ │ └── registry.ts # Provider registry
608
+ │ └── *.test.ts # Component and adapter tests
609
+ ├── package.json
610
+ ├── tsconfig.json
611
+ └── README.md
612
+ ```
613
+
614
+ ## License
615
+
616
+ Apache-2.0
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=_ignore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_ignore.d.ts","sourceRoot":"","sources":["../../../src/client/_generated/_ignore.ts"],"names":[],"mappings":""}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ // This file exists so that the _generated directory is tracked by git.
3
+ // The actual generated files are created by `npx convex dev` or `npx convex codegen`.
4
+ //# sourceMappingURL=_ignore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_ignore.js","sourceRoot":"","sources":["../../../src/client/_generated/_ignore.ts"],"names":[],"mappings":";AAAA,uEAAuE;AACvE,sFAAsF"}