diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index 28ec45e71..df5de3049 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -955,9 +955,13 @@ export function mapSleepSession( * Google Health `Exercise.exerciseType` → HealthLog `WorkoutSportType`. The * enum arrives UPPERCASE (`RUNNING`, `STRENGTH_TRAINING`, …); the resolver * lowercases + underscores before the lookup, so the keys here are the - * normalised forms. Unknown types fall through to a generic label; the column - * is free-text so an unmapped type still persists (just not under a canonical - * sport bucket). + * normalised forms. An unmapped type resolves to the literal `"other"`: the + * upstream label does NOT survive in `sportType`, and the column read back + * cannot tell a genuine `OTHER_WORKOUT` from a sport this map has never heard + * of. What preserves it is `GoogleHealthMappedWorkout.sportTypeRaw`, which the + * ingest writes verbatim to `Workout.metadata.googleExerciseType` + * (`sync-workout.ts`) — so a session that shows as "other" can still be traced + * back to what Google sent, without re-fetching it. */ const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { walk: "walking", @@ -1055,6 +1059,12 @@ export function mapGoogleHealthSportType(raw: unknown): string { export interface GoogleHealthMappedWorkout { externalId: string; sportType: string; + /** + * The upstream `exercise.exerciseType` verbatim (trimmed), or null when the + * session carried none. Provenance only — never the canonical `sportType`, + * which `mapGoogleHealthSportType()` owns. + */ + sportTypeRaw: string | null; startedAt: Date; endedAt: Date; durationSec: number; @@ -1113,6 +1123,9 @@ function readInstant( * the start instant is the fallback. metricsSummary carries no max/min HR → * null. * + * `exerciseType` leaves here twice: once mapped onto a canonical sport bucket, + * once verbatim as `sportTypeRaw` so the ingest can keep the provenance. + * * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors * them to the correct UTC instant rather than the process zone. */ @@ -1136,6 +1149,10 @@ export function mapWorkout( : `exercise:${startedAt.toISOString()}`; const sportRaw = readPath(point, `${k}.exerciseType`); + const sportTypeRaw = + typeof sportRaw === "string" && sportRaw.trim() !== "" + ? sportRaw.trim() + : null; const energyKcal = readNumber(point, [`${k}.metricsSummary.caloriesKcal`]); const distanceMm = readNumber(point, [ @@ -1148,6 +1165,7 @@ export function mapWorkout( return { externalId, sportType: mapGoogleHealthSportType(sportRaw), + sportTypeRaw, startedAt, endedAt, durationSec, diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 1e8615101..48e46beb3 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -208,6 +208,18 @@ same run via Apple Health / WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker collapses the cross-source twin per the user's source ladder. +**Sport provenance.** The `other` fallback is lossy: `sportType` alone cannot +say whether Google reported `OTHER_WORKOUT` or a sport the map does not know. +So the ingest also writes the label verbatim to +`Workout.metadata.googleExerciseType` (`GoogleHealthMappedWorkout.sportTypeRaw` +→ `sync-workout.ts`), matching Polar's `polarSport`, Strava's `stravaType` and +WHOOP's `whoopSportName`. Provenance only — nothing reads it back as the +canonical sport. The key is omitted, never nulled, when a session carries no +type, so a re-sync cannot clear what an earlier run recorded. Rows written +before this existed carry no key; a full sync (`{fullSync:true}`) re-fetches the +session under the same externalId and fills it in, since insert and update share +one payload. + ## Idempotency `(userId, type, source: "GOOGLE_HEALTH", externalId)` unique. Spot/daily metric diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index c46b51616..80e1c252a 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -4,6 +4,9 @@ * Reads exercise sessions from the `activity_and_fitness.readonly` Restricted * bundle and upserts each into the `Workout` table as `source = GOOGLE_HEALTH`, * keyed on `(userId, source, externalId)` so a re-fetch overwrites in place. + * Insert and update share one payload object, so a full sync repairs rows an + * older build wrote — including the raw activity type, which earlier releases + * discarded. * * A Google Health run and the same run via Apple Health (or WHOOP) stay distinct * `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows` @@ -76,6 +79,16 @@ export async function syncUserWorkout( avgHeartRate: w.avgHeartRate, maxHeartRate: w.maxHeartRate, minHeartRate: w.minHeartRate, + // Raw pre-mapping label, kept for provenance once + // `mapGoogleHealthSportType()` has resolved `sportType` to a canonical + // bucket — every unmapped `exerciseType` collapses to `"other"`, so + // without this the activity Google reported is gone after ingest. Never + // read back as the canonical `sportType`. Absent (rather than null) when + // the session carried no type, so a re-sync never clears metadata a + // previous run wrote. + ...(w.sportTypeRaw + ? { metadata: { googleExerciseType: w.sportTypeRaw } } + : {}), }; try { const [inserted] = await prisma.workout.createManyAndReturn({