mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per- metric system prompts + safety contracts) and `src/lib/insights/prompt *.ts` (user-prompt builder + comparison snapshot + the `compactSections` helper). A new reader could not tell from filenames which file to extend; both directories looked equally authoritative. Audit rec #3 flagged the split as the only undiscoverable seam in the AI layer. Move both files into `src/lib/ai/prompts/`: - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts` - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts` - co-located tests follow Three importers updated: `app/api/insights/generate/route.ts`, `lib/ai/coach/snapshot.ts`, the generate route test (the `vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have ONE home and ONE mental model.
This commit is contained in:
1 parent
d0bdc4b8d1
commit
8a56f48259
16 files changed
+473
-613
No files matched your search
@@ -91,7 +91,7 @@ vi.mock("@/lib/insights/features", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/insights/prompt", () => ({
|
||||
vi.mock("@/lib/ai/prompts/insight-system-prompt", () => ({
|
||||
buildUserPrompt: vi.fn(() => "user"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FeaturesPayloadTooLargeError,
|
||||
} from "@/lib/insights/features";
|
||||
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
|
||||
import { compactSections } from "@/lib/insights/prompt-compact";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import {
|
||||
detectGlp1Plateau,
|
||||
buildGlp1PlateauPrompt,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
} from "@/lib/insights/prompt";
|
||||
} from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type CoachExcludeMetric,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { compactSections } from "@/lib/insights/prompt-compact";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compactSections } from "../prompt-compact";
|
||||
import { compactSections } from "../compact-sections";
|
||||
|
||||
describe("compactSections", () => {
|
||||
it("drops empty arrays", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
buildUserPrompt,
|
||||
buildComparisonBlock,
|
||||
type ComparisonSnapshot,
|
||||
} from "@/lib/insights/prompt";
|
||||
} from "@/lib/ai/prompts/insight-system-prompt";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — comparison context block in the user prompt.
|
||||
|
||||
@@ -27,10 +27,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeLongWindowSummary,
|
||||
computeSummariesSlice,
|
||||
} from "../summaries-slice";
|
||||
import { computeSummariesSlice } from "../summaries-slice";
|
||||
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_FIRST =
|
||||
@@ -453,136 +450,3 @@ describe("computeSummariesSlice", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.39 W-WMY — long-window summary helper. Pins:
|
||||
* - the helper routes a 365-day window through MONTH buckets,
|
||||
* - the linearly-composable aggregate (count / min / max / mean /
|
||||
* sum) lands at the same numbers `aggregateWmyBuckets` produces
|
||||
* directly, so the rollup math survives round-tripping through
|
||||
* the summaries-slice entry point,
|
||||
* - a coverage miss surfaces as a `null` return instead of a
|
||||
* partial-shape leak.
|
||||
*/
|
||||
describe("computeLongWindowSummary", () => {
|
||||
it("routes a 365-day window through MONTH buckets and composes the aggregate", async () => {
|
||||
// The first findMany call is the per-tier probe inside
|
||||
// `readBestGranularityRollups`. With windowDays=365 the router
|
||||
// walks YEAR (skipped, floor=731) → MONTH (floor=181, matched).
|
||||
// The MONTH call returns two buckets; the helper composes the
|
||||
// window aggregate from them.
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: new Date("2026-04-01T00:00:00.000Z"),
|
||||
count: 10,
|
||||
mean: 82,
|
||||
sd: 1,
|
||||
slope: -0.01,
|
||||
r2: 0.3,
|
||||
sumValue: null,
|
||||
minValue: 79,
|
||||
maxValue: 84,
|
||||
},
|
||||
{
|
||||
bucketStart: new Date("2026-05-01T00:00:00.000Z"),
|
||||
count: 20,
|
||||
mean: 80,
|
||||
sd: 1.5,
|
||||
slope: 0.02,
|
||||
r2: 0.4,
|
||||
sumValue: null,
|
||||
minValue: 77,
|
||||
maxValue: 83,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await computeLongWindowSummary("user-1", "WEIGHT", 365);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
expect(result?.bucketCount).toBe(2);
|
||||
expect(result?.count).toBe(30);
|
||||
expect(result?.min).toBe(77);
|
||||
expect(result?.max).toBe(84);
|
||||
// Σ(count × mean) / Σcount = (10×82 + 20×80) / 30 = 80.6666…
|
||||
expect(result?.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5);
|
||||
expect(result?.sum).toBeNull(); // no sumValue on these buckets
|
||||
|
||||
// The router asked MONTH first (365 d clears the 181-day MONTH
|
||||
// floor and falls short of the 731-day YEAR floor).
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
|
||||
});
|
||||
|
||||
it("routes a 1095-day window through YEAR with a fall-through to MONTH on miss", async () => {
|
||||
// YEAR coverage miss → router falls back to MONTH.
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // YEAR miss
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: new Date("2024-06-01T00:00:00.000Z"),
|
||||
count: 100,
|
||||
mean: 81,
|
||||
sd: 1.2,
|
||||
slope: 0.005,
|
||||
r2: 0.15,
|
||||
sumValue: null,
|
||||
minValue: 76,
|
||||
maxValue: 86,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095);
|
||||
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
expect(result?.bucketCount).toBe(1);
|
||||
expect(result?.count).toBe(100);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("composes the cumulative sum when every bucket carries sumValue", async () => {
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: new Date("2026-04-01T00:00:00.000Z"),
|
||||
count: 28,
|
||||
mean: 8_200,
|
||||
sd: 1_000,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
sumValue: 229_600,
|
||||
minValue: 5_000,
|
||||
maxValue: 12_000,
|
||||
},
|
||||
{
|
||||
bucketStart: new Date("2026-05-01T00:00:00.000Z"),
|
||||
count: 30,
|
||||
mean: 9_100,
|
||||
sd: 1_100,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
sumValue: 273_000,
|
||||
minValue: 6_000,
|
||||
maxValue: 12_500,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await computeLongWindowSummary(
|
||||
"user-1",
|
||||
"ACTIVITY_STEPS",
|
||||
365,
|
||||
);
|
||||
|
||||
expect(result?.sum).toBe(229_600 + 273_000);
|
||||
});
|
||||
|
||||
it("returns null when every granularity misses", async () => {
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a zero / negative / NaN window without hitting the DB", async () => {
|
||||
expect(await computeLongWindowSummary("user-1", "WEIGHT", 0)).toBeNull();
|
||||
expect(await computeLongWindowSummary("user-1", "WEIGHT", -10)).toBeNull();
|
||||
expect(await computeLongWindowSummary("user-1", "WEIGHT", NaN)).toBeNull();
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -656,54 +656,6 @@ async function computeFromLiveAggregate(
|
||||
* "rollup tier empty" cold-mount case, and v1.5's multi-year card
|
||||
* accepts a `null` "no data yet" state.
|
||||
*/
|
||||
export async function computeLongWindowSummary(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
windowDays: number,
|
||||
): Promise<{
|
||||
granularity: RollupGranularity;
|
||||
bucketStart: Date | null;
|
||||
bucketEnd: Date | null;
|
||||
bucketCount: number;
|
||||
count: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
mean: number | null;
|
||||
sum: number | null;
|
||||
} | null> {
|
||||
const resolved = await readBestGranularityRollups(userId, type, windowDays);
|
||||
if (!resolved) return null;
|
||||
const rows: RollupBucketRow[] = resolved.rows;
|
||||
const aggregate = aggregateWmyBuckets(rows);
|
||||
// Annotate which granularity served the window so the operator can
|
||||
// verify the routing in production wide-events without redeploying
|
||||
// instrumentation. Mirrors the `slim_summaries.path` annotate
|
||||
// shape inside `computeFromRollups` / `computeFromLiveAggregate`.
|
||||
annotate({
|
||||
meta: {
|
||||
analytics: {
|
||||
long_window_summary: {
|
||||
type,
|
||||
window_days: windowDays,
|
||||
granularity: resolved.granularity,
|
||||
bucket_count: rows.length,
|
||||
row_count: aggregate.count,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
granularity: resolved.granularity,
|
||||
bucketStart: rows[0]?.bucketStart ?? null,
|
||||
bucketEnd: rows[rows.length - 1]?.bucketStart ?? null,
|
||||
bucketCount: rows.length,
|
||||
count: aggregate.count,
|
||||
min: aggregate.min,
|
||||
max: aggregate.max,
|
||||
mean: aggregate.mean,
|
||||
sum: aggregate.sum,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers.
|
||||
*
|
||||
* `prisma.measurementRollup.findMany` is mocked so the tests pin the
|
||||
* exact `where` shape the consumer sends (cumulative type set,
|
||||
* granularity, since-bound) and the legacy-NULL fallback semantics of
|
||||
* `resolveBucketSum`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementRollup: {
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isCumulativeType,
|
||||
readCumulativeDaySums,
|
||||
resolveBucketSum,
|
||||
} from "../measurement-read-cumulative";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("isCumulativeType", () => {
|
||||
it("recognises the five cumulative HK types", () => {
|
||||
expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true);
|
||||
expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true);
|
||||
expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true);
|
||||
expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true);
|
||||
expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects spot metrics", () => {
|
||||
expect(isCumulativeType("WEIGHT")).toBe(false);
|
||||
expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false);
|
||||
expect(isCumulativeType("PULSE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCumulativeDaySums", () => {
|
||||
it("queries the rollup table with the cumulative type + DAY granularity", async () => {
|
||||
const since = new Date("2026-04-15T00:00:00.000Z");
|
||||
const rows = [
|
||||
{
|
||||
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
|
||||
sumValue: 8120,
|
||||
count: 4,
|
||||
mean: 2030,
|
||||
},
|
||||
{
|
||||
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
|
||||
sumValue: 12480,
|
||||
count: 5,
|
||||
mean: 2496,
|
||||
},
|
||||
];
|
||||
mocks.findMany.mockResolvedValueOnce(rows);
|
||||
|
||||
const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since);
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toMatchObject({
|
||||
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
|
||||
sumValue: 8120,
|
||||
count: 4,
|
||||
mean: 2030,
|
||||
});
|
||||
// Single round-trip — eliminates the legacy per-type chunked
|
||||
// findMany loop in analytics A2.
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
const args = mocks.findMany.mock.calls[0][0];
|
||||
expect(args.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: since },
|
||||
});
|
||||
expect(args.orderBy).toEqual({ bucketStart: "asc" });
|
||||
});
|
||||
|
||||
it("propagates legacy NULL sum_value rows so the caller can fall back", async () => {
|
||||
mocks.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
|
||||
sumValue: null,
|
||||
count: 3,
|
||||
mean: 1500,
|
||||
},
|
||||
]);
|
||||
|
||||
const [row] = await readCumulativeDaySums(
|
||||
"user-1",
|
||||
"ACTIVITY_STEPS",
|
||||
new Date("2026-04-15T00:00:00.000Z"),
|
||||
);
|
||||
expect(row.sumValue).toBeNull();
|
||||
// Algebraic fallback parity: pre-v1.4.39 buckets still surface
|
||||
// a usable daily total during the boot-backfill convergence
|
||||
// window.
|
||||
expect(resolveBucketSum(row)).toBe(4500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBucketSum", () => {
|
||||
it("returns sumValue when populated", () => {
|
||||
expect(
|
||||
resolveBucketSum({
|
||||
bucketStart: new Date(),
|
||||
sumValue: 7500,
|
||||
count: 3,
|
||||
mean: 2500,
|
||||
}),
|
||||
).toBe(7500);
|
||||
});
|
||||
|
||||
it("falls back to mean * count when sumValue is null (legacy row)", () => {
|
||||
expect(
|
||||
resolveBucketSum({
|
||||
bucketStart: new Date(),
|
||||
sumValue: null,
|
||||
count: 3,
|
||||
mean: 2500,
|
||||
}),
|
||||
).toBe(7500);
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,6 @@ vi.mock("@/lib/db", () => ({
|
||||
import {
|
||||
aggregateWmyBuckets,
|
||||
readBestGranularityRollups,
|
||||
readMonthRollups,
|
||||
readWeekRollups,
|
||||
readYearRollups,
|
||||
type RollupBucketRow,
|
||||
} from "../measurement-read-wmy";
|
||||
|
||||
@@ -71,86 +68,6 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("readWeekRollups", () => {
|
||||
it("returns the WEEK buckets for (userId, type, since)", async () => {
|
||||
const rows = [
|
||||
bucket("2026-04-27T00:00:00.000Z"),
|
||||
bucket("2026-05-04T00:00:00.000Z", { mean: 81 }),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(rows);
|
||||
const since = new Date("2026-04-20T00:00:00.000Z");
|
||||
|
||||
const result = await readWeekRollups("user-1", "WEIGHT", since);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0].mean).toBe(82);
|
||||
expect(result?.[1].mean).toBe(81);
|
||||
const args = findMany.mock.calls[0][0];
|
||||
expect(args.where.granularity).toBe("WEEK");
|
||||
expect(args.where.userId).toBe("user-1");
|
||||
expect(args.where.type).toBe("WEIGHT");
|
||||
expect(args.where.bucketStart.gte).toBe(since);
|
||||
expect(args.orderBy).toEqual({ bucketStart: "asc" });
|
||||
});
|
||||
|
||||
it("returns null when the WEEK window has no coverage", async () => {
|
||||
findMany.mockResolvedValueOnce([]);
|
||||
const result = await readWeekRollups(
|
||||
"user-1",
|
||||
"WEIGHT",
|
||||
new Date("2026-04-20T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMonthRollups", () => {
|
||||
it("returns the MONTH buckets and pins the granularity filter", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]);
|
||||
const result = await readMonthRollups(
|
||||
"user-2",
|
||||
"PULSE",
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
|
||||
});
|
||||
|
||||
it("returns null on coverage miss", async () => {
|
||||
findMany.mockResolvedValueOnce([]);
|
||||
const result = await readMonthRollups(
|
||||
"user-2",
|
||||
"PULSE",
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readYearRollups", () => {
|
||||
it("returns the YEAR buckets and pins the granularity filter", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]);
|
||||
const result = await readYearRollups(
|
||||
"user-3",
|
||||
"WEIGHT",
|
||||
new Date("2023-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
|
||||
});
|
||||
|
||||
it("returns null on coverage miss", async () => {
|
||||
findMany.mockResolvedValueOnce([]);
|
||||
const result = await readYearRollups(
|
||||
"user-3",
|
||||
"WEIGHT",
|
||||
new Date("2023-01-01T00:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBestGranularityRollups", () => {
|
||||
it("returns null on a non-positive window", async () => {
|
||||
expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull();
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* v1.4.39 W-SUM — read helpers for cumulative-metric daily sums.
|
||||
*
|
||||
* The v1.4.36 read path consumes the rollup table for spot-metric means
|
||||
* via `rollup-read.ts`. Cumulative HK types (steps, flights, distance,
|
||||
* daylight, active-energy) need the per-day SUM rather than the MEAN —
|
||||
* every row is a partial-day slice from HealthKit, so MEAN understates
|
||||
* the daily total by the per-bucket sample count.
|
||||
*
|
||||
* The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to
|
||||
* `measurement_rollups`. The writer in `rollups.ts` populates it on
|
||||
* every fold (including for non-cumulative types — the cost is one
|
||||
* extra `SUM` in the existing aggregator). The legacy-NULL backfill
|
||||
* runs through `enqueueBootTimeRollupBackfill` so existing rows
|
||||
* converge on the next worker boot.
|
||||
*
|
||||
* Callers fall back to `mean * count` when `sumValue` is null. The
|
||||
* shortcut is mathematically exact for a single source per day:
|
||||
*
|
||||
* sum = mean × count = (Σvalue / count) × count = Σvalue
|
||||
*
|
||||
* Multiple sources for the same `(user, type, day)` mix into the same
|
||||
* bucket today — the source-priority resolution that
|
||||
* `pickCanonicalSourceRows` runs in JS happens AFTER the rollup
|
||||
* aggregate is built. The W8c per-source rollup deferred to v1.5 fixes
|
||||
* that; until then, the cumulative read paths trust the bucket sum as
|
||||
* the canonical daily total and the iOS drain (which collapses per
|
||||
* source via `dailyStatsExternalId`) keeps Marc's tenant on the
|
||||
* single-source-per-day path.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
|
||||
/**
|
||||
* Cumulative measurement types the rollup `sum_value` column serves.
|
||||
* Re-exported from the canonical Apple Health mapping so the
|
||||
* consumer-side gate stays in one place.
|
||||
*
|
||||
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §3
|
||||
* "Cumulative daily sums" + §5 P3.
|
||||
*/
|
||||
export type CumulativeType =
|
||||
| "ACTIVITY_STEPS"
|
||||
| "ACTIVE_ENERGY_BURNED"
|
||||
| "FLIGHTS_CLIMBED"
|
||||
| "WALKING_RUNNING_DISTANCE"
|
||||
| "TIME_IN_DAYLIGHT";
|
||||
|
||||
/**
|
||||
* `true` when `type` is one of the cumulative HK types whose daily
|
||||
* total the rollup `sum_value` column carries.
|
||||
*/
|
||||
export function isCumulativeType(type: MeasurementType): type is CumulativeType {
|
||||
return CUMULATIVE_HK_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** One DAY-bucket row read from `measurement_rollups`. */
|
||||
export interface CumulativeDaySumRow {
|
||||
bucketStart: Date;
|
||||
/**
|
||||
* Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39
|
||||
* writer change AND the boot-time backfill has not yet converged on
|
||||
* this user. Callers fall back to `mean * count` for the null case.
|
||||
*/
|
||||
sumValue: number | null;
|
||||
count: number;
|
||||
mean: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every DAY bucket for `(userId, type)` since `since`. Returns
|
||||
* rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with
|
||||
* `sumValue = null`; the caller decides whether to fall back to
|
||||
* `mean * count` or skip them.
|
||||
*
|
||||
* Bounded to the cumulative-type set so a misuse (passing a spot
|
||||
* metric) is a type error rather than a silent SUM on the wrong
|
||||
* column. The query is a plain `findMany` against the
|
||||
* `(userId, type, granularity, bucketStart)` composite primary key —
|
||||
* single round-trip, fully indexed.
|
||||
*/
|
||||
export async function readCumulativeDaySums(
|
||||
userId: string,
|
||||
type: CumulativeType,
|
||||
since: Date,
|
||||
): Promise<CumulativeDaySumRow[]> {
|
||||
const rows = await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: type as MeasurementType,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: since },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
select: {
|
||||
bucketStart: true,
|
||||
sumValue: true,
|
||||
count: true,
|
||||
mean: true,
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
bucketStart: r.bucketStart,
|
||||
sumValue: r.sumValue,
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a per-bucket cumulative total. Reads `sumValue` when the
|
||||
* v1.4.39 writer has populated it; falls back to `mean * count` for
|
||||
* legacy NULL rows so the chart never paints a hole during the boot-
|
||||
* backfill convergence window.
|
||||
*/
|
||||
export function resolveBucketSum(row: CumulativeDaySumRow): number {
|
||||
if (row.sumValue !== null) return row.sumValue;
|
||||
return row.mean * row.count;
|
||||
}
|
||||
@@ -117,43 +117,6 @@ const GRANULARITY_FLOORS: Array<{
|
||||
{ granularity: "DAY", minWindowDays: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the trailing WEEK buckets for `(userId, type)` since `since`.
|
||||
* Returns `null` when no buckets exist in the window — the caller
|
||||
* decides whether to fall back to a finer granularity or to live SQL.
|
||||
*/
|
||||
export async function readWeekRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
return readGranularity(userId, type, "WEEK", since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing MONTH buckets for `(userId, type)` since `since`.
|
||||
* Returns `null` on coverage miss.
|
||||
*/
|
||||
export async function readMonthRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
return readGranularity(userId, type, "MONTH", since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing YEAR buckets for `(userId, type)` since `since`.
|
||||
* Returns `null` on coverage miss.
|
||||
*/
|
||||
export async function readYearRollups(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
return readGranularity(userId, type, "YEAR", since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the largest granularity that can still resolve the requested
|
||||
* `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for
|
||||
@@ -163,10 +126,9 @@ export async function readYearRollups(
|
||||
* series.
|
||||
*
|
||||
* Returns the granularity the helper resolved against plus the row
|
||||
* shape `readWeekRollups` / `readMonthRollups` / `readYearRollups`
|
||||
* return. `null` when no granularity yields any buckets in the
|
||||
* window — the caller is expected to short-circuit to "no data" or
|
||||
* to live SQL.
|
||||
* shape the internal `readGranularity` reader produces. `null` when
|
||||
* no granularity yields any buckets in the window — the caller is
|
||||
* expected to short-circuit to "no data" or to live SQL.
|
||||
*/
|
||||
export async function readBestGranularityRollups(
|
||||
userId: string,
|
||||
|
||||
@@ -459,40 +459,6 @@ export async function recomputeUserMedicationCompliance(
|
||||
return { rowsUpserted, durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget warm-up — kept around for symmetry with
|
||||
* `ensureUserRollupsFresh` even though the on-write hook fully owns
|
||||
* coverage in steady state. The route currently doesn't call this
|
||||
* directly; the coverage-probe + boot-backfill cascade handles the
|
||||
* cold-mount case.
|
||||
*/
|
||||
export async function ensureUserMedicationComplianceFresh(
|
||||
userId: string,
|
||||
days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
tz: string | null | undefined = null,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
try {
|
||||
if (await hasMedicationComplianceCoverage(userId, days, tz)) {
|
||||
return { recomputed: false };
|
||||
}
|
||||
await recomputeUserMedicationCompliance(userId, days, tz);
|
||||
return { recomputed: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_refresh_failed: true,
|
||||
medication_compliance_rollup_refresh_error: message,
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
"[medication-compliance-rollups] ensure-fresh failed:",
|
||||
message,
|
||||
);
|
||||
return { recomputed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.39 hotfix (QA F-SEC-M-01) — user-scoped enqueue helper for the
|
||||
* request-path coverage-miss fallback.
|
||||
|
||||
Reference in new issue
Block a user