mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
60c30e1e3a
commit
3db4c33f49
3 files changed
+46
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user