feat(analytics): expose per-type lastSeenByType + bfcache-friendly Cache-Control

The dashboard tile strip needs to render an "Letzter Wert vor Xd" caption on
tiles whose latest reading is older than a week so a metric the user hasn't
logged in a while keeps its tile visible (with an explicit how-old hint)
instead of disappearing from the strip.

- `/api/analytics` default path captures the freshest `measuredAt` per
  `MeasurementType` in the existing per-type chunked walk and surfaces it as
  `lastSeenByType[type] = { lastSeenAt, daysAgo } | null`. Types the user
  never logged report `null`.
- `?slice=summaries` (slim slice) carries the same field — the DISTINCT ON
  pass already orders by `measured_at DESC` to pick `latest.value`; adding
  `measured_at` to the projection costs zero round-trips.
- Both responses stamp `Cache-Control: private, max-age=0, must-revalidate`
  (the typed `NO_STORE_BUT_BFCACHE` constant) so back-forward navigation can
  restore the dashboard from bfcache instead of paying a full reload. The
  framework default for cookie-touching routes is `no-store, must-revalidate`,
  which Chromium counts as a hard bfcache breaker.
- `AnalyticsRawPayload` gains an optional `lastSeenByType` field so call
  sites read the shape with type-safety; consumers that don't care stay
  unchanged.

Three new test cases pin the contract: default-path freshness map keyed on
the chunked reader, slim-slice freshness map keyed on the DISTINCT ON pass,
and the bfcache-friendly Cache-Control header on the wire for both slices.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:33:34 +02:00
1 parent 9c22789489
commit 9a8a9e9ab2
5 files changed
+235 -8

No files matched your search

+83 -1
View File
@@ -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 `<TrendCard>` 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<Response>
)();
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<Response>
)(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();
});
});
+51 -2
View File
@@ -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<string, {
lastSeenAt: string;
daysAgo: number;
} | null> = {};
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 `<TrendCard>`. 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;
});
/**
@@ -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).
+44 -2
View File
@@ -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 `<TrendCard staleDays>`).
*/
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<LatestRow[]>`
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<string, number>();
// 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<string, Date>();
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<string, DataSummary> = {};
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 };
}
+11
View File
@@ -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
* `<TrendCard>` 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;