mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(series): follow the source priority ladder in the dense and raw series reads
The rollup tier and sleep resolve every (type, day) to the source the person's ladder picks 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, a reading present in both showed twice, and the configured ladder had no effect on those charts. Both reads now take the ladder-winning source per day through the shared canonicalMeasurementsCte helper, as the other live readers do: - the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2), and the stats strip over its rows; - the generic raw read (weight, body fat, steps, resting heart rate, ...). The collapse is per day for the hour grain too, as everywhere else. The helper gains an optional single-type scope so a series scans one metric, not every row the person has in the window. Only a type that has a ladder is collapsed. For a type without one (glucose, body water, bone mass) the helper breaks the tie by source name, which would silently hide one of two real sources, so those keep every source as before. A single-source account reads exactly what is stored. Tests: a real-database test covering the ladder in both directions, the per-day fallback, a reading mirrored into two sources, the day-bucket, hour- bucket and raw reads, a type with no ladder and a single-source account. The route's unit test file reads its fixtures through a small adapter for the new raw statements, and the dense-read guard drops the exception for the removed findMany. The OpenAPI description states the rule. Refs #1108
This commit is contained in:
1 parent
02b6f06521
commit
bb1acfab64
10 files changed
+566
-57
No files matched your search
@@ -101,8 +101,6 @@ const ALLOWED: Record<string, string> = {
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
|
||||
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
|
||||
"app/api/measurements/series/route.ts::(shorthand)":
|
||||
"The raw branch of the per-kind series. Every dense kind reaches it only inside 90 days and under 10 000 rows (counted first); the sparse kinds are a few readings a day.",
|
||||
"app/api/measurements/batch/route.ts::p.row.type as MeasurementType":
|
||||
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
|
||||
"app/api/measurements/route.ts::(shorthand)":
|
||||
|
||||
@@ -9,6 +9,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.28.25 — dense kinds (glucose / pulse) day-bucket in SQL for
|
||||
// windows beyond the 90-day raw cap.
|
||||
$queryRaw: vi.fn(),
|
||||
// The series reads go through the ladder-winning source per day (see
|
||||
// `seriesRowsFrom`), which is raw SQL with the user id as `$1`.
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -65,8 +68,47 @@ function req(query: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements/series?${query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route reads its series through `$queryRawUnsafe` (the ladder-winning
|
||||
* source per day). This file's fixtures are written as rows per type and as the
|
||||
* two aggregate results, so the adapter routes each statement back to them:
|
||||
* the day/hour bucket and the stats aggregate take the next `$queryRaw` result,
|
||||
* and a raw read asks `findMany` for the type named in the SQL and returns the
|
||||
* rows in the column names the statement selects. The SQL itself (which source
|
||||
* wins, which rows count) is covered against a real database in
|
||||
* `tests/integration/series-source-ladder.test.ts`.
|
||||
*/
|
||||
function adaptSeriesSql(sql: string): Promise<unknown> {
|
||||
if (/GROUP BY local_day|STDDEV_POP/.test(sql)) {
|
||||
return (prisma.$queryRaw as unknown as () => Promise<unknown>)();
|
||||
}
|
||||
const type = /'([A-Z0-9_]+)'::"measurement_type"/.exec(sql)?.[1];
|
||||
const find = prisma.measurement.findMany as unknown as (
|
||||
a: unknown,
|
||||
) => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
}>
|
||||
>;
|
||||
return find({ where: { type } }).then((rows) =>
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
measured_at: r.measuredAt,
|
||||
value_min: r.valueMin ?? null,
|
||||
value_max: r.valueMax ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.$queryRawUnsafe).mockImplementation(((sql: string) =>
|
||||
adaptSeriesSql(sql)) as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
@@ -216,14 +258,14 @@ describe("GET /api/measurements/series", () => {
|
||||
expect(body.data.points[1].valueMin).toBeNull();
|
||||
expect(body.data.points[1].valueMax).toBeNull();
|
||||
|
||||
// The pulse read selects the spread columns.
|
||||
const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0];
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).select.valueMin,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).select.valueMax,
|
||||
).toBe(true);
|
||||
// The pulse read selects the spread columns, and reads the pulse rows of
|
||||
// this person's ladder-winning source (the user id is the first parameter).
|
||||
const [sql, userId] = vi.mocked(prisma.$queryRawUnsafe).mock
|
||||
.calls[0] as unknown as [string, string];
|
||||
expect(sql).toContain('m."value_min"');
|
||||
expect(sql).toContain('m."value_max"');
|
||||
expect(sql).toContain(`'PULSE'::"measurement_type"`);
|
||||
expect(userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
|
||||
@@ -627,8 +669,12 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
|
||||
expect(body.data.stats.count).toBe(43_200);
|
||||
expect(body.data.stats.mean).toBe(72.1);
|
||||
// The bucket read folds per hour, not per day.
|
||||
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(bucketSql.slice(1)).toContain("hour");
|
||||
const bucketCall = vi
|
||||
.mocked(prisma.$queryRawUnsafe)
|
||||
.mock.calls.find(([sql]) =>
|
||||
/GROUP BY local_day/.test(sql as string),
|
||||
) as unknown[];
|
||||
expect(bucketCall.slice(1)).toContain("hour");
|
||||
});
|
||||
|
||||
it("hour-buckets a CGM glucose window too dense to send raw, in the user's unit", async () => {
|
||||
@@ -690,8 +736,12 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
|
||||
max: 14.4,
|
||||
stdDev: 2,
|
||||
});
|
||||
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(bucketSql.slice(1)).toContain("hour");
|
||||
const bucketCall = vi
|
||||
.mocked(prisma.$queryRawUnsafe)
|
||||
.mock.calls.find(([sql]) =>
|
||||
/GROUP BY local_day/.test(sql as string),
|
||||
) as unknown[];
|
||||
expect(bucketCall.slice(1)).toContain("hour");
|
||||
});
|
||||
|
||||
it("keeps fingerstick glucose at or under the row cap raw", async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
@@ -399,49 +400,57 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
|
||||
// every raw row into JS: one aggregate pass in Postgres returns at
|
||||
// most `days` rows regardless of sample density. Parameter-bound
|
||||
// tagged-template `$queryRaw`, mirroring the rollup tier's
|
||||
// `date_trunc(... ) GROUP BY` aggregate (measurement-rollups.ts).
|
||||
const userTz = await resolveUserTimezone(user.id);
|
||||
// most `days` rows regardless of sample density. Bound parameters
|
||||
// mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate
|
||||
// (measurement-rollups.ts), and the rows are the ladder-winning source per
|
||||
// day, the same rule the rollup tier and sleep apply.
|
||||
const [userTz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const bucketRows = await prisma.$queryRaw<
|
||||
const seriesRows = seriesRowsFrom(priorityJson, type, days);
|
||||
const bucketRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}>
|
||||
>`
|
||||
>(
|
||||
`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."value",
|
||||
m."value_min",
|
||||
m."value_max",
|
||||
date_trunc(
|
||||
${grain},
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${userTz}
|
||||
$2,
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
|
||||
) AS local_day
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${since}
|
||||
AND m."deleted_at" IS NULL
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $4
|
||||
)
|
||||
SELECT
|
||||
local_day AT TIME ZONE ${userTz} AS bucket_start,
|
||||
local_day AT TIME ZONE $3 AS bucket_start,
|
||||
AVG("value")::double precision AS mean,
|
||||
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
|
||||
MAX(COALESCE("value_max", "value"))::double precision AS max_value
|
||||
FROM localized
|
||||
GROUP BY local_day
|
||||
ORDER BY bucket_start ASC
|
||||
`;
|
||||
`,
|
||||
user.id,
|
||||
grain,
|
||||
userTz,
|
||||
since,
|
||||
);
|
||||
// Stats stay aggregated over the RAW rows (not the day buckets) so the
|
||||
// strip's mean/min/max/stdDev/count are the same figures the raw path
|
||||
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
|
||||
// JS population-variance helper, exact row count. Aggregate-only —
|
||||
// no row transfer.
|
||||
const [rawAgg] = await prisma.$queryRaw<
|
||||
const [rawAgg] = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
@@ -449,19 +458,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>`
|
||||
>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${since}
|
||||
AND m."deleted_at" IS NULL
|
||||
`;
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $2
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) =>
|
||||
grain === "day"
|
||||
@@ -538,21 +548,37 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// hourly average. Only PULSE rows ever carry a non-null spread (the
|
||||
// hourly HR bucket); every other kind selects the bare value shape.
|
||||
const includeSpread = kind === "pulse";
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type,
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
...(includeSpread ? { valueMin: true, valueMax: true } : {}),
|
||||
},
|
||||
});
|
||||
// The ladder-winning source per day (see `seriesRowsFrom`), so a second
|
||||
// provider neither blends into nor duplicates the series. Bounded as before:
|
||||
// every dense kind reaches this read only inside 90 days and under 10 000
|
||||
// rows (counted first), and the sparse kinds are a few readings a day.
|
||||
const sourcePriority = await loadUserSourcePriority(user.id);
|
||||
const rawRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
value_min: number | null;
|
||||
value_max: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max"
|
||||
FROM ${seriesRowsFrom(sourcePriority, type, days)}
|
||||
WHERE m."measured_at" >= $2
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
const rows = rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
measuredAt: r.measured_at,
|
||||
...(includeSpread
|
||||
? { valueMin: r.value_min, valueMax: r.value_max }
|
||||
: {}),
|
||||
}));
|
||||
if (kind === "glucose") {
|
||||
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
|
||||
// the user's display unit AT SERIALIZATION so the wire DTO is unit-
|
||||
|
||||
@@ -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