mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
9c22789489
commit
9a8a9e9ab2
5 files changed
+235
-8
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user