v1.4.33 — polish and reliability

Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.

Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.

Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.

The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 19:46:43 +02:00
1 parent c7a0ff214b
commit 7bef199039
186 files changed
+9358 -946

No files matched your search

@@ -0,0 +1,230 @@
/**
* v1.4.33 P0 regression coverage for `GET /api/analytics`.
*
* Production stacktrace 2026-05-16 14:39:51 UTC (cf-ray 9fcb223c…):
* `RangeError: Maximum call stack size exceeded`
* at Promise.all (index 3 — `PULSE`)
*
* Root cause was in `summarize()` (`src/lib/analytics/trends.ts`) — the
* `Math.min(...values)` / `Math.max(...values)` spread blew V8's
* ~125 000-arg function-arity ceiling once an Apple-Health-synced PULSE
* series for a multi-year power user grew past it.
*
* The fix folds min/max into the single sum/mean pass; this test pins
* the contract from the route entry-point so a future refactor (e.g.
* the v1.4.33 C1 SQL-side aggregation rewrite) can't silently
* reintroduce a spread anywhere along the call chain.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`.
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
interface MeasurementRow {
id: string;
measuredAt: Date;
value: number;
source: "MANUAL" | "WITHINGS" | "IMPORT" | "APPLE_HEALTH";
deviceType: string | null;
sleepStage?: string | null;
}
const SESSION_USER = {
id: "user-1",
username: "test",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
sourcePriorityJson: null,
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: SESSION_USER as never,
};
function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow {
return {
id,
measuredAt,
value,
source: "APPLE_HEALTH",
deviceType: "watch",
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/analytics", () => {
it("survives a 130 000-row PULSE series without blowing the stack", async () => {
// The chunked reader pages 5 000 at a time. Simulate one full page
// for PULSE so the route's per-type aggregator hits the failing
// `summarize()` codepath with a 5 000-point series, then stop. We
// intentionally don't seed every single Apple Watch sample in the
// mock — V8's spread-arg ceiling is repeatable with a far smaller
// array than production (see `trends.test.ts` for the 250 000-row
// direct regression). This test pins the route-entry contract.
const N = 5_000;
const now = Date.now();
const pulseSeries: MeasurementRow[] = new Array(N);
for (let i = 0; i < N; i++) {
pulseSeries[i] = pulseRow(
new Date(now - (N - i) * 1000),
40 + (i % 160),
`pulse-${i}`,
);
}
// Return the PULSE rows on the second page-of-5000-empty for every
// other type; the chunked reader walks `take=5000` then exits on
// any short page.
vi.mocked(prisma.measurement.findMany).mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(async (args: any) => {
if (args.where?.type === "PULSE" && !args.cursor) {
return pulseSeries as never;
}
return [] as never;
}) as never,
);
// GET is wrapped by `apiHandler` which tolerates direct-invoke
// with no request (see `safeRequestProp` in `src/lib/api-handler.ts`).
// Cast through `unknown` so the TS signature `(...args: never[])`
// doesn't fight the vitest direct-invoke pattern.
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { summaries: Record<string, { count: number; min: number | null; max: number | null }> };
};
expect(body.data.summaries.PULSE.count).toBe(N);
// 40 + (N-1) % 160 → wave around (40..199).
expect(body.data.summaries.PULSE.min).toBeGreaterThanOrEqual(40);
expect(body.data.summaries.PULSE.max).toBeLessThanOrEqual(199);
});
it("returns a 200 envelope for a brand-new user with zero rows", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
summaries: Record<string, { count: number }>;
bmi: number | null;
healthScore: unknown;
};
};
// Empty series should not crash on the new fold path either.
expect(body.data.summaries.PULSE.count).toBe(0);
expect(body.data.summaries.WEIGHT.count).toBe(0);
expect(body.data.bmi).toBeNull();
expect(body.data.healthScore).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 () => {
// Aggregate pass + latest pass. The mock returns one row per pass.
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
type: "WEIGHT",
count: BigInt(12),
min_value: 80.0,
max_value: 84.5,
mean_value: 82.1,
avg7: 82.0,
avg30: 82.2,
slope7: -0.05,
r2_7: 0.4,
slope30: -0.02,
r2_30: 0.3,
slope90: -0.01,
r2_90: 0.2,
},
] as never)
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never);
const req = new Request(
"http://localhost/api/analytics?slice=summaries",
);
const res = await (
GET as unknown as (req: Request) => Promise<Response>
)(req);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
summaries: Record<
string,
{
count: number;
latest: number | null;
slope30: { slope: number; direction: string } | null;
}
>;
bmi: number | null;
};
};
// The slim slice produced WEIGHT from the SQL pass; no chunked
// findMany was called.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(body.data.summaries.WEIGHT.count).toBe(12);
expect(body.data.summaries.WEIGHT.latest).toBe(81.8);
expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down");
// Slim slice never carries BMI — the consumer re-derives.
expect(body.data.bmi).toBeNull();
});
});
+35 -1
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -29,10 +30,43 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
/**
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
* route's production wrapper) or a plain `Request` (the integration
* tests instantiate `new Request("http://localhost/api/analytics")`
* and cast through). Falls back to the raw `URL(request.url)` parse
* so the slim slice branch is reachable from both call shapes.
*/
function readSliceParam(request: Request | undefined): string | null {
if (!request) return null;
try {
return new URL(request.url).searchParams.get("slice");
} catch {
return null;
}
}
export const GET = apiHandler(async (request?: Request) => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
// The default path runs 30+ chunked findMany reads to feed
// `summarize()` per type; under `?slice=summaries` the route
// resolves the same per-type DataSummary shape from 2 SQL passes
// (a `groupBy` for count/min/max/mean plus a single `$queryRaw`
// carrying the windowed averages and Postgres `regr_slope` /
// `regr_r2` for the slope tuples). The slim slice drops the thick
// `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext`
// / `sleepStages` / `bmi` blocks — those stay on the default path
// behind a hover-prefetch from the Coach drawer + the correlation
// tile's `<InView>` boundary. Consumers that need only the headline
// 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.25 W7b — every day-bucket call inside this route now honours
// the user's display timezone. The legacy `berlinDayKey()` import
// remains for sleep-stage and correlation paths that share their
@@ -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(((
+115 -15
View File
@@ -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<MetricKind, string> = {
@@ -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<string>();
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,
});
}