mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live service sends a samplePk and grams. A weigh-in now takes its id from samplePk or else its own GMT instant, reads each mass by magnitude (above 1000 is grams), and is written whole or not at all: a missing weight, a missing instant or one implausible value drops every row of it, counted on the wide event by reason. Refused resources: a lasting 4xx on one resource (a cycle summary for an account that tracks no cycle) is noted once on the progress column, counts as done for the backfill, is skipped by the hourly sync and is listed in the card's details. It no longer holds the backfill open or turns the connection red. The cycle leg never parks the connection, whatever it is refused with. Backfill: weigh-ins walk back to the start of Garmin Connect on their own, so history before the first watch day and a scale-only account arrive. Progress and the completion stamp are written only while the connection the slice started on is still the stored one. Key: connect sends the key to the one service the person picks (Garmin Connect or Garmin Connect China), never to both. A parked key is asked about again once a day with a single token exchange and reconnected when Garmin accepts it. The key's end is read from the token's own iat and exp, an estimate the key outlived is cleared after an accepted exchange, and the validity can be corrected from the card's menu (PATCH /api/garmin/key-expiry, with the day the key was created) without pasting it again. Sync: an incremental run reaches back to the last successful sync, up to a year, so an outage longer than the 30-day window leaves no gap; a night Garmin no longer reports (deleted, or turned into a nap) is retired. Card: connect errors are translated from meta.errorCode in every locale. Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and ACUTE_TRAINING_LOAD get source ladders of their own (the device or the bridge first, then the hand-entered sources in their usual order), join the ranked types and the provenance picker, and the recovery-empty description names them.
222 lines
8.9 KiB
TypeScript
222 lines
8.9 KiB
TypeScript
/**
|
|
* v1.11.1 — source-aware SQL collapse helpers.
|
|
*
|
|
* The rollup writer mints one row per (type, day, source); the live
|
|
* measurement reads see one raw row per reading. For overlapping standard
|
|
* vitals (e.g. WHOOP + Apple Watch resting heart rate) both the rollup
|
|
* all-time aggregate and the live 90-day aggregates must resolve to the
|
|
* source-priority ladder's canonical source per (type, day) before composing,
|
|
* or a dual-source user double-counts / blends two devices.
|
|
*
|
|
* `collapseRollupRowsBySource` does this in application code for the row-by-row
|
|
* readers. These helpers do the equivalent IN SQL so the slim slice + the
|
|
* comprehensive aggregator + the live `/api/measurements` fallback keep their
|
|
* single-round-trip `GROUP BY type` shape (no six-figure row transfer).
|
|
*
|
|
* Every spliced literal is a closed-enum value (`MeasurementType` /
|
|
* `MeasurementSource`) asserted against `/^[A-Z0-9_]+$/`, the same whitelist
|
|
* convention the rollup writer uses for its date-trunc + type-list splices.
|
|
* The user id is always passed as a bound parameter, never spliced.
|
|
*/
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import { assertInstantSql } from "@/lib/analytics/request-clock-sql";
|
|
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
|
import {
|
|
getSourceLadder,
|
|
parseSourcePriority,
|
|
} from "@/lib/validations/source-priority";
|
|
|
|
/**
|
|
* Measurement types that carry a source-priority ladder — the overlapping
|
|
* vitals plus the cumulative metrics. Single-source types are intentionally
|
|
* absent: they only ever have one source per (type, day), so the collapse is
|
|
* a no-op for them and they fall into the CASE's ELSE branch.
|
|
*/
|
|
export const RANKED_TYPES: readonly MeasurementType[] = [
|
|
"RESTING_HEART_RATE",
|
|
"HEART_RATE_VARIABILITY",
|
|
// v1.42 (#1110) — the `hrvRmssd` ladder.
|
|
"HRV_RMSSD",
|
|
"RESPIRATORY_RATE",
|
|
"OXYGEN_SATURATION",
|
|
"BODY_TEMPERATURE",
|
|
"SKIN_TEMPERATURE",
|
|
"WEIGHT",
|
|
"BODY_FAT",
|
|
"BLOOD_PRESSURE_SYS",
|
|
"BLOOD_PRESSURE_DIA",
|
|
"PULSE",
|
|
"VO2_MAX",
|
|
"RECOVERY_SCORE",
|
|
// v1.18.10 — STRESS_SCORE carries a source-priority ladder (`stress`)
|
|
// so the SQL collapse ranks a future device-native producer above the
|
|
// COMPUTED proxy deterministically. No-op until a second producer lands.
|
|
"STRESS_SCORE",
|
|
// Garmin's day scores and the bridged training figures.
|
|
"BODY_BATTERY",
|
|
"STRESS_LEVEL",
|
|
"TRAINING_READINESS",
|
|
"ACUTE_TRAINING_LOAD",
|
|
"SLEEP_DURATION",
|
|
"ACTIVITY_STEPS",
|
|
"ACTIVE_ENERGY_BURNED",
|
|
"WALKING_RUNNING_DISTANCE",
|
|
"FLIGHTS_CLIMBED",
|
|
];
|
|
|
|
// Enum members can carry digits (e.g. VO2_MAX, BLOOD_PRESSURE_SYS) — allow
|
|
// 0-9 so the whitelist doesn't reject a legitimate closed-enum value.
|
|
const ENUM_RE = /^[A-Z0-9_]+$/;
|
|
function assertEnumLiteral(value: string): string {
|
|
if (!ENUM_RE.test(value)) {
|
|
throw new Error(`unsafe enum literal for SQL splice: ${value}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Build a SQL CASE expression mapping `(typeCol, sourceCol)` to an integer
|
|
* rank where 0 = the highest-priority source on that type's ladder. A source
|
|
* absent from a type's ladder — and every row of a type without a ladder —
|
|
* gets rank 90 so a deterministic tiebreak (`source` name, or `count DESC`
|
|
* where available) decides. Resolves the user's `sourcePriorityJson` ladders;
|
|
* `null` yields the default ladders.
|
|
*/
|
|
export function buildSourceRankCase(
|
|
priorityJson: unknown,
|
|
typeCol: string,
|
|
sourceCol: string,
|
|
): string {
|
|
const resolved = parseSourcePriority(priorityJson);
|
|
const branches: string[] = [];
|
|
for (const type of RANKED_TYPES) {
|
|
const metricKey = metricKeyForType(type);
|
|
if (!metricKey) continue;
|
|
const ladder = getSourceLadder(resolved, metricKey);
|
|
if (ladder.length === 0) continue;
|
|
const whens = ladder
|
|
.map((source, i) => `WHEN '${assertEnumLiteral(source)}' THEN ${i}`)
|
|
.join(" ");
|
|
branches.push(
|
|
`WHEN ${typeCol} = '${assertEnumLiteral(type)}' THEN ` +
|
|
`(CASE ${sourceCol} ${whens} ELSE 90 END)`,
|
|
);
|
|
}
|
|
if (branches.length === 0) return "90";
|
|
return `CASE ${branches.join(" ")} ELSE 90 END`;
|
|
}
|
|
|
|
const INTERVAL_RE = /^\d+ (day|days|month|months|year|years)$/;
|
|
function assertInterval(value: string): string {
|
|
if (!INTERVAL_RE.test(value)) {
|
|
throw new Error(`unsafe interval literal for SQL splice: ${value}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* A trailing window: the readings from the instant `from` on, or from
|
|
* `interval` before it when one is given.
|
|
*/
|
|
export interface CanonicalWindow {
|
|
from: string;
|
|
interval?: string;
|
|
}
|
|
|
|
/**
|
|
* Build the canonical-source SELECT body — the raw `measurements` rows
|
|
* restricted to the ladder-winning source per (type, day) — WITHOUT the
|
|
* outer `( … ) m` alias wrapper. One scan reads the window's rows; a window
|
|
* function names each (type, day)'s canonical source on every row, and the
|
|
* rows of any other source are dropped.
|
|
*
|
|
* Use this when the same canonical set must be referenced more than once in
|
|
* one statement (e.g. a `window_stats` CTE plus the outer aggregate): bind it
|
|
* once via `WITH cm AS (${canonicalMeasurementsCte(...)})` and select
|
|
* `FROM cm m` everywhere, so the pick runs once rather than per reference.
|
|
* For the single-reference case use `canonicalMeasurementsFrom`, which wraps
|
|
* this body in the alias.
|
|
*
|
|
* The rows carry every `measurements` column plus `canonical_source`, the
|
|
* picked source (equal to `source` on every row that survives). Readers
|
|
* select the columns they need, so the extra one never reaches them.
|
|
*
|
|
* The user id is bound as `$1`. `rankUnqualified` must be a CASE built with
|
|
* unqualified `"type"`/`"source"` columns. `window`, when given, scopes the
|
|
* scan to the readings from `from` on, or from `interval` (e.g. `"90 days"`,
|
|
* whitelisted) before it. `from` is an instant the caller took
|
|
* (`boundInstantSql` or `instantLiteralSql`, see `request-clock-sql.ts`), the
|
|
* request's `now` for a trailing window, so the cut runs on the caller's
|
|
* clock and never on the database's. `onlyType`, when given, scopes the scan
|
|
* to that one metric.
|
|
*
|
|
* The pick is the first source of the day in ladder order (rank, then source
|
|
* name), the same order the `DISTINCT ON` pick it replaced used, over the
|
|
* same rows, so every (type, day) keeps the same source;
|
|
* `tests/integration/canonical-source-pick-parity.test.ts` holds the
|
|
* `DISTINCT ON` statement as its reference.
|
|
*
|
|
* Why no join: the earlier shape grouped the window to `(type, day, source)`,
|
|
* picked per day with `DISTINCT ON`, and joined the picks back to the rows.
|
|
* Whether that join ran as a hash join or as a nested loop that rescanned the
|
|
* pick for every row was the planner's call, made from table statistics. On
|
|
* a table without them (a new install, or an import still under the
|
|
* autoanalyze threshold) it estimated one row and chose the nested loop: on a
|
|
* 6,000-row fixture the all-time read took seconds, on a ten-year account it
|
|
* ran past the statement timeout. A window over one scan has no such choice
|
|
* to get wrong; `canonical-source-pick-unanalysed.test.ts` holds that.
|
|
*/
|
|
export function canonicalMeasurementsCte(
|
|
rankUnqualified: string,
|
|
window?: CanonicalWindow,
|
|
onlyType?: MeasurementType,
|
|
): string {
|
|
const bound = !window
|
|
? null
|
|
: window.interval === undefined
|
|
? assertInstantSql(window.from)
|
|
: `${assertInstantSql(window.from)} - INTERVAL '${assertInterval(window.interval)}'`;
|
|
const since = bound ? `AND mm."measured_at" >= ${bound}` : "";
|
|
// A single-type reader (a series for one metric) scopes the scan to that
|
|
// type, so the pick reads one metric's rows instead of every row the user
|
|
// has in the window.
|
|
const typeOnly = onlyType
|
|
? `AND mm."type" = '${assertEnumLiteral(onlyType)}'::"measurement_type"`
|
|
: "";
|
|
return `
|
|
SELECT w.*
|
|
FROM (
|
|
SELECT mm.*,
|
|
FIRST_VALUE(mm."source") OVER (
|
|
PARTITION BY mm."type", date_trunc('day', mm."measured_at")
|
|
ORDER BY (${rankUnqualified}), mm."source"::text
|
|
) AS canonical_source
|
|
FROM measurements mm
|
|
WHERE mm."user_id" = $1
|
|
AND mm."deleted_at" IS NULL
|
|
${since}
|
|
${typeOnly}
|
|
) w
|
|
WHERE w."source" = w.canonical_source`;
|
|
}
|
|
|
|
/**
|
|
* Build a FROM-clause subquery (aliased `m`) that restricts raw `measurements`
|
|
* to the canonical-source rows per (type, day): the ladder-winning source for
|
|
* each day, and only that source's readings. Use it in place of
|
|
* `FROM measurements m` so a live aggregate over an overlapping vital never
|
|
* blends two devices — the same collapse the rollup readers apply, kept in
|
|
* lockstep for live/rollup parity.
|
|
*
|
|
* The user id is bound as `$1`; `rankUnqualified`, `window` and `onlyType` are
|
|
* those of `canonicalMeasurementsCte`.
|
|
*/
|
|
export function canonicalMeasurementsFrom(
|
|
rankUnqualified: string,
|
|
window?: CanonicalWindow,
|
|
onlyType?: MeasurementType,
|
|
): string {
|
|
return `(${canonicalMeasurementsCte(rankUnqualified, window, onlyType)}
|
|
) m`;
|
|
}
|