mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate cascade closure. - /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket sparkline + 60 s response cache. - Coach API gate inventory test surfaced and closed two orphan routes (chat[id] GET+DELETE, chat-message feedback POST). - Cross-tz fast-path runtime guard falls back to live for users more than ±3 h from UTC; proper bucket minting stays v1.5. - es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus three medication-cluster placeholder restorations. - IANA timezone validation closes the User.timezone self-DoS regression the new dashboard SQL would have surfaced. - Unit suite 4520 → 4524. See CHANGELOG.md for the full set.
This commit is contained in:
1 parent
fe64f66c7f
commit
a550031a84
66 files changed
+7910
-3717
No files matched your search
@@ -23,6 +23,10 @@ import { join } from "node:path";
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const EN_PATH = join(ROOT, "messages/en.json");
|
||||
const DE_PATH = join(ROOT, "messages/de.json");
|
||||
const ES_PATH = join(ROOT, "messages/es.json");
|
||||
const FR_PATH = join(ROOT, "messages/fr.json");
|
||||
const IT_PATH = join(ROOT, "messages/it.json");
|
||||
const PL_PATH = join(ROOT, "messages/pl.json");
|
||||
|
||||
interface Messages {
|
||||
common: { add: string };
|
||||
@@ -44,9 +48,18 @@ function load(path: string): Messages {
|
||||
}
|
||||
|
||||
describe("dashboard quick-add submenu labels", () => {
|
||||
// v1.4.38 W-D P1-4 — extend the collision guard from en+de to all
|
||||
// six locales so a future translation that accidentally reuses the
|
||||
// trigger word (or a sibling row's verb) is caught before shipping.
|
||||
// es/fr/it/pl currently still hold the literal English block; the
|
||||
// guard runs against whatever string lives there today.
|
||||
it.each([
|
||||
["en", EN_PATH],
|
||||
["de", DE_PATH],
|
||||
["es", ES_PATH],
|
||||
["fr", FR_PATH],
|
||||
["it", IT_PATH],
|
||||
["pl", PL_PATH],
|
||||
])(
|
||||
"%s: measurement and mood entries have distinct labels and neither equals the trigger label",
|
||||
(_locale, path) => {
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
|
||||
interface DrainBody {
|
||||
userId?: string;
|
||||
@@ -60,6 +63,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
// v1.4.38 — the admin route deliberately omits `cutoffHours` so the
|
||||
// operator-triggered one-shot collapses every row the helper can
|
||||
// reach. `DRAIN_CUMULATIVE_CUTOFF_HOURS` is imported (and referenced
|
||||
// here so the symbol stays live in the import graph) as the canonical
|
||||
// 36-hour grace window the nightly worker passes; an operator who
|
||||
// wants to mirror the worker's behaviour from this endpoint can copy
|
||||
// the constant into the body schema in a future revision.
|
||||
void DRAIN_CUMULATIVE_CUTOFF_HOURS;
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId,
|
||||
dryRun,
|
||||
|
||||
+103
-19
@@ -83,13 +83,22 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// mount, and the Coach drawer all hit this endpoint within seconds
|
||||
// of each other; the 60s TTL converts the 7.99s combined dashboard
|
||||
// wait to a Map lookup on every subsequent caller.
|
||||
const body = await cached(
|
||||
const cachedBody = await cached(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
// v1.4.38 — `lastSeenByType` lives inside the cached envelope as
|
||||
// `{ lastSeenAt }` only; derive the `daysAgo` caption per call so
|
||||
// the staleness label updates correctly across day boundaries.
|
||||
// The cache TTL is 60s, but the day boundary it can straddle would
|
||||
// otherwise leave the cached `daysAgo` off by one until the next
|
||||
// refresh. Re-deriving on read is a single Date subtraction per
|
||||
// type and stays on the hot path's Map-lookup side of the cache.
|
||||
const body = enrichLastSeenDaysAgo(cachedBody);
|
||||
|
||||
const response = apiSuccess(body);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation
|
||||
// restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`:
|
||||
@@ -104,6 +113,44 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
/**
|
||||
* v1.4.38 — derive `daysAgo` from the cached `lastSeenAt` ISO string
|
||||
* per request. The cached envelope deliberately omits `daysAgo` so
|
||||
* the dashboard tile staleness caption stays correct across day
|
||||
* boundaries even when the body has been sitting in the 60s LRU.
|
||||
*
|
||||
* Returns a shallow-copied envelope with the enriched
|
||||
* `lastSeenByType` map; everything else is passed through unchanged.
|
||||
*/
|
||||
function enrichLastSeenDaysAgo<
|
||||
T extends {
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
},
|
||||
>(
|
||||
body: T,
|
||||
): Omit<T, "lastSeenByType"> & {
|
||||
lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
} {
|
||||
const nowMs = Date.now();
|
||||
const enriched: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||||
if (slot === null) {
|
||||
enriched[type] = null;
|
||||
continue;
|
||||
}
|
||||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||||
}
|
||||
return { ...body, lastSeenByType: enriched };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-G — the heavy default-slice body, lifted out of the route
|
||||
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
|
||||
@@ -163,13 +210,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// tiles whose latest reading is older than a week. The series read
|
||||
// already orders ascending (`fetchMeasurementSeriesChunked`'s stable
|
||||
// `(measuredAt, id)` order); the last entry is the freshest sample.
|
||||
// Surface as ISO + a server-computed `daysAgo` so the client never
|
||||
// has to do tz-aware date math to colour the tile.
|
||||
const nowForStaleness = new Date();
|
||||
const lastSeenByType: Record<string, {
|
||||
lastSeenAt: string;
|
||||
daysAgo: number;
|
||||
} | null> = {};
|
||||
//
|
||||
// v1.4.38 — the cached body now carries only `lastSeenAt` (ISO);
|
||||
// `daysAgo` is derived in the GET wrapper per call. Storing the
|
||||
// computed delta inside the cache let the value stale across day
|
||||
// boundaries (60s TTL straddling midnight could surface a "vor 3d"
|
||||
// caption that should read "vor 4d"). The ISO timestamp does not
|
||||
// age, so re-deriving on read costs nothing and pins the caption
|
||||
// to the real wall-clock at every request.
|
||||
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
|
||||
|
||||
let totalRowsReadForAggregate = 0;
|
||||
const measurementsByType = await Promise.all(
|
||||
@@ -184,13 +233,8 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// most-recent point without an extra pass.
|
||||
const latest = measurements.at(-1);
|
||||
if (latest) {
|
||||
const daysAgo = Math.floor(
|
||||
(nowForStaleness.getTime() - latest.measuredAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
lastSeenByType[type] = {
|
||||
lastSeenAt: latest.measuredAt.toISOString(),
|
||||
daysAgo,
|
||||
};
|
||||
} else {
|
||||
lastSeenByType[type] = null;
|
||||
@@ -309,6 +353,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
/**
|
||||
* v1.4.38 — prior-week BP-in-target pct so the Health-Score
|
||||
* week-over-week delta reflects BP movement. Pre-v1.4.38 the
|
||||
* Health-Score helper pinned both windows to the same value, so the
|
||||
* BP pillar always zeroed out of the delta. Computed by running a
|
||||
* second `computeBpInTargetFastPath` against `now - 7d` and reading
|
||||
* the resulting `last30Days.pct`.
|
||||
*/
|
||||
let bpInTargetPctPriorWeek: number | null = null;
|
||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
@@ -323,18 +376,43 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||||
// documented per-day-mean approximation.
|
||||
const windows = await computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
});
|
||||
//
|
||||
// v1.4.38 — two parallel runs: one anchored at `now` for the
|
||||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||||
// helper has a real prior-week BP pct to feed into its previous-
|
||||
// window snapshot. The two probes share the coverage map and the
|
||||
// same rollup / live branch decision; the extra call is one extra
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek] = await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
bpInTargetPct = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null;
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
@@ -394,6 +472,12 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
const healthScore = await computeUserHealthScoreFastPath({
|
||||
userId: user.id,
|
||||
bpInTargetPct,
|
||||
// v1.4.38 — feed the prior-week BP pct into the Health-Score
|
||||
// helper's previous-window snapshot so the week-over-week delta
|
||||
// reflects BP movement instead of zeroing out of the BP pillar.
|
||||
// Computed alongside the current windows via a second
|
||||
// `computeBpInTargetFastPath` run anchored at `now - 7d`.
|
||||
bpInTargetPctPriorWeek,
|
||||
heightCm: user.heightCm ?? null,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
|
||||
@@ -3,8 +3,13 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
|
||||
measurement: { groupBy: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
|
||||
// latest reading, the 7-day rollup-bucket sparkline, and the
|
||||
// 365-day distinct-day streak set. Three raw calls per request,
|
||||
// in the order the Promise.all dispatches them.
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -32,6 +37,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -53,7 +59,12 @@ function makeReq(): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// v1.4.38 W-F — three `$queryRaw` invocations land inside the
|
||||
// Promise.all in the order [latest7d, sparkline, streakDays]. The
|
||||
// analytics-cache wrap is also process-local, so we must reset it
|
||||
// between tests to keep cross-test isolation.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
// v1.4.33 maintainer-item-1 — the route now issues a `groupBy` for
|
||||
// per-type all-time count + most-recent timestamp. Default to an
|
||||
// empty aggregate so legacy tests keep their "no data" expectations.
|
||||
|
||||
@@ -8,6 +8,34 @@
|
||||
* The shape is fixed for the iOS client and intentionally normalised —
|
||||
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
|
||||
* (BLOOD_PRESSURE_SYS etc.).
|
||||
*
|
||||
* Cold-mount performance (v1.4.38 W-F)
|
||||
* ------------------------------------
|
||||
* The legacy shape ran an unbounded `prisma.measurement.findMany` over
|
||||
* the trailing 7 days plus a second unbounded `findMany` over the
|
||||
* trailing 365 days for the streak-day set. On a power-user account
|
||||
* (Apple Health step samples ≈ thousands per day) those two queries
|
||||
* dominated the wall-clock at ~4.6 s cold even though the DOWNSTREAM
|
||||
* JS code only ever needed:
|
||||
* - the latest value per type within the 7-day window
|
||||
* - a small sparkline (≤7 daily aggregates) per type
|
||||
* - the set of YYYY-MM-DD day-keys with any activity in 365 days
|
||||
*
|
||||
* v1.4.38 swaps the two unbounded reads for SQL aggregates:
|
||||
* - `DISTINCT ON (type)` over the 7-day window → one row per type
|
||||
* carrying the latest value + measuredAt (≤ N_metrics rows).
|
||||
* - `measurement_rollups` DAY buckets keyed `(user_id, granularity,
|
||||
* bucketStart)` → at most 7 buckets per metric × N_metrics rows.
|
||||
* Sparkline points become the bucket means rather than individual
|
||||
* raw samples, which is a *smoother* trend signal for high-volume
|
||||
* metrics like ACTIVITY_STEPS and bounded for every other metric.
|
||||
* - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the
|
||||
* 365-day window → at most 365 dates (vs. up to 100k raw rows).
|
||||
*
|
||||
* The whole response is wrapped in the 60 s analytics LRU cache so
|
||||
* subsequent iOS polls inside the window hit memory. Invalidation runs
|
||||
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
* `invalidateUserMedications` hooks.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -18,6 +46,7 @@ import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -161,6 +190,34 @@ function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the most recent reading
|
||||
* per measurement type inside the 7-day window. One row per metric
|
||||
* the user touched in the window; replaces the legacy unbounded
|
||||
* `prisma.measurement.findMany`. */
|
||||
interface LatestIn7dRow {
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — per-day measurement_rollup bucket inside the 7-day
|
||||
* window. At most 7 buckets per metric × N metrics — bounded by
|
||||
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count. */
|
||||
interface SparklineRow {
|
||||
type: MeasurementType;
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
|
||||
* The route only needs YYYY-MM-DD strings, so the aggregate runs
|
||||
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
|
||||
* the day-keys directly. At most 365 rows; vs. the legacy `findMany`
|
||||
* which could return tens of thousands. */
|
||||
interface ActivityDayRow {
|
||||
day_key: string;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
@@ -171,6 +228,42 @@ export const GET = apiHandler(async () => {
|
||||
// it).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU.
|
||||
// Subsequent iOS polls inside the TTL hit memory; measurement /
|
||||
// mood / medication writes invalidate the user-bucket via the
|
||||
// existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
// `invalidateUserMedications` hooks.
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildDashboardSummary>
|
||||
>>,
|
||||
`${user.id}|dashboard-summary`,
|
||||
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
interface SummaryBuilderContext {
|
||||
greetingName: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
user: { displayName: string | null; username: string; locale: string | null },
|
||||
): SummaryBuilderContext {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
return { greetingName, locale };
|
||||
}
|
||||
|
||||
async function buildDashboardSummary(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
ctx: SummaryBuilderContext,
|
||||
) {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
const streakWindowStart = new Date(
|
||||
@@ -186,46 +279,107 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — pull per-type all-time counts + latest
|
||||
// timestamps alongside the 7-day sparkline window so the iOS tile
|
||||
// can keep rendering when the recent window is empty but the user
|
||||
// has logged the metric before. `groupBy` with `_count._all` +
|
||||
// `_max(measuredAt)` is a single Postgres aggregate round-trip; no
|
||||
// additional row materialisation beyond the aggregate values
|
||||
// themselves.
|
||||
const [recentMeasurements, allTimeAggregate, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: measurementTypes },
|
||||
measuredAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
// v1.4.38 W-F — per-sub-query wall-clock timing for prod observability.
|
||||
// Captured on the cache-miss path; the cache-hit path skips this whole
|
||||
// builder. Reported under `meta.dashboard.sub_*_ms` so the next
|
||||
// perf-verify can attribute a regression to a specific sub-query
|
||||
// without re-instrumenting the route.
|
||||
const timings: Record<string, number> = {};
|
||||
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
|
||||
const t0 = Date.now();
|
||||
const result = await builder();
|
||||
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
|
||||
return result;
|
||||
};
|
||||
|
||||
// v1.4.38 W-F — six bounded sub-queries replace the legacy 4
|
||||
// unbounded ones. Row counts are now:
|
||||
// - latestIn7d: ≤ N metric types (one row per type via DISTINCT ON)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80)
|
||||
// - allTimeAggregate: ≤ N metric types (unchanged)
|
||||
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
|
||||
// - streakActivity: ≤ daily intake count × 365 (unchanged)
|
||||
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
|
||||
const [
|
||||
latestIn7d,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
todaysIntakes,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
] = await Promise.all([
|
||||
time("latest7d", () =>
|
||||
prisma.$queryRaw<LatestIn7dRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type" AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${sevenDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
),
|
||||
time("sparkline", () =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
SELECT
|
||||
r."type" AS type,
|
||||
r."bucket_start" AS bucket_start,
|
||||
r."mean"::double precision AS mean
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."bucket_start" >= ${sevenDaysAgo}
|
||||
ORDER BY r."type", r."bucket_start" ASC
|
||||
`,
|
||||
),
|
||||
time("allTime", () =>
|
||||
prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: { userId: user.id, type: { in: measurementTypes } },
|
||||
where: { userId, type: { in: measurementTypes } },
|
||||
_count: { _all: true },
|
||||
_max: { measuredAt: true },
|
||||
}),
|
||||
),
|
||||
time("todaysIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
userId,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { id: true, takenAt: true, skipped: true },
|
||||
}),
|
||||
),
|
||||
time("streakIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
userId,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
),
|
||||
// v1.4.38 W-F — replaces the legacy 365-day `measurement.findMany`
|
||||
// that pulled every raw row in the window only to collapse them to
|
||||
// day-keys in JS. The SQL `date_trunc` returns at most 365 rows
|
||||
// even on accounts with millions of step samples. The `userTz`
|
||||
// conversion is folded server-side so the day boundary matches
|
||||
// `userDayKey`'s output (it formats the timestamp in the user's
|
||||
// tz before slicing YYYY-MM-DD).
|
||||
time("streakDays", () =>
|
||||
prisma.$queryRaw<ActivityDayRow[]>`
|
||||
SELECT DISTINCT
|
||||
to_char(
|
||||
(m."measured_at" AT TIME ZONE ${userTz}),
|
||||
'YYYY-MM-DD'
|
||||
) AS day_key
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${streakWindowStart}
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
@@ -253,20 +407,13 @@ export const GET = apiHandler(async () => {
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.38 W-F — activity-day set assembled from the bounded streak
|
||||
// queries. The measurement side already arrives as YYYY-MM-DD keys
|
||||
// from the SQL `date_trunc + AT TIME ZONE` aggregate, byte-identical
|
||||
// to what `userDayKey(measuredAt, userTz)` would produce in JS.
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements)
|
||||
activityDays.add(userDayKey(m.measuredAt, userTz));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
const wider = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
|
||||
for (const row of measurementStreakDays) {
|
||||
if (row.day_key) activityDays.add(row.day_key);
|
||||
}
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
|
||||
@@ -274,24 +421,28 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const streak = computeStreak(activityDays, userTz);
|
||||
|
||||
// Per-type latest + sparkline.
|
||||
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
|
||||
for (const m of recentMeasurements) {
|
||||
const list = byType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
byType.set(m.type, list);
|
||||
// v1.4.38 W-F — per-type latest (one row per type) + sparkline
|
||||
// (bucket means per day) assembled from the two new SQL aggregates.
|
||||
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
|
||||
for (const row of latestIn7d) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
at: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
const sparkByType = new Map<MeasurementType, number[]>();
|
||||
for (const row of sparkBuckets) {
|
||||
const list = sparkByType.get(row.type) ?? [];
|
||||
list.push(Number(row.mean));
|
||||
sparkByType.set(row.type, list);
|
||||
}
|
||||
|
||||
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
|
||||
const list = byType.get(type);
|
||||
if (!list || list.length === 0) return null;
|
||||
return list[list.length - 1];
|
||||
return latestByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
function sparkOf(type: MeasurementType): number[] {
|
||||
const list = byType.get(type);
|
||||
if (!list) return [];
|
||||
return list.map((p) => p.value);
|
||||
return sparkByType.get(type) ?? [];
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
@@ -324,10 +475,9 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
{
|
||||
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
const latestSys = latestOf("BLOOD_PRESSURE_SYS");
|
||||
const latestDia = latestOf("BLOOD_PRESSURE_DIA");
|
||||
const sysSpark = sparkOf("BLOOD_PRESSURE_SYS");
|
||||
const sysMeta = metaForType("BLOOD_PRESSURE_SYS");
|
||||
const diaMeta = metaForType("BLOOD_PRESSURE_DIA");
|
||||
// BP is a paired metric — the tile is "alive" whenever either side
|
||||
@@ -349,8 +499,8 @@ export const GET = apiHandler(async () => {
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
@@ -443,14 +593,13 @@ export const GET = apiHandler(async () => {
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
return apiSuccess({
|
||||
annotate({ meta: timings });
|
||||
|
||||
return {
|
||||
greeting: {
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
@@ -465,5 +614,5 @@ export const GET = apiHandler(async () => {
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
lastUpdated: now.toISOString(),
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.4.38 W-C M6 — Coach API route gate inventory.
|
||||
*
|
||||
* The web surfaces are protected by the `flags.coach` short-circuit in
|
||||
* every Coach-bearing component (see
|
||||
* `src/lib/feature-flags/__tests__/coach-cascade.test.tsx`). The
|
||||
* server-side mirror is `requireAssistantSurface("coach")` in every
|
||||
* Coach-only route handler. Without a discovery test, a future
|
||||
* contributor who lands a new `/api/insights/<foo>/route.ts` whose body
|
||||
* touches the Coach stack would have to remember the gate by hand — a
|
||||
* silent miss leaks the surface even when the operator turned the
|
||||
* Coach matrix off.
|
||||
*
|
||||
* The walk below scans every `route.ts` under `src/app/api/insights/`
|
||||
* and groups each handler into one of three buckets:
|
||||
*
|
||||
* 1. Coach-gated — file imports + invokes
|
||||
* `requireAssistantSurface("coach")`.
|
||||
* 2. Other-gated — file invokes `requireAssistantSurface()` with a
|
||||
* different surface (`insightStatus`,
|
||||
* `correlations`, `briefing`, `healthScoreExplainer`).
|
||||
* 3. Allowlisted — non-Coach Insights routes that don't gate on
|
||||
* the matrix at all (provider chain, settings,
|
||||
* targets, glp1-timeline, feedback). Allowlist
|
||||
* lives alongside the test for easy review.
|
||||
*
|
||||
* Anything that doesn't fit one of the three buckets is an orphan and
|
||||
* fails the test by name so the fix is one search-and-add.
|
||||
*/
|
||||
|
||||
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// Non-Coach assistant surfaces — gated on a sibling sub-flag.
|
||||
"src/app/api/insights/blood-pressure-status/route.ts",
|
||||
"src/app/api/insights/bmi-status/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
"src/app/api/insights/mood-status/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// Settings + chain inventory — surface-agnostic infra reads. The
|
||||
// settings UI itself is server-rendered and the chain page hides
|
||||
// when no provider is configured; neither one carries assistant
|
||||
// prose. Disabling Coach must not break these reads — the user can
|
||||
// still inspect and reconfigure the chain that gets re-enabled.
|
||||
"src/app/api/insights/provider-chain/route.ts",
|
||||
"src/app/api/insights/settings/route.ts",
|
||||
// Read-only data feeds — `/targets` is a non-assistant Zielwerte
|
||||
// page driven by classifications, not LLM prose; the GLP-1 timeline
|
||||
// is the same data the medication page paints whether the assistant
|
||||
// is on or off. Recommendation feedback (`/feedback`) records a
|
||||
// thumbs-up/-down on a NON-Coach recommendation row that was
|
||||
// produced before the operator flipped the flag; gating it would
|
||||
// mean the operator's mid-flight toggle erases the user's
|
||||
// ability to dispose of stale recommendations.
|
||||
"src/app/api/insights/feedback/route.ts",
|
||||
"src/app/api/insights/glp1-timeline/route.ts",
|
||||
"src/app/api/insights/targets/route.ts",
|
||||
];
|
||||
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
const ANY_GATE_NEEDLE = "requireAssistantSurface(";
|
||||
|
||||
/**
|
||||
* Return true when the file contains the needle on a line that is NOT
|
||||
* a pure comment. A documentation comment that mentions the gate
|
||||
* (`// requireAssistantSurface("coach") protects this surface`) must
|
||||
* not satisfy the gate-presence check — otherwise a contributor who
|
||||
* deletes the actual call but leaves the docstring would slip through.
|
||||
*/
|
||||
function fileHasGateCall(text: string, needle: string): boolean {
|
||||
return text.split("\n").some((line) => {
|
||||
if (!line.includes(needle)) return false;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("*")) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `route.ts` under `src/app/api/insights/` (including
|
||||
* dynamic segments under `[id]`). Returns POSIX-style paths relative
|
||||
* to the repo root.
|
||||
*/
|
||||
function findInsightsRouteFiles(): string[] {
|
||||
// __dirname here is `src/app/api/insights/__tests__/`, so the
|
||||
// insights root is one directory up.
|
||||
const insightsRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(insightsRoot, "..", "..", "..", "..");
|
||||
const hits: string[] = [];
|
||||
|
||||
function walk(dir: string): void {
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
if (entry === "node_modules") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry !== "route.ts") continue;
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
walk(insightsRoot);
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
describe("Coach API route gate inventory", () => {
|
||||
it("every Coach-bearing insights route imports requireAssistantSurface('coach')", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
|
||||
const routes = findInsightsRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
const allowlistOther = new Set(NON_COACH_GATED_ROUTES);
|
||||
const allowlistNone = new Set(NOT_COACH_OWNED_ROUTES);
|
||||
|
||||
const orphans: Array<{ path: string; reason: string }> = [];
|
||||
|
||||
for (const path of routes) {
|
||||
const full = resolve(repoRoot, path);
|
||||
const text = readFileSync(full, "utf8");
|
||||
const hasCoachGate = fileHasGateCall(text, COACH_GATE_NEEDLE);
|
||||
const hasAnyGate = fileHasGateCall(text, ANY_GATE_NEEDLE);
|
||||
|
||||
if (hasCoachGate) continue;
|
||||
|
||||
if (allowlistOther.has(path)) {
|
||||
// Sanity — the file should still gate on SOME assistant
|
||||
// surface. A stale entry on this allowlist is misleading
|
||||
// documentation; flip the failure mode so the orphan is
|
||||
// reported with the right diagnosis.
|
||||
if (!hasAnyGate) {
|
||||
orphans.push({
|
||||
path,
|
||||
reason:
|
||||
"listed in NON_COACH_GATED_ROUTES but has no requireAssistantSurface() call at all",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowlistNone.has(path)) continue;
|
||||
|
||||
orphans.push({
|
||||
path,
|
||||
reason: `missing requireAssistantSurface("coach"); add the gate or move the route onto an allowlist (NON_COACH_GATED_ROUTES / NOT_COACH_OWNED_ROUTES)`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
orphans,
|
||||
[
|
||||
"Coach API route gate inventory found unaccounted-for handler(s):",
|
||||
...orphans.map((o) => ` - ${o.path}: ${o.reason}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("allowlists do not reference deleted route files", () => {
|
||||
const known = new Set(findInsightsRouteFiles());
|
||||
|
||||
const stale = [...NON_COACH_GATED_ROUTES, ...NOT_COACH_OWNED_ROUTES].filter(
|
||||
(path) => !known.has(path),
|
||||
);
|
||||
|
||||
expect(
|
||||
stale,
|
||||
[
|
||||
"Allowlist entries point to files that no longer exist —",
|
||||
"delete the stale entries from",
|
||||
"`src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`:",
|
||||
...stale.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
|
||||
import {
|
||||
deleteConversation,
|
||||
@@ -25,6 +26,10 @@ interface RouteCtx {
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.4.38 W-C M6 — operator can hide the Coach surface app-wide;
|
||||
// the conversation reader is part of the Coach stack (encrypted
|
||||
// assistant prose), so a disabled surface must 403 here too.
|
||||
await requireAssistantSurface("coach");
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
@@ -44,6 +49,10 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.4.38 W-C M6 — same gate as GET; a disabled Coach surface
|
||||
// means the user can't reach the conversation list anyway, so the
|
||||
// delete affordance must stay behind the same kill-switch.
|
||||
await requireAssistantSurface("coach");
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { resolveCoachFeedbackAttribution } from "@/lib/ai/feedback-attribution";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
@@ -55,6 +56,11 @@ interface RouteContext {
|
||||
|
||||
async function handlePost(request: NextRequest, ctx: RouteContext) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.4.38 W-C M6 — Coach message feedback is a Coach surface
|
||||
// affordance; gate it on the same operator kill-switch so a
|
||||
// disabled Coach matrix can't be skirted through the feedback
|
||||
// endpoint.
|
||||
await requireAssistantSurface("coach");
|
||||
const { id: messageId } = await ctx.params;
|
||||
|
||||
// Per-user rate limit — same shape as the recommendation feedback
|
||||
|
||||
@@ -100,9 +100,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||||
},
|
||||
orderBy: { measuredAt: sortDir },
|
||||
// Cap the drill-down — a phone-only stepCount day can hit a few
|
||||
// hundred rows; the route ceiling keeps a pathological day bounded.
|
||||
take: Math.min(limit, 1000),
|
||||
// v1.4.38 — the 1000-row cap now lives on the validator (refine
|
||||
// on `(limit, dayKey)` returning 422 when a caller asks for
|
||||
// more). The route reads `limit` straight through because the
|
||||
// validator has already clamped it.
|
||||
take: limit,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
|
||||
+4
-1
@@ -572,7 +572,10 @@ export default function DashboardPage() {
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
{/* Menu items must each carry a self-contained verb-phrase
|
||||
("Log measurement", "Log mood") — the trigger above already
|
||||
says "Add", and the icon is `aria-hidden`, so the visible
|
||||
|
||||
@@ -165,6 +165,17 @@ export function MedicationIntakeQuickAdd({
|
||||
const json = await res.json();
|
||||
return json.data as MedicationOption[];
|
||||
},
|
||||
// v1.4.38 — share the parent dashboard's medications cache.
|
||||
// `queryKeys.medications()` resolves to `["medications"]` — the
|
||||
// same key the dashboard's onboarding checklist subscriber holds.
|
||||
// Without a staleTime the sheet-mount triggers a fresh
|
||||
// `/api/medications` GET on every open even when the parent cache
|
||||
// already has current data. Match the dashboard's 60s window and
|
||||
// skip the refetch-on-mount so opening the sheet inside the TTL
|
||||
// reads straight from the React-Query cache.
|
||||
staleTime: 60_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const medications = useMemo(
|
||||
@@ -297,9 +308,18 @@ export function MedicationIntakeQuickAdd({
|
||||
);
|
||||
|
||||
// Empty-state branch: the user has no active medications. Render a
|
||||
// hint + CTA into the body so the user has a single tap into the
|
||||
// medication-creation surface. The form itself is suppressed so we
|
||||
// don't ship an unusable Save button.
|
||||
// hint into the body and promote the "Medikament anlegen" CTA into
|
||||
// the sheet footer slot so the primary action lives where the user
|
||||
// expects it (matches the populated form's Save-button slot). The
|
||||
// form itself is suppressed so we don't ship an unusable Save
|
||||
// button.
|
||||
//
|
||||
// v1.4.38 W-D P2-1 — symmetry fix. The previous shape rendered the
|
||||
// CTA in the body and a footer with only the Close button, which
|
||||
// read as the dialog losing a save button. Now the body carries the
|
||||
// hint and the footer carries `<Cancel> <Link asChild>Medikament
|
||||
// anlegen</Link>` so the footer-slot promise holds across both
|
||||
// branches.
|
||||
if (!medicationsLoading && medications.length === 0) {
|
||||
const emptyFooter = (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
@@ -310,9 +330,14 @@ export function MedicationIntakeQuickAdd({
|
||||
onClick={onCancel}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.close")}
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild className="min-h-11 sm:min-h-9">
|
||||
<Link href="/medications">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
@@ -332,16 +357,6 @@ export function MedicationIntakeQuickAdd({
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link href="/medications">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{footerSlot ? createPortal(emptyFooter, footerSlot) : emptyFooter}
|
||||
</>
|
||||
|
||||
@@ -105,7 +105,15 @@ export function InsightStatusCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="animate-insight-in border-l-dracula-purple border-l-2">
|
||||
// v1.4.38 W-D P2-2 — `aria-live="polite"` so screen readers
|
||||
// announce the assessment when it swaps into the same slot the
|
||||
// structured skeleton occupied. The skeleton card already carries
|
||||
// `aria-busy="true" aria-live="polite"`; mirroring the live region
|
||||
// on the success card closes the load→loaded transition signal.
|
||||
<Card
|
||||
aria-live="polite"
|
||||
className="animate-insight-in border-l-dracula-purple border-l-2"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -525,6 +525,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — stable drill-down id so the
|
||||
// disclosure chevron can thread aria-controls to the
|
||||
// expanded panel. dayKey is unique per row when
|
||||
// grouped; fall back to m.id otherwise.
|
||||
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
<TableRow>
|
||||
@@ -585,6 +590,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
toggleExpand(m.dayKey as string)
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
@@ -617,7 +623,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isGrouped && isExpanded && (
|
||||
<TableRow>
|
||||
<TableRow id={drilldownId}>
|
||||
<TableCell colSpan={6} className="p-0">
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
@@ -644,6 +650,8 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — see desktop counterpart.
|
||||
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -712,6 +720,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() => toggleExpand(m.dayKey as string)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
@@ -742,12 +751,14 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</div>
|
||||
</div>
|
||||
{isGrouped && isExpanded && (
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="mobile"
|
||||
/>
|
||||
<div id={drilldownId}>
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="mobile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
@@ -395,13 +398,28 @@ export function Glp1MedicationCard({
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
currentWindowStatus.status === "in_window"
|
||||
? "text-success font-medium"
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(currentWindowStatus.status === "in_window"
|
||||
? "text-success"
|
||||
: currentWindowStatus.status === "late"
|
||||
? "text-dracula-yellow font-medium"
|
||||
: "text-warning font-medium"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
|
||||
glyph so colour-blind users (red-green) can
|
||||
disambiguate take-now from late from very-late.
|
||||
WCAG 1.4.1 (Use of Color). */}
|
||||
{currentWindowStatus.status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : currentWindowStatus.status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{currentWindowStatus.status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: currentWindowStatus.status === "late"
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
SkipForward,
|
||||
Flame,
|
||||
Pencil,
|
||||
@@ -294,13 +297,28 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
currentWindowStatus.status === "in_window"
|
||||
? "text-success font-medium"
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(currentWindowStatus.status === "in_window"
|
||||
? "text-success"
|
||||
: currentWindowStatus.status === "late"
|
||||
? "text-dracula-yellow font-medium"
|
||||
: "text-warning font-medium"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
|
||||
glyph so colour-blind users (red-green) can
|
||||
disambiguate take-now from late from very-late.
|
||||
WCAG 1.4.1 (Use of Color). */}
|
||||
{currentWindowStatus.status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : currentWindowStatus.status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{currentWindowStatus.status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: currentWindowStatus.status === "late"
|
||||
|
||||
@@ -365,7 +365,11 @@ export function GettingStartedChecklist() {
|
||||
// few primary buttons rendered above the fold on the
|
||||
// onboarded-user dashboard so the regression was visible to
|
||||
// the touch-target spec.
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-10"
|
||||
// v1.4.38 W-D P3-3 — align the desktop floor with the
|
||||
// dashboard +Hinzufügen pattern (`sm:min-h-9`); the
|
||||
// `sm:min-h-10` here was a no-op against the Button default
|
||||
// of `h-10`.
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
|
||||
onClick={() => setDismissedAll(true)}
|
||||
>
|
||||
{t("gettingStarted.dismissAll")}
|
||||
|
||||
@@ -163,7 +163,11 @@ export function ArztberichtHeroCard() {
|
||||
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
|
||||
// we override the default `<Button>` height on mobile and
|
||||
// fall back to the compact `sm:h-10` on pointer devices.
|
||||
className="h-11 px-5 text-sm font-medium sm:h-10"
|
||||
// v1.4.38 W-D P3-2 — pair the explicit `h-11` with a
|
||||
// `min-h-11` floor (and a `sm:min-h-9` desktop floor) so a
|
||||
// future className override can't accidentally re-lift the
|
||||
// mobile minimum below the touch-target contract.
|
||||
className="h-11 min-h-11 px-5 text-sm font-medium sm:h-10 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
aria-describedby={valueStatementId}
|
||||
|
||||
@@ -42,14 +42,18 @@ function SelectTrigger({
|
||||
// desktop input rhythm. `size=sm` (filter chips) intentionally
|
||||
// keeps the 32 px footprint.
|
||||
// v1.4.37 W4a item 4 — split the trigger's horizontal padding
|
||||
// (`pl-3 pr-2.5`) and push the chevron icon 4 px further right
|
||||
// via `[&_svg:last-child]:mr-1` so the trailing glyph parks at
|
||||
// the same visual gutter as the browser-native date-input
|
||||
// calendar icon (~16-20 px on Chromium). The old uniform
|
||||
// `px-3` parked the chevron 12 px from the edge, which read
|
||||
// visibly tighter than the date-picker glyph when both
|
||||
// controls shared a form row.
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2.5 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:last-child]:mr-1",
|
||||
// (`pl-3 pr-2`) so the trailing chevron parks at the same
|
||||
// visual gutter as the browser-native date-input calendar
|
||||
// icon. The old uniform `px-3` parked the chevron 12 px from
|
||||
// the edge, which read visibly tighter than the date-picker
|
||||
// glyph when both controls shared a form row.
|
||||
// v1.4.38 W-D P1-6 — drop the `[&_svg:last-child]:mr-1`
|
||||
// double-shift and tighten `pr-2.5` to `pr-2`. The previous
|
||||
// 14 px combined gutter (10 + 4) hit Chromium-Material native
|
||||
// date-inputs cleanly but lagged ~4 px against Safari and
|
||||
// Chromium-legacy themes; `pr-2` (8 px) lands the chevron
|
||||
// inside the gutter the non-Material engines actually paint.
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -101,6 +101,40 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
// v1.4.38 — looksLikeIp now defers to node:net's `isIP` instead of
|
||||
// a hex/dot character-class regex. Structurally invalid candidates
|
||||
// (`:::`, `1.2`, `gg:hh::1`) used to slip through the regex and
|
||||
// pollute the rate-limit / audit log with non-IP strings; they are
|
||||
// now rejected at the helper.
|
||||
it("rejects structurally invalid IPv4 candidates (1.2)", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("rejects structurally invalid IPv6 candidates (:::)", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("rejects hex-but-not-IPv6 chains", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "gg:hh::1, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("accepts well-formed IPv6 addresses", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "1";
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "2001:db8::1" }),
|
||||
);
|
||||
expect(ip).toBe("2001:db8::1");
|
||||
});
|
||||
|
||||
it("falls back to x-real-ip if XFF missing", () => {
|
||||
const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" }));
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
|
||||
@@ -279,4 +279,114 @@ describe("computeBpInTargetFastPath", () => {
|
||||
expect(result.path).toBe("live");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-tz runtime guard (v1.4.38 W-A)", () => {
|
||||
it("takes the rollup path for a Berlin user (near-UTC)", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-berlin",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
userTz: "Europe/Berlin",
|
||||
});
|
||||
|
||||
expect(result.path).toBe("rollup");
|
||||
expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled();
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const bpCall = calls.find(
|
||||
(c) => c?.meta?.analytics?.bp_in_target !== undefined,
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.tz_guard).toBe("near-utc");
|
||||
expect(bpCall?.meta.analytics.bp_in_target.path).toBe("rollup");
|
||||
});
|
||||
|
||||
it("falls back to live for Honolulu (-10h) even with full coverage", async () => {
|
||||
// Coverage map says rollup-eligible, but the user's tz forces
|
||||
// the live path because the rollup UTC-midnight day-key would
|
||||
// slip a calendar day relative to the local window boundaries.
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-honolulu",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
userTz: "Pacific/Honolulu",
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const bpCall = calls.find(
|
||||
(c) => c?.meta?.analytics?.bp_in_target !== undefined,
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.tz_guard).toBe(
|
||||
"non-utc-live-fallback",
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.path).toBe("live");
|
||||
});
|
||||
|
||||
it("falls back to live for Tokyo (+9h) even with full coverage", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-tokyo",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
userTz: "Asia/Tokyo",
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to near-UTC when the caller omits userTz (legacy compat)", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-legacy",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("rollup");
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const bpCall = calls.find(
|
||||
(c) => c?.meta?.analytics?.bp_in_target !== undefined,
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.tz_guard).toBe("near-utc");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -194,4 +194,83 @@ describe("computeCorrelationHypothesesFastPath", () => {
|
||||
expect(PROBE).toHaveBeenCalledWith("user-fresh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-tz runtime guard (v1.4.38 W-A)", () => {
|
||||
it("takes the rollup path for a Berlin user (near-UTC)", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["PULSE", true],
|
||||
["WEIGHT", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeCorrelationHypothesesFastPath({
|
||||
userId: "user-berlin",
|
||||
userTz: "Europe/Berlin",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("rollup");
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
|
||||
expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled();
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const corrCall = calls.find((c) => c?.meta?.correlations !== undefined);
|
||||
expect(corrCall?.meta.correlations.tz_guard).toBe("near-utc");
|
||||
});
|
||||
|
||||
it("falls back to live for Honolulu (-10h) even with full coverage", async () => {
|
||||
// Coverage map says rollup-eligible, but the user's tz forces
|
||||
// the live path because the rollup bucketStart (UTC midnight)
|
||||
// would slip a calendar day relative to userDayKey(..., Honolulu).
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["PULSE", true],
|
||||
["WEIGHT", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeCorrelationHypothesesFastPath({
|
||||
userId: "user-honolulu",
|
||||
userTz: "Pacific/Honolulu",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3);
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const corrCall = calls.find((c) => c?.meta?.correlations !== undefined);
|
||||
expect(corrCall?.meta.correlations.tz_guard).toBe("non-utc-live-fallback");
|
||||
expect(corrCall?.meta.correlations.path).toBe("live");
|
||||
});
|
||||
|
||||
it("falls back to live for Tokyo (+9h) even with full coverage", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["PULSE", true],
|
||||
["WEIGHT", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeCorrelationHypothesesFastPath({
|
||||
userId: "user-tokyo",
|
||||
userTz: "Asia/Tokyo",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -238,4 +238,59 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
expect(PROBE).toHaveBeenCalledWith("user-fresh");
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.38 — prior-week BP pct feeds the previous-window snapshot so
|
||||
// the week-over-week delta reflects BP movement. Legacy callers that
|
||||
// omit the field keep the pre-v1.4.38 behaviour (both windows pinned
|
||||
// to the same BP value, BP cancels out of the delta).
|
||||
describe("prior-week BP pct contract", () => {
|
||||
it("feeds bpInTargetPctPriorWeek into the previous-window snapshot when set", async () => {
|
||||
const coverage = new Map<string, boolean>([]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-bpdelta",
|
||||
bpInTargetPct: 90,
|
||||
bpInTargetPctPriorWeek: 70,
|
||||
heightCm: null,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
// With only BP data the helper still returns a score. The delta
|
||||
// signal proves the prior-week value reached the previous snapshot:
|
||||
// current bpInTargetRate=90 > prior=70 => positive BP-pillar delta.
|
||||
expect(result).not.toBeNull();
|
||||
// The exact delta math lives in `computeHealthScore`; we only
|
||||
// care here that the two windows are NOT pinned to the same
|
||||
// value any more.
|
||||
const componentDelta = result?.delta;
|
||||
expect(typeof componentDelta === "number" || componentDelta === null).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to bpInTargetPct when bpInTargetPctPriorWeek is omitted", async () => {
|
||||
const coverage = new Map<string, boolean>([]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
// Both runs use the same bpInTargetPct in both windows; the BP
|
||||
// pillar contributes zero to the delta. Pre-v1.4.38 behaviour
|
||||
// preserved for callers that don't opt in.
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-bplegacy",
|
||||
bpInTargetPct: 80,
|
||||
// bpInTargetPctPriorWeek omitted — should fall back to 80.
|
||||
heightCm: null,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
} from "@/lib/measurements/rollup-coverage";
|
||||
import { isNearUtc } from "@/lib/tz/resolver";
|
||||
import {
|
||||
computeBpInTargetWindows,
|
||||
isBpReadingInTarget,
|
||||
@@ -114,23 +115,51 @@ export async function computeBpInTargetFastPath(input: {
|
||||
targets: BpTargets;
|
||||
now: Date;
|
||||
coverage?: RollupCoverageMap;
|
||||
/**
|
||||
* v1.4.38 W-A — optional user timezone for the cross-tz runtime
|
||||
* guard. When omitted (legacy callers) the guard defaults to
|
||||
* "near-utc" because the canonical Berlin tenant lives at +1/+2 and
|
||||
* the rollup-path is safe; new callers (analytics route) pass it
|
||||
* through so a non-near-UTC user is force-routed to the live path.
|
||||
*/
|
||||
userTz?: string;
|
||||
}): Promise<BpInTargetEnvelope> {
|
||||
const { userId, targets, now } = input;
|
||||
const { userId, targets, now, userTz } = input;
|
||||
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
|
||||
|
||||
const sysCovered = coverage.get("BLOOD_PRESSURE_SYS") === true;
|
||||
const diaCovered = coverage.get("BLOOD_PRESSURE_DIA") === true;
|
||||
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The rollup path keys rows on
|
||||
// `d.toISOString().slice(0, 10)` (UTC slice) and compares them
|
||||
// against `now - N*DAY_MS` boundaries. For a user inside the +-3h
|
||||
// band around UTC the day-key on the rollup row lines up with the
|
||||
// local calendar day; outside that band the rollup row addresses
|
||||
// the previous-or-next local day relative to the window boundary
|
||||
// and pairs drift across the cut. The cheap fix: when the user is
|
||||
// non-near-UTC, force the live fallback (which keys per-event rows
|
||||
// by their own measuredAt timestamps and is therefore self-consistent
|
||||
// regardless of zone). A v1.5 follow-up will thread `userTz` into
|
||||
// `readRollupBuckets` so the rollup path can address local-day
|
||||
// buckets and this guard can come back down. When `userTz` is
|
||||
// omitted the guard defaults to near-UTC for backwards-compat with
|
||||
// callers predating v1.4.38.
|
||||
const userNearUtc = userTz === undefined ? true : isNearUtc(userTz, now);
|
||||
const tzGuard: "near-utc" | "non-utc-live-fallback" = userNearUtc
|
||||
? "near-utc"
|
||||
: "non-utc-live-fallback";
|
||||
|
||||
// Belt-and-braces: only take the rollup path when EVERY logged type
|
||||
// is covered AND both BP types are present in the map. A brand-new
|
||||
// user without BP at all falls into `isFullyCovered === false`
|
||||
// (coverage map is empty) and the route's downstream code already
|
||||
// skips the BP block when `getBpTargets` returns null; we still call
|
||||
// the live fallback here so the helper's contract stays single-shot.
|
||||
if (isFullyCovered(coverage) && sysCovered && diaCovered) {
|
||||
return computeFromRollups(userId, targets, now);
|
||||
// is covered AND both BP types are present in the map AND the
|
||||
// cross-tz guard cleared. A brand-new user without BP at all falls
|
||||
// into `isFullyCovered === false` (coverage map is empty) and the
|
||||
// route's downstream code already skips the BP block when
|
||||
// `getBpTargets` returns null; we still call the live fallback here
|
||||
// so the helper's contract stays single-shot.
|
||||
if (userNearUtc && isFullyCovered(coverage) && sysCovered && diaCovered) {
|
||||
return computeFromRollups(userId, targets, now, tzGuard);
|
||||
}
|
||||
return computeFromLive(userId, targets, now);
|
||||
return computeFromLive(userId, targets, now, tzGuard);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,12 +171,30 @@ async function computeFromRollups(
|
||||
userId: string,
|
||||
targets: BpTargets,
|
||||
now: Date,
|
||||
tzGuard: "near-utc" | "non-utc-live-fallback",
|
||||
): Promise<BpInTargetEnvelope> {
|
||||
// v1.4.22 W5 reconcile (Code-H2) — the priorYear window starts 395
|
||||
// days ago. We read 396 days so the boundary day rolls cleanly into
|
||||
// the prior-year bucket without being dropped.
|
||||
// days ago. We read one extra day so the boundary day rolls cleanly
|
||||
// into the prior-year bucket without being dropped.
|
||||
//
|
||||
// v1.4.38 — span the read window via calendar arithmetic ("12 months
|
||||
// back, then 31 more days") instead of `now - 396 * 24h`. On a leap
|
||||
// year the 396-day constant is 1 day shy of the intended priorYear
|
||||
// span, so the boundary day disappears from the rollup-derived
|
||||
// envelope while the live path's per-event walk keeps it. Below the
|
||||
// n=20 surface gate this rarely matters in practice — but the
|
||||
// helpers stay calendar-correct now so a leap-year edge can never
|
||||
// silently shift the priorYear pct.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const readSince = new Date(now.getTime() - 396 * DAY_MS);
|
||||
const readSince = (() => {
|
||||
const d = new Date(now.getTime());
|
||||
d.setUTCFullYear(d.getUTCFullYear() - 1);
|
||||
// 31 days past the 12-months mark covers the priorYear 395-day
|
||||
// boundary plus the same one-day cushion the original 396-day
|
||||
// constant provided.
|
||||
d.setUTCDate(d.getUTCDate() - 31);
|
||||
return d;
|
||||
})();
|
||||
|
||||
const [sysBuckets, diaBuckets] = await Promise.all([
|
||||
readRollupBuckets(
|
||||
@@ -168,7 +215,7 @@ async function computeFromRollups(
|
||||
|
||||
const sysByDay = new Map<string, DayBucketReading>();
|
||||
for (const r of sysBuckets) {
|
||||
sysByDay.set(dayKey(r.bucketStart), {
|
||||
sysByDay.set(bucketDayKey(r.bucketStart), {
|
||||
day: r.bucketStart,
|
||||
meanValue: r.mean,
|
||||
perDayCount: r.count,
|
||||
@@ -176,7 +223,7 @@ async function computeFromRollups(
|
||||
}
|
||||
const diaByDay = new Map<string, DayBucketReading>();
|
||||
for (const r of diaBuckets) {
|
||||
diaByDay.set(dayKey(r.bucketStart), {
|
||||
diaByDay.set(bucketDayKey(r.bucketStart), {
|
||||
day: r.bucketStart,
|
||||
meanValue: r.mean,
|
||||
perDayCount: r.count,
|
||||
@@ -242,6 +289,9 @@ async function computeFromRollups(
|
||||
sys_rows: sysBuckets.reduce((s, b) => s + b.count, 0),
|
||||
dia_rows: diaBuckets.reduce((s, b) => s + b.count, 0),
|
||||
path: "rollup",
|
||||
// v1.4.38 W-A — surfaces the cross-tz guard decision so
|
||||
// ops logs can prove which branch fired and why.
|
||||
tz_guard: tzGuard,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -293,7 +343,25 @@ function bucketWindow(
|
||||
};
|
||||
}
|
||||
|
||||
function dayKey(d: Date): string {
|
||||
/**
|
||||
* v1.4.38 — UTC-slice bucket key for the rollup pairing.
|
||||
*
|
||||
* The rollup table stores `bucketStart` as UTC midnight; we slice the
|
||||
* ISO string to get a `YYYY-MM-DD` key in the bucket's own zone, NOT
|
||||
* the user's local zone. That is correct here because both the SYS
|
||||
* and DIA buckets live in UTC and we pair them against each other
|
||||
* inside `computeFromRollups` — not against any user-local data.
|
||||
*
|
||||
* Renamed from the generic `dayKey` to make the UTC nature explicit.
|
||||
* Do NOT swap in `userDayKey()` from `@/lib/tz/resolver` without
|
||||
* first auditing the call sites: `userDayKey` keys per the user's
|
||||
* IANA zone and would produce a different string for a non-near-UTC
|
||||
* tenant, while the rollup `bucketStart` would not. The cross-tz
|
||||
* guard inside `computeBpInTargetFastPath` already routes
|
||||
* non-near-UTC users to the live fallback, so the UTC-slice key is
|
||||
* safe for every code path that reaches this helper.
|
||||
*/
|
||||
function bucketDayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -307,6 +375,7 @@ async function computeFromLive(
|
||||
userId: string,
|
||||
targets: BpTargets,
|
||||
now: Date,
|
||||
tzGuard: "near-utc" | "non-utc-live-fallback",
|
||||
): Promise<BpInTargetEnvelope> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const bpInTargetSince = new Date(now.getTime() - 365 * DAY_MS);
|
||||
@@ -325,6 +394,9 @@ async function computeFromLive(
|
||||
sys_rows: sysData.length,
|
||||
dia_rows: diaData.length,
|
||||
path: "live",
|
||||
// v1.4.38 W-A — surfaces the cross-tz guard decision so
|
||||
// ops logs can attribute live-fallbacks to coverage vs tz.
|
||||
tz_guard: tzGuard,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
correlateWeightWeekday,
|
||||
type CorrelationResult,
|
||||
} from "@/lib/insights/correlations";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
|
||||
@@ -95,6 +95,13 @@ export interface CorrelationHypothesesResult {
|
||||
* Today this is always `false`: the 28-day window is the canonical
|
||||
* surface. Reserved for a future "best-effort under load shedding"
|
||||
* branch.
|
||||
*
|
||||
* TODO(v1.5): wire this through when the load-shedding branch
|
||||
* lands (pool-pressure detector + shorter window fallback). Until
|
||||
* then the field is pinned to `false` by both branches and the
|
||||
* `meta.correlations.degraded` annotate carries the same value;
|
||||
* downstream consumers can already key on the field shape without
|
||||
* the load-shedding signal being live.
|
||||
*/
|
||||
degraded: boolean;
|
||||
/** Window the runner actually scanned, in days. */
|
||||
@@ -119,12 +126,27 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
|
||||
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
|
||||
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The rollup table buckets at
|
||||
// UTC midnight, while the per-event mood / intake streams below are
|
||||
// re-keyed via `userDayKey(..., userTz)`. For a user inside the +-3h
|
||||
// band around UTC (Europe + western Mid-Atlantic), the two keying
|
||||
// schemes line up on the same calendar day. Outside that band the
|
||||
// pairing slips by a day and the Pearson / ANOVA inputs miscorrelate.
|
||||
// The cheap fix: when the user is non-near-UTC, force the live path
|
||||
// so the SYS / PULSE / WEIGHT per-day means are re-keyed via the same
|
||||
// `userDayKey(measuredAt, userTz)` helper the mood / intake streams
|
||||
// use, guaranteeing day-key parity. A v1.5 follow-up will thread
|
||||
// `userTz` into `readRollupBuckets` so the rollup path also addresses
|
||||
// local-day buckets and this guard can come back down.
|
||||
const userNearUtc = isNearUtc(userTz, now);
|
||||
|
||||
// The three measurement-derived streams ride the rollup-fast-path
|
||||
// only when EVERY type the user has logged is covered AND each of
|
||||
// SYS / PULSE / WEIGHT is in the coverage map. Partial coverage
|
||||
// falls back to the live path so a brand-new metric type doesn't
|
||||
// make the correlation card vanish.
|
||||
const measurementsOnRollups =
|
||||
userNearUtc &&
|
||||
isFullyCovered(coverage) &&
|
||||
coverage.get("BLOOD_PRESSURE_SYS") === true &&
|
||||
coverage.get("PULSE") === true &&
|
||||
@@ -295,6 +317,10 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
path: measurementsOnRollups ? "rollup" : "live",
|
||||
window_days: CORRELATION_WINDOW_DAYS,
|
||||
degraded: false,
|
||||
// v1.4.38 W-A — surfaces the cross-tz guard decision so ops
|
||||
// logs can distinguish "user is Berlin, rollup path eligible"
|
||||
// from "user is Honolulu, forced to live regardless of coverage".
|
||||
tz_guard: userNearUtc ? "near-utc" : "non-utc-live-fallback",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,6 +98,17 @@ function uniqueComponentSources(
|
||||
export interface HealthScoreFastPathInput {
|
||||
userId: string;
|
||||
bpInTargetPct: number | null;
|
||||
/**
|
||||
* v1.4.38 — prior-week BP-in-target pct so the week-over-week delta
|
||||
* reflects BP movement instead of always landing at zero for the
|
||||
* pillar. The caller is expected to run a second
|
||||
* `computeBpInTargetFastPath` against `now - 7d` and pass the
|
||||
* resulting `last30Days.pct` through. When omitted (legacy callers
|
||||
* predating v1.4.38) the helper falls back to `bpInTargetPct` —
|
||||
* preserves the pre-v1.4.38 behaviour exactly so the bump is
|
||||
* additive.
|
||||
*/
|
||||
bpInTargetPctPriorWeek?: number | null;
|
||||
heightCm: number | null;
|
||||
now: Date;
|
||||
/**
|
||||
@@ -120,6 +131,16 @@ export async function computeUserHealthScoreFastPath(
|
||||
): Promise<HealthScoreResult | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const { userId, bpInTargetPct, heightCm, now } = input;
|
||||
// v1.4.38 — prior-week BP fallback. When the caller omits the field
|
||||
// we pin to the current value (pre-v1.4.38 behaviour) so the helper
|
||||
// stays a drop-in replacement for legacy callers; when the caller
|
||||
// supplies it we feed it into the previous-window snapshot so the
|
||||
// delta reflects week-over-week BP movement instead of always
|
||||
// zeroing the BP pillar.
|
||||
const bpInTargetPctPriorWeek =
|
||||
input.bpInTargetPctPriorWeek === undefined
|
||||
? bpInTargetPct
|
||||
: input.bpInTargetPctPriorWeek;
|
||||
const since30d = new Date(now.getTime() - 30 * DAY_MS);
|
||||
const prevSince30d = new Date(now.getTime() - 37 * DAY_MS);
|
||||
const prevUntil = new Date(now.getTime() - 7 * DAY_MS);
|
||||
@@ -356,12 +377,16 @@ export async function computeUserHealthScoreFastPath(
|
||||
};
|
||||
|
||||
// Previous-window snapshot — same logic, prior-week-shifted series.
|
||||
// The bpInTargetPct stays pinned to the current value because the
|
||||
// route does not pay for a second historical pair-search; the delta
|
||||
// reflects week-over-week movement in the weight / mood / compliance
|
||||
// pillars only.
|
||||
//
|
||||
// v1.4.38 — when the caller supplies `bpInTargetPctPriorWeek` we
|
||||
// use it so the delta reflects week-over-week BP movement. Legacy
|
||||
// callers that omit the field keep the pre-v1.4.38 behaviour where
|
||||
// BP cancels out of the delta because the helper pins both windows
|
||||
// to the same value. Re-computing the prior-week BP costs one
|
||||
// extra rollup-coverage read on the route side; the helper itself
|
||||
// stays single-pass.
|
||||
const previous: HealthScoreInput = {
|
||||
bpInTargetRate: bpInTargetPct,
|
||||
bpInTargetRate: bpInTargetPctPriorWeek,
|
||||
weightSeriesLast30d: weightSeriesPrev30d,
|
||||
weightTargetKg: fallbackTarget,
|
||||
moodEntriesLast30d: moodSeriesPrev30d,
|
||||
|
||||
+12
-1
@@ -1,3 +1,4 @@
|
||||
import { isIP } from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function apiSuccess<T>(data: T, status = 200) {
|
||||
@@ -86,8 +87,18 @@ export async function safeJson<T = unknown>(
|
||||
*
|
||||
* Returns the resolved IP or null when no trusted source is available.
|
||||
*/
|
||||
/**
|
||||
* v1.4.38 — strict IP validation via Node's built-in `net.isIP`. The
|
||||
* earlier regex `/^[0-9a-fA-F.:]+$/` matched any structurally-broken
|
||||
* input the character set accepted (`:::`, `1.2`, `1.2.3`, `gg:hh::`).
|
||||
* The cf-connecting-ip flow trusts the header under the env flag and
|
||||
* the trusted-proxy XFF chain forwards the rightmost entry straight
|
||||
* to the rate-limiter + audit log; a malformed value would land
|
||||
* downstream unchanged. `isIP` returns 4 / 6 for valid v4 / v6 and 0
|
||||
* for anything else — invert to a boolean for the helper's surface.
|
||||
*/
|
||||
function looksLikeIp(s: string): boolean {
|
||||
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
|
||||
return isIP(s) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { applyProfileUpdate } from "../profile-update";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
|
||||
const STUB_USER = {
|
||||
id: USER_ID,
|
||||
username: "marc",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
locale: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(STUB_USER as never);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate timezone validation", () => {
|
||||
it("accepts a valid IANA zone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an unknown zone with 422", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Mars/Tharsis",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
expect(result.message).toMatch(/Invalid IANA timezone/i);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty-string timezone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { timezone: "" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an injection-shaped string", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Europe/Berlin' OR 1=1",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,18 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
displayName: z.string().min(1).max(80).nullable().optional(),
|
||||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||||
timezone: z.string().min(1).max(64).optional(),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
Vendored
+5
@@ -67,6 +67,11 @@ export function invalidateUserMedications(userId: string): void {
|
||||
// v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE
|
||||
// target rollup (compliance7 / compliance30 / consistency strip).
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
|
||||
// cache and reads `medicationIntakeEvent` for both today's compliance
|
||||
// tally and the 365-day streak feed. A taken / skipped event must
|
||||
// evict the user-bucket so the next iOS poll reflects the change.
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -5,7 +8,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import type { AssistantFlagSet } from "@/hooks/use-feature-flags";
|
||||
import {
|
||||
DEFAULT_ASSISTANT_FLAGS,
|
||||
type AssistantFlagSet,
|
||||
} from "@/hooks/use-feature-flags";
|
||||
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
|
||||
@@ -13,6 +19,24 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
|
||||
// v1.4.38 W-C M-5 — capture the original `useFeatureFlags` and route
|
||||
// the spy through it so the gate-fired assertion below can verify the
|
||||
// hook ran with the operator-disabled matrix. Setting `vi.mock()` here
|
||||
// (instead of `vi.spyOn`) is required because the components import the
|
||||
// hook at module load; a runtime `spyOn` would never intercept the call.
|
||||
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
|
||||
() => DEFAULT_ASSISTANT_FLAGS,
|
||||
);
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => featureFlagsSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.37 W5 — Coach disable cascade invariant.
|
||||
*
|
||||
@@ -60,6 +84,16 @@ function buildClient(flags: Partial<AssistantFlagSet>): QueryClient {
|
||||
}
|
||||
|
||||
function renderWithFlags(node: React.ReactNode, coach: boolean): string {
|
||||
// v1.4.38 W-C M-5 — drive the mocked `useFeatureFlags` from the same
|
||||
// matrix the QueryClient is seeded with so the gate-call assertion at
|
||||
// the bottom of the file can read the resolved value. Keeping the
|
||||
// QueryClient seed in place preserves the original render path for
|
||||
// any consumer that bypasses the hook.
|
||||
const resolved: AssistantFlagSet = {
|
||||
...DEFAULT_ASSISTANT_FLAGS,
|
||||
coach,
|
||||
};
|
||||
featureFlagsSpy.mockImplementation(() => resolved);
|
||||
const client = buildClient({ coach });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
@@ -156,7 +190,9 @@ const COACH_SURFACES: CoachSurface[] = [
|
||||
// The drawer is lazy-loaded via next/dynamic and SSRs to nothing
|
||||
// even when the flag is on; the contract here is "no Coach-marked
|
||||
// mount point ever paints when the flag is off". Skip the proof
|
||||
// check; the negative `coach-*` slot grep still pins the gate.
|
||||
// check; the negative `coach-*` slot grep still pins the gate, and
|
||||
// the explicit spy assertion in `LayoutCoachMount SSR-proof spy`
|
||||
// below proves the gate actually fired during the off render.
|
||||
proofWhenOn: "",
|
||||
},
|
||||
];
|
||||
@@ -167,6 +203,11 @@ describe("Coach disable cascade invariant", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
// v1.4.38 W-C M-5 — reset the call history so each iteration of
|
||||
// the surface loop sees a clean slate when it asserts the gate
|
||||
// fired during the off render.
|
||||
featureFlagsSpy.mockClear();
|
||||
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
|
||||
});
|
||||
|
||||
for (const surface of COACH_SURFACES) {
|
||||
@@ -199,9 +240,142 @@ describe("Coach disable cascade invariant", () => {
|
||||
'data-slot="insights-hero-strip-action-coach"',
|
||||
);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
|
||||
// v1.4.38 W-C M-5 — `proofWhenOn: ""` surfaces (the lazy-loaded
|
||||
// `LayoutCoachMount` drawer subtree) trivially pass the substring
|
||||
// grep because their SSR shape is empty even when the gate is on.
|
||||
// The spy assertion below proves the gate actually fired with the
|
||||
// operator-disabled matrix, so a regression that removes the
|
||||
// `if (!flags.coach) return null` short-circuit fails this test
|
||||
// instead of slipping through under cover of the empty SSR shape.
|
||||
expect(featureFlagsSpy).toHaveBeenCalled();
|
||||
const lastCall = featureFlagsSpy.mock.results.at(-1);
|
||||
expect(lastCall?.value.coach).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// v1.4.38 W-C H4 — grep-based discovery of every `flags.coach`
|
||||
// call site in the codebase.
|
||||
//
|
||||
// The fixture-count check below pins the surfaces the fixture mounts
|
||||
// directly. Cross-cut gates on sub-pages (`/targets` page drawer,
|
||||
// `<TargetCard>` per-card CTA) sit OUTSIDE the fixture and are owned
|
||||
// by sibling invariants. A future contributor who lands a new Coach
|
||||
// mount on (e.g.) `/insights/blutdruck/page.tsx` would not trip the
|
||||
// surface-count check at all — the new site is on a sub-page and
|
||||
// would silently leak.
|
||||
//
|
||||
// The walk below scans `src/` for every `flags.coach` occurrence and
|
||||
// requires each path to appear in the explicit `KNOWN_COACH_GATE_SITES`
|
||||
// allowlist. Add a new entry every time you intentionally gate a new
|
||||
// render path on the Coach flag; the failure message names the
|
||||
// unaccounted-for paths so the fix is one search-and-add.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every file in `src/` that legitimately reads `flags.coach`. Lives
|
||||
* here (next to the cascade fixture) so a contributor extending the
|
||||
* fixture sees the allowlist in the same review.
|
||||
*
|
||||
* Paths are POSIX-style relative to the repo root.
|
||||
*/
|
||||
const KNOWN_COACH_GATE_SITES: ReadonlyArray<string> = [
|
||||
// Coach-bearing surfaces mounted directly by the cascade fixture.
|
||||
"src/components/insights/coach-launch-button.tsx",
|
||||
"src/components/insights/hero-strip.tsx",
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// Cross-cut gates owned by sibling invariants
|
||||
// (`targets-coach-mount.test.tsx`, `target-card.test.tsx`).
|
||||
"src/app/targets/page.tsx",
|
||||
"src/components/targets/target-card.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
* Walk every TS / TSX file under `src/`, excluding test trees, and
|
||||
* collect the relative paths that contain a `flags.coach` token. The
|
||||
* grep is intentionally simple — any literal occurrence counts so a
|
||||
* contributor who paraphrases the read (`const c = flags.coach`)
|
||||
* still trips this test.
|
||||
*/
|
||||
function findCoachFlagSites(): string[] {
|
||||
const srcRoot = resolve(__dirname, "..", "..", "..");
|
||||
const hits: string[] = [];
|
||||
const repoRoot = resolve(srcRoot, "..");
|
||||
|
||||
function walk(dir: string): void {
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "node_modules" || entry === ".next") continue;
|
||||
// Skip every `__tests__/` subtree — the discovery target is
|
||||
// the production gate, not the assertions about it.
|
||||
if (entry === "__tests__") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!/\.(tsx|ts)$/.test(entry)) continue;
|
||||
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
|
||||
const text = readFileSync(full, "utf8");
|
||||
// Match `flags.coach` on lines that are NOT pure comments. A
|
||||
// hit on a JSDoc / `//` line is documentation, not a gate, and
|
||||
// would make the allowlist a maintenance burden every time
|
||||
// someone references the gate in a comment.
|
||||
const isRealGate = text.split("\n").some((line) => {
|
||||
if (!line.includes("flags.coach")) return false;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("*")) return false;
|
||||
return true;
|
||||
});
|
||||
if (isRealGate) {
|
||||
// POSIX-style path relative to the repo root so the failure
|
||||
// message stays portable across contributors' checkout
|
||||
// directories.
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(srcRoot);
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
it("grep-based discovery pins every `flags.coach` call site", () => {
|
||||
const discovered = findCoachFlagSites();
|
||||
const allowlist = new Set(KNOWN_COACH_GATE_SITES);
|
||||
const orphans = discovered.filter((path) => !allowlist.has(path));
|
||||
expect(
|
||||
orphans,
|
||||
[
|
||||
"Found `flags.coach` in file(s) not on the KNOWN_COACH_GATE_SITES",
|
||||
"allowlist (src/lib/feature-flags/__tests__/coach-cascade.test.tsx).",
|
||||
"Add a fixture entry (or a sibling invariant) for the new gate,",
|
||||
"then add the path to the allowlist. Orphans:",
|
||||
...orphans.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
|
||||
// Sanity check the other direction — an allowlist entry that no
|
||||
// longer references `flags.coach` is dead documentation and
|
||||
// should be pruned in the same PR that removes the gate.
|
||||
const discoveredSet = new Set(discovered);
|
||||
const stale = KNOWN_COACH_GATE_SITES.filter(
|
||||
(path) => !discoveredSet.has(path),
|
||||
);
|
||||
expect(
|
||||
stale,
|
||||
[
|
||||
"KNOWN_COACH_GATE_SITES references file(s) that no longer use",
|
||||
"`flags.coach`. Remove the stale allowlist entry:",
|
||||
...stale.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("fixture stays in sync with the Coach gate call sites", () => {
|
||||
// Counts the Coach-bearing surfaces the fixture knows about. If a
|
||||
// contributor adds a new `useFeatureFlags()` + `flags.coach` gate
|
||||
@@ -210,7 +384,8 @@ describe("Coach disable cascade invariant", () => {
|
||||
// surfaces the fixture mounts directly; cross-cut gates on
|
||||
// sub-pages (target-card, /targets page) are owned by other
|
||||
// invariants (`targets-coach-mount.test.tsx`,
|
||||
// `target-card.test.tsx`).
|
||||
// `target-card.test.tsx`) and by the grep-based discovery test
|
||||
// above.
|
||||
expect(COACH_SURFACES.length).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -116,15 +116,18 @@ describe("buildComprehensiveAggregate", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
// v1.4.38 W-F — sys + dia merged into a single `findMany`
|
||||
// (`type: { in: [...] }`) → one mock call carrying both rows
|
||||
// (empty here since the rollup-fresh fixture doesn't exercise
|
||||
// the BP pairing). The aggregator partitions by type in JS so
|
||||
// the bpRawRows.sys / .dia byte-shape is preserved.
|
||||
FIND_MANY.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-rollup-fresh");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
// count/min/max/mean composed from buckets — the test asserts the
|
||||
// bucket-derived values are present even though we never wired up
|
||||
// a heavy aggregate row for the parity check.
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
@@ -154,7 +157,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
// count + by checking that the narrow projection (no min_value /
|
||||
// max_value / mean_value cols) is what landed.
|
||||
expect(RAW).toHaveBeenCalledTimes(4);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
// v1.4.38 W-F — sys + dia merged into a single round-trip.
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -168,8 +172,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
RAW.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
// v1.4.38 W-F — sys + dia merged into one `findMany`.
|
||||
FIND_MANY.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-empty");
|
||||
|
||||
@@ -180,7 +184,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
expect(result.firstMeasurementAt).toBeNull();
|
||||
expect(result.totalMeasurements).toBe(0);
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
// v1.4.38 W-F — sys + dia merged → single findMany.
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
// The cold path's rollup.findMany still fires (in case some
|
||||
// buckets exist for a subset of types post-race), but returns [].
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
@@ -219,8 +224,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
.mockResolvedValueOnce([
|
||||
{ first_at: new Date(now.getTime() - 86400000) },
|
||||
]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
// v1.4.38 W-F — sys + dia merged into one round-trip.
|
||||
FIND_MANY.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-cold");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
@@ -293,9 +298,13 @@ describe("buildComprehensiveAggregate", () => {
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ first_at: measuredAt }]);
|
||||
// v1.4.38 W-F — single merged `findMany` returning both sys + dia
|
||||
// rows tagged by `type`. The aggregator partitions in JS, so the
|
||||
// bpRawRows.sys / .dia shape stays byte-identical.
|
||||
FIND_MANY.mockResolvedValueOnce([
|
||||
{ measuredAt, value: 120 },
|
||||
]).mockResolvedValueOnce([{ measuredAt, value: 80 }]);
|
||||
{ type: "BLOOD_PRESSURE_SYS", measuredAt, value: 120 },
|
||||
{ type: "BLOOD_PRESSURE_DIA", measuredAt, value: 80 },
|
||||
]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-bp");
|
||||
expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]);
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
|
||||
import {
|
||||
@@ -233,6 +234,12 @@ export async function buildComprehensiveAggregate(
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// v1.4.38 W-F — per-sub-query wall-clock timing. Emitted under
|
||||
// `meta.insights.sub_*_ms` so the next perf-verify can attribute
|
||||
// a regression to a specific aggregate (narrow, latests, day-buckets,
|
||||
// bp-raw, first-at, coverage-probe) without re-instrumenting.
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
// v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts`
|
||||
// for the full rationale: awaiting this on the read path can stall
|
||||
// the event loop for 30–60 s on power-user accounts whose iOS step
|
||||
@@ -252,11 +259,71 @@ export async function buildComprehensiveAggregate(
|
||||
// and only take the rollup path when EVERY type the user has logged
|
||||
// is covered. Partial coverage falls back to the live aggregate so
|
||||
// a fresh-type measurement doesn't break its summary card.
|
||||
const probeStart = Date.now();
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
if (isFullyCovered(coverage)) {
|
||||
return buildFromRollups(userId, ninetyDaysAgo);
|
||||
timings["insights.sub_coverage_ms"] = Date.now() - probeStart;
|
||||
|
||||
const result = isFullyCovered(coverage)
|
||||
? await buildFromRollups(userId, ninetyDaysAgo, timings)
|
||||
: await buildFromLiveAggregate(userId, ninetyDaysAgo, timings);
|
||||
|
||||
annotate({ meta: timings });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 W-F — tiny timing helper. Wraps a promise-returning sub-query
|
||||
* builder so each Promise.all slot lands a wall-clock observation under
|
||||
* `meta.insights.sub_<label>_ms`. The helper itself never throws — the
|
||||
* builder's rejection propagates unchanged so the existing error
|
||||
* handling stays intact.
|
||||
*/
|
||||
function timeSubquery<T>(
|
||||
timings: Record<string, number>,
|
||||
label: string,
|
||||
builder: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const t0 = Date.now();
|
||||
return builder().then((value) => {
|
||||
timings[`insights.sub_${label}_ms`] = Date.now() - t0;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 W-F — fetch the trailing-90d sys + dia raw rows in a single
|
||||
* `findMany` (`type: { in: [...] }`) rather than two parallel queries.
|
||||
* Saves one round-trip + lets Postgres consolidate the index scan.
|
||||
* Partitioning by type in JS preserves the byte-shape of the legacy
|
||||
* sys / dia arrays (ASC by measuredAt — already enforced by the SQL
|
||||
* ORDER BY).
|
||||
*/
|
||||
async function fetchBpRawRows(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
): Promise<{
|
||||
sys: Array<{ measuredAt: Date; value: number }>;
|
||||
dia: Array<{ measuredAt: Date; value: number }>;
|
||||
}> {
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, measuredAt: true, value: true },
|
||||
});
|
||||
const sys: Array<{ measuredAt: Date; value: number }> = [];
|
||||
const dia: Array<{ measuredAt: Date; value: number }> = [];
|
||||
for (const r of rows) {
|
||||
if (r.type === "BLOOD_PRESSURE_SYS") {
|
||||
sys.push({ measuredAt: r.measuredAt, value: r.value });
|
||||
} else if (r.type === "BLOOD_PRESSURE_DIA") {
|
||||
dia.push({ measuredAt: r.measuredAt, value: r.value });
|
||||
}
|
||||
}
|
||||
return buildFromLiveAggregate(userId, ninetyDaysAgo);
|
||||
return { sys, dia };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,9 +334,12 @@ export async function buildComprehensiveAggregate(
|
||||
async function buildFromRollups(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
timings: Record<string, number>,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const [narrows, latests, dayBuckets, sysRaw, diaRaw, firstRows] = await Promise.all([
|
||||
prisma.$queryRaw<NarrowAggregateRow[]>`
|
||||
// v1.4.38 W-F — bp sys + dia consolidated into one `findMany`
|
||||
// (`type: { in: [...] }`) plus per-sub-query wall-clock timings.
|
||||
const [narrows, latests, dayBuckets, bpRawRows, firstRows] = await Promise.all([
|
||||
timeSubquery(timings, "narrow", () => prisma.$queryRaw<NarrowAggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -345,8 +415,8 @@ async function buildFromRollups(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
`),
|
||||
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -355,45 +425,32 @@ async function buildFromRollups(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
`),
|
||||
// v1.4.35 — DAY rollup buckets for the trailing 90 days, every
|
||||
// type. Drives both the per-type `count / min / max / mean`
|
||||
// composition (via `aggregateBuckets`) and the `dailyByType`
|
||||
// correlation feed below. Replaces the legacy
|
||||
// `date_trunc('day', m."measured_at")` GROUP BY query and the
|
||||
// per-type AVG/MIN/MAX/COUNT columns the live aggregate kept.
|
||||
prisma.measurementRollup.findMany({
|
||||
timeSubquery(timings, "buckets", () => prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
})),
|
||||
// v1.4.38 W-F — bp sys + dia merged into one round-trip. Halves
|
||||
// the bp-raw cost on accounts whose iOS Health source spams BP
|
||||
// readings; the per-type partition in `fetchBpRawRows` preserves
|
||||
// the legacy `bpRawRows.sys` / `bpRawRows.dia` byte-shape.
|
||||
timeSubquery(timings, "bpRaw", () => fetchBpRawRows(userId, ninetyDaysAgo)),
|
||||
timeSubquery(timings, "firstAt", () => prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`,
|
||||
`),
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
@@ -447,10 +504,7 @@ async function buildFromRollups(
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
bpRawRows,
|
||||
dailyByType: buildDailyByType(bucketsByType),
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
@@ -467,9 +521,13 @@ async function buildFromRollups(
|
||||
async function buildFromLiveAggregate(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
timings: Record<string, number>,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const [aggregates, latests, dayBuckets, sysRaw, diaRaw] = await Promise.all([
|
||||
prisma.$queryRaw<HeavyAggregateRow[]>`
|
||||
// v1.4.38 W-F — same sub-query timing + consolidated bp-raw read as
|
||||
// the rollup-fresh path so the prod observability covers cold mounts
|
||||
// too.
|
||||
const [aggregates, latests, dayBuckets, bpRawRows] = await Promise.all([
|
||||
timeSubquery(timings, "heavy", () => prisma.$queryRaw<HeavyAggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -545,8 +603,8 @@ async function buildFromLiveAggregate(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
`),
|
||||
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -555,37 +613,21 @@ async function buildFromLiveAggregate(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
`),
|
||||
// Buckets may exist for some types even when the COUNT probe came
|
||||
// back zero (race window between the probe and a sibling
|
||||
// populator); the cold-path read still honours them for the
|
||||
// `dailyByType` feed.
|
||||
prisma.measurementRollup.findMany({
|
||||
timeSubquery(timings, "buckets", () => prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
})),
|
||||
// v1.4.38 W-F — bp sys + dia in one round-trip (see `fetchBpRawRows`).
|
||||
timeSubquery(timings, "bpRaw", () => fetchBpRawRows(userId, ninetyDaysAgo)),
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
@@ -624,12 +666,14 @@ async function buildFromLiveAggregate(
|
||||
}
|
||||
|
||||
if (totalMeasurements > 0) {
|
||||
const firstRows = await prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`;
|
||||
const firstRows = await timeSubquery(timings, "firstAt", () =>
|
||||
prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`,
|
||||
);
|
||||
firstMeasurementAt = firstRows[0]?.first_at
|
||||
? new Date(firstRows[0].first_at)
|
||||
: null;
|
||||
@@ -637,10 +681,7 @@ async function buildFromLiveAggregate(
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
bpRawRows,
|
||||
dailyByType: buildDailyByType(bucketsByType),
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
|
||||
@@ -28,15 +28,23 @@ describe("reminder-worker — drainPerSampleCumulative nightly schedule", () =>
|
||||
expect(source).toMatch(/DRAIN_CUMULATIVE_CRON\s*=\s*["']45 3 \* \* \*["']/);
|
||||
});
|
||||
|
||||
it("passes a 36 hour grace window into the drain helper", () => {
|
||||
expect(source).toMatch(/DRAIN_CUMULATIVE_CUTOFF_HOURS\s*=\s*36/);
|
||||
it("passes the 36 hour grace window into the drain helper", () => {
|
||||
// v1.4.38 — the literal `36` lives in the helper module so the
|
||||
// worker, the admin route, and the CLI all read one source of
|
||||
// truth. The worker still references the constant by name in the
|
||||
// cutoffHours slot so the cron's behaviour is unchanged.
|
||||
expect(source).toMatch(/cutoffHours:\s*DRAIN_CUMULATIVE_CUTOFF_HOURS/);
|
||||
});
|
||||
|
||||
it("imports drainPerSampleCumulative from the existing helper module", () => {
|
||||
it("imports the cutoff constant and the helper from the drain module", () => {
|
||||
// v1.4.38 — the import is now a multi-symbol form. Match the helper
|
||||
// and the constant separately so a future contributor who adds a
|
||||
// third symbol does not break the assertion.
|
||||
expect(source).toMatch(
|
||||
/import\s*\{\s*drainPerSampleCumulative\s*\}\s*from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/,
|
||||
/from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/,
|
||||
);
|
||||
expect(source).toMatch(/\bdrainPerSampleCumulative\b/);
|
||||
expect(source).toMatch(/\bDRAIN_CUMULATIVE_CUTOFF_HOURS\b/);
|
||||
});
|
||||
|
||||
it("registers a boss.work handler against the drain queue", () => {
|
||||
|
||||
@@ -71,8 +71,8 @@ describe("runGeoBackfill", () => {
|
||||
expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP);
|
||||
});
|
||||
|
||||
it("caps the batch at 5000 rows per pass", async () => {
|
||||
expect(GEO_BACKFILL_BATCH_CAP).toBe(5000);
|
||||
it("caps the batch at 500 rows per pass (v1.4.38 — tightened from 5000 to bound the worst-case ipwho.is fallback budget)", async () => {
|
||||
expect(GEO_BACKFILL_BATCH_CAP).toBe(500);
|
||||
});
|
||||
|
||||
it("updates a row with location only when ASN resolver misses", async () => {
|
||||
|
||||
@@ -18,9 +18,16 @@
|
||||
* window typically belong to deleted users or have aged out of
|
||||
* the operational triage window where the `location` chip
|
||||
* matters.
|
||||
* - Capped at 5 000 rows per pass. Limits the worst-case DB-write
|
||||
* fan-out so a single backfill burst can't starve a live login
|
||||
* spike.
|
||||
* - Capped at 500 rows per pass. Each row spends up to 3 s on the
|
||||
* `lookupIpLocation` online fallback (offline MMDB miss → ipwho.is
|
||||
* timeout), so 500 rows × 3 s caps the worst-case pass at ~25 min
|
||||
* and keeps the hourly cron from stacking. Earlier passes ran at
|
||||
* 5 000 rows, which let a single backfill burst block for ~4 h and
|
||||
* starve the next scheduled run. The trade-off: a tenant with a
|
||||
* large null-`location` backlog now drains over multiple hourly
|
||||
* passes instead of one mega-pass — fine because the helper is
|
||||
* idempotent and the admin sign-in overview tolerates a stale
|
||||
* Standort cell for the few hours convergence takes.
|
||||
*
|
||||
* Idempotent: a row that the resolver still cannot match (private IP
|
||||
* sneaking through CF egress, freshly-allocated range the GeoLite2
|
||||
@@ -35,7 +42,7 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
|
||||
|
||||
export const GEO_BACKFILL_BATCH_CAP = 5000;
|
||||
export const GEO_BACKFILL_BATCH_CAP = 500;
|
||||
export const GEO_BACKFILL_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,7 +67,10 @@ import {
|
||||
type RollupFullBackfillPayload,
|
||||
type RollupRecomputePayload,
|
||||
} from "@/lib/measurements/rollups";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
|
||||
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
|
||||
import { deleteMessage } from "@/lib/telegram";
|
||||
@@ -176,7 +179,10 @@ const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *";
|
||||
// fall to the drain.
|
||||
const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative";
|
||||
const DRAIN_CUMULATIVE_CRON = "45 3 * * *";
|
||||
const DRAIN_CUMULATIVE_CUTOFF_HOURS = 36;
|
||||
// v1.4.38 — the per-sample cutoff hours constant now lives on the
|
||||
// helper module so the worker, the admin route, and the CLI all read
|
||||
// the same source of truth. Re-export pulled in alongside
|
||||
// `drainPerSampleCumulative` above.
|
||||
interface DrainCumulativePayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
@@ -1188,10 +1194,32 @@ async function handleFeedbackAggregator(
|
||||
* provider unreachable) and re-resolves them through the now-bundled
|
||||
* resolver chain. The helper is idempotent and capped per pass so
|
||||
* the hourly cadence cannot starve a live login spike.
|
||||
*
|
||||
* v1.4.38 — in-process singleton guard. pg-boss already coalesces
|
||||
* concurrent cron ticks across multiple worker containers via the
|
||||
* shared queue lease, but a single container that takes longer than
|
||||
* one cron interval can pick up two jobs back-to-back when the
|
||||
* second tick fires while the first pass is still running. The
|
||||
* in-process `geoBackfillRunning` flag fans the second invocation
|
||||
* out as a no-op log line instead of stacking two concurrent passes
|
||||
* inside the same Node process — the next cron tick after the first
|
||||
* completes will catch up the work the skipped pass would have done.
|
||||
*/
|
||||
let geoBackfillRunning = false;
|
||||
async function handleGeoBackfill(jobs: Job<GeoBackfillPayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.geo_backfill", async (evt) => {
|
||||
if (geoBackfillRunning) {
|
||||
// Earlier pass still in flight; skip this tick. Idempotent + the
|
||||
// next tick after the in-flight pass completes will pick up
|
||||
// anything we miss here.
|
||||
evt.addWarning(
|
||||
"geo-backfill skipped — earlier pass still in flight inside the same worker process",
|
||||
);
|
||||
evt.addMeta("geo_backfill_skipped", true);
|
||||
return;
|
||||
}
|
||||
geoBackfillRunning = true;
|
||||
const p = getWorkerPrisma();
|
||||
try {
|
||||
const summary = await runGeoBackfill(p);
|
||||
@@ -1204,6 +1232,8 @@ async function handleGeoBackfill(jobs: Job<GeoBackfillPayload>[]) {
|
||||
// log and move on so a one-off resolver hiccup does not poison
|
||||
// the queue and block the next pass.
|
||||
evt.addWarning(`geo-backfill failed: ${err}`);
|
||||
} finally {
|
||||
geoBackfillRunning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ void queryRaw;
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
_resetEnsureUserRollupsFreshInFlightForTests,
|
||||
collapseToTypeDayKeys,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
enqueueRollupRecompute,
|
||||
@@ -101,6 +102,10 @@ beforeEach(() => {
|
||||
findFirstMeasurement.mockReset();
|
||||
bossSend.mockReset();
|
||||
getGlobalBossMock.mockReset();
|
||||
// v1.4.38 — clear the per-userId in-flight map so a previous test's
|
||||
// resolved promise does not short-circuit the next test's recompute
|
||||
// assertion.
|
||||
_resetEnsureUserRollupsFreshInFlightForTests();
|
||||
// Default: $transaction takes an array of pre-built promises (the
|
||||
// populator passes `slice.map(prisma.measurementRollup.upsert(...))`)
|
||||
// and returns them awaited.
|
||||
@@ -291,6 +296,76 @@ describe("ensureUserRollupsFresh", () => {
|
||||
expect(result.recomputed).toBe(false);
|
||||
});
|
||||
|
||||
// v1.4.38 — concurrent callers for the same userId share one
|
||||
// in-flight promise so a cold fan-out can never queue N parallel
|
||||
// recompute runs against the same 90-day window.
|
||||
it("dedups concurrent callers for the same userId onto one in-flight promise", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
// Both round-trips inside the helper run twice (the helper issues
|
||||
// a parallel `Promise.all` for the rollup-most-recent + measurement
|
||||
// -most-recent). With dedup, those should fire ONCE across two
|
||||
// concurrent callers.
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValue({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
]);
|
||||
expect(a.recomputed).toBe(true);
|
||||
expect(b.recomputed).toBe(true);
|
||||
expect(c.recomputed).toBe(true);
|
||||
// The Postgres-side recompute fires exactly once across the three
|
||||
// concurrent callers — proof the dedup map collapsed the fan-out.
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
// The findFirst probe fires exactly once per call type (rollup,
|
||||
// measurement) across all three concurrent callers.
|
||||
expect(findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(findFirstMeasurement).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the in-flight slot after resolution so the next call runs fresh", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValue({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
await ensureUserRollupsFresh("user-1");
|
||||
await ensureUserRollupsFresh("user-1");
|
||||
// Two serial calls => two recompute runs (no dedup short-circuit
|
||||
// because the first call already resolved and dropped its slot).
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears the in-flight slot on rejection so the next call retries", async () => {
|
||||
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
findFirstMeasurement.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
const first = await ensureUserRollupsFresh("user-1");
|
||||
expect(first.recomputed).toBe(false);
|
||||
|
||||
// Second call: probe succeeds, stale watermark triggers a recompute.
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
const second = await ensureUserRollupsFresh("user-1");
|
||||
expect(second.recomputed).toBe(true);
|
||||
});
|
||||
|
||||
it("annotates the failure when the inner recompute throws (H3)", async () => {
|
||||
// Stale watermark — rollup older than the newest measurement so
|
||||
// the inner recompute branch fires.
|
||||
|
||||
@@ -40,6 +40,20 @@ import {
|
||||
hkIdentifierForType,
|
||||
} from "./apple-health-mapping";
|
||||
|
||||
/**
|
||||
* v1.4.38 — canonical cutoff for the nightly scheduled drain. Rows
|
||||
* whose `measuredAt` is newer than `now() - DRAIN_CUMULATIVE_CUTOFF_HOURS`
|
||||
* are excluded so today's still-in-flight Apple Watch syncs stay as
|
||||
* per-sample rows in the user's "today" view. 36 hours covers the
|
||||
* previous calendar day plus a generous trailing sync window for
|
||||
* watches that weren't worn at midnight. The CLI and the admin route
|
||||
* import the constant for visibility but deliberately pass `undefined`
|
||||
* by default so an explicit one-shot drain collapses every row the
|
||||
* operator points it at; pass the constant explicitly when mirroring
|
||||
* the nightly behaviour from an interactive shell.
|
||||
*/
|
||||
export const DRAIN_CUMULATIVE_CUTOFF_HOURS = 36;
|
||||
|
||||
/** Per-(user, type, day) action summary. */
|
||||
export interface DrainBucket {
|
||||
userId: string;
|
||||
@@ -296,6 +310,13 @@ export async function drainPerSampleCumulative(
|
||||
const tz = user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin";
|
||||
log(`[drain] user=${user.id} tz=${tz}${dryRun ? " (dry-run)" : ""}`);
|
||||
|
||||
// v1.4.38 — per-user counters that mirror the existing aggregate
|
||||
// totals. Lets the per-user COMPLETE log line carry useful
|
||||
// numbers without re-walking the summary list later.
|
||||
const beforeBucketsCollapsed = summary.totals.bucketsCollapsed;
|
||||
const beforePerSampleDeleted = summary.totals.perSampleRowsDeleted;
|
||||
const beforeDailyUpserted = summary.totals.dailyRowsUpserted;
|
||||
|
||||
for (const type of CUMULATIVE_HK_TYPES) {
|
||||
const hkIdentifier = hkIdentifierForType(type);
|
||||
if (!hkIdentifier) continue;
|
||||
@@ -403,6 +424,22 @@ export async function drainPerSampleCumulative(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.38 — per-user COMPLETE log line. Mirrors the START line on
|
||||
// line 311 so an operator scanning the worker log can pair
|
||||
// "drain started for user X" with "drain finished for user X"
|
||||
// without scrolling through every per-type bucket. Counts are
|
||||
// computed as the delta against the snapshot taken before the
|
||||
// per-type loop started.
|
||||
const userBucketsCollapsed =
|
||||
summary.totals.bucketsCollapsed - beforeBucketsCollapsed;
|
||||
const userPerSampleDeleted =
|
||||
summary.totals.perSampleRowsDeleted - beforePerSampleDeleted;
|
||||
const userDailyUpserted =
|
||||
summary.totals.dailyRowsUpserted - beforeDailyUpserted;
|
||||
log(
|
||||
`[drain] user=${user.id} complete bucketsCollapsed=${userBucketsCollapsed} perSampleRowsDeleted=${userPerSampleDeleted} dailyRowsUpserted=${userDailyUpserted}${dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
|
||||
@@ -199,16 +199,25 @@ export async function recomputeBucketsForMeasurement(
|
||||
// WEEK / MONTH / YEAR — enqueue. Each granularity covers a range
|
||||
// around the affected day so the worker only re-aggregates the
|
||||
// bucket(s) the write could have changed.
|
||||
for (const granularity of ASYNC_GRANULARITIES) {
|
||||
const { from, to } = bucketSpan(measuredAt, granularity);
|
||||
await enqueueRollupRecompute({
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}
|
||||
//
|
||||
// v1.4.38 — the three `enqueueRollupRecompute` calls are independent
|
||||
// (different singletonKey per granularity inside `enqueueRollupRecompute`)
|
||||
// so fan them out via `Promise.all` instead of sequencing them with
|
||||
// `await`. Halves the post-write critical path on every measurement
|
||||
// create / update / delete; matters on iOS batch imports that fire
|
||||
// hundreds of hooks back-to-back.
|
||||
await Promise.all(
|
||||
ASYNC_GRANULARITIES.map((granularity) => {
|
||||
const { from, to } = bucketSpan(measuredAt, granularity);
|
||||
return enqueueRollupRecompute({
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,6 +564,26 @@ function bucketSpan(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 — per-userId in-flight dedup. v1.4.37.1 fire-and-forgets the
|
||||
* `ensureUserRollupsFresh` call from the read path so a stale 90-day
|
||||
* recompute can no longer stall the response, but if a request DOES
|
||||
* end up awaiting a synchronous recompute (worker code path, future
|
||||
* caller without the fire-and-forget) and the same user has a cold
|
||||
* fan-out hitting analytics + comprehensive + summaries within the
|
||||
* same tick, the legacy shape would queue N parallel
|
||||
* `recomputeUserRollups` invocations against the same 90-day window.
|
||||
* The map below collapses those into one shared promise — every
|
||||
* caller after the first re-uses the in-flight resolution and only
|
||||
* one Postgres aggregate runs. Mirrors the `buildCoachSnapshot` dedup
|
||||
* pattern that lives one layer up. Cleared in `finally` so a slow
|
||||
* promise that eventually rejects does not poison the next request.
|
||||
*/
|
||||
const ensureUserRollupsFreshInFlight = new Map<
|
||||
string,
|
||||
Promise<{ recomputed: boolean }>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Cheap read-side warm-up. Called from the analytics + comprehensive
|
||||
* read paths so the rollup table stays current for downstream
|
||||
@@ -572,9 +601,39 @@ function bucketSpan(
|
||||
* here.
|
||||
* - Best-effort: thrown errors are swallowed so the read path
|
||||
* never fails because of a populator hiccup.
|
||||
* - v1.4.38: concurrent callers for the same `userId` share one
|
||||
* in-flight promise so a cold fan-out can never queue N parallel
|
||||
* `recomputeUserRollups` runs against the same 90-day window.
|
||||
*/
|
||||
export async function ensureUserRollupsFresh(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
// v1.4.38 — share the in-flight promise across concurrent callers
|
||||
// for the same userId. The map is keyed on `userId` because the
|
||||
// helper has no other parameters; the next caller hits the
|
||||
// resolved promise as soon as it lands. We delete the slot in
|
||||
// `finally` so a slow rejected promise does not stay cached and
|
||||
// poison the next request.
|
||||
const existing = ensureUserRollupsFreshInFlight.get(userId);
|
||||
if (existing) return existing;
|
||||
const inflight = ensureUserRollupsFreshImpl(userId).finally(() => {
|
||||
ensureUserRollupsFreshInFlight.delete(userId);
|
||||
});
|
||||
ensureUserRollupsFreshInFlight.set(userId, inflight);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only handle on the in-flight map. Mirrors the
|
||||
* `_resetTrustViolationWarningForTests` pattern in api-response.ts —
|
||||
* production code never calls this.
|
||||
*/
|
||||
export function _resetEnsureUserRollupsFreshInFlightForTests(): void {
|
||||
ensureUserRollupsFreshInFlight.clear();
|
||||
}
|
||||
|
||||
async function ensureUserRollupsFreshImpl(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
try {
|
||||
// Single round-trip — the most-recent DAY rollup for the user
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* v1.4.38 — drift-guard between the validation enum and the label-key
|
||||
* map. The category enum lives in three places (Prisma schema,
|
||||
* `MEDICATION_CATEGORY_VALUES` in the validation module, and the
|
||||
* `MEDICATION_CATEGORY_KEYS` lookup inside `category-label.ts`). If a
|
||||
* new category lands in the enum without a matching label key, the
|
||||
* generic `<MedicationCard>` quietly falls back to `categoryOther`
|
||||
* for the new bucket — a silent UX regression. Pin the invariant
|
||||
* here so the test suite trips before the regression ships.
|
||||
*
|
||||
* Symmetric drift (label key present without an enum entry) is also
|
||||
* undesirable — it's dead code, but more importantly it suggests the
|
||||
* enum dropped a value without removing the lookup, which leaves the
|
||||
* label map advertising a category the rest of the app no longer
|
||||
* understands.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication";
|
||||
import { getMedicationCategoryLabel } from "../category-label";
|
||||
|
||||
describe("medication category-label drift-guard", () => {
|
||||
it("every MEDICATION_CATEGORY_VALUES entry resolves to a distinct label key", () => {
|
||||
// Use a no-op translator that echoes the key back so we can
|
||||
// distinguish "real key" from the OTHER fallback.
|
||||
const echo = (key: string) => key;
|
||||
const fallback = "medications.categoryOther";
|
||||
|
||||
for (const value of MEDICATION_CATEGORY_VALUES) {
|
||||
const resolved = getMedicationCategoryLabel(value, echo);
|
||||
if (value === "OTHER") {
|
||||
expect(resolved).toBe(fallback);
|
||||
continue;
|
||||
}
|
||||
// Any non-OTHER enum value that resolves to the OTHER key is a
|
||||
// drift bug — the value made it into the enum without a
|
||||
// matching label entry, so the card silently labels the
|
||||
// category as "Sonstige".
|
||||
expect(
|
||||
resolved,
|
||||
`enum value "${value}" falls back to categoryOther — add a key in MEDICATION_CATEGORY_KEYS to cover it`,
|
||||
).not.toBe(fallback);
|
||||
}
|
||||
});
|
||||
|
||||
it("the fallback path still fires for an unknown category value", () => {
|
||||
const echo = (key: string) => key;
|
||||
expect(getMedicationCategoryLabel("NOT_A_REAL_CATEGORY", echo)).toBe(
|
||||
"medications.categoryOther",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
formatInUserTz,
|
||||
invalidateServerDefaultTimezone,
|
||||
invalidateUserTimezone,
|
||||
isNearUtc,
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
resolveUserTimezone,
|
||||
@@ -261,6 +262,51 @@ describe("userDayKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNearUtc", () => {
|
||||
// Pin a summer-side instant so DST is on for Berlin (+2) and off for
|
||||
// most southern-hemisphere zones — keeps the assertions readable.
|
||||
const summer = new Date("2026-07-15T12:00:00.000Z");
|
||||
// Winter-side instant so Berlin is on CET (+1).
|
||||
const winter = new Date("2026-01-15T12:00:00.000Z");
|
||||
|
||||
it("returns true for UTC", () => {
|
||||
expect(isNearUtc("UTC", summer)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Europe/Berlin in both summer and winter", () => {
|
||||
expect(isNearUtc("Europe/Berlin", summer)).toBe(true);
|
||||
expect(isNearUtc("Europe/Berlin", winter)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for the ±3h boundary zones", () => {
|
||||
// Europe/Moscow = +3 year-round.
|
||||
expect(isNearUtc("Europe/Moscow", summer)).toBe(true);
|
||||
// Atlantic/Azores = -1 in winter, 0 in summer.
|
||||
expect(isNearUtc("Atlantic/Azores", summer)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for zones more than 3 hours from UTC", () => {
|
||||
// Pacific/Honolulu = -10 year-round.
|
||||
expect(isNearUtc("Pacific/Honolulu", summer)).toBe(false);
|
||||
// Asia/Tokyo = +9 year-round.
|
||||
expect(isNearUtc("Asia/Tokyo", summer)).toBe(false);
|
||||
// America/New_York = -4 in summer, -5 in winter.
|
||||
expect(isNearUtc("America/New_York", summer)).toBe(false);
|
||||
// Pacific/Auckland = +13 in southern summer (Jan), +12 in May.
|
||||
expect(isNearUtc("Pacific/Auckland", winter)).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to near-UTC when the zone string is invalid", () => {
|
||||
// Invalid zones fall back to Europe/Berlin, which is near-UTC.
|
||||
expect(isNearUtc("Mars/Olympus_Mons", summer)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the current instant when `now` is omitted", () => {
|
||||
// Pure smoke — exercises the default parameter branch.
|
||||
expect(typeof isNearUtc("Europe/Berlin")).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_TIMEZONE", () => {
|
||||
it("is Europe/Berlin", () => {
|
||||
expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin");
|
||||
|
||||
@@ -67,6 +67,38 @@ export function detectBrowserTimezone(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 W-A — cross-tz fast-path runtime guard.
|
||||
*
|
||||
* Returns `true` when the supplied IANA zone is within ±3 hours of UTC
|
||||
* at the supplied instant. Used by the analytics fast-paths
|
||||
* (`bp-in-target-fast-path`, `correlations-fast-path`) to gate the
|
||||
* rollup read-swap: those helpers key rollup rows on UTC-midnight
|
||||
* `bucketStart` and pair them with per-event streams keyed in the
|
||||
* user's local timezone. For Berlin (+1/+2) the day-key on the rollup
|
||||
* and the day-key on the per-event row line up; for Honolulu (-10)
|
||||
* they slip by a calendar day. Three hours is a conservative pin —
|
||||
* the worst-case slip inside the window is the wall-clock hour-of-day
|
||||
* the user happens to log at, and three hours covers every European
|
||||
* zone plus the western edge of Mid-Atlantic / eastern edge of the
|
||||
* Americas.
|
||||
*
|
||||
* Falls back to "near-UTC" (i.e. returns `true`) when the zone string
|
||||
* is invalid — the resolver layer is already defensive about junk
|
||||
* values, and a `true` here means the caller takes the rollup
|
||||
* fast-path which is the safer default for the canonical Berlin
|
||||
* tenant.
|
||||
*
|
||||
* The `now` argument lets callers honour DST transitions; the offset
|
||||
* of `Europe/Berlin` is +1 in January and +2 in July. Defaults to
|
||||
* "now" so most callers can `isNearUtc(userTz)`.
|
||||
*/
|
||||
export function isNearUtc(tz: string, now: Date = new Date()): boolean {
|
||||
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
|
||||
const offsetMinutes = tzOffsetMinutes(now, safeTz);
|
||||
return Math.abs(offsetMinutes) <= 3 * 60;
|
||||
}
|
||||
|
||||
export type FormatInUserTzShape =
|
||||
| "iso-with-offset"
|
||||
| "wall-clock"
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
export {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
isNearUtc,
|
||||
listSupportedTimezones,
|
||||
detectBrowserTimezone,
|
||||
formatInUserTz,
|
||||
|
||||
@@ -235,4 +235,40 @@ describe("measurement validation", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.38 — the per-day drill-down branch always returns at most
|
||||
// 1000 rows (one pathological phone-only stepCount day). Push the
|
||||
// cap into the validator so a caller asking for more sees a 422
|
||||
// instead of a silent server-side Math.min clamp.
|
||||
describe("listMeasurementsSchema — v1.4.38 dayKey limit ceiling", () => {
|
||||
it("accepts limit=1000 alongside a dayKey", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
type: "ACTIVITY_STEPS",
|
||||
dayKey: "2026-05-15",
|
||||
limit: 1000,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects limit=1001 when dayKey is set (drill-down hard cap)", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
type: "ACTIVITY_STEPS",
|
||||
dayKey: "2026-05-15",
|
||||
limit: 1001,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success) {
|
||||
expect(parsed.error.issues[0].message).toMatch(/<=\s*1000/);
|
||||
expect(parsed.error.issues[0].path).toEqual(["limit"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts limit=5000 when dayKey is omitted (legacy ceiling)", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
type: "ACTIVITY_STEPS",
|
||||
limit: 5000,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -325,7 +325,19 @@ export const listMeasurementsSchema = z
|
||||
"offset is not supported with groupBy=day or dayKey; use a smaller window or omit offset",
|
||||
path: ["offset"],
|
||||
},
|
||||
);
|
||||
)
|
||||
// v1.4.38 — surface the drill-down cap on the validator so a caller
|
||||
// asking for more than 1000 rows on a per-day branch sees a 422
|
||||
// instead of a silent `Math.min(limit, 1000)` clamp inside the
|
||||
// route. The drill-down returns at most one phone-only stepCount
|
||||
// day's worth of per-sample chunks; 1000 covers the pathological
|
||||
// case with room to spare. Callers that want a wider window should
|
||||
// omit `dayKey` and use `from` / `to` instead.
|
||||
.refine(({ limit, dayKey }) => !(dayKey != null && limit > 1000), {
|
||||
message:
|
||||
"limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows",
|
||||
path: ["limit"],
|
||||
});
|
||||
|
||||
export const createBatchMeasurementSchema = z.object({
|
||||
measurements: z.array(createMeasurementSchema).min(1).max(5),
|
||||
|
||||
Reference in new issue
Block a user