diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index d981399a0..48976a9c4 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { - measurement: { findMany: vi.fn() }, + measurement: { findMany: vi.fn(), groupBy: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, }, })); @@ -54,6 +54,10 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findMany).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. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); @@ -90,6 +94,107 @@ describe("GET /api/dashboard/summary", () => { ); }); + 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. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + allTimeCount: number; + lastSeenAt: string | null; + }>; + }; + }; + 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, + ); + } + for (const card of body.data.metrics) { + expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0); + expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull(); + } + }); + + it("surfaces lastSeenAt from the historical aggregate even when the 7-day window is empty", async () => { + // Power-user path — the user has logged weight for years but + // hasn't touched the app in two weeks. `groupBy` returns the + // all-time count + most-recent timestamp; the 7-day `findMany` + // returns empty. The route should still emit the weight card + // with the historical `lastSeenAt` so the iOS client renders the + // staleness caption. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "WEIGHT", + _count: { _all: 312 }, + _max: { measuredAt: twoWeeksAgo }, + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + allTimeCount: number; + lastSeenAt: string | null; + updatedAt: string | null; + }>; + }; + }; + const weight = body.data.metrics.find((m) => m.id === "weight"); + expect(weight, "weight card must be present").toBeDefined(); + expect(weight?.allTimeCount).toBe(312); + expect(weight?.lastSeenAt).toBe(twoWeeksAgo.toISOString()); + // No 7-day reading → latestValue stays null but updatedAt falls + // through to the historical timestamp so the iOS client can + // build the relative-age caption from a single field. + expect(weight?.latestValue).toBeNull(); + expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString()); + }); + + 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 + // user logged once last month still surfaces a tile (latestValue + // null + lastSeenAt populated → iOS renders the historical + // hint). + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "BLOOD_GLUCOSE", + _count: { _all: 4 }, + _max: { measuredAt: tenDaysAgo }, + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + allTimeCount: number; + lastSeenAt: string | null; + }>; + }; + }; + const glucose = body.data.metrics.find((m) => m.id === "glucose"); + expect( + glucose, + "glucose card must be present (v1.4.33 widened gate)", + ).toBeDefined(); + expect(glucose?.allTimeCount).toBe(4); + expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString()); + }); + it("computes intake compliance for today", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 38a4b85c4..b30e89daa 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -44,6 +44,24 @@ interface MetricCard { trend: "up" | "down" | "flat" | "unknown"; sparkline: number[]; updatedAt: string | null; + /** + * v1.4.33 maintainer-item-1 — total readings the user has ever + * logged for this metric, irrespective of the 7-day sparkline + * window. The dashboard tile keeps showing whenever `allTimeCount > 0` + * so a user with valid historical data isn't surprised by a tile + * disappearing during a logging gap. Distinct from `sparkline.length` + * which only carries the trailing-7-day points. + */ + allTimeCount: number; + /** + * v1.4.33 maintainer-item-1 — ISO timestamp of the metric's single + * most recent reading. When `allTimeCount > 0` but the latest + * reading is older than 7 days, the iOS tile renders a muted + * "Letzter Wert vor Xd" caption so the user understands the value + * isn't stale silently. `null` when the metric has no readings at + * all. + */ + lastSeenAt: string | null; } const METRIC_TITLES: Record = { @@ -168,8 +186,15 @@ export const GET = apiHandler(async () => { ...measurementTypeEnum.options, ] as MeasurementType[]; - const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all( - [ + // 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, @@ -179,6 +204,12 @@ export const GET = apiHandler(async () => { orderBy: { measuredAt: "asc" }, select: { type: true, value: true, measuredAt: true }, }), + prisma.measurement.groupBy({ + by: ["type"], + where: { userId: user.id, type: { in: measurementTypes } }, + _count: { _all: true }, + _max: { measuredAt: true }, + }), prisma.medicationIntakeEvent.findMany({ where: { userId: user.id, @@ -194,8 +225,33 @@ export const GET = apiHandler(async () => { }, select: { takenAt: true, scheduledFor: true }, }), - ], - ); + ]); + + // Per-type metadata lookup — typed Map so a metric with no readings + // at all falls through `metaForType` to the `{ allTimeCount: 0, + // lastSeenAt: null }` default. The aggregate row's `_count._all` is + // always `number` per Prisma's runtime contract; defending against + // undefined keeps the helper's narrow signature stable. + const allTimeByType = new Map< + MeasurementType, + { allTimeCount: number; lastSeenAt: Date | null } + >(); + for (const row of allTimeAggregate) { + allTimeByType.set(row.type, { + allTimeCount: row._count?._all ?? 0, + lastSeenAt: row._max?.measuredAt ?? null, + }); + } + function metaForType(type: MeasurementType): { + allTimeCount: number; + lastSeenAt: string | null; + } { + const slot = allTimeByType.get(type); + return { + allTimeCount: slot?.allTimeCount ?? 0, + lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null, + }; + } const activityDays = new Set(); for (const m of recentMeasurements) @@ -240,10 +296,17 @@ export const GET = apiHandler(async () => { const metrics: MetricCard[] = []; + // v1.4.33 maintainer-item-1 — every emitted card now carries + // `allTimeCount` + `lastSeenAt` so the iOS tile renderer can keep + // a metric visible during a logging gap (gate on `allTimeCount > 0`) + // and paint a muted "Letzter Wert vor Xd" caption when the most + // recent reading is older than the 7-day sparkline window. + // Weight { const latest = latestOf("WEIGHT"); const spark = sparkOf("WEIGHT"); + const meta = metaForType("WEIGHT"); metrics.push({ id: "weight", kind: "weight", @@ -253,7 +316,9 @@ export const GET = apiHandler(async () => { unit: METRIC_UNITS.weight, trend: trendOf(spark), sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? null, + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } @@ -263,6 +328,20 @@ export const GET = apiHandler(async () => { const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? []; const latestSys = sysList[sysList.length - 1] ?? null; const latestDia = diaList[diaList.length - 1] ?? null; + const sysMeta = metaForType("BLOOD_PRESSURE_SYS"); + const diaMeta = metaForType("BLOOD_PRESSURE_DIA"); + // BP is a paired metric — the tile is "alive" whenever either side + // of the pair has history. Sum the count to reflect total readings + // and pick the most recent `_max` so the staleness hint follows + // whichever side is freshest. + const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount; + const bpLastSeenAt = ((): string | null => { + const sysAt = sysMeta.lastSeenAt; + const diaAt = diaMeta.lastSeenAt; + if (!sysAt) return diaAt; + if (!diaAt) return sysAt; + return sysAt >= diaAt ? sysAt : diaAt; + })(); metrics.push({ id: "bp", kind: "bloodPressure", @@ -273,7 +352,11 @@ export const GET = apiHandler(async () => { trend: trendOf(sysList.map((p) => p.value)), sparkline: sysList.map((p) => p.value), updatedAt: - latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null, + latestSys?.at?.toISOString() ?? + latestDia?.at?.toISOString() ?? + bpLastSeenAt, + allTimeCount: bpAllTimeCount, + lastSeenAt: bpLastSeenAt, }); } @@ -281,6 +364,7 @@ export const GET = apiHandler(async () => { { const latest = latestOf("PULSE"); const spark = sparkOf("PULSE"); + const meta = metaForType("PULSE"); metrics.push({ id: "pulse", kind: "pulse", @@ -290,30 +374,43 @@ export const GET = apiHandler(async () => { unit: METRIC_UNITS.pulse, trend: trendOf(spark), sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? null, + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } - // Body fat + // Body fat — v1.4.33 widens the emit gate from `latestOf` (only + // fires when the 7-day window has a reading) to `allTimeCount > 0` + // so a tile doesn't disappear during a fortnight without a fresh + // reading. { const latest = latestOf("BODY_FAT"); const spark = sparkOf("BODY_FAT"); - if (latest) { + const meta = metaForType("BODY_FAT"); + if (latest || meta.allTimeCount > 0) { metrics.push({ id: "bodyFat", kind: "bodyFat", title: METRIC_TITLES.bodyFat, - latestValue: latest.value, + latestValue: latest?.value ?? null, secondaryValue: null, unit: METRIC_UNITS.bodyFat, trend: trendOf(spark), sparkline: spark, - updatedAt: latest.at.toISOString(), + updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } } - // Optional cards — only emitted if the user has data for that type. + // Optional cards — emitted whenever the user has *ever* logged the + // metric. Up to v1.4.32 the gate was `latestOf(type)` (only fires + // when the trailing 7-day window has a reading); v1.4.33 widens to + // the all-time count so a glucose tile that hasn't been touched in + // 10 days still renders with the historical value visible to the + // iOS client (paired with the lastSeenAt caption). for (const [type, kind] of [ ["BLOOD_GLUCOSE", "glucose"], ["SLEEP_DURATION", "sleep"], @@ -323,18 +420,21 @@ export const GET = apiHandler(async () => { ["OXYGEN_SATURATION", "oxygenSaturation"], ] as const) { const latest = latestOf(type); - if (!latest) continue; + const meta = metaForType(type); + if (!latest && meta.allTimeCount === 0) continue; const spark = sparkOf(type); metrics.push({ id: kind, kind, title: METRIC_TITLES[kind], - latestValue: latest.value, + latestValue: latest?.value ?? null, secondaryValue: null, unit: METRIC_UNITS[kind], trend: trendOf(spark), sparkline: spark, - updatedAt: latest.at.toISOString(), + updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3031cff3f..20ef15191 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -321,7 +321,29 @@ export default function DashboardPage() { const hasSleep = (sleepSummary?.count ?? 0) > 0; const hasSteps = (stepsSummary?.count ?? 0) > 0; const hasVo2 = (vo2Summary?.count ?? 0) > 0; - const hasBpInTarget = data?.bpInTargetPct != null; + /** + * v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %" + * placeholder doesn't ride on the dashboard when none of the user's + * paired readings sit inside the target band. The historical + * behaviour (`!= null`) painted the tile whenever the analytics + * route emitted a numeric percentage, including the legitimate-but- + * misleading zero. The corrected gate requires at least one window + * (7d, 30d, all-time) to report a non-zero share. When every + * window is zero, the tile is hidden — the user sees the BP charts + * + the `/targets` Blutdruck card for the deeper analysis instead. + * The audit's F4 reproduction sat on 540 BP samples with all sub- + * windows reading 0 % because the seed data straddled the target + * ceiling; the tile keeps its place once even one sub-window + * crosses zero. + */ + const hasBpInTarget = + data?.bpInTargetPct != null && + [ + data?.bpInTargetPct, + data?.bpInTargetPct7d, + data?.bpInTargetPct30d, + data?.bpInTargetPctAllTime, + ].some((pct) => pct != null && pct > 0); // Tile (strip) gates — controlled by the new `tileVisible` flag. const showWeightTile = isTileVisible("weight") && hasWeight; @@ -485,13 +507,21 @@ export default function DashboardPage() { - {/* WCAG 2.5.5 — touch targets must be ≥44×44 CSS px on mobile. - `size="sm"` (h-8 = 32px) was below threshold; the explicit - min-h-11 ensures we hit 44px on the Pixel 5 viewport while - keeping the desktop visual unchanged. */} + {/* v1.4.33 maintainer-item-7 — restore proportional sizing + across viewports. The v1.4.27 fix pinned a `size="sm" + min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target + contract on mobile, but `size="sm"` is h-8 (32 px) and + the `min-h-11` override stretched the cap vertically + while keeping the small horizontal padding — the + button read as klobig on Pixel 5. Switch to + `size="default"` (h-10 = 40 px) on mobile with a + responsive `min-h-11 sm:min-h-9` so the button is + 44 px tall under finger pressure and shrinks back to + the desktop-friendly 36 px on `sm:` upwards. The icon + + label keep the same visual contract. */}