diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index ca125c00b..65f80256d 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -172,11 +172,76 @@ describe("GET /api/analytics", () => { expect(body.data.healthScore).toBeNull(); }); + // v1.4.34 IW-B — the dashboard tile strip reads `lastSeenByType[type]?.daysAgo` + // and forwards it to each `` so a metric the user hasn't + // logged in a while keeps its tile visible with an "Letzter Wert vor + // Xd" caption instead of disappearing. + it("emits lastSeenByType keyed on the freshest measuredAt per type", async () => { + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); + + vi.mocked(prisma.measurement.findMany).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (async (args: any) => { + if (args.where?.type === "WEIGHT" && !args.cursor) { + return [ + { + id: "w-old", + measuredAt: eightDaysAgo, + value: 80.5, + source: "MANUAL", + deviceType: null, + }, + ] as never; + } + if (args.where?.type === "PULSE" && !args.cursor) { + return [ + { + id: "p-fresh", + measuredAt: oneDayAgo, + value: 72, + source: "APPLE_HEALTH", + deviceType: "watch", + }, + ] as never; + } + return [] as never; + }) as never, + ); + + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + // bfcache-friendly Cache-Control — verifies the IW-B header + // posture is on the wire. + expect(res.headers.get("Cache-Control")).toBe( + "private, max-age=0, must-revalidate", + ); + + const body = (await res.json()) as { + data: { + lastSeenByType: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + >; + }; + }; + expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeGreaterThanOrEqual(7); + expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(9); + expect(body.data.lastSeenByType.PULSE?.daysAgo).toBeGreaterThanOrEqual(0); + expect(body.data.lastSeenByType.PULSE?.daysAgo).toBeLessThanOrEqual(2); + // Types the user never logged report `null` so the tile-strip + // helper falls through without painting a caption. + expect(body.data.lastSeenByType.BLOOD_GLUCOSE).toBeNull(); + }); + // v1.4.33 C1 — slim summaries slice. The route branches on // `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw` // passes carry the per-type DataSummary shape with the same // contract the dashboard tile strip reads. it("returns the slim summaries slice when ?slice=summaries is set", async () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // Aggregate pass + latest pass. The mock returns one row per pass. vi.mocked(prisma.$queryRaw) .mockResolvedValueOnce([ @@ -196,7 +261,9 @@ describe("GET /api/analytics", () => { r2_90: 0.2, }, ] as never) - .mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never); + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo }, + ] as never); const req = new Request( "http://localhost/api/analytics?slice=summaries", @@ -205,6 +272,11 @@ describe("GET /api/analytics", () => { GET as unknown as (req: Request) => Promise )(req); expect(res.status).toBe(200); + // v1.4.34 IW-B — bfcache-friendly directive rides on the slim + // slice too. + expect(res.headers.get("Cache-Control")).toBe( + "private, max-age=0, must-revalidate", + ); const body = (await res.json()) as { data: { summaries: Record< @@ -216,6 +288,10 @@ describe("GET /api/analytics", () => { } >; bmi: number | null; + lastSeenByType: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + >; }; }; // The slim slice produced WEIGHT from the SQL pass; no chunked @@ -226,5 +302,11 @@ describe("GET /api/analytics", () => { expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down"); // Slim slice never carries BMI — the consumer re-derives. expect(body.data.bmi).toBeNull(); + // v1.4.34 IW-B — slim slice surfaces lastSeenByType too so the + // tile-strip caption works regardless of which slice the client + // read. + expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeGreaterThanOrEqual(4); + expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(6); + expect(body.data.lastSeenByType.PULSE).toBeNull(); }); }); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index b409f96a9..a934a8191 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -64,7 +65,12 @@ export const GET = apiHandler(async (request?: Request) => { // tile values land on this branch and skip the heavy chain. if (readSliceParam(request) === "summaries") { const slim = await computeSummariesSlice(user.id); - return apiSuccess(slim); + // v1.4.34 IW-B — bfcache-friendly directive on the slim slice too + // so a back-forward navigation that landed on the dashboard tile + // strip can restore from memory instead of paying a full reload. + const slimRes = apiSuccess(slim); + slimRes.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return slimRes; } // v1.4.25 W7b — every day-bucket call inside this route now honours @@ -91,6 +97,19 @@ export const GET = apiHandler(async (request?: Request) => { // when the column is null or malformed. const sourcePriorityJson = user.sourcePriorityJson; + // v1.4.34 IW-B — capture the most-recent `measuredAt` per type so the + // dashboard tile strip can render an "Letzter Wert vor Xd" caption on + // 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 = {}; + let totalRowsReadForAggregate = 0; const measurementsByType = await Promise.all( types.map((type) => @@ -98,6 +117,23 @@ export const GET = apiHandler(async (request?: Request) => { includeSleepStage: true, }).then((measurements) => { totalRowsReadForAggregate += measurements.length; + // v1.4.34 IW-B — record the freshest `measuredAt` for this type + // (or null when the user has never logged this metric). The + // helper returns rows sorted ascending so `.at(-1)` is the + // 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; + } // v1.4.23 — Apple Health's sleep ingest stores one row per // stage per night. Summarising the raw rows would treat each // stage as its own datapoint and grossly understate "average @@ -310,7 +346,7 @@ export const GET = apiHandler(async (request?: Request) => { }); } - return apiSuccess({ + const response = apiSuccess({ summaries: results, bmi, bpInTargetPct, @@ -323,7 +359,20 @@ export const GET = apiHandler(async (request?: Request) => { correlations, healthScore, sleepStages, + // v1.4.34 IW-B — per-type freshness map drives the dashboard's + // staleness caption on each ``. Additive: clients that + // don't read the field stay unchanged. + lastSeenByType, }); + // v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation + // restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`: + // `private` keeps shared caches out of personal data, `max-age=0` forces + // revalidation on every navigation so session swaps detect on the wire, + // and `must-revalidate` holds the staleness contract. Replaces the + // framework's stock `no-store` which Chromium treats as a hard + // bfcache breaker. + response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return response; }); /** diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 1ba88676f..9839726cc 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -75,7 +75,9 @@ describe("computeSummariesSlice", () => { slope90: 0.001, r2_90: 0.12, }, - ]).mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4 }]); + ]).mockResolvedValueOnce([ + { type: "WEIGHT", value: 81.4, measured_at: new Date() }, + ]); const result = await computeSummariesSlice("user-1"); const weight = result.summaries.WEIGHT; @@ -128,7 +130,9 @@ describe("computeSummariesSlice", () => { slope90: null, r2_90: null, }, - ]).mockResolvedValueOnce([{ type: "PULSE", value: 72 }]); + ]).mockResolvedValueOnce([ + { type: "PULSE", value: 72, measured_at: new Date() }, + ]); const result = await computeSummariesSlice("user-1"); expect(result.summaries.PULSE.slope7).toBeNull(); @@ -136,6 +140,43 @@ describe("computeSummariesSlice", () => { expect(result.summaries.PULSE.slope90).toBeNull(); }); + // v1.4.34 IW-B — surface the per-type freshness map so the + // dashboard tile strip can render an "Letzter Wert vor Xd" caption + // even when the consumer read from the slim slice (the path the + // tile-strip pre-fetch uses on cold paint). + it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => { + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + RAW.mockResolvedValueOnce([ + { + type: "WEIGHT", + count: BigInt(5), + min_value: 80, + max_value: 84, + mean_value: 82, + avg7: null, + avg30: 82, + slope7: null, + r2_7: null, + slope30: 0.005, + r2_30: 0.2, + slope90: null, + r2_90: null, + }, + ]).mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.3, measured_at: tenDaysAgo }, + ]); + + const result = await computeSummariesSlice("user-1"); + const ws = result.lastSeenByType.WEIGHT; + expect(ws).not.toBeNull(); + expect(ws?.daysAgo).toBeGreaterThanOrEqual(9); + expect(ws?.daysAgo).toBeLessThanOrEqual(11); + expect(ws?.lastSeenAt).toBe(tenDaysAgo.toISOString()); + // Types the user never logged report null so call sites can + // fall through without painting a caption. + expect(result.lastSeenByType.PULSE).toBeNull(); + }); + it("seeds the latest value from the DISTINCT ON pass per type", async () => { RAW.mockResolvedValueOnce([ { @@ -153,7 +194,9 @@ describe("computeSummariesSlice", () => { slope90: 0, r2_90: 0, }, - ]).mockResolvedValueOnce([{ type: "PULSE", value: 88 }]); + ]).mockResolvedValueOnce([ + { type: "PULSE", value: 88, measured_at: new Date() }, + ]); const result = await computeSummariesSlice("user-1"); // Latest is NOT max — comes from the row at MAX(measured_at). diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 209996c75..6b7be7ee8 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -63,6 +63,14 @@ interface AggregateRow { interface LatestRow { type: string; value: number; + /** + * v1.4.34 IW-B — per-type freshness timestamp. The DISTINCT ON pass + * already orders by `measured_at DESC` to pick the latest row's + * value; surfacing `measured_at` from the same row costs zero extra + * round-trips and feeds the dashboard tile-strip's stale-data + * caption (driven via ``). + */ + measured_at: Date; } /** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */ @@ -113,6 +121,16 @@ export interface SummariesSlice { * default slice. */ bmi: null; + /** + * v1.4.34 IW-B — per-type freshness map matching the default-slice + * shape so the dashboard tile strip can render a "Letzter Wert vor + * Xd" caption on stale tiles regardless of which slice the client + * read from. Types the user has never logged report `null`. + */ + lastSeenByType: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + >; } export async function computeSummariesSlice( @@ -178,7 +196,8 @@ export async function computeSummariesSlice( prisma.$queryRaw` SELECT DISTINCT ON (m."type") m."type"::text AS type, - m."value"::double precision AS value + m."value"::double precision AS value, + m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = ${userId} ORDER BY m."type", m."measured_at" DESC @@ -186,16 +205,39 @@ export async function computeSummariesSlice( ]); const latestByType = new Map(); + // v1.4.34 IW-B — capture the per-type `measured_at` alongside the + // value so we can surface the freshness map on the slim slice + // without an extra round-trip. + const lastSeenAtByType = new Map(); for (const row of latests) { latestByType.set(row.type, Number(row.value)); + if (row.measured_at) { + lastSeenAtByType.set(row.type, new Date(row.measured_at)); + } } // Seed every enum option so consumers can read `summaries.PULSE` and // get a deterministic empty shape rather than `undefined`. Mirrors // the default route which iterates `measurementTypeEnum.options`. const summaries: Record = {}; + const lastSeenByType: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + > = {}; for (const type of measurementTypeEnum.options) { summaries[type] = emptySummary(); + lastSeenByType[type] = null; + } + + const nowForStaleness = Date.now(); + for (const [type, measuredAt] of lastSeenAtByType.entries()) { + const daysAgo = Math.floor( + (nowForStaleness - measuredAt.getTime()) / (24 * 60 * 60 * 1000), + ); + lastSeenByType[type] = { + lastSeenAt: measuredAt.toISOString(), + daysAgo, + }; } let totalRows = 0; @@ -232,5 +274,5 @@ export async function computeSummariesSlice( }, }); - return { summaries, bmi: null }; + return { summaries, bmi: null, lastSeenByType }; } diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts index a7397e4c5..80c6f902d 100644 --- a/src/lib/queries/use-analytics-query.ts +++ b/src/lib/queries/use-analytics-query.ts @@ -76,6 +76,17 @@ export interface AnalyticsRawPayload { correlations?: unknown; healthScore?: unknown; sleepStages?: unknown; + /** + * v1.4.34 IW-B — per-type freshness map. The dashboard's tile strip + * reads `lastSeenByType[type]?.daysAgo` and forwards it to each + * `` so a stale tile renders an "Letzter Wert vor Xd" + * caption instead of disappearing. Present on both the thick default + * and the slim `?slice=summaries` branch. + */ + lastSeenByType?: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + >; // Keep the contract open — additive server fields don't need a // type bump every release. [key: string]: unknown;