mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-I
This commit is contained in:
commit
9c2b57df33
10 files changed
+566
-57
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user