Merge remote-tracking branch 'origin/main' into wip/v140-I

This commit is contained in:
Marc-André Bombeck committed 2026-10-03 22:00:18 +02:00
commit 9c2b57df33
10 files changed
+566 -57

No files matched your search

+17 -3
View File
@@ -123,11 +123,13 @@ function assertInterval(value: string): string {
* The user id is bound as `$1`. `rankUnqualified` must be a CASE built with
* unqualified `"type"`/`"source"` columns. `sinceInterval` (e.g. `"90 days"`)
* is whitelisted and, when given, scopes both the inner pick and the outer
* filter to a trailing window.
* filter to a trailing window. `onlyType`, when given, scopes both halves to
* that one metric.
*/
export function canonicalMeasurementsCte(
rankUnqualified: string,
sinceInterval?: string,
onlyType?: MeasurementType,
): string {
const sinceInner = sinceInterval
? `AND "measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'`
@@ -135,6 +137,15 @@ export function canonicalMeasurementsCte(
const sinceOuter = sinceInterval
? `AND mm."measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'`
: "";
// A single-type reader (a series for one metric) scopes both halves to that
// type, so the pick scans one metric's rows instead of every row the user
// has in the window.
const typeInner = onlyType
? `AND "type" = '${assertEnumLiteral(onlyType)}'::"measurement_type"`
: "";
const typeOuter = onlyType
? `AND mm."type" = '${assertEnumLiteral(onlyType)}'::"measurement_type"`
: "";
return `
SELECT mm.*
FROM measurements mm
@@ -147,6 +158,7 @@ export function canonicalMeasurementsCte(
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
) c
ON c.t = mm."type"
@@ -154,7 +166,8 @@ export function canonicalMeasurementsCte(
AND c.canon = mm."source"
WHERE mm."user_id" = $1
AND mm."deleted_at" IS NULL
${sinceOuter}`;
${sinceOuter}
${typeOuter}`;
}
/**
@@ -173,7 +186,8 @@ export function canonicalMeasurementsCte(
export function canonicalMeasurementsFrom(
rankUnqualified: string,
sinceInterval?: string,
onlyType?: MeasurementType,
): string {
return `(${canonicalMeasurementsCte(rankUnqualified, sinceInterval)}
return `(${canonicalMeasurementsCte(rankUnqualified, sinceInterval, onlyType)}
) m`;
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { canonicalMeasurementsCte } from "@/lib/analytics/source-rank-sql";
import { seriesRowsFrom } from "../series-canonical";
describe("seriesRowsFrom", () => {
it("collapses a type that has a ladder to the ladder's source per day", () => {
const sql = seriesRowsFrom(null, "PULSE", 30);
expect(sql).toContain("DISTINCT ON");
// Scoped to the one metric, in both halves of the helper.
expect(sql).toContain(`"type" = 'PULSE'::"measurement_type"`);
expect(sql).toContain(`mm."type" = 'PULSE'::"measurement_type"`);
expect(sql.trimEnd().endsWith(") m")).toBe(true);
});
it("reads one day wider than the request, so the edge day is picked from all its readings", () => {
expect(seriesRowsFrom(null, "WEIGHT", 30)).toContain("INTERVAL '31 days'");
expect(seriesRowsFrom(null, "WEIGHT", 3650)).toContain(
"INTERVAL '3651 days'",
);
});
it("applies the person's ladder, not only the default one", () => {
const first = seriesRowsFrom(
{ pulse: ["FITBIT", "APPLE_HEALTH"] },
"PULSE",
7,
);
const second = seriesRowsFrom(
{ pulse: ["APPLE_HEALTH", "FITBIT"] },
"PULSE",
7,
);
expect(first).not.toEqual(second);
expect(first).toMatch(/WHEN 'FITBIT' THEN 0 WHEN 'APPLE_HEALTH' THEN 1/);
expect(second).toMatch(/WHEN 'APPLE_HEALTH' THEN 0 WHEN 'FITBIT' THEN 1/);
});
it.each(["BLOOD_GLUCOSE", "TOTAL_BODY_WATER", "BONE_MASS"] as const)(
"keeps every source for %s, which has no ladder",
(type) => {
const sql = seriesRowsFrom(null, type, 30);
expect(sql).not.toContain("DISTINCT ON");
expect(sql).toContain(`mm."type" = '${type}'::"measurement_type"`);
expect(sql).toContain(`mm."user_id" = $1`);
expect(sql).toContain(`mm."deleted_at" IS NULL`);
},
);
it.each([0, -1, 1.5, 3651, Number.NaN])("refuses days = %s", (days) => {
expect(() => seriesRowsFrom(null, "PULSE", days)).toThrow(/days/);
});
});
describe("canonicalMeasurementsCte, one-metric scope", () => {
const rank = `CASE "source" WHEN 'A' THEN 0 ELSE 90 END`;
it("is unchanged when no type is given", () => {
const sql = canonicalMeasurementsCte(rank, "90 days");
expect(sql).not.toContain("measurement_type");
});
it("scopes the pick and the join to the type when one is given", () => {
const sql = canonicalMeasurementsCte(rank, "90 days", "PULSE");
expect(sql.match(/'PULSE'::"measurement_type"/g)).toHaveLength(2);
});
it("refuses anything that is not a closed-enum literal", () => {
expect(() =>
canonicalMeasurementsCte(rank, undefined, "PULSE'; DROP" as never),
).toThrow(/unsafe enum literal/);
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* The rows a metric series reads, restricted to the source the user's priority
* ladder picks for each day.
*
* The rollup tier and sleep already resolve every (type, day) to the
* ladder-winning source before they aggregate. The dense and raw reads of
* `/api/measurements/series` filtered on user and type only, so with a second
* provider a day's value blended the two and the configured ladder had no
* effect on those charts. This is the one place that applies the same rule to
* them, through the same SQL helper the other live readers use.
*
* Only a type that HAS a ladder is collapsed (`RANKED_TYPES`). For a type
* without one (glucose, body water, bone mass) the helper would break the tie
* by source name, which silently hides one of two real sources, so those keep
* every source exactly as before.
*
* Returns a FROM-clause subquery aliased `m`; the user id is bound as `$1`.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
RANKED_TYPES,
buildSourceRankCase,
canonicalMeasurementsFrom,
} from "@/lib/analytics/source-rank-sql";
const ENUM_RE = /^[A-Z0-9_]+$/;
export function seriesRowsFrom(
priorityJson: unknown,
type: MeasurementType,
days: number,
): string {
if (!ENUM_RE.test(type)) {
throw new Error(`unsafe enum literal for SQL splice: ${type}`);
}
if (!Number.isInteger(days) || days < 1 || days > 3650) {
throw new Error(`days out of range for SQL splice: ${days}`);
}
if (!RANKED_TYPES.includes(type)) {
return `(
SELECT mm.*
FROM measurements mm
WHERE mm."user_id" = $1
AND mm."type" = '${type}'::"measurement_type"
AND mm."deleted_at" IS NULL
) m`;
}
// One day wider than the request: the caller trims to its exact `since`, so
// the edge day's source is picked from all of its readings, not from the
// slice the interval happens to include.
return canonicalMeasurementsFrom(
buildSourceRankCase(priorityJson, '"type"', '"source"'),
`${days + 1} days`,
type,
);
}
+1 -1
View File
@@ -840,7 +840,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Per-kind time series (iOS chart source)",
description:
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.",
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Where a metric has a source-priority ladder (pulse, weight, SpO2, HRV, ...), each day shows the readings of the source the person's ladder picks for that day, so a second provider never blends into or duplicates a day; a metric without a ladder (glucose, body water, bone mass) keeps every source. Auth via cookie or Bearer.",
requestParams: {
query: seriesQuerySchema,
},