From 600aa3697cf4468dc5bbe4b1a18d5521c853fa82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 21:55:01 +0200 Subject: [PATCH] fix(api/dashboard): drop 7-day filter on latestEver + widen sparkline (REG-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der `/api/dashboard/summary`-Endpoint hat für Blutdruck und Puls `latestValue: null` plus `sparkline: []` zurückgegeben, sobald der letzte Wert des Nutzers älter als sieben Tage war. Der iOS-Kachel hatte dann weder Zahl noch Chart, obwohl die Daten in der DB lagen und der Detail-Screen sie auch korrekt anzeigte. Fünf iOS-seitige Anläufe blieben wirkungslos, weil der Bug im Server-SQL saß. - `latestIn7d` → `latestEver`: WHERE-Filter `m.measured_at >= ${sevenDaysAgo}` entfernt. `DISTINCT ON (type)` über alle nicht- gelöschten Rows liefert weiterhin nur eine Zeile pro Typ, jetzt aber den all-time-letzten Wert. Zeilenanzahl bleibt durch `|measurementTypes|` beschränkt. - `sparkBuckets`: Kalender-Filter `r.bucket_start >= ${sevenDaysAgo}` durch ein `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start DESC) <= SPARK_DAYS` ersetzt. Liefert die letzten sieben Tages-Buckets pro Typ, unabhängig vom Alter. Eine BP-Messung von vor 60 Tagen bekommt damit wieder ein Chart. - BP- und Puls-Karten zusätzlich um das gleiche `if (latest || meta.allTimeCount > 0)`-Gate ergänzt, das BodyFat seit v1.4.33 schon hatte. Accounts, die diese Werte noch nie geloggt haben, bekommen keine leeren Platzhalter-Kacheln mehr. Tests in `src/app/api/dashboard/summary/__tests__/route.test.ts` angepasst plus vier neue Cases für REG-11: - BP mit 60 Tage altem Wert → Kachel emittiert mit historischem `latestValue` + `sparkline`. - BP mit Null-Readings ever → Kachel NICHT emittiert. - Weight innerhalb 7d → Verhalten unverändert (Regression-Guard). - Sparkline-Window nimmt 7 Daily-Buckets auch wenn alle älter als 7 Kalendertage. --- .../dashboard/summary/__tests__/route.test.ts | 231 ++++++++++++++++-- src/app/api/dashboard/summary/route.ts | 168 ++++++++----- 2 files changed, 324 insertions(+), 75 deletions(-) diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 07d6b4717..30d0a50c6 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -116,20 +116,29 @@ describe("GET /api/dashboard/summary", () => { expect(body.data.greeting.salutation).toBe("Hi, marc"); expect(body.data.streak.currentDays).toBe(0); expect(body.data.compliance.scheduledToday).toBe(0); + // REG-11 (v1.4.44): weight is the only always-emitted base metric. + // BP + pulse + bodyFat + optional kinds gate on `latest || + // allTimeCount > 0` so accounts that have never logged a kind don't + // get an empty placeholder tile. expect(body.data.metrics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "weight" }), - expect.objectContaining({ id: "bp" }), - expect.objectContaining({ id: "pulse" }), - ]), + expect.arrayContaining([expect.objectContaining({ id: "weight" })]), ); + expect( + body.data.metrics.find((m) => m.id === "bp"), + "bp must not emit when allTimeCount === 0 (REG-11)", + ).toBeUndefined(); + expect( + body.data.metrics.find((m) => m.id === "pulse"), + "pulse must not emit when allTimeCount === 0 (REG-11)", + ).toBeUndefined(); }); - it("emits allTimeCount + lastSeenAt for every base metric (v1.4.33 maintainer-item-1)", async () => { - // Empty-data path — every metric card still ships the new fields - // so the iOS client can render a tile + "Letzter Wert vor Xd" - // hint regardless of whether the route saw any rows in the 7-day - // window. + it("emits allTimeCount + lastSeenAt for the weight card on empty data (v1.4.33 maintainer-item-1)", async () => { + // REG-11 (v1.4.44): the always-emitted base set shrank to just + // `weight` — BP + pulse now gate on `latest || allTimeCount > 0` + // so an account that has never logged either doesn't get a noisy + // empty tile. Weight still ships unconditionally because the + // onboarding flow expects the tile as a primary CTA. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await callGet(makeReq()); const body = (await res.json()) as { @@ -142,11 +151,9 @@ describe("GET /api/dashboard/summary", () => { }; }; const baseIds = body.data.metrics.map((m) => m.id); - for (const required of ["weight", "bp", "pulse"]) { - expect(baseIds, `${required} card missing from metrics list`).toContain( - required, - ); - } + expect(baseIds, "weight card missing from metrics list").toContain( + "weight", + ); for (const card of body.data.metrics) { expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0); expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull(); @@ -192,6 +199,200 @@ describe("GET /api/dashboard/summary", () => { expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString()); }); + it("emits the BP tile with the historical value when the last reading is 60 days old (REG-11)", async () => { + // The original 7-day calendar filter in the latest-reading SQL + // dropped any row outside the trailing-7-day window, so a BP + // measurement logged 60 days ago surfaced as `latestValue: null` + // and `sparkline: []` — the iOS tile then had nothing to render. + // Post-REG-11 the `latestEver` aggregate carries the row + the + // `ROW_NUMBER`-windowed sparkline carries the trailing 7 daily + // rollup buckets regardless of how old they are. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000); + const sixtyOneDaysAgo = new Date(Date.now() - 61 * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "BLOOD_PRESSURE_SYS", + _count: { _all: 14 }, + _max: { measuredAt: sixtyDaysAgo }, + }, + { + type: "BLOOD_PRESSURE_DIA", + _count: { _all: 14 }, + _max: { measuredAt: sixtyDaysAgo }, + }, + ] as never); + // Promise.all order: [latestEver, sparkline, streakDays]. + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { + type: "BLOOD_PRESSURE_SYS", + value: 132, + measured_at: sixtyDaysAgo, + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 84, + measured_at: sixtyDaysAgo, + }, + ] as never) + .mockResolvedValueOnce([ + { + type: "BLOOD_PRESSURE_SYS", + bucket_start: sixtyOneDaysAgo, + mean: 130, + count: 1, + sum_value: null, + }, + { + type: "BLOOD_PRESSURE_SYS", + bucket_start: sixtyDaysAgo, + mean: 132, + count: 1, + sum_value: null, + }, + ] as never) + .mockResolvedValueOnce([] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + secondaryValue: number | null; + sparkline: number[]; + allTimeCount: number; + lastSeenAt: string | null; + }>; + }; + }; + const bp = body.data.metrics.find((m) => m.id === "bp"); + expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined(); + expect(bp?.latestValue).toBe(132); + expect(bp?.secondaryValue).toBe(84); + expect(bp?.allTimeCount).toBe(28); + expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString()); + expect(bp?.sparkline).toEqual([130, 132]); + }); + + it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => { + // Accounts that have never logged BP should not get an empty + // placeholder tile. Mirrors the bodyFat gate that's been in place + // since v1.4.33. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { metrics: Array<{ id: string }> }; + }; + expect( + body.data.metrics.find((m) => m.id === "bp"), + "bp tile must not emit when no BP reading exists", + ).toBeUndefined(); + expect( + body.data.metrics.find((m) => m.id === "pulse"), + "pulse tile must not emit when no pulse reading exists", + ).toBeUndefined(); + }); + + it("keeps the weight tile fresh when the latest reading is within 7d (REG-11 regression guard)", async () => { + // The post-REG-11 SQL drops the calendar filter — the recent-path + // (last reading <7d) must still produce identical output so we + // don't accidentally regress the fresh-data case. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "WEIGHT", + _count: { _all: 200 }, + _max: { measuredAt: twoDaysAgo }, + }, + ] as never); + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.4, measured_at: twoDaysAgo }, + ] as never) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: twoDaysAgo, + mean: 82.4, + count: 1, + sum_value: null, + }, + ] as never) + .mockResolvedValueOnce([] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + sparkline: number[]; + allTimeCount: number; + }>; + }; + }; + const weight = body.data.metrics.find((m) => m.id === "weight"); + expect(weight?.latestValue).toBe(82.4); + expect(weight?.sparkline).toEqual([82.4]); + expect(weight?.allTimeCount).toBe(200); + }); + + it("sparkline window takes the last 7 daily buckets even when they're all older than 7 days (REG-11)", async () => { + // The new `ROW_NUMBER() OVER (PARTITION BY type ORDER BY + // bucket_start DESC)` window guarantees the route asks the DB for + // exactly the trailing `SPARK_DAYS` buckets per type. Because the + // SQL runs in Postgres, the mock here only proves the route hands + // the buckets through in chronological order without dropping any + // for age. The asserted order matches the SQL `ORDER BY type, + // bucket_start ASC` so the iOS chart paints left-to-right with the + // oldest bucket first. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const days = (n: number) => new Date(Date.now() - n * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "PULSE", + _count: { _all: 42 }, + _max: { measuredAt: days(30) }, + }, + ] as never); + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { type: "PULSE", value: 71, measured_at: days(30) }, + ] as never) + .mockResolvedValueOnce([ + { type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null }, + { type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null }, + { type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null }, + { type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null }, + { type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null }, + { type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null }, + { type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null }, + ] as never) + .mockResolvedValueOnce([] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + sparkline: number[]; + }>; + }; + }; + const pulse = body.data.metrics.find((m) => m.id === "pulse"); + expect(pulse, "pulse tile must emit for stale history").toBeDefined(); + expect(pulse?.latestValue).toBe(71); + expect(pulse?.sparkline).toEqual([68, 70, 69, 71, 73, 72, 71]); + expect(pulse?.sparkline).toHaveLength(7); + }); + it("emits optional cards when allTimeCount > 0 but the 7-day window is empty", async () => { // Glucose / sleep / steps used to only emit when the 7-day // window had a reading. v1.4.33 widens the gate so a metric the diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 27895ba19..cf6e7cbbf 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -22,13 +22,21 @@ * - 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). + * - `DISTINCT ON (type)` → one row per type carrying the latest + * value + measuredAt (≤ N_metrics rows). REG-11 (v1.4.44) dropped + * the trailing-7-day window from the WHERE clause; the tile now + * surfaces the all-time-latest reading per type so a 60-day-old BP + * measurement still feeds the tile (paired with the stale-caption + * hint built off `lastSeenAt`). * - `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. + * REG-11 (v1.4.44) switched the calendar-window filter for a + * `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start + * DESC)` window so the sparkline takes the last `SPARK_DAYS` + * buckets per type regardless of age. * - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the * 365-day window → at most 365 dates (vs. up to 100k raw rows). * @@ -192,25 +200,40 @@ function computeStreak(activityDays: Set, 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 { +/** v1.4.38 W-F — `DISTINCT ON (type)` row for the single most recent + * reading per measurement type, irrespective of age. One row per + * metric the user has ever touched; replaces the legacy unbounded + * `prisma.measurement.findMany`. + * + * REG-11 (v1.4.44): dropped the trailing-7-day window from the WHERE + * clause. The old shape returned `latestValue: null` + an empty + * sparkline for accounts whose last BP / pulse reading was older than + * 7 days, leaving the iOS tile blank even though the historical row + * was still in the database. The all-time aggregate (`groupBy` on + * `measurements`) already proves the row exists; the row count is + * still bounded by `|measurementTypes|` via `DISTINCT ON`. */ +interface LatestEverRow { 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 +/** v1.4.38 W-F — per-day measurement_rollup bucket feeding the dashboard + * sparkline. At most 7 buckets per metric × N metrics — bounded by * `SPARK_DAYS * |measurementTypes|` rather than the raw row count. * * v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile * (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket * MEAN. Spot metrics ignore the column; cumulative tiles fall back to * `mean * count` when the legacy NULL hits (boot-backfill convergence - * window). */ + * window). + * + * REG-11 (v1.4.44): switched from a calendar-window filter + * (`bucket_start >= sevenDaysAgo`) to a `ROW_NUMBER() OVER (PARTITION + * BY type ORDER BY bucket_start DESC)` window so the sparkline takes + * the last `SPARK_DAYS` daily buckets per type regardless of age. An + * account whose last BP reading is 60 days old now still gets the + * trailing 7 days of historical buckets feeding the tile chart. */ interface SparklineRow { type: MeasurementType; bucket_start: Date; @@ -308,7 +331,10 @@ async function buildDashboardSummary( ctx: SummaryBuilderContext, ) { const now = new Date(); - const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000); + // REG-11 (v1.4.44): the trailing-7-day pivot used to anchor the + // latest-reading + sparkline filters is gone — both sub-queries now + // take the most recent N rows per type regardless of age. Only the + // streak window still anchors on a calendar slice. const streakWindowStart = new Date( now.getTime() - STREAK_WINDOW_DAYS * 86_400_000, ); @@ -337,46 +363,56 @@ async function buildDashboardSummary( // 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) + // - latestEver: ≤ N metric types (one row per type via DISTINCT ON, + // REG-11: dropped the 7-day window so a stale-but- + // valid historical reading still feeds the tile) + // - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80, + // REG-11: taken via ROW_NUMBER window, no calendar + // filter, so a 60-day-old metric still paints a chart) // - 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, + latestEver, sparkBuckets, allTimeAggregate, todaysIntakes, streakActivity, measurementStreakDays, ] = await Promise.all([ - time("latest7d", () => - prisma.$queryRaw` + time("latestEver", () => + prisma.$queryRaw` 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} AND m."deleted_at" IS NULL ORDER BY m."type", m."measured_at" DESC `, ), time("sparkline", () => prisma.$queryRaw` - SELECT - r."type" AS type, - r."bucket_start" AS bucket_start, - r."mean"::double precision AS mean, - r."count"::int AS count, - r."sum_value"::double precision AS sum_value - 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 + SELECT type, bucket_start, mean, count, sum_value + FROM ( + SELECT + r."type" AS type, + r."bucket_start" AS bucket_start, + r."mean"::double precision AS mean, + r."count"::int AS count, + r."sum_value"::double precision AS sum_value, + ROW_NUMBER() OVER ( + PARTITION BY r."type" + ORDER BY r."bucket_start" DESC + ) AS rn + FROM measurement_rollups r + WHERE r."user_id" = ${userId} + AND r."granularity" = 'DAY' + ) sub + WHERE rn <= ${SPARK_DAYS} + ORDER BY type, bucket_start ASC `, ), time("allTime", () => @@ -474,8 +510,11 @@ async function buildDashboardSummary( // v1.4.38 W-F — per-type latest (one row per type) + sparkline // (bucket means per day) assembled from the two new SQL aggregates. + // REG-11 (v1.4.44): `latestEver` carries the most recent reading + // regardless of age — the iOS tile shows the historical value plus a + // muted "Letzter Wert vor Xd" caption rather than nothing at all. const latestByType = new Map(); - for (const row of latestIn7d) { + for (const row of latestEver) { latestByType.set(row.type, { value: Number(row.value), at: new Date(row.measured_at), @@ -541,7 +580,10 @@ async function buildDashboardSummary( }); } - // Blood pressure (paired sys/dia) + // Blood pressure (paired sys/dia) — REG-11 (v1.4.44): gate the emit + // on `latestSys || latestDia || bpAllTimeCount > 0` so an account + // that has never logged BP doesn't get an empty placeholder tile. + // Mirrors the body-fat gate below. { const latestSys = latestOf("BLOOD_PRESSURE_SYS"); const latestDia = latestOf("BLOOD_PRESSURE_DIA"); @@ -560,42 +602,48 @@ async function buildDashboardSummary( if (!diaAt) return sysAt; return sysAt >= diaAt ? sysAt : diaAt; })(); - metrics.push({ - id: "bp", - kind: "bloodPressure", - title: METRIC_TITLES.bloodPressure, - latestValue: latestSys?.value ?? null, - secondaryValue: latestDia?.value ?? null, - unit: METRIC_UNITS.bloodPressure, - trend: trendOf(sysSpark), - sparkline: sysSpark, - updatedAt: - latestSys?.at?.toISOString() ?? - latestDia?.at?.toISOString() ?? - bpLastSeenAt, - allTimeCount: bpAllTimeCount, - lastSeenAt: bpLastSeenAt, - }); + if (latestSys || latestDia || bpAllTimeCount > 0) { + metrics.push({ + id: "bp", + kind: "bloodPressure", + title: METRIC_TITLES.bloodPressure, + latestValue: latestSys?.value ?? null, + secondaryValue: latestDia?.value ?? null, + unit: METRIC_UNITS.bloodPressure, + trend: trendOf(sysSpark), + sparkline: sysSpark, + updatedAt: + latestSys?.at?.toISOString() ?? + latestDia?.at?.toISOString() ?? + bpLastSeenAt, + allTimeCount: bpAllTimeCount, + lastSeenAt: bpLastSeenAt, + }); + } } - // Pulse + // Pulse — REG-11 (v1.4.44): gate the emit on `latest || allTimeCount + // > 0` so an account that has never logged pulse doesn't get an empty + // placeholder tile. Mirrors the body-fat gate below. { const latest = latestOf("PULSE"); const spark = sparkOf("PULSE"); const meta = metaForType("PULSE"); - metrics.push({ - id: "pulse", - kind: "pulse", - title: METRIC_TITLES.pulse, - latestValue: latest?.value ?? null, - secondaryValue: null, - unit: METRIC_UNITS.pulse, - trend: trendOf(spark), - sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, - allTimeCount: meta.allTimeCount, - lastSeenAt: meta.lastSeenAt, - }); + if (latest || meta.allTimeCount > 0) { + metrics.push({ + id: "pulse", + kind: "pulse", + title: METRIC_TITLES.pulse, + latestValue: latest?.value ?? null, + secondaryValue: null, + unit: METRIC_UNITS.pulse, + trend: trendOf(spark), + sparkline: spark, + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, + }); + } } // Body fat — v1.4.33 widens the emit gate from `latestOf` (only