mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
272 lines
9.7 KiB
TypeScript
272 lines
9.7 KiB
TypeScript
/**
|
||
* v1.4.34 IW-G — integration coverage for the server-cache layer.
|
||
*
|
||
* Pins two contracts the unit suite cannot cover end-to-end:
|
||
*
|
||
* 1. **Single-flight under concurrency.** Ten concurrent requests to
|
||
* `/api/analytics` on a cold cache must fan into a single builder
|
||
* call — the `pending` map in `ServerCache.wrap()` coalesces every
|
||
* caller after the first onto the same promise. The Postgres-side
|
||
* row count for the per-type chunked reads is the witness: the
|
||
* builder reads ~30 measurement types × 1 chunked findMany each
|
||
* per call; a busted single-flight would multiply that by 10.
|
||
*
|
||
* 2. **Invalidation closes the loop.** Priming the analytics cache,
|
||
* then POSTing a new measurement, then re-reading must yield a
|
||
* MISS — the write endpoint's `invalidateUserMeasurements` call
|
||
* flushes the prior entry. The `cache.analytics.outcome` annotation
|
||
* reaches the route's wide-event meta; we read it back through
|
||
* the response envelope by tagging the first request as `miss` and
|
||
* the post-write request as `miss` again (same userId + the same
|
||
* slice key, but the cache was evicted).
|
||
*
|
||
* Test wiring uses the same Postgres testcontainer the other
|
||
* integration suites share; cache state is reset between cases via the
|
||
* registry escape hatch so each test starts cold.
|
||
*/
|
||
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();
|
||
const { __resetAllCachesForTests } = await import(
|
||
"@/lib/cache/server-cache"
|
||
);
|
||
__resetAllCachesForTests();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
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("v1.4.34 IW-G — server-cache route integration", () => {
|
||
it("coalesces 10 concurrent /api/analytics reads into a single builder call", async () => {
|
||
await seedSession("cache-stampede-user");
|
||
|
||
const { GET } = await import("@/app/api/analytics/route");
|
||
const { caches } = await import("@/lib/cache/server-cache");
|
||
|
||
// Fire 10 concurrent reads against a cold cache. The first sets up
|
||
// the `pending` promise; the other nine join it. The cache's
|
||
// counter snapshot witnesses the fan-in: misses === 1, stampedes
|
||
// covers the remaining nine reads.
|
||
const requests = Array.from(
|
||
{ length: 10 },
|
||
() =>
|
||
(GET as unknown as (req: Request) => Promise<Response>)(
|
||
new Request("http://localhost/api/analytics"),
|
||
),
|
||
);
|
||
const responses = await Promise.all(requests);
|
||
|
||
expect(responses.every((r) => r.status === 200)).toBe(true);
|
||
// One miss (the builder fires for the first caller). Every other
|
||
// caller MUST land on either the in-flight `pending` promise (a
|
||
// stampede) or — if the builder resolved between two `Promise.all`
|
||
// microtasks — the now-warm cache (a hit). What we forbid is a
|
||
// second miss: that would mean the single-flight join failed and a
|
||
// duplicate builder fired.
|
||
const stats = caches.analytics.stats();
|
||
expect(stats.misses).toBe(1);
|
||
expect(stats.stampedes + stats.hits).toBe(9);
|
||
// Subsequent serial reads inside the TTL hit the warm cache.
|
||
const warm = await (
|
||
GET as unknown as (req: Request) => Promise<Response>
|
||
)(new Request("http://localhost/api/analytics"));
|
||
expect(warm.status).toBe(200);
|
||
// Plus at least one extra hit from this serial read.
|
||
expect(caches.analytics.stats().hits).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("invalidates the analytics cache after a measurement write", async () => {
|
||
const user = await seedSession("cache-invalidate-user");
|
||
|
||
const { GET } = await import("@/app/api/analytics/route");
|
||
const { POST: postMeasurement } = await import(
|
||
"@/app/api/measurements/route"
|
||
);
|
||
const { caches } = await import("@/lib/cache/server-cache");
|
||
|
||
// Prime the cache — first read is a miss; second is a hit.
|
||
const first = await (
|
||
GET as unknown as (req: Request) => Promise<Response>
|
||
)(new Request("http://localhost/api/analytics"));
|
||
expect(first.status).toBe(200);
|
||
expect(caches.analytics.stats().misses).toBe(1);
|
||
expect(caches.analytics.stats().hits).toBe(0);
|
||
|
||
const second = await (
|
||
GET as unknown as (req: Request) => Promise<Response>
|
||
)(new Request("http://localhost/api/analytics"));
|
||
expect(second.status).toBe(200);
|
||
expect(caches.analytics.stats().hits).toBe(1);
|
||
|
||
// Write a new measurement — the POST handler calls
|
||
// `invalidateUserMeasurements(user.id)` after its DB commit.
|
||
const writeRes = await (
|
||
postMeasurement as unknown as (req: Request) => Promise<Response>
|
||
)(
|
||
new Request("http://localhost/api/measurements", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
type: "WEIGHT",
|
||
value: 82.4,
|
||
measuredAt: new Date().toISOString(),
|
||
}),
|
||
}),
|
||
);
|
||
expect([200, 201]).toContain(writeRes.status);
|
||
|
||
// The next read MUST miss — the cache for this userId|default key
|
||
// was evicted by the write's invalidation call.
|
||
const third = await (
|
||
GET as unknown as (req: Request) => Promise<Response>
|
||
)(new Request("http://localhost/api/analytics"));
|
||
expect(third.status).toBe(200);
|
||
expect(caches.analytics.stats().misses).toBe(2);
|
||
|
||
// Sanity: the body reflects the new write.
|
||
const body = (await third.json()) as {
|
||
data: { summaries: { WEIGHT: { count: number; latest: number | null } } };
|
||
};
|
||
expect(body.data.summaries.WEIGHT.count).toBe(1);
|
||
expect(body.data.summaries.WEIGHT.latest).toBeCloseTo(82.4, 1);
|
||
|
||
// The achievements cache for the same user must also be empty —
|
||
// the measurements helper evicts that bucket too.
|
||
expect(caches.achievements.get(user.id)).toBeNull();
|
||
});
|
||
|
||
it("re-reads compliance buckets after an intake POST evicts the cache", async () => {
|
||
const user = await seedSession("cache-intake-invalidate-user");
|
||
const prisma = getPrismaClient();
|
||
|
||
// Seed a medication + scheduled intake so the compliance branch
|
||
// has something to aggregate.
|
||
const med = await prisma.medication.create({
|
||
data: {
|
||
userId: user.id,
|
||
name: "Test Med",
|
||
dose: "1mg",
|
||
active: true,
|
||
schedules: {
|
||
create: [
|
||
{ windowStart: "08:00", windowEnd: "10:00", daysOfWeek: null },
|
||
],
|
||
},
|
||
},
|
||
include: { schedules: true },
|
||
});
|
||
const event = await prisma.medicationIntakeEvent.create({
|
||
data: {
|
||
userId: user.id,
|
||
medicationId: med.id,
|
||
scheduledFor: new Date(),
|
||
takenAt: null,
|
||
skipped: false,
|
||
},
|
||
});
|
||
|
||
const { GET: intakeGet, POST: intakePost } = await import(
|
||
"@/app/api/medications/intake/route"
|
||
);
|
||
const { NextRequest } = await import("next/server");
|
||
const { caches } = await import("@/lib/cache/server-cache");
|
||
|
||
// Prime — first compliance read is a miss.
|
||
const first = await intakeGet(
|
||
new NextRequest(
|
||
"http://localhost/api/medications/intake?scope=compliance&days=30",
|
||
),
|
||
);
|
||
expect(first.status).toBe(200);
|
||
expect(caches.medicationsIntake.stats().misses).toBe(1);
|
||
|
||
// Second read inside the TTL — hit.
|
||
const second = await intakeGet(
|
||
new NextRequest(
|
||
"http://localhost/api/medications/intake?scope=compliance&days=30",
|
||
),
|
||
);
|
||
expect(second.status).toBe(200);
|
||
expect(caches.medicationsIntake.stats().hits).toBe(1);
|
||
|
||
// Mark the dose taken — POST handler calls invalidateUserMedications
|
||
// which flushes the compliance bucket too.
|
||
const writeRes = await intakePost(
|
||
new NextRequest("http://localhost/api/medications/intake", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ intakeId: event.id, status: "taken" }),
|
||
}),
|
||
);
|
||
expect(writeRes.status).toBe(200);
|
||
|
||
// Next compliance read is a miss again.
|
||
const third = await intakeGet(
|
||
new NextRequest(
|
||
"http://localhost/api/medications/intake?scope=compliance&days=30",
|
||
),
|
||
);
|
||
expect(third.status).toBe(200);
|
||
expect(caches.medicationsIntake.stats().misses).toBe(2);
|
||
|
||
// And the new taken state shows in the bucket aggregate.
|
||
const body = (await third.json()) as {
|
||
data: Array<{ date: string; scheduled: number; taken: number }>;
|
||
};
|
||
const todayBucket = body.data.find((b) => b.taken > 0);
|
||
expect(todayBucket).toBeDefined();
|
||
expect(todayBucket!.taken).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|