Skip to content

feat: sync GPS route data from HealthKit workouts - #764

Merged
Asherlc merged 15 commits into
mainfrom
Asherlc/activity-gps-map
Apr 7, 2026
Merged

Asherlc merged 15 commits into
mainfrom
Asherlc/activity-gps-map

Conversation

@Asherlc

@Asherlc Asherlc commented Apr 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Activities synced from HealthKit via the iOS app were missing GPS map data because the native module never queried HKWorkoutRoute
  • Adds GPS + time-series data syncing to 5 additional providers that had the data available but weren't fetching it
  • Adds a reusable TCX parser (src/tcx/parser.ts) for XML-based activity file formats
  • The web and mobile UIs already have map rendering components — this change feeds data into that pipeline from all providers that offer it

Providers with GPS support added

Provider Method Data gained
Apple Health (iOS app) HKWorkoutRoute query GPS route coordinates
Coros FIT file download (fitUrl) GPS + HR + power + cadence + altitude
Fitbit TCX download (tcxLink) GPS + HR + cadence + power
Polar TCX download (exercises with has_route) GPS + HR + cadence
Suunto FIT file export endpoint GPS + HR + power + cadence + altitude

Providers that already had GPS (unchanged)

Strava, Garmin, Wahoo, Ride With GPS, Zwift, Apple Health (XML export)

Key changes

  • New TCX parser (src/tcx/parser.ts): SAX-based parser extracting GPS, HR, cadence, power, speed, altitude from TCX files
  • Swift (HealthKitModule.swift): queryWorkoutRoutes function using HKWorkoutRoute + HKWorkoutRouteQuery
  • Server (health-kit-sync.ts): pushWorkoutRoutes tRPC endpoint for iOS route data
  • Mobile sync: queries routes after pushing workouts, sends to server
  • Provider updates: Coros, Fitbit, Polar, Suunto now download activity files and parse GPS + time-series

Future work

Komoot, Decathlon, MapMyFitness, and Peloton outdoor have GPS data available but their APIs need per-provider investigation.

Test plan

  • Server unit tests pass (health-kit-sync, TCX parser)
  • Mobile unit tests pass (health-kit-sync, background sync, auto sync)
  • Coros, Fitbit, Polar, Suunto unit tests pass
  • Swift tests pass (HealthKit types count updated)
  • Deploy and trigger syncs for each provider
  • Verify GPS maps appear on outdoor activity detail pages

🤖 Generated with Claude Code

Activities synced from HealthKit via the iOS app were missing GPS map
data because the native module never queried HKWorkoutRoute. This adds
end-to-end support for fetching workout route locations and storing them
as sensor_sample rows.

- Swift: request HKSeriesType.workoutRoute() permission, add
  queryWorkoutRoutes function that fetches CLLocation data
- Server: add pushWorkoutRoutes tRPC endpoint that looks up the
  activity by workout UUID and inserts lat/lng/altitude/speed/
  gps_accuracy sensor samples
- Mobile sync: after pushing workouts, query routes for each and
  push to the new endpoint
- Tests: server unit tests for route processing, mobile sync tests
  for route fetching/pushing, updated mocks across all test files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 6, 2026 20:21
@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Storybook preview for 1ef1ba1c is ready: Open Storybook

This comment updates automatically on each PR push.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end syncing of HealthKit workout GPS route data (via HKWorkoutRoute) so activities imported from the iOS app can populate the existing route/map rendering pipeline on web/mobile.

Changes:

  • Server: add pushWorkoutRoutes tRPC endpoint and store route points as fitness.sensor_sample rows (lat/lng/altitude/speed/gps_accuracy).
  • iOS HealthKit module: request HKSeriesType.workoutRoute() read permission and implement queryWorkoutRoutes.
  • Mobile sync: query routes after pushing workouts and send them to the server; update mocks and unit tests accordingly.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/server/src/routers/health-kit-sync.ts Adds route schemas, processWorkoutRoutes, and pushWorkoutRoutes endpoint.
