mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user