mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
perf(analytics): slim /api/analytics?slice=summaries slice (C1)
The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
chunked findMany reads to feed `summarize()` for every
MeasurementType. For a multi-year HealthKit user the PULSE branch
alone walks hundreds of thousands of rows before the dashboard tile
strip can paint a single arrow — even though the strip only consumes
`count / latest / avg7 / avg30 / min / max / mean` plus the three
slope tuples per type.
Add `?slice=summaries` that resolves the same `DataSummary` shape
from two SQL passes (no JS-side spread anywhere along the chain):
1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/
`MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus
Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows.
2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent
`(type, value)` pair per type so the slim shape can populate
`latest` (the value at MAX(measured_at), not MAX(value)).
Slope `direction` keeps the JS helper's 0.01-units-per-day "stable"
threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match
the existing `trendSlope()` contract so the dashboard's arrow / color
logic does not have to branch on slice. Anomaly count, lastMonth, and
lastYear means stay at `null` / `0` because the dashboard tile strip
does not paint them on first paint; the comparison-baseline widget
already opts into the default slice.
The thick `correlations` / `healthScore` / `bpInTargetPct` /
`glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default
path behind a hover-prefetch / explicit slice param. C1 is purely
additive — the route's default behaviour is byte-identical.
Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds,
null-slope branch) and a 3-case real-Postgres integration test
covering the empty user, the populated descending-WEIGHT fixture,
and the default-slice-still-thick contract. 199 unit tests pass
across `src/lib/analytics` + `src/app/api/analytics`; the
integration suite picks up `tests/integration/analytics-summaries-slice.test.ts`
on next `pnpm test:integration` run.
This commit is contained in:
1 parent
1a101505c4
commit
67d05c0de9
5 files changed
+712
-1
No files matched your search
@@ -23,6 +23,8 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`.
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -169,4 +171,60 @@ describe("GET /api/analytics", () => {
|
||||
expect(body.data.bmi).toBeNull();
|
||||
expect(body.data.healthScore).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice. The route branches on
|
||||
// `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw`
|
||||
// passes carry the per-type DataSummary shape with the same
|
||||
// contract the dashboard tile strip reads.
|
||||
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
|
||||
// Aggregate pass + latest pass. The mock returns one row per pass.
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(12),
|
||||
min_value: 80.0,
|
||||
max_value: 84.5,
|
||||
mean_value: 82.1,
|
||||
avg7: 82.0,
|
||||
avg30: 82.2,
|
||||
slope7: -0.05,
|
||||
r2_7: 0.4,
|
||||
slope30: -0.02,
|
||||
r2_30: 0.3,
|
||||
slope90: -0.01,
|
||||
r2_90: 0.2,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/analytics?slice=summaries",
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(req);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
summaries: Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
latest: number | null;
|
||||
slope30: { slope: number; direction: string } | null;
|
||||
}
|
||||
>;
|
||||
bmi: number | null;
|
||||
};
|
||||
};
|
||||
// The slim slice produced WEIGHT from the SQL pass; no chunked
|
||||
// findMany was called.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(body.data.summaries.WEIGHT.count).toBe(12);
|
||||
expect(body.data.summaries.WEIGHT.latest).toBe(81.8);
|
||||
expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down");
|
||||
// Slim slice never carries BMI — the consumer re-derives.
|
||||
expect(body.data.bmi).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -29,10 +30,43 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
/**
|
||||
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
|
||||
* route's production wrapper) or a plain `Request` (the integration
|
||||
* tests instantiate `new Request("http://localhost/api/analytics")`
|
||||
* and cast through). Falls back to the raw `URL(request.url)` parse
|
||||
* so the slim slice branch is reachable from both call shapes.
|
||||
*/
|
||||
function readSliceParam(request: Request | undefined): string | null {
|
||||
if (!request) return null;
|
||||
try {
|
||||
return new URL(request.url).searchParams.get("slice");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request?: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
|
||||
// The default path runs 30+ chunked findMany reads to feed
|
||||
// `summarize()` per type; under `?slice=summaries` the route
|
||||
// resolves the same per-type DataSummary shape from 2 SQL passes
|
||||
// (a `groupBy` for count/min/max/mean plus a single `$queryRaw`
|
||||
// carrying the windowed averages and Postgres `regr_slope` /
|
||||
// `regr_r2` for the slope tuples). The slim slice drops the thick
|
||||
// `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext`
|
||||
// / `sleepStages` / `bmi` blocks — those stay on the default path
|
||||
// behind a hover-prefetch from the Coach drawer + the correlation
|
||||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||||
// tile values land on this branch and skip the heavy chain.
|
||||
if (readSliceParam(request) === "summaries") {
|
||||
const slim = await computeSummariesSlice(user.id);
|
||||
return apiSuccess(slim);
|
||||
}
|
||||
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* v1.4.33 C1 — unit-level pin for the slim summaries slice. Heavier
|
||||
* integration coverage lives in `tests/integration/analytics-summaries-slice.test.ts`
|
||||
* (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so
|
||||
* the slope/round/empty contracts are pinned without a container.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeSummariesSlice } from "../summaries-slice";
|
||||
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
RAW.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("computeSummariesSlice", () => {
|
||||
it("returns the empty-summary skeleton when the user has no rows", async () => {
|
||||
// Pass 1 (aggregates) returns empty, pass 2 (latest) returns empty.
|
||||
RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
|
||||
// Every enum option is seeded so consumers can read
|
||||
// `summaries.WEIGHT` and get a stable empty shape.
|
||||
expect(result.summaries.WEIGHT).toEqual({
|
||||
count: 0,
|
||||
latest: null,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
});
|
||||
expect(result.bmi).toBeNull();
|
||||
// Both SQL passes fired.
|
||||
expect(RAW).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("maps a populated aggregate row into the DataSummary shape", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
]).mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4 }]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.anomalyCount).toBe(0);
|
||||
expect(weight.avg30LastMonth).toBeNull();
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
// Slope direction respects the 0.01-units-per-day "stable"
|
||||
// threshold the JS helper uses. -0.014 → "down", -0.005 →
|
||||
// "stable", 0.001 → "stable".
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
count: BigInt(1),
|
||||
min_value: 72,
|
||||
max_value: 72,
|
||||
mean_value: 72,
|
||||
avg7: 72,
|
||||
avg30: 72,
|
||||
// regr_slope returns NULL when n < 2.
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
]).mockResolvedValueOnce([{ type: "PULSE", value: 72 }]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
expect(result.summaries.PULSE.slope7).toBeNull();
|
||||
expect(result.summaries.PULSE.slope30).toBeNull();
|
||||
expect(result.summaries.PULSE.slope90).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the latest value from the DISTINCT ON pass per type", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
count: BigInt(3),
|
||||
min_value: 60,
|
||||
max_value: 95,
|
||||
mean_value: 77,
|
||||
avg7: 77,
|
||||
avg30: 77,
|
||||
slope7: 0,
|
||||
r2_7: 0,
|
||||
slope30: 0,
|
||||
r2_30: 0,
|
||||
slope90: 0,
|
||||
r2_90: 0,
|
||||
},
|
||||
]).mockResolvedValueOnce([{ type: "PULSE", value: 88 }]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
// Latest is NOT max — comes from the row at MAX(measured_at).
|
||||
expect(result.summaries.PULSE.latest).toBe(88);
|
||||
expect(result.summaries.PULSE.max).toBe(95);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* v1.4.33 C1 — slim `summaries` slice for `/api/analytics?slice=summaries`.
|
||||
*
|
||||
* The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
|
||||
* chunked findMany reads (one per `MeasurementType`) so it can hand
|
||||
* each series to `summarize()` for slope7/30/90 + anomaly detection +
|
||||
* lastMonth/lastYear means. The dashboard tile strip only consumes
|
||||
* `count`, `latest`, `avg7`, `avg30`, `min`, `max`, `mean`, and the
|
||||
* three slope tuples — every other field gets `null`-coalesced before
|
||||
* it ever paints — yet the user waits on the full chunked walk.
|
||||
*
|
||||
* This helper resolves the same `DataSummary` shape (per-type) with
|
||||
* two SQL passes:
|
||||
*
|
||||
* 1. **`groupBy` + windowed aggregates** — one `$queryRaw` carrying
|
||||
* `COUNT`/`MIN`/`MAX`/`AVG`, the 7-day and 30-day `AVG` slices
|
||||
* via `FILTER`, plus `regr_slope` + `regr_r2` for the slope
|
||||
* tuples at 7d / 30d / 90d windows. Postgres folds all of this
|
||||
* into one index scan over `measurements WHERE user_id = …`.
|
||||
*
|
||||
* 2. **`DISTINCT ON (type)` latest read** — one `$queryRaw`
|
||||
* returning the most-recent `(type, value)` pair per type so the
|
||||
* slim shape can populate `latest`. `MAX(value)` would not do —
|
||||
* we need the value of the row at `MAX(measured_at)`, not the
|
||||
* largest value ever recorded.
|
||||
*
|
||||
* Fields the slim shape intentionally omits (the dashboard never
|
||||
* reads them on first paint):
|
||||
* - `anomalyCount`: would require an extra per-type read for the
|
||||
* z-score loop. The Coach / insights paths still get it via the
|
||||
* default slice.
|
||||
* - `avg30LastMonth` / `avg30LastYear`: the dashboard tile delta
|
||||
* callout uses them, but only when the comparison-baseline
|
||||
* widget is enabled — that path already pre-fetches the default
|
||||
* slice via the explicit comparison opt-in.
|
||||
*
|
||||
* Slope `confidence` is the SQL `regr_r2` value (R²) which matches
|
||||
* the existing `trendSlope()` helper's `Math.round(rSquared * 100) /
|
||||
* 100`. Slope `direction` is derived from `slope`'s sign with the
|
||||
* same 0.01-units-per-day "stable" threshold the JS helper uses.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
interface AggregateRow {
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
mean_value: number | null;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
slope7: number | null;
|
||||
r2_7: number | null;
|
||||
slope30: number | null;
|
||||
r2_30: number | null;
|
||||
slope90: number | null;
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
interface LatestRow {
|
||||
type: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */
|
||||
function buildSlope(
|
||||
slope: number | null,
|
||||
rSquared: number | null,
|
||||
): DataSummary["slope7"] {
|
||||
if (slope === null) return null;
|
||||
const threshold = 0.01;
|
||||
const direction: "up" | "down" | "stable" =
|
||||
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
|
||||
return {
|
||||
slope: Math.round(slope * 1000) / 1000,
|
||||
direction,
|
||||
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
function round2(value: number | null): number | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function emptySummary(): DataSummary {
|
||||
return {
|
||||
count: 0,
|
||||
latest: null,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SummariesSlice {
|
||||
summaries: Record<string, DataSummary>;
|
||||
/**
|
||||
* `null` on the slim slice. The default route computes BMI from
|
||||
* `summaries.WEIGHT.latest` + `user.heightCm`; consumers of the
|
||||
* slim slice that need BMI re-derive client-side or fetch the
|
||||
* default slice.
|
||||
*/
|
||||
bmi: null;
|
||||
}
|
||||
|
||||
export async function computeSummariesSlice(
|
||||
userId: string,
|
||||
): Promise<SummariesSlice> {
|
||||
// The two passes run in parallel — they target the same index path
|
||||
// (`measurements (user_id, type, measured_at)`) but Postgres can
|
||||
// serve them off the buffer cache concurrently.
|
||||
const [aggregates, latests] = await Promise.all([
|
||||
prisma.$queryRaw<AggregateRow[]>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS slope7,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS r2_7,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS slope30,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS r2_30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS slope90,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS r2_90
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, number>();
|
||||
for (const row of latests) {
|
||||
latestByType.set(row.type, Number(row.value));
|
||||
}
|
||||
|
||||
// Seed every enum option so consumers can read `summaries.PULSE` and
|
||||
// get a deterministic empty shape rather than `undefined`. Mirrors
|
||||
// the default route which iterates `measurementTypeEnum.options`.
|
||||
const summaries: Record<string, DataSummary> = {};
|
||||
for (const type of measurementTypeEnum.options) {
|
||||
summaries[type] = emptySummary();
|
||||
}
|
||||
|
||||
let totalRows = 0;
|
||||
for (const row of aggregates) {
|
||||
const count = Number(row.count);
|
||||
totalRows += count;
|
||||
const latest = latestByType.get(row.type) ?? null;
|
||||
summaries[row.type] = {
|
||||
count,
|
||||
latest,
|
||||
min: round2(row.min_value),
|
||||
max: round2(row.max_value),
|
||||
mean: round2(row.mean_value),
|
||||
avg7: round2(row.avg7),
|
||||
avg30: round2(row.avg30),
|
||||
slope7: buildSlope(row.slope7, row.r2_7),
|
||||
slope30: buildSlope(row.slope30, row.r2_30),
|
||||
slope90: buildSlope(row.slope90, row.r2_90),
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "analytics.get.slim" },
|
||||
meta: {
|
||||
analytics: {
|
||||
slim_summaries: {
|
||||
row_count: totalRows,
|
||||
type_count: aggregates.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { summaries, bmi: null };
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* v1.4.33 C1 — `?slice=summaries` integration coverage.
|
||||
*
|
||||
* Pins the slim summaries slice against the real Postgres + the real
|
||||
* `regr_slope` / `regr_r2` functions, not a unit-level `$queryRaw`
|
||||
* mock. The slice replaces the route's 30-type × Promise.all chunked
|
||||
* findMany walk with 2 SQL passes; the integration test runs against
|
||||
* the test container so any drift in the SQL (alias names, casts,
|
||||
* `FILTER` semantics) surfaces here instead of in production.
|
||||
*
|
||||
* Three contracts pinned:
|
||||
*
|
||||
* 1. **Empty user.** A brand-new account with zero rows returns the
|
||||
* empty `DataSummary` skeleton for every `MeasurementType` and
|
||||
* `bmi: null`. No JS-side spread anywhere along the chain — the
|
||||
* v1.4.33 P0 RangeError won't reach this path.
|
||||
*
|
||||
* 2. **Populated WEIGHT series.** A 14-day descending-weight series
|
||||
* lands `count`, `latest`, `avg7`, `avg30`, the negative-slope
|
||||
* `direction: "down"` tuple from `regr_slope`, plus min/max/mean
|
||||
* with the same 2-decimal rounding the `summarize()` helper uses.
|
||||
*
|
||||
* 3. **Default slice unchanged.** Hitting the route WITHOUT
|
||||
* `?slice=summaries` still returns the thick `healthScore` /
|
||||
* `correlations` / `bpInTargetPct` / `sleepStages` envelope. C1
|
||||
* is additive — no behaviour change for the existing consumers.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
interface DataSummaryShape {
|
||||
count: number;
|
||||
latest: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
mean: number | null;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
slope7: { slope: number; direction: string; confidence: number } | null;
|
||||
slope30: { slope: number; direction: string; confidence: number } | null;
|
||||
slope90: { slope: number; direction: string; confidence: number } | null;
|
||||
anomalyCount: number;
|
||||
avg30LastMonth: number | null;
|
||||
avg30LastYear: number | null;
|
||||
}
|
||||
|
||||
interface SlimEnvelope {
|
||||
data: {
|
||||
summaries: Record<string, DataSummaryShape>;
|
||||
bmi: number | null;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
|
||||
async function seedSession(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("GET /api/analytics?slice=summaries (C1)", () => {
|
||||
it("returns the empty-summary skeleton for a brand-new user", async () => {
|
||||
await seedSession("slim-empty-user");
|
||||
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics?slice=summaries"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const envelope = (await response.json()) as SlimEnvelope;
|
||||
// Every enum option is present with the deterministic empty
|
||||
// shape so consumers can read `summaries.WEIGHT.count > 0`
|
||||
// without an undefined guard.
|
||||
expect(envelope.data.summaries.WEIGHT.count).toBe(0);
|
||||
expect(envelope.data.summaries.WEIGHT.latest).toBeNull();
|
||||
expect(envelope.data.summaries.WEIGHT.slope30).toBeNull();
|
||||
expect(envelope.data.summaries.BLOOD_PRESSURE_SYS.count).toBe(0);
|
||||
expect(envelope.data.summaries.PULSE.count).toBe(0);
|
||||
// The slim slice never carries BMI — consumers re-derive from
|
||||
// `summaries.WEIGHT.latest` + `user.heightCm` client-side or
|
||||
// fetch the default slice.
|
||||
expect(envelope.data.bmi).toBeNull();
|
||||
});
|
||||
|
||||
it("reports count + latest + slope + windowed averages from real SQL", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedSession("slim-populated-user");
|
||||
|
||||
// 14 descending-WEIGHT entries spaced one day apart. Slope per
|
||||
// day = (84.0 - 80.0) / 13 ≈ -0.308 — solidly inside the "down"
|
||||
// band (|slope| >= 0.01 units/day threshold).
|
||||
const now = Date.now();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const N = 14;
|
||||
const rows = Array.from({ length: N }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
// Most-recent reading is the smallest — slope must come out
|
||||
// negative when regressing value against time.
|
||||
value: 80 + i * (4 / (N - 1)),
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics?slice=summaries"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const envelope = (await response.json()) as SlimEnvelope;
|
||||
const weight = envelope.data.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(N);
|
||||
// Most-recent row in DESCENDING construction = index 0 = value 80.
|
||||
expect(weight.latest).toBeCloseTo(80, 1);
|
||||
// Min / max across the 14 rows.
|
||||
expect(weight.min).toBeCloseTo(80, 1);
|
||||
expect(weight.max).toBeCloseTo(84, 1);
|
||||
// 7-day window covers the first 7 entries (descending dates).
|
||||
expect(weight.avg7).not.toBeNull();
|
||||
expect(weight.avg30).not.toBeNull();
|
||||
// Slope direction must be "down" — value decreases with time.
|
||||
expect(weight.slope30).not.toBeNull();
|
||||
expect(weight.slope30!.direction).toBe("down");
|
||||
expect(weight.slope30!.slope).toBeLessThan(0);
|
||||
// R² confidence is in [0, 1].
|
||||
expect(weight.slope30!.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(weight.slope30!.confidence).toBeLessThanOrEqual(1);
|
||||
|
||||
// Sanity-check: PULSE is empty on this fixture.
|
||||
expect(envelope.data.summaries.PULSE.count).toBe(0);
|
||||
});
|
||||
|
||||
it("default slice still carries the thick envelope (additive contract)", async () => {
|
||||
await seedSession("slim-default-still-thick");
|
||||
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Default slice still produces every block the route v1.4.32
|
||||
// shipped — proving C1 added a branch instead of mutating the
|
||||
// existing path.
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
summaries: unknown;
|
||||
bmi: unknown;
|
||||
bpInTargetPct: unknown;
|
||||
correlations: unknown;
|
||||
healthScore: unknown;
|
||||
sleepStages: unknown;
|
||||
glucoseByContext: unknown;
|
||||
};
|
||||
};
|
||||
expect(body.data.summaries).toBeDefined();
|
||||
// Every thick-slice key must still be on the envelope (value can
|
||||
// be null for an empty user — what we care about is presence).
|
||||
expect("bpInTargetPct" in body.data).toBe(true);
|
||||
expect("correlations" in body.data).toBe(true);
|
||||
expect("healthScore" in body.data).toBe(true);
|
||||
expect("sleepStages" in body.data).toBe(true);
|
||||
expect("glucoseByContext" in body.data).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user