packages/server/src/routers/health-kit-sync.test.ts Adds unit tests for pushWorkoutRoutes.
packages/mobile/test-setup.ts Extends HealthKit native module mock with queryWorkoutRoutes.
packages/mobile/modules/health-kit/ios/HealthKitTypes.swift Adds HKSeriesType.workoutRoute() to requested read types.
packages/mobile/modules/health-kit/ios/HealthKitModule.swift Implements queryWorkoutRoutes by reading HKWorkoutRoute locations.
packages/mobile/modules/health-kit/index.ts Exposes RouteLocation type and queryWorkoutRoutes wrapper.
packages/mobile/lib/useAutoSync.ts Passes queryWorkoutRoutes into auto-sync adapter.
packages/mobile/lib/useAutoSync.test.ts Updates module mock to include queryWorkoutRoutes.
packages/mobile/lib/health-kit-sync.ts Queries per-workout routes and calls pushWorkoutRoutes.
packages/mobile/lib/health-kit-sync.test.ts Adds tests for route querying/pushing behavior.
packages/mobile/lib/background-health-kit-sync.ts Includes queryWorkoutRoutes in background sync adapter.
packages/mobile/lib/background-health-kit-sync.test.ts Updates mocks to include pushWorkoutRoutes and queryWorkoutRoutes.
packages/mobile/app/_layout.tsx Wires pushWorkoutRoutes into the injected sync tRPC client.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +833 to +856
// Insert each location as sensor_sample rows (one per channel)
for (const location of route.locations) {
for (const { channel, getValue, round } of ROUTE_CHANNELS) {
const value = getValue(location);
if (value == null) continue;

const scalar = round ? Math.round(value) : value;
await db.execute(
sql`INSERT INTO fitness.sensor_sample
(recorded_at, user_id, provider_id, activity_id, device_id, source_type, channel, scalar)
VALUES (
${location.date}::timestamptz,
${userId},
${PROVIDER_ID},
${activityId}::uuid,
${route.sourceName ?? null},
${"api"},
${channel},
${scalar}::real
)`,
);
inserted++;
}
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processWorkoutRoutes performs one INSERT per (location × channel), inside nested for loops. For workout routes with thousands of points this becomes tens of thousands of separate DB round-trips and will be very slow / likely to time out. Consider batching inserts (e.g., build VALUES ${sql.join(...)} per channel and per BATCH_SIZE, similar to ActivityRecordingRepository.saveActivity) so each batch is inserted with a single statement.

Suggested change
// Insert each location as sensor_sample rows (one per channel)
for (const location of route.locations) {
for (const { channel, getValue, round } of ROUTE_CHANNELS) {
const value = getValue(location);
if (value == null) continue;
const scalar = round ? Math.round(value) : value;
await db.execute(
sql`INSERT INTO fitness.sensor_sample
(recorded_at, user_id, provider_id, activity_id, device_id, source_type, channel, scalar)
VALUES (
${location.date}::timestamptz,
${userId},
${PROVIDER_ID},
${activityId}::uuid,
${route.sourceName ?? null},
${"api"},
${channel},
${scalar}::real
)`,
);
inserted++;
}
}
const pendingValues: ReturnType<typeof sql>[] = [];
const flushPendingValues = async () => {
if (pendingValues.length === 0) return;
await db.execute(
sql`INSERT INTO fitness.sensor_sample
(recorded_at, user_id, provider_id, activity_id, device_id, source_type, channel, scalar)
VALUES ${sql.join(pendingValues, sql`, `)}`,
);
inserted += pendingValues.length;
pendingValues.length = 0;
};
// Insert each location as sensor_sample rows, batching rows to reduce DB round-trips
for (const location of route.locations) {
for (const { channel, getValue, round } of ROUTE_CHANNELS) {
const value = getValue(location);
if (value == null) continue;
const scalar = round ? Math.round(value) : value;
pendingValues.push(
sql`(
${location.date}::timestamptz,
${userId},
${PROVIDER_ID},
${activityId}::uuid,
${route.sourceName ?? null},
${"api"},
${channel},
${scalar}::real
)`,
);
if (pendingValues.length >= BATCH_SIZE) {
await flushPendingValues();
}
}
}
await flushPendingValues();

Copilot uses AI. Check for mistakes.
Comment on lines +808 to +825
for (const route of routes) {
if (route.locations.length === 0) continue;

// Look up the activity by external_id
const externalId = `hk:workout:${route.workoutUuid}`;
const activityIdSchema = z.object({ id: z.string() });
const activityRows = await executeWithSchema(
db,
activityIdSchema,
sql`SELECT id FROM fitness.activity
WHERE user_id = ${userId}
AND provider_id = ${PROVIDER_ID}
AND external_id = ${externalId}
LIMIT 1`,
);

const activityId = activityRows[0]?.id ?? null;

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processWorkoutRoutes does an activity lookup query for each route (SELECT id ... LIMIT 1). If multiple routes are pushed in one request, this creates an N+1 query pattern. Consider resolving all workoutUuid -> activityId mappings in one query (e.g., WHERE external_id IN (...)) before inserting samples, then insert route samples using the resolved IDs.

Suggested change
for (const route of routes) {
if (route.locations.length === 0) continue;
// Look up the activity by external_id
const externalId = `hk:workout:${route.workoutUuid}`;
const activityIdSchema = z.object({ id: z.string() });
const activityRows = await executeWithSchema(
db,
activityIdSchema,
sql`SELECT id FROM fitness.activity
WHERE user_id = ${userId}
AND provider_id = ${PROVIDER_ID}
AND external_id = ${externalId}
LIMIT 1`,
);
const activityId = activityRows[0]?.id ?? null;
const externalIds = Array.from(
new Set(
routes
.filter((route) => route.locations.length > 0)
.map((route) => `hk:workout:${route.workoutUuid}`),
),
);
const activityIdByExternalId = new Map<string, string>();
if (externalIds.length > 0) {
const activityRowSchema = z.object({
id: z.string(),
external_id: z.string(),
});
const activityRows = await executeWithSchema(
db,
activityRowSchema,
sql`SELECT id, external_id FROM fitness.activity
WHERE user_id = ${userId}
AND provider_id = ${PROVIDER_ID}
AND external_id IN (${sql.join(
externalIds.map((externalId) => sql`${externalId}`),
sql`, `,
)})`,
);
for (const activityRow of activityRows) {
activityIdByExternalId.set(activityRow.external_id, activityRow.id);
}
}
for (const route of routes) {
if (route.locations.length === 0) continue;
const externalId = `hk:workout:${route.workoutUuid}`;
const activityId = activityIdByExternalId.get(externalId) ?? null;

Copilot uses AI. Check for mistakes.
Comment on lines +458 to +466
let locationQuery = HKWorkoutRouteQuery(route: route) { _, locations, done, locationError in
if locationError != nil {
if done { group.leave() }
return
}
guard let locations = locations else {
if done { group.leave() }
return
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HKWorkoutRouteQuery error handling can leave the DispatchGroup unbalanced: when locationError != nil and done is false, the closure returns without calling group.leave(), so group.notify may never fire and the JS promise can hang indefinitely. Ensure the group is left exactly once per route even on error (and consider resolving/rejecting with a partial result rather than hanging).

Copilot uses AI. Check for mistakes.
Comment on lines +450 to +487
// Collect all locations from all routes
var allLocations: [[String: Any]] = []
let group = DispatchGroup()

for route in routes {
group.enter()
var routeLocations: [[String: Any]] = []

let locationQuery = HKWorkoutRouteQuery(route: route) { _, locations, done, locationError in
if locationError != nil {
if done { group.leave() }
return
}
guard let locations = locations else {
if done { group.leave() }
return
}
for location in locations {
var dict: [String: Any] = [
"date": HealthKitQueries.formatDate(location.timestamp),
"lat": location.coordinate.latitude,
"lng": location.coordinate.longitude,
]
if location.altitude >= 0 || location.verticalAccuracy >= 0 {
dict["altitude"] = location.altitude
}
if location.speed >= 0 {
dict["speed"] = location.speed
}
if location.horizontalAccuracy >= 0 {
dict["horizontalAccuracy"] = location.horizontalAccuracy
}
routeLocations.append(dict)
}
if done {
allLocations.append(contentsOf: routeLocations)
group.leave()
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allLocations is mutated from multiple HKWorkoutRouteQuery callbacks. These callbacks are not guaranteed to run on the main queue, so allLocations.append(...) can race across routes. Consider collecting results on a serial queue (or dispatching the append to .main) and also sorting the final locations by timestamp before resolving so the returned polyline order is deterministic.

Copilot uses AI. Check for mistakes.
"lat": location.coordinate.latitude,
"lng": location.coordinate.longitude,
]
if location.altitude >= 0 || location.verticalAccuracy >= 0 {

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Altitude inclusion check if location.altitude >= 0 || location.verticalAccuracy >= 0 will include altitude values even when verticalAccuracy is negative (invalid), as long as altitude happens to be non-negative. This can store invalid altitude samples. Prefer gating altitude on verticalAccuracy >= 0 (and allow negative altitude when accuracy is valid).

Suggested change
if location.altitude >= 0 || location.verticalAccuracy >= 0 {
if location.verticalAccuracy >= 0 {

Copilot uses AI. Check for mistakes.
Comment thread packages/mobile/lib/health-kit-sync.ts Outdated
Comment on lines +178 to +188
const routes: WorkoutRoutePayload[] = [];
for (const workout of workouts) {
const locations = await healthKit.queryWorkoutRoutes(workout.uuid);
if (locations.length > 0) {
routes.push({
workoutUuid: workout.uuid,
sourceName: workout.sourceName,
locations,
});
}
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncHealthKitToServer queries routes sequentially (await inside a for loop). With many workouts this can significantly extend sync time and delay subsequent steps. Consider parallelizing with a bounded concurrency (e.g., Promise.allSettled with a small pool) and/or short-circuiting after some max number of workouts/points to avoid very large route payloads.

Suggested change
const routes: WorkoutRoutePayload[] = [];
for (const workout of workouts) {
const locations = await healthKit.queryWorkoutRoutes(workout.uuid);
if (locations.length > 0) {
routes.push({
workoutUuid: workout.uuid,
sourceName: workout.sourceName,
locations,
});
}
}
const routeQueryConcurrency = Math.min(4, workouts.length);
const routeGroups = await Promise.all(
Array.from({ length: routeQueryConcurrency }, async (_, workerIndex) => {
const workerRoutes: WorkoutRoutePayload[] = [];
for (let workoutIndex = workerIndex; workoutIndex < workouts.length; workoutIndex += routeQueryConcurrency) {
const workout = workouts[workoutIndex];
const locations = await healthKit.queryWorkoutRoutes(workout.uuid);
if (locations.length > 0) {
workerRoutes.push({
workoutUuid: workout.uuid,
sourceName: workout.sourceName,
locations,
});
}
}
return workerRoutes;
}),
);
const routes = routeGroups.flat();

Copilot uses AI. Check for mistakes.
Comment thread packages/mobile/lib/health-kit-sync.ts Outdated
Comment on lines +180 to +193
const locations = await healthKit.queryWorkoutRoutes(workout.uuid);
if (locations.length > 0) {
routes.push({
workoutUuid: workout.uuid,
sourceName: workout.sourceName,
locations,
});
}
}

if (routes.length > 0) {
onProgress?.(`Pushing ${routes.length} workout routes...`);
const routeResult = await trpcClient.healthKitSync.pushWorkoutRoutes.mutate({ routes });
totalInserted += routeResult.inserted;

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryWorkoutRoutes failures will currently throw and abort the entire sync (there’s no try/catch around the per-workout route query loop, and route errors aren’t appended to the errors array like quantity push errors are). Consider handling route query/push errors as non-fatal (record in errors and continue) so a route permission/query issue doesn’t prevent workout/sleep syncing.

Suggested change
const locations = await healthKit.queryWorkoutRoutes(workout.uuid);
if (locations.length > 0) {
routes.push({
workoutUuid: workout.uuid,
sourceName: workout.sourceName,
locations,
});
}
}
if (routes.length > 0) {
onProgress?.(`Pushing ${routes.length} workout routes...`);
const routeResult = await trpcClient.healthKitSync.pushWorkoutRoutes.mutate({ routes });
totalInserted += routeResult.inserted;
try {
const locations = await healthKit.queryWorkoutRoutes(workout.uuid);
if (locations.length > 0) {
routes.push({
workoutUuid: workout.uuid,
sourceName: workout.sourceName,
locations,
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`Failed to query workout routes for workout ${workout.uuid}: ${message}`);
}
}
if (routes.length > 0) {
onProgress?.(`Pushing ${routes.length} workout routes...`);
try {
const routeResult = await trpcClient.healthKitSync.pushWorkoutRoutes.mutate({ routes });
totalInserted += routeResult.inserted;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`Failed to push workout routes: ${message}`);
}

Copilot uses AI. Check for mistakes.
Comment on lines +1440 to +1449
// Find the gps_accuracy insert and verify the value is rounded
const gpsAccuracyCall = execute.mock.calls.find((call: unknown[]) => {
const serialized = JSON.stringify(call[0]);
return (
serialized.includes("INSERT INTO fitness.sensor_sample") &&
serialized.includes("gps_accuracy")
);
});
expect(gpsAccuracyCall).toBeDefined();
});

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "rounds gps_accuracy to integer" test only asserts that an insert call mentioning gps_accuracy exists, but it doesn't actually verify that the rounded scalar value is what gets inserted. Strengthen this test to assert the inserted value (e.g., by inspecting the SQL parameters / bindings) so regressions in rounding behavior are caught.

Copilot uses AI. Check for mistakes.
The testReadTypesTotalCount assertion expected 57 types but adding
HKSeriesType.workoutRoute() bumped it to 58. Also adds an explicit
test for the workout route type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Preview Environment

URL https://pr-764.preview.dofek.fit
Login https://pr-764.preview.dofek.fit/auth/dev-login
Server 116.203.208.103
Commit a5e4403

Use the login URL above to mint the seeded dev-session cookie for the preview database.
(Email+password login for previews is on the roadmap)

Coros workouts include a fitUrl field pointing to a pre-signed FIT file.
Download and parse it using the existing FIT parser to get GPS (lat/lng),
heart rate, power, cadence, altitude, and all other channels — matching
what Wahoo already does.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Preview Environment

URL https://pr-764.preview.dofek.fit
Login https://pr-764.preview.dofek.fit/auth/dev-login
Server 116.203.208.103
Commit dc54875

Use the login URL above to mint the seeded dev-session cookie for the preview database.
(Email+password login for previews is on the roadmap)

Asherlc and others added 2 commits April 6, 2026 13:48
Write a reusable TCX parser (src/tcx/parser.ts) that extracts GPS
coordinates, altitude, heart rate, cadence, speed, and power from
Training Center XML files.

Integrate with Fitbit: when an activity has a tcxLink, download and
parse the TCX file to store sensor_sample rows with GPS + metrics.
Previously Fitbit only stored activity summaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Polar: download TCX for exercises with has_route=true, parse with the
shared TCX parser for GPS + HR + cadence data.

Suunto: download FIT files via the /v2/workout/exportFit/{key} endpoint,
parse with the existing FIT parser for full time-series including GPS.

Both previously only stored activity summaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Preview Environment

URL https://pr-764.preview.dofek.fit
Login https://pr-764.preview.dofek.fit/auth/dev-login
Server 116.203.208.103
Commit e18075a

Use the login URL above to mint the seeded dev-session cookie for the preview database.
(Email+password login for previews is on the roadmap)

- Move fitRecordsToMetricStream to src/fit/records.ts to fix import
  boundary violation (Coros and Suunto were importing from wahoo/parsers)
- Re-export from wahoo/parsers.ts for backward compatibility
- Add "trackpoint"/"trackpoints" to cspell dictionary
- Fix coros-extra.test.ts mock to include returning() and delete().where()
  chains needed by the FIT file download path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Preview Environment

URL https://pr-764.preview.dofek.fit
Login https://pr-764.preview.dofek.fit/auth/dev-login
Server 116.203.208.103
Commit ee758ed

Use the login URL above to mint the seeded dev-session cookie for the preview database.
(Email+password login for previews is on the roadmap)

Asherlc and others added 6 commits April 6, 2026 14:43
- Add MSW handler for Polar TCX export endpoint in integration test
  (exercises with has_route=true now trigger TCX download)
- Add SAX callback names and TCX element names to cspell dictionary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server (processWorkoutRoutes):
- Batch INSERT: accumulate VALUES and flush per BATCH_SIZE instead of
  one INSERT per channel per location (was O(n*5) round-trips)
- Bulk activity lookup: resolve all workoutUuid→activityId in one
  IN query instead of N+1 SELECTs

Swift (queryWorkoutRoutes):
- Fix DispatchGroup imbalance: always call group.leave() on done=true
  regardless of error state, preventing promise hangs
- Thread safety: collect route locations on a serial queue
- Sort final locations by timestamp for deterministic polyline order
- Gate altitude on verticalAccuracy >= 0 only (not altitude >= 0)

Mobile sync:
- Parallelize route queries with bounded concurrency (4 workers)
- Wrap route query/push in try/catch so failures are non-fatal errors
  instead of aborting the entire sync

Tests:
- Add tests for route query and push error handling
- Strengthen gps_accuracy rounding test to verify actual value
- Update mock shapes for batched insert + bulk lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Suunto sync now tries to download FIT files for each workout.
Add an MSW handler that returns 404 (gracefully skipped by the
provider) to prevent unhandled request errors in tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous preview server never became reachable (cloud-init completed
but HTTPS health check timed out). Re-run reused the stale server.
Empty commit forces Terraform to recreate with fresh user_data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The preview server was destroyed and recreated on every push because
commit_sha was embedded in user_data, changing it each time. This nuked
Docker volumes (including Caddy's ACME certs), hitting Let's Encrypt
rate limits after 5 deploys per week.

Now follows the production deploy-config pattern:
- user_data is stable (commit_sha hardcoded to "initial") so the server
  is only created once per PR
- A null_resource with remote-exec SSHes in on each push to pull the
  latest image and restart containers
- Docker volumes (and ACME certs) persist across deploys
- SSH setup added to the preview workflow for the provisioner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The null_resource only pulled images but didn't update the compose or
Caddyfile on disk. If those files change in the PR, the server wouldn't
pick them up. Now uploads both via file provisioner before restarting,
matching how prod's deploy-config uploads config before running deploy.sh.

Also triggers on compose/caddy content hash changes, not just commit_sha.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Asherlc
Asherlc enabled auto-merge (squash) April 6, 2026 23:21
@Asherlc
Asherlc disabled auto-merge April 6, 2026 23:40
Split the 1136-line monolith into focused modules following the
established Wahoo provider pattern:

- client.ts (~160 lines): Zod schemas + FitbitClient API client
- parsers.ts (~140 lines): Pure parse functions + activity type mapping
- persisters.ts (~160 lines): DB upsert functions (activity, sleep,
  daily metrics, body measurements) — eliminates duplication between
  sync() and syncWebhookEvent()
- provider.ts (~350 lines): FitbitProvider orchestration, OAuth, webhooks
- index.ts: Barrel re-exports for backward compatibility

This speeds up Stryker mutation testing by letting it target smaller
files with proportionally fewer tests per mutant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Mobile Preview

Channel pr-764
Deep Link dofek://preview/pr-764
Commit 3cc65fb

To test on device:

  1. Build and install the preview client: PREVIEW_CHANNEL=pr-764 npx expo prebuild --clean -p ios
  2. Or tap deep link on an existing preview build: dofek://preview/pr-764

Each PR gets its own channel. Build a preview client with PREVIEW_CHANNEL=pr-{N} to test.

The workspace export for dofek/providers/fitbit still pointed to the
deleted fitbit.ts. Update to fitbit/index.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Mobile Preview

Channel pr-764
Deep Link dofek://preview/pr-764
Commit 6c0f93f

To test on device:

  1. Build and install the preview client: PREVIEW_CHANNEL=pr-764 npx expo prebuild --clean -p ios
  2. Or tap deep link on an existing preview build: dofek://preview/pr-764

Each PR gets its own channel. Build a preview client with PREVIEW_CHANNEL=pr-{N} to test.

@Asherlc
Asherlc enabled auto-merge (squash) April 7, 2026 00:14
Delete index.ts barrel and have all consumers import directly from
client.ts, parsers.ts, or provider.ts. This makes the dependency
graph explicit and matches how Stryker traces mutations to tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Mobile Preview

Channel pr-764
Deep Link dofek://preview/pr-764
Commit 1ef1ba1

To test on device:

  1. Build and install the preview client: PREVIEW_CHANNEL=pr-764 npx expo prebuild --clean -p ios
  2. Or tap deep link on an existing preview build: dofek://preview/pr-764

Each PR gets its own channel. Build a preview client with PREVIEW_CHANNEL=pr-{N} to test.

@Asherlc
Asherlc merged commit ef5bf06 into main Apr 7, 2026
50 of 51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants