fix(google-health): keep the raw exercise type on the workout row

An unmapped Google `exerciseType` resolves to the literal "other", and
until now that was the end of the label: nothing else carried it, so a
workout Google typed as a sport the map does not know became
indistinguishable from a genuine `OTHER_WORKOUT`. Carry the trimmed
upstream value through `GoogleHealthMappedWorkout.sportTypeRaw` and write
it to `Workout.metadata.googleExerciseType`, the shape Polar, Strava and
WHOOP already use. Insert and update share one payload, so a full sync
repairs rows written by an older build.

The comment above the type map claimed an unmapped type "still persists"
because the column is free-text. It does not, and it did not when the
comment was written. Prose that asserts what the code does not do is the
same defect as the missing write, so correct it here rather than later.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 22:40:09 +02:00
1 parent 60c30e1e3a
commit 3db4c33f49
3 files changed
+46 -3

No files matched your search

+21 -3
View File
@@ -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<string, string> = {
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,
+12
View File
@@ -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
+13
View File
@@ -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({