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,220 @@
/**
* v1.4.33 C1 — `?slice=summaries` integration coverage.
*
* Pins the slim summaries slice against the real Postgres + the real
* `regr_slope` / `regr_r2` functions, not a unit-level `$queryRaw`
* mock. The slice replaces the route's 30-type × Promise.all chunked
* findMany walk with 2 SQL passes; the integration test runs against
* the test container so any drift in the SQL (alias names, casts,
* `FILTER` semantics) surfaces here instead of in production.
*
* Three contracts pinned:
*
* 1. **Empty user.** A brand-new account with zero rows returns the
* empty `DataSummary` skeleton for every `MeasurementType` and
* `bmi: null`. No JS-side spread anywhere along the chain — the
* v1.4.33 P0 RangeError won't reach this path.
*
* 2. **Populated WEIGHT series.** A 14-day descending-weight series
* lands `count`, `latest`, `avg7`, `avg30`, the negative-slope
* `direction: "down"` tuple from `regr_slope`, plus min/max/mean
* with the same 2-decimal rounding the `summarize()` helper uses.
*
* 3. **Default slice unchanged.** Hitting the route WITHOUT
* `?slice=summaries` still returns the thick `healthScore` /
* `correlations` / `bpInTargetPct` / `sleepStages` envelope. C1
* is additive — no behaviour change for the existing consumers.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface DataSummaryShape {
count: number;
latest: number | null;
min: number | null;
max: number | null;
mean: number | null;
avg7: number | null;
avg30: number | null;
slope7: { slope: number; direction: string; confidence: number } | null;
slope30: { slope: number; direction: string; confidence: number } | null;
slope90: { slope: number; direction: string; confidence: number } | null;
anomalyCount: number;
avg30LastMonth: number | null;
avg30LastYear: number | null;
}
interface SlimEnvelope {
data: {
summaries: Record<string, DataSummaryShape>;
bmi: number | null;
};
error: null;
}
async function seedSession(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
describe("GET /api/analytics?slice=summaries (C1)", () => {
it("returns the empty-summary skeleton for a brand-new user", async () => {
await seedSession("slim-empty-user");
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics?slice=summaries"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as SlimEnvelope;
// Every enum option is present with the deterministic empty
// shape so consumers can read `summaries.WEIGHT.count > 0`
// without an undefined guard.
expect(envelope.data.summaries.WEIGHT.count).toBe(0);
expect(envelope.data.summaries.WEIGHT.latest).toBeNull();
expect(envelope.data.summaries.WEIGHT.slope30).toBeNull();
expect(envelope.data.summaries.BLOOD_PRESSURE_SYS.count).toBe(0);
expect(envelope.data.summaries.PULSE.count).toBe(0);
// The slim slice never carries BMI — consumers re-derive from
// `summaries.WEIGHT.latest` + `user.heightCm` client-side or
// fetch the default slice.
expect(envelope.data.bmi).toBeNull();
});
it("reports count + latest + slope + windowed averages from real SQL", async () => {
const prisma = getPrismaClient();
const user = await seedSession("slim-populated-user");
// 14 descending-WEIGHT entries spaced one day apart. Slope per
// day = (84.0 - 80.0) / 13 ≈ -0.308 — solidly inside the "down"
// band (|slope| >= 0.01 units/day threshold).
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
const N = 14;
const rows = Array.from({ length: N }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
// Most-recent reading is the smallest — slope must come out
// negative when regressing value against time.
value: 80 + i * (4 / (N - 1)),
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - i * DAY_MS),
}));
await prisma.measurement.createMany({ data: rows });
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics?slice=summaries"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as SlimEnvelope;
const weight = envelope.data.summaries.WEIGHT;
expect(weight.count).toBe(N);
// Most-recent row in DESCENDING construction = index 0 = value 80.
expect(weight.latest).toBeCloseTo(80, 1);
// Min / max across the 14 rows.
expect(weight.min).toBeCloseTo(80, 1);
expect(weight.max).toBeCloseTo(84, 1);
// 7-day window covers the first 7 entries (descending dates).
expect(weight.avg7).not.toBeNull();
expect(weight.avg30).not.toBeNull();
// Slope direction must be "down" — value decreases with time.
expect(weight.slope30).not.toBeNull();
expect(weight.slope30!.direction).toBe("down");
expect(weight.slope30!.slope).toBeLessThan(0);
// R² confidence is in [0, 1].
expect(weight.slope30!.confidence).toBeGreaterThanOrEqual(0);
expect(weight.slope30!.confidence).toBeLessThanOrEqual(1);
// Sanity-check: PULSE is empty on this fixture.
expect(envelope.data.summaries.PULSE.count).toBe(0);
});
it("default slice still carries the thick envelope (additive contract)", async () => {
await seedSession("slim-default-still-thick");
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
// Default slice still produces every block the route v1.4.32
// shipped — proving C1 added a branch instead of mutating the
// existing path.
const body = (await response.json()) as {
data: {
summaries: unknown;
bmi: unknown;
bpInTargetPct: unknown;
correlations: unknown;
healthScore: unknown;
sleepStages: unknown;
glucoseByContext: unknown;
};
};
expect(body.data.summaries).toBeDefined();
// Every thick-slice key must still be on the envelope (value can
// be null for an empty user — what we care about is presence).
expect("bpInTargetPct" in body.data).toBe(true);
expect("correlations" in body.data).toBe(true);
expect("healthScore" in body.data).toBe(true);
expect("sleepStages" in body.data).toBe(true);
expect("glucoseByContext" in body.data).toBe(true);
});
});