fix(api/dashboard): drop 7-day filter on latestEver + widen sparkline (REG-11)

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:55:01 +02:00
1 parent 0fbe68a313
commit 600aa3697c
2 files changed
+324 -75

No files matched your search

@@ -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
+108 -60
View File
@@ -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<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 {
/** 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<LatestIn7dRow[]>`
time("latestEver", () =>
prisma.$queryRaw<LatestEverRow[]>`
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<SparklineRow[]>`
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<MeasurementType, { value: number; at: Date }>();
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