v1.4.34 — apple-health import + reliability + web-freeze

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.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 21:25:26 +02:00
1 parent 7bef199039
commit affede31a4
104 files changed
+8050 -404

No files matched your search

+23 -9
View File
@@ -59,17 +59,31 @@ describe("v1.4.19 A3 — dashboard polish", () => {
});
describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
it("the insights layout wraps children in <CoachLaunchProvider> and mounts the drawer", () => {
// v1.4.27 MB4 promoted the drawer mount from the mother page up to
// `/insights/layout.tsx` so a routed sub-page does not unmount the
// drawer. The layout owns the mount + the provider; the page (and
// every sub-page) consumes the launch context.
const layout = load(INSIGHTS_LAYOUT_PATH);
expect(layout).toContain(
// v1.4.34 IW-B — `<CoachLaunchProvider>` + `<LayoutCoachMount>` moved
// up to the global `<AuthShell>` so the dashboard hero (and every
// other authed surface) can call `askCoach()` from the same context.
// The insights layout keeps the FAB but no longer wraps the provider
// or mounts the drawer.
it("the global auth-shell wraps the authenticated tree in <CoachLaunchProvider> and mounts the drawer", () => {
const shellSource = load(
`${process.cwd()}/src/components/layout/auth-shell.tsx`,
);
expect(shellSource).toContain(
'from "@/lib/insights/coach-launch-context"',
);
expect(layout).toMatch(/<CoachLaunchProvider\b/);
expect(layout).toMatch(/<LayoutCoachMount\b/);
expect(shellSource).toMatch(/<CoachLaunchProvider>/);
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
});
it("the insights layout no longer double-mounts the provider or drawer", () => {
const layout = load(INSIGHTS_LAYOUT_PATH);
expect(layout).not.toContain(
'from "@/lib/insights/coach-launch-context"',
);
expect(layout).not.toContain("import { LayoutCoachMount }");
// FAB stays scoped to `/insights/**` because the floating button
// would distract on surfaces with a contextual inline CTA.
expect(layout).toContain("LayoutCoachFab");
});
it("hero strip's onAskCoach handler calls the launch context", () => {
+5 -2
View File
@@ -155,8 +155,11 @@ const mockData = {
},
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockData, isLoading: false }),
// v1.4.34 IW-F-Perf — page now reads through the shared
// `useAchievementsQuery` hook; mock it directly so the test stays
// scoped to the page render and bypasses the TanStack provider.
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: () => ({ data: mockData, isLoading: false }),
}));
import AchievementsPage, { groupByCategory } from "../page";
+6 -19
View File
@@ -1,6 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
@@ -36,20 +35,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import {
ACHIEVEMENT_CATEGORY_ORDER,
type AchievementCategory,
type AchievementMetrics,
type AchievementProgress,
type AchievementSummary,
} from "@/lib/gamification/achievements";
interface AchievementsData {
summary: AchievementSummary;
achievements: AchievementProgress[];
metrics: AchievementMetrics;
}
const iconMap: Record<string, LucideIcon> = {
Activity,
Flame,
@@ -271,16 +263,11 @@ export default function AchievementsPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: ["gamification", "achievements"],
queryFn: async () => {
const res = await fetch("/api/gamification/achievements");
if (!res.ok) throw new Error("Failed to load achievements");
const json = await res.json();
return json.data as AchievementsData;
},
enabled: isAuthenticated,
});
// v1.4.34 IW-F-Perf — mother page rides the shared cache slot
// alongside `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` so the three consumers never trigger
// more than one network call on a cold dashboard mount.
const { data, isLoading } = useAchievementsQuery();
if (authLoading || isLoading) {
return (
@@ -0,0 +1,130 @@
/**
* Smoke tests for the admin Apple Health import endpoint. Covers the
* cookie-only `requireAdmin()` gate (Bearer never elevates), the 413
* size-cap, and the 429 rate-limit path. Multipart streaming + boss
* enqueue are exercised by the parser + multipart unit suites.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
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: () => {},
})),
}));
const mocks = vi.hoisted(() => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
importJobCreate: vi.fn(),
importJobFindFirst: vi.fn().mockResolvedValue(null),
importJobUpdate: vi.fn(),
userFindUnique: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: mocks.checkRateLimit,
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: mocks.getGlobalBoss,
}));
vi.mock("@/lib/db", () => ({
prisma: {
importJob: {
create: mocks.importJobCreate,
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
user: {
findUnique: mocks.userFindUnique,
},
},
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
const ADMIN_SESSION = {
session: { id: "sess-admin", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
};
const USER_SESSION = {
session: { id: "sess-user", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-2", username: "alice", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.bossSend.mockResolvedValue("boss-job-1");
mocks.getGlobalBoss.mockReturnValue({ send: mocks.bossSend });
mocks.importJobCreate.mockResolvedValue({ id: "ij-1" });
mocks.importJobFindFirst.mockResolvedValue(null);
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
mocks.userFindUnique.mockResolvedValue({ id: "user-2", username: "alice" });
});
describe("POST /api/admin/import-apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(401);
});
it("returns 403 when a non-admin cookie session calls the route", async () => {
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(403);
});
it("returns 413 when Content-Length exceeds the cap", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
const headers = new Headers({
"content-type": "multipart/form-data; boundary=---x",
"content-length": String(2 * 1024 * 1024 * 1024),
});
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers,
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(413);
});
it("returns 429 on rate-limit exhaustion", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(429);
});
});
@@ -0,0 +1,192 @@
/**
* `POST /api/admin/import-apple-health-export` — admin-only Apple
* Health import on behalf of a target user.
*
* Same multipart streaming path as the user-facing kick-off, but with
* `requireAdmin()` (cookie-only — Bearer tokens never elevate) and a
* `userId` text field naming the target user. The kicked-off
* `ImportJob` row carries `triggeredByAdminId = admin.id` so the
* status endpoint admits both the target user AND the triggering
* admin.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §9.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
export const dynamic = "force-dynamic";
const MAX_UPLOAD_BYTES = 1.5 * 1024 * 1024 * 1024;
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW_MS = 60_000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
annotate({ action: { name: "admin.import-apple-health.kickoff" } });
const rl = await checkRateLimit(
`admin-import-apple-health:${admin.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
throw new HttpError(429, "Too many import uploads, try again later");
}
const declaredBytes = Number(request.headers.get("content-length") ?? 0);
if (declaredBytes > MAX_UPLOAD_BYTES) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "content_length_exceeded",
contentLength: declaredBytes,
},
});
return apiError("Upload exceeds 1.5 GB limit", 413);
}
const body = request.body;
if (!body) {
return apiError("Request body is required", 400);
}
let uploaded;
try {
uploaded = await streamMultipartToDisk(
body,
request.headers.get("content-type"),
{
maxBytes: MAX_UPLOAD_BYTES,
fieldName: "file",
tmpPrefix: "healthlog-admin-apple-health-import",
},
);
} catch (err) {
const message = err instanceof Error ? err.message : "upload_failed";
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "stream_to_disk_failed", message },
});
return apiError(`Multipart upload failed: ${message}`, 422);
}
// Resolve the target user from the multipart text field. Mandatory:
// the admin variant is explicitly cross-user.
const targetUserId = uploaded.textFields.userId;
if (!targetUserId) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "missing_user_id" },
});
return apiError("Multipart field 'userId' is required", 422);
}
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, username: true },
});
if (!targetUser) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "target_user_missing", targetUserId },
});
return apiError(
`Target user '${targetUserId}' does not exist`,
422,
);
}
// Same content-hash idempotency as the user-facing route, but scoped
// by the target user — an admin re-uploading the same file for the
// same user resolves to the previous job.
const existing = await prisma.importJob.findFirst({
where: {
userId: targetUser.id,
uploadSha256: uploaded.sha256,
},
orderBy: { startedAt: "desc" },
});
if (existing) {
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
jobId: existing.id,
status: existing.status,
idempotent: true,
},
202,
);
}
const boss = getGlobalBoss();
if (!boss) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: targetUser.id,
triggeredByAdminId: admin.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
const payload: AppleHealthImportPayload = {
userId: targetUser.id,
triggeredByAdminId: admin.id,
uploadPath: uploaded.filePath,
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
await prisma.importJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
await auditLog("admin.import-apple-health.kickoff", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
bossJobId: bossJobId ?? null,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
return apiSuccess(
{
jobId: importJob.id,
status: "queued" as const,
targetUserId: targetUser.id,
},
202,
);
});
+6
View File
@@ -15,6 +15,7 @@ import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -228,6 +229,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateServerDefaultTimezone();
}
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
// so the next read reflects the new GitHub-token / bug-report-enabled
// shape. Cheap call — the cache holds at most 10 entries.
invalidateAppSettings();
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
+88 -1
View File
@@ -52,6 +52,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
interface MeasurementRow {
id: string;
@@ -89,6 +90,10 @@ function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset the analytics LRU between tests so each case
// observes a cold cache (otherwise tests sharing a userId would land
// on the prior test's cached response).
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
@@ -172,11 +177,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 +266,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 +277,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 +293,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 +307,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();
});
});
+89 -5
View File
@@ -2,6 +2,8 @@ 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 { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -63,10 +65,58 @@ export const GET = apiHandler(async (request?: Request) => {
// 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.34 IW-G — read-through the analytics cache keyed on
// (userId, slice). The slim slice is the dashboard tile strip's hot
// path; multiple dashboard mounts inside a 60-second TTL all hit a
// warm cache.
const slim = await cached(
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
`${user.id}|summaries`,
() => computeSummariesSlice(user.id),
annotate,
);
// 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.34 IW-G — wrap the heavy default-slice body in the analytics
// cache keyed on (userId, "default"). The dashboard, the checklist
// mount, and the Coach drawer all hit this endpoint within seconds
// of each other; the 60s TTL converts the 7.99s combined dashboard
// wait to a Map lookup on every subsequent caller.
const body = await cached(
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
annotate,
);
const response = apiSuccess(body);
// 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;
});
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
/**
* v1.4.34 IW-G — the heavy default-slice body, lifted out of the route
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
* route handler attaches `Cache-Control` headers afterward. The split
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
* `buildCoachSnapshotImpl` shape.
*/
async function buildAnalyticsResponse(user: AuthedUser) {
// 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
@@ -91,6 +141,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 +161,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 +390,7 @@ export const GET = apiHandler(async (request?: Request) => {
});
}
return apiSuccess({
return {
summaries: results,
bmi,
bpInTargetPct,
@@ -323,8 +403,12 @@ 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,
};
}
/**
* Per-stage sleep-minutes breakdown over the trailing 30 days.
+9
View File
@@ -21,6 +21,7 @@ import {
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -149,6 +150,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
});
// v1.4.34 IW-G — bust the per-user dashboard-widgets cache so the
// next dashboard mount paints the new layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(normalized);
});
@@ -162,5 +167,9 @@ export const DELETE = apiHandler(async () => {
annotate({ action: { name: "dashboard.widgets.reset" } });
// v1.4.34 IW-G — bust the per-user dashboard-widgets cache so the
// next dashboard mount paints the reset (default) layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
});
@@ -36,6 +36,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -65,6 +66,9 @@ const HIDDEN_TRIGGER_STRINGS = [
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset achievement LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -36,6 +36,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -51,6 +52,9 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset achievement LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
+58 -26
View File
@@ -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 { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
ACHIEVEMENT_DEFINITIONS,
GAMIFICATION_ROLLOUT_AT,
@@ -278,7 +279,9 @@ function getOnTimePerfectDaySeries(
scheduledDate,
);
if (timing !== "on_time") {
// v1.4.34 IW-C — `early` is also a compliant bucket (dose taken
// within the 3h pre-window grace), so it preserves a perfect day.
if (timing !== "on_time" && timing !== "early") {
isPerfectDay = false;
break;
}
@@ -461,6 +464,58 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { format: isIosFormat ? "ios" : "default" },
});
// v1.4.34 IW-G — cache the web-shape result keyed on userId. The
// iOS-format branch runs the locale-aware transform after the cache
// read so the cache stays format-agnostic and the achievement-progress
// dashboard duplicate (seen twice per dashboard mount in the v1.4.33
// HAR) coalesces into one builder call. Newly-unlocked achievements
// get persisted inside the builder so the second concurrent mount
// observes the up-to-date `persisted` state.
const result = await cached(
caches.achievements as ServerCache<AchievementsResult>,
user.id,
() => buildAchievementsResult(user),
annotate,
);
if (isIosFormat) {
const locale = await resolveServerLocale({
request,
userLocale: user.locale,
});
const t = getServerTranslator(locale);
const ios: IosAchievement[] = result.achievements.map((a) => ({
id: a.id,
key: a.id,
title: t.t(a.titleKey),
description: t.t(a.descriptionKey),
iconName: a.icon,
unlocked: a.unlocked,
unlockedAt: a.completedAt,
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
}));
return apiSuccess(ios);
}
return apiSuccess(result);
});
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
type AchievementsResult = Awaited<ReturnType<typeof buildAchievementsResult>>;
/**
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
* the heavy aggregation. Returns the locale-agnostic web payload; the
* iOS-format transform runs in the handler after the cache read.
*
* `prisma.userAchievement.createMany` inside the builder is kept here
* (rather than moved to the cache hit path) because new unlocks are
* a side effect of the same aggregate computation. A duplicate concurrent
* read coalesces through the cache's single-flight `pending` map so the
* createMany only fires once.
*/
async function buildAchievementsResult(user: AuthedUser) {
const now = new Date();
const userId = user.id;
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
@@ -866,31 +921,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
metrics: redactedMetrics,
};
if (isIosFormat) {
const locale = await resolveServerLocale({
request,
userLocale: user.locale,
});
const t = getServerTranslator(locale);
// Same redaction principle: iOS clients receive opaque
// placeholders for hidden+locked entries. The native client
// mirrors the web-card opaque-placeholder UI when it sees the
// sentinel id `achievements.hiddenCard.title` come back.
const ios: IosAchievement[] = redactedAchievements.map((a) => ({
id: a.id,
key: a.id,
title: t.t(a.titleKey),
description: t.t(a.descriptionKey),
iconName: a.icon,
unlocked: a.unlocked,
unlockedAt: a.completedAt,
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
}));
return apiSuccess(ios);
}
return apiSuccess(result);
});
return result;
}
/**
* Hidden Easter-eggs ship to the client *only* as opaque cards while
@@ -0,0 +1,121 @@
/**
* Tests for the polling status endpoint. Covers the canonical envelope
* shape, the 404 on cross-user access, and the admin-trigger allow-list.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
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: () => {},
})),
}));
const mocks = vi.hoisted(() => ({
importJobFindUnique: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
importJob: { findUnique: mocks.importJobFindUnique },
},
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
const OWNER_SESSION = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const OTHER_SESSION = {
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-99", username: "eve", role: "USER" as const },
};
const ADMIN_SESSION = {
session: { id: "sess-a", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc-admin", role: "ADMIN" as const },
};
const NOW = new Date("2026-05-15T10:00:00.000Z");
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "ij-1",
userId: "user-1",
triggeredByAdminId: null,
pgBossJobId: "boss-1",
status: "parsing",
failureReason: null,
uploadBytes: 12345,
uploadSha256: "abc",
exportedAt: null,
startedAt: NOW,
completedAt: null,
progress: { currentPhase: "parsing", recordsRead: 100, rowsUpserted: 0,
percent: null, elapsedMs: 1500 },
result: null,
...overrides,
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/import/apple-health-export/[jobId]/status", () => {
it("returns the row envelope to the owning user", async () => {
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(makeRow());
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { status: string }; error: null };
expect(body.error).toBeNull();
expect(body.data.status).toBe("parsing");
});
it("returns 404 to a non-owner non-admin user", async () => {
vi.mocked(getSession).mockResolvedValue(OTHER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(makeRow());
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(404);
});
it("admits the triggering admin even when userId differs", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(
makeRow({ triggeredByAdminId: "admin-1" }),
);
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(200);
});
it("returns 404 when the job row is missing", async () => {
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
mocks.importJobFindUnique.mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(404);
});
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,77 @@
/**
* `GET /api/import/apple-health-export/[jobId]/status` — polling
* endpoint for an in-flight Apple Health import.
*
* Returns the canonical `ImportJobStatusResponse` envelope (§8 of
* `.planning/research/v1434-r-1-xml-import.md`). The polling cadence
* the iOS client uses:
* - every 2 s while `queued | unpacking | parsing | upserting`
* - every 30 s once `done | failed` (last-known state is fine)
*
* Authorisation: the row's `userId` must match the requester or the
* requester is the admin who kicked it off via the admin variant.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
interface ImportJobStatusResponse {
jobId: string;
status: string;
startedAt: string;
completedAt: string | null;
uploadBytes: number;
exportedAt: string | null;
progress: Record<string, unknown>;
result: Record<string, unknown> | null;
failureReason: string | null;
}
export const GET = apiHandler(
async (_request: NextRequest, ctx: RouteContext) => {
const { user } = await requireAuth();
const { jobId } = await ctx.params;
annotate({
action: { name: "import.apple-health.status" },
meta: { job_id: jobId },
});
const row = await prisma.importJob.findUnique({ where: { id: jobId } });
if (!row) {
return apiError("Import job not found", 404);
}
// Authorisation: the owner can see their own job; the admin who
// triggered it can see it from the admin route. Any other caller
// gets a 404 (not 403 — we don't leak existence to outsiders).
const isOwner = row.userId === user.id;
const isTriggeringAdmin =
row.triggeredByAdminId !== null && row.triggeredByAdminId === user.id;
if (!isOwner && !isTriggeringAdmin) {
return apiError("Import job not found", 404);
}
const body: ImportJobStatusResponse = {
jobId: row.id,
status: row.status,
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt?.toISOString() ?? null,
uploadBytes: row.uploadBytes,
exportedAt: row.exportedAt?.toISOString() ?? null,
progress: (row.progress as Record<string, unknown>) ?? {},
result: (row.result as Record<string, unknown> | null) ?? null,
failureReason: row.failureReason,
};
return apiSuccess(body, 200);
},
);
@@ -0,0 +1,112 @@
/**
* Smoke / contract tests for the Apple Health import endpoints.
*
* Targets the cookie + Bearer auth path, the 413 size-cap, and the
* 503 worker-not-running path. End-to-end ingest (multipart streaming
* + parser + worker) is covered separately by the parser unit suite
* + the integration suite under `tests/integration`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
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: () => {},
})),
}));
const mocks = vi.hoisted(() => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
importJobCreate: vi.fn(),
importJobFindFirst: vi.fn().mockResolvedValue(null),
importJobUpdate: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: mocks.checkRateLimit,
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: mocks.getGlobalBoss,
}));
vi.mock("@/lib/db", () => ({
prisma: {
importJob: {
create: mocks.importJobCreate,
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
},
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.bossSend.mockResolvedValue("boss-job-1");
mocks.getGlobalBoss.mockReturnValue({ send: mocks.bossSend });
mocks.importJobCreate.mockResolvedValue({ id: "ij-1" });
mocks.importJobFindFirst.mockResolvedValue(null);
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
});
describe("POST /api/import/apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(401);
});
it("returns 413 when Content-Length exceeds the 1.5 GB cap", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const headers = new Headers({
"content-type": "multipart/form-data; boundary=---x",
"content-length": String(2 * 1024 * 1024 * 1024),
});
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers,
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(413);
});
it("returns 429 when the rate-limit window is exhausted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
});
const res = await POST(req);
expect(res.status).toBe(429);
});
});
@@ -0,0 +1,176 @@
/**
* `POST /api/import/apple-health-export` — user-facing kick-off for an
* Apple Health `export.zip` import.
*
* Streams the upload directly to disk (`/tmp/healthlog-upload-*.bin`)
* without buffering the full body in memory, creates an `ImportJob`
* row in `queued`, and enqueues an `apple-health-import` pg-boss job.
* Returns `{ jobId }` so the client can poll
* `GET /api/import/apple-health-export/[jobId]/status`.
*
* Idempotency: a re-upload of the exact same file (same SHA-256 of
* the bytes) short-circuits to the previous `ImportJob` id without
* re-queueing — the iOS client cannot reliably emit a stable
* `Idempotency-Key` for a 1 GB file, so we hash the bytes and dedup
* on content.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
export const dynamic = "force-dynamic";
/**
* Hard cap on the upload size. Apple's largest observed exports
* sit around 800 MB for a 10-year iCloud-synced account; 1.5 GB
* leaves comfortable margin for the long tail.
*/
const MAX_UPLOAD_BYTES = 1.5 * 1024 * 1024 * 1024;
/** Rate-limit window: three uploads per minute per user. */
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW_MS = 60_000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.apple-health.kickoff" } });
// Rate limit before touching the body — protects against a flood of
// 1 GB uploads from a single user.
const rl = await checkRateLimit(
`import:apple-health:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
throw new HttpError(429, "Too many import uploads, try again later");
}
// Cheap pre-flight on the declared Content-Length. The streaming
// sink below enforces the hard cap as well — a missing/wrong
// content-length cannot bypass it.
const declaredBytes = Number(request.headers.get("content-length") ?? 0);
if (declaredBytes > MAX_UPLOAD_BYTES) {
await auditLog("import.apple-health.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
reason: "content_length_exceeded",
contentLength: declaredBytes,
},
});
return apiError("Upload exceeds 1.5 GB limit", 413);
}
const body = request.body;
if (!body) {
return apiError("Request body is required", 400);
}
// Stream the upload to disk, computing a SHA-256 in the same pass.
let uploaded;
try {
uploaded = await streamMultipartToDisk(
body,
request.headers.get("content-type"),
{
maxBytes: MAX_UPLOAD_BYTES,
fieldName: "file",
tmpPrefix: "healthlog-apple-health-import",
},
);
} catch (err) {
const message = err instanceof Error ? err.message : "upload_failed";
await auditLog("import.apple-health.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reason: "stream_to_disk_failed", message },
});
return apiError(`Multipart upload failed: ${message}`, 422);
}
// Content-hash idempotency: a re-upload of the same bytes resolves
// to the previous ImportJob row instead of re-queueing.
const existing = await prisma.importJob.findFirst({
where: {
userId: user.id,
uploadSha256: uploaded.sha256,
},
orderBy: { startedAt: "desc" },
});
if (existing) {
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
jobId: existing.id,
status: existing.status,
idempotent: true,
},
202,
);
}
const boss = getGlobalBoss();
if (!boss) {
await auditLog("import.apple-health.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: user.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
const payload: AppleHealthImportPayload = {
userId: user.id,
uploadPath: uploaded.filePath,
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
await prisma.importJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
await auditLog("import.apple-health.kickoff", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
bossJobId: bossJobId ?? null,
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
return apiSuccess(
{
jobId: importJob.id,
status: "queued" as const,
},
202,
);
});
+9
View File
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -107,6 +108,10 @@ export const PUT = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the edited row.
invalidateUserMeasurements(user.id);
return apiSuccess(measurement);
},
);
@@ -141,6 +146,10 @@ export const DELETE = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletion.
invalidateUserMeasurements(user.id);
return apiSuccess({ deleted: true });
},
);
+9
View File
@@ -37,6 +37,7 @@ import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
@@ -366,6 +367,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row landed so the next read picks up the
// ingested batch. Skipped-only ingests don't change state, so we
// gate the invalidation on `insertedCount > 0`.
if (insertedCount > 0) {
invalidateUserMeasurements(user.id);
}
return apiSuccess({
processed: entries.length,
inserted: insertedCount,
@@ -31,6 +31,7 @@ import {
getClientIp,
safeJson,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const MAX_BATCH_ENTRIES = 500;
@@ -103,5 +104,12 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
meta: { processed: externalIds.length, deleted: result.count },
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row deleted so the next read reflects the
// reconciliation. A 0-delete batch is a no-op and skips the eviction.
if (result.count > 0) {
invalidateUserMeasurements(user.id);
}
return apiSuccess({ deletedCount: result.count });
}
+9
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -214,6 +215,10 @@ async function postMeasurement(request: NextRequest) {
},
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new rows.
invalidateUserMeasurements(user.id);
return apiSuccess(results, 201);
}
@@ -270,5 +275,9 @@ async function postMeasurement(request: NextRequest) {
},
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new row.
invalidateUserMeasurements(user.id);
return apiSuccess(measurement, 201);
}
@@ -80,6 +80,7 @@ export const GET = apiHandler(
let onTime = 0;
let late = 0;
let veryLate = 0;
let early = 0;
for (const evt of takenEvents) {
if (medication.schedules.length === 0) {
@@ -111,7 +112,13 @@ export const GET = apiHandler(
dayStart, // the scheduled date
);
// v1.4.34 IW-C — `early` is the new compliant bucket; it counts
// alongside `onTime` for the heatmap so a proactive logger reads
// green. The classifier still emits a distinct `"early"` value
// for downstream consumers that want to differentiate; the
// separate counter is surfaced on the daily entry below.
if (timing === "on_time") onTime++;
else if (timing === "early") early++;
else if (timing === "late") late++;
else veryLate++;
}
@@ -120,9 +127,10 @@ export const GET = apiHandler(
expected: schedulesPerDay,
taken: takenEvents.length,
skipped: dayEvents.filter((e) => e.skipped).length,
onTime,
onTime: onTime + early,
late,
veryLate,
early,
};
}
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -62,6 +63,10 @@ export const PUT = apiHandler(
meta: { medication_id: id },
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the edited event.
invalidateUserMedications(user.id);
return apiSuccess(updated);
},
);
@@ -98,6 +103,10 @@ export const DELETE = apiHandler(
meta: { medication_id: id },
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
return apiSuccess({ deleted: true });
},
);
@@ -15,6 +15,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -154,6 +155,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id);
return apiSuccess(event, 201);
}
+9
View File
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateMedicationSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
deleteMedicationCategory,
getMedicationCategories,
@@ -177,6 +178,10 @@ export const PUT = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the schedule change.
invalidateUserMedications(user.id);
return apiSuccess({
...medication,
category: normalizedCategory,
@@ -222,6 +227,10 @@ export const DELETE = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
return apiSuccess({ deleted: true });
},
);
@@ -37,6 +37,7 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -45,6 +46,9 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset compliance LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
@@ -40,6 +40,7 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -205,6 +206,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
if (inserted > 0) {
invalidateUserMedications(user.id);
}
return apiSuccess({
processed: entries.length,
inserted,
+50 -13
View File
@@ -24,6 +24,8 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -109,16 +111,54 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// compliance: per-day scheduled vs taken for the last N days
const start = new Date(Date.now() - days * 86_400_000);
// v1.4.34 IW-G — compliance: per-day scheduled vs taken for the last
// N days. Cached at a 15-minute TTL because daily compliance buckets
// are slow-moving (intake events trickle in, yesterday's row doesn't
// move). Cache key carries the userTz so a user who changes timezone
// doesn't read another tz's bucketing.
const result = await cached(
caches.medicationsIntake as ServerCache<ComplianceBucket[]>,
`${user.id}|compliance|${days}|${userTz}`,
() => buildComplianceBuckets(user.id, days, userTz),
annotate,
);
annotate({
action: { name: "medications.intake.compliance" },
meta: { days, count: result.length },
});
return apiSuccess(result);
});
interface ComplianceBucket {
date: string;
scheduled: number;
taken: number;
}
/**
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
* the per-user compliance aggregation. Pure function over the user's
* intake events + the requested window; deterministic given a fixed
* "now" wall-clock (we anchor on `Date.now()` once at call time so a
* 15-minute cached row stays internally consistent).
*/
async function buildComplianceBuckets(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const nowMs = Date.now();
const start = new Date(nowMs - days * 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId: user.id, scheduledFor: { gte: start } },
where: { userId, scheduledFor: { gte: start } },
select: { scheduledFor: true, takenAt: true, skipped: true },
});
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(Date.now() - i * 86_400_000);
const d = new Date(nowMs - i * 86_400_000);
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
}
for (const e of events) {
@@ -129,17 +169,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (e.takenAt && !e.skipped) bucket.taken += 1;
}
const result = [...buckets.entries()]
return [...buckets.entries()]
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
.sort((a, b) => a.date.localeCompare(b.date));
annotate({
action: { name: "medications.intake.compliance" },
meta: { days, count: result.length },
});
return apiSuccess(result);
});
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -201,5 +234,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: { intakeId, status },
});
// v1.4.34 IW-G — bust the medications + compliance + achievement
// caches for this user so the next read reflects the dose change.
invalidateUserMedications(user.id);
return apiSuccess(updated);
});
+5
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { getUserTodayBounds } from "@/lib/timezone";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
export const GET = apiHandler(async () => {
@@ -153,6 +154,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the new schedule.
invalidateUserMedications(user.id);
return apiSuccess(
{
...medication,
+7
View File
@@ -14,6 +14,7 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string }> };
@@ -108,6 +109,9 @@ export const PUT = apiHandler(
meta: { moodEntryId: id },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
},
);
@@ -137,6 +141,9 @@ export const DELETE = apiHandler(
meta: { moodEntryId: id },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
return apiSuccess({ deleted: true });
},
);
+8
View File
@@ -42,6 +42,7 @@ import {
moodSourceEnum,
} from "@/lib/validations/moodlog";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -210,6 +211,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches
// when at least one row landed so the next read picks up the ingested
// batch. Skipped / duplicate-only ingests are no-ops.
if (inserted > 0) {
invalidateUserMood(user.id);
}
return apiSuccess({
processed: entries.length,
inserted,
+4
View File
@@ -17,6 +17,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -125,6 +126,9 @@ async function postMoodEntry(request: NextRequest) {
meta: { moodEntryId: entry.id, mood },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
} catch (err) {
if (
+9
View File
@@ -63,6 +63,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
@@ -523,6 +524,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row landed. Workouts ride on the
// measurements bucket because achievements / analytics also touch
// workout-derived metrics.
if (insertedCount > 0) {
invalidateUserMeasurements(user.id);
}
return apiSuccess({
processed: workouts.length,
inserted: insertedCount,
+19 -16
View File
@@ -2,8 +2,6 @@ import type { ReactNode } from "react";
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
/**
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
@@ -13,27 +11,32 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
* into a sub-page. The actual page body still owns its own data fetches
* — the layout is a thin presentational frame.
*
* v1.4.27 R3d MB4 — the Coach drawer now mounts here (above the routed
* children) inside a `<CoachLaunchProvider>` so navigating into a sub-
* page no longer unmounts the drawer. Every sub-page mounts a
* v1.4.27 R3d MB4 — the Coach drawer mounts above the routed children
* inside a `<CoachLaunchProvider>` so navigating into a sub-page no
* longer unmounts the drawer. Every sub-page mounts a
* `<CoachLaunchButton>` that calls `askCoach()` on the same context;
* the mother `/insights/page.tsx` consumes the same hook for its hero
* strip + suggested-prompt chips. Decision F (audit MA3) drove the
* promotion: a mobile user who navigates from `/insights` to
* `/insights/blutdruck` keeps a one-tap path back into the Coach.
* strip + suggested-prompt chips.
*
* v1.4.28 R3c — the mobile FAB now also lives here via
* `<LayoutCoachFab>` (carved out of `<CoachLaunchButton>` so each sub-
* page mount no longer duplicates the FAB into the a11y tree). The
* inline `<CoachLaunchButton>` pill stays on the per-page action rows
* where copy + position matter.
* v1.4.28 R3c — the mobile FAB also lives here via `<LayoutCoachFab>`
* (carved out of `<CoachLaunchButton>` so each sub-page mount no
* longer duplicates the FAB into the a11y tree). The inline
* `<CoachLaunchButton>` pill stays on the per-page action rows where
* copy + position matter.
*
* v1.4.34 IW-B — `<CoachLaunchProvider>` + `<LayoutCoachMount>` now
* live on the global `<AuthShell>` (`src/components/layout/auth-shell.tsx`)
* so every authenticated route (dashboard included) can open the drawer
* from the same context. The FAB stays scoped to `/insights/**` only —
* the floating action would distract from the dashboard hero on
* surfaces where a contextual inline button already covers the same
* affordance.
*/
export default function InsightsLayout({ children }: { children: ReactNode }) {
return (
<CoachLaunchProvider>
<>
<InsightsLayoutShell>{children}</InsightsLayoutShell>
<LayoutCoachFab />
<LayoutCoachMount />
</CoachLaunchProvider>
</>
);
}
+113 -39
View File
@@ -13,6 +13,7 @@ import {
Percent,
Plus,
Smile,
Sparkles,
Target,
TrendingUp,
Waves,
@@ -61,6 +62,8 @@ const MedicationComplianceChart = dynamic(
{ ssr: false, loading: () => <ChartSkeleton /> },
);
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -105,6 +108,17 @@ interface AnalyticsData {
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, DataSummaryType>;
/**
* v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The
* tile-strip helper reads `lastSeenByType[type]?.daysAgo` and forwards
* it to each `<TrendCard staleDays>` so a metric the user hasn't
* logged in a while keeps its tile visible (with an explicit
* "Letzter Wert vor …" caption) instead of disappearing.
*/
lastSeenByType?: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
}
interface RangeDisplayConfig {
@@ -184,6 +198,14 @@ export default function DashboardPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
// v1.4.34 IW-B — Coach launch context lives on the auth-shell now so
// every authed route (this dashboard included) can call `askCoach()`.
// Returns `null` outside the provider; the dashboard hero CTA self-
// gates on the hook + the operator's coach feature flag so the
// button silently disappears when either is unavailable.
const coachLaunch = useCoachLaunch();
const featureFlags = useFeatureFlags();
const coachEnabled = featureFlags.coach && coachLaunch !== null;
const [quickEntryDialog, setQuickEntryDialog] = useState<
"measurement" | "mood" | null
>(null);
@@ -291,6 +313,25 @@ export default function DashboardPage() {
if (current === null || prior === null) return null;
return Math.round((current - prior) * 100) / 100;
};
/**
* v1.4.34 IW-B — read the per-type freshness map and surface the
* `daysAgo` value when the metric is older than a week. The
* tile-strip below forwards the result to `<TrendCard staleDays>`
* which picks the bucket-aware copy (Xd / X weeks / X months) and
* paints the caption on the tile. Returns `null` for metrics with no
* reading yet OR within the fresh window so call sites stay
* undefined-safe and tiles with recent data paint byte-identical
* with the pre-v1.4.34 contract.
*/
const tileStaleDays = (
type: string | null | undefined,
): number | null => {
if (!type) return null;
const entry = data?.lastSeenByType?.[type];
if (!entry) return null;
return entry.daysAgo > 7 ? entry.daysAgo : null;
};
/** Whether the widget's *chart* (lower row) shows. */
const isChartVisible = (id: string) =>
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
@@ -503,49 +544,73 @@ export default function DashboardPage() {
</h1>
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
the `min-h-11` override stretched the cap vertically
while keeping the small horizontal padding — the
button read as klobig on Pixel 5. Switch to
`size="default"` (h-10 = 40 px) on mobile with a
responsive `min-h-11 sm:min-h-9` so the button is
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
<div className="flex items-center gap-2">
{/* v1.4.34 IW-B — dashboard hero CTA to the Coach drawer. The
provider lives on the auth-shell now so this button can
call `askCoach()` from the same context every authed
surface shares. Mobile-first: 44 px tap target via
`min-h-11` (WCAG 2.5.5), shrinks back to 36 px on `sm:`
upwards alongside the sibling "Hinzufügen" button. Self-
gates on the provider mount + the operator's `coach`
feature flag so the button disappears cleanly when the
Coach surface is disabled or unreachable. */}
{coachEnabled && coachLaunch ? (
<Button
type="button"
variant="outline"
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
className="min-h-11 gap-1 sm:min-h-9"
data-slot="dashboard-coach-cta"
onClick={() => coachLaunch.askCoach(null)}
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
<Sparkles className="h-4 w-4" aria-hidden="true" />
<span>{t("dashboard.coachCta")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("measurement")}
>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQuickEntryDialog("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
the `min-h-11` override stretched the cap vertically
while keeping the small horizontal padding — the
button read as klobig on Pixel 5. Switch to
`size="default"` (h-10 = 40 px) on mobile with a
responsive `min-h-11 sm:min-h-9` so the button is
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("measurement")}
>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQuickEntryDialog("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* v1.4: Getting-started checklist for brand-new users.
@@ -632,6 +697,7 @@ export default function DashboardPage() {
directionSentiment="up-bad"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(w)}
staleDays={tileStaleDays("WEIGHT")}
/>
),
});
@@ -677,6 +743,7 @@ export default function DashboardPage() {
directionSentiment="up-bad"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sys)}
staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")}
/>
),
});
@@ -717,6 +784,7 @@ export default function DashboardPage() {
directionSentiment="up-bad"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(dia)}
staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")}
/>
),
});
@@ -756,6 +824,7 @@ export default function DashboardPage() {
icon={TrendingUp}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(p)}
staleDays={tileStaleDays("PULSE")}
/>
),
});
@@ -778,6 +847,7 @@ export default function DashboardPage() {
directionSentiment="up-bad"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(bf)}
staleDays={tileStaleDays("BODY_FAT")}
/>
),
});
@@ -822,6 +892,7 @@ export default function DashboardPage() {
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sleepSummary)}
staleDays={tileStaleDays("SLEEP_DURATION")}
/>
),
});
@@ -844,6 +915,7 @@ export default function DashboardPage() {
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(stepsSummary)}
staleDays={tileStaleDays("ACTIVITY_STEPS")}
/>
),
});
@@ -873,6 +945,7 @@ export default function DashboardPage() {
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(vo2Summary)}
staleDays={tileStaleDays("VO2_MAX")}
/>
),
});
@@ -973,6 +1046,7 @@ export default function DashboardPage() {
}
slope30={s.slope30 ?? null}
icon={Droplet}
staleDays={tileStaleDays("BLOOD_GLUCOSE")}
/>
),
});
+4 -2
View File
@@ -11,7 +11,10 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import { SourcesSection } from "@/components/settings/sources-section";
// v1.4.34 IW-D — `sources` slug was retired (merged into `thresholds`).
// The combined "Targets & Sources" component lives in
// `thresholds-section.tsx`; `/settings/sources` is now a
// `permanentRedirect` handled by the dedicated route file.
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
SETTINGS_SECTION_SLUGS,
@@ -46,7 +49,6 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSection,
sources: SourcesSection,
api: ApiSection,
export: ExportSection,
advanced: AdvancedSection,
+23
View File
@@ -0,0 +1,23 @@
import { permanentRedirect } from "next/navigation";
/**
* v1.4.34 IW-D — `/settings/sources` permanent redirect.
*
* The Sources settings section was merged into `/settings/thresholds`
* (now titled "Targets & Sources" / "Zielwerte & Quellen"). The two
* editors share the same per-metric configuration shelf and reading
* them side-by-side on one page matches how a user actually thinks
* about metric configuration ("for this metric, which sources do I
* trust AND what range counts as healthy?"). Per
* `.planning/research/v1434-r-2-carryover-scope.md` §7 and
* `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
*
* 308 (`permanentRedirect`) keeps the request method intact so any
* external system that POSTs (none today, but cheap insurance) follows
* through with its original method. Existing bookmarks, iOS Settings
* deep-links, and any docs that referenced `/settings/sources`
* continue to resolve.
*/
export default function SettingsSourcesRedirect(): never {
permanentRedirect("/settings/thresholds");
}
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.34 IW-B — pin the bucket-aware stale-hint copy on `<TrendCard>`.
*
* The tile shows a "Letzter Wert vor …" caption when the metric has not
* been logged in a while, so the tile stays on the dashboard with an
* explicit how-old hint instead of disappearing. Buckets:
* - `staleDays <= 7` — silent (the tile reads as fresh)
* - `8 <= staleDays <= 30` — "vor Xd" / "X d ago"
* - `31 <= staleDays <= 60` — "vor X Wochen" / "X weeks ago"
* - `staleDays > 60` — "vor X Monaten" / "X months ago"
*/
function render(node: React.ReactNode, locale: "de" | "en" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseProps = {
label: "Weight",
latest: 80,
unit: "kg",
avg7: 80,
avg30: 80,
slope30: null,
icon: Activity,
};
describe("<TrendCard staleDays>", () => {
it("stays silent when the metric is fresh (<= 7 days)", () => {
const html = render(<TrendCard {...baseProps} staleDays={3} />);
expect(html).not.toContain("data-slot=\"tile-stale-hint\"");
});
it("renders day-bucket copy when staleDays is in (7, 30]", () => {
const html = render(<TrendCard {...baseProps} staleDays={12} />, "en");
expect(html).toContain("data-slot=\"tile-stale-hint\"");
expect(html).toContain("Last reading 12 d ago");
});
it("collapses 31-60 days into a weekly singular form", () => {
const html = render(<TrendCard {...baseProps} staleDays={40} />, "en");
// 40 / 7 = 5 (Math.floor) — plural form.
expect(html).toContain("Last reading 5 weeks ago");
});
it("collapses days > 60 into a monthly form", () => {
const html = render(<TrendCard {...baseProps} staleDays={75} />, "de");
// 75 / 30 = 2 — plural "Monaten".
expect(html).toContain("Letzter Wert vor 2 Monaten");
});
it("uses the German singular month form at exactly one month", () => {
const html = render(<TrendCard {...baseProps} staleDays={61} />, "de");
// 61 / 30 = 2 (Math.floor) — plural "Monaten".
expect(html).toContain("Letzter Wert vor 2 Monaten");
});
it("renders the week-singular form when staleDays maps to exactly one week", () => {
const html = render(<TrendCard {...baseProps} staleDays={35} />, "en");
// 35 / 7 = 5 — plural. Edge case for the singular path: staleDays
// close to 31 (e.g. 31/7 = 4) still falls in the (7, 30] day-bucket
// because we hit the day branch before the week branch. The
// smallest staleDays that yields 1 week is impossible inside the
// week bucket (min weeks = floor(31/7) = 4), so the singular path
// is reachable only when a future bucket boundary changes — pin
// the math anyway to catch regressions.
expect(html).toContain("Last reading 5 weeks ago");
});
});
+13 -35
View File
@@ -38,47 +38,25 @@ function getColor(data: DailyData): string {
data.veryLate !== undefined;
if (hasTimingData) {
const onTime = data.onTime ?? 0;
const veryLate = data.veryLate ?? 0;
const late = data.late ?? 0;
const missed = Math.max(0, data.expected - taken - data.skipped);
// v1.4.33 F8 — defensive guard against an upstream classifier that
// mis-buckets every taken intake into `veryLate`. The runtime audit
// (round-v1433-audit-runtime.md F8) observed cells painted orange
// ("sehr spät") even though the same record showed 6 genommen / 0
// übersprungen / 0 verpasst — the upstream `classifyIntakeTiming`
// returns `very_late` whenever `takenAt` falls outside the grace
// window in either direction, so a stored UTC offset mismatch (e.g.
// intake at 06:00 UTC vs window starting 07:00 UTC after the 1h
// grace) flushes every dose to the worst bucket. Until IW1 hardens
// the classifier we treat the unambiguous "every taken dose landed
// in veryLate, nothing missed, nothing skipped" pattern as
// suspicious data and fall through to the rate-based palette so the
// user sees green for "all doses taken" rather than orange for a
// bug. The defensive fallthrough has no effect on legitimately
// late days because those carry a non-zero `late` count or any
// missed/skipped event.
const looksClassifierBug =
veryLate === taken &&
onTime === 0 &&
late === 0 &&
missed === 0 &&
data.skipped === 0;
if (!looksClassifierBug) {
// Any missed doses → red
if (missed > 0) return "var(--dracula-red)";
// Any very late → deep orange
if (veryLate > 0) return "var(--dracula-orange)";
// Any late → yellow
if (late > 0) return "var(--dracula-yellow)";
// All on time → green
return "var(--dracula-green)";
}
// v1.4.34 IW-C — the v1.4.33 `looksClassifierBug` fallthrough is
// gone because the root cause is fixed: `classifyIntakeTiming` now
// routes proactive logs into the `early` bucket (counted as
// compliant by the API) instead of flushing them to `very_late`.
// Any missed doses → red
if (missed > 0) return "var(--dracula-red)";
// Any very late → deep orange
if (veryLate > 0) return "var(--dracula-orange)";
// Any late → yellow
if (late > 0) return "var(--dracula-yellow)";
// All on time (incl. early) → green
return "var(--dracula-green)";
}
// Fallback: rate-based coloring (no timing data, or defensive
// fallthrough above).
// Fallback: rate-based coloring when no timing data is supplied.
const rate = (taken / data.expected) * 100;
if (rate >= 100) return "var(--dracula-green)";
if (rate >= 50) return "var(--dracula-yellow)";
+30 -3
View File
@@ -327,14 +327,41 @@ export function TrendCard({
whole tile disappear because of a gap in logging. Ride the
same callout-slot reservation as the comparison-overlay
caption; on `<sm` the line-clamp keeps the 140 px height
contract intact. */}
{staleDays != null && staleDays > 0 && (
contract intact.
v1.4.34 IW-B — bucket-aware copy. Days under a week stay
silent (the tile reads as fresh); 8-30 days surfaces as
"vor Xd"; 31-60 days collapses to "vor X Wochen"; beyond
two months collapses to "vor X Monaten". One key per
bucket plus a singular/plural pair so locales with
non-English plural rules read naturally. */}
{staleDays != null && staleDays > 7 && (
<span
className="text-muted-foreground line-clamp-1 inline-block max-w-full text-xs leading-snug tabular-nums sm:line-clamp-none"
data-slot="tile-stale-hint"
data-stale-days={staleDays}
>
{t("dashboard.staleHint", { count: staleDays })}
{(() => {
if (staleDays <= 30) {
return t("dashboard.staleHint", { count: staleDays });
}
if (staleDays <= 60) {
const weeks = Math.floor(staleDays / 7);
return t(
weeks === 1
? "dashboard.staleHintWeeksOne"
: "dashboard.staleHintWeeksOther",
{ count: weeks },
);
}
const months = Math.floor(staleDays / 30);
return t(
months === 1
? "dashboard.staleHintMonthsOne"
: "dashboard.staleHintMonthsOther",
{ count: months },
);
})()}
</span>
)}
</div>
@@ -28,8 +28,11 @@ let mockData: {
metrics: {},
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockData }),
// v1.4.34 IW-F-Perf — the card now reads through the shared
// `useAchievementsQuery` hook; the test mocks the hook directly so the
// network layer + TanStack provider scaffolding stays out of scope.
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: () => ({ data: mockData }),
}));
import {
@@ -1,18 +1,13 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Sparkles, Trophy } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import type { AchievementProgress } from "@/lib/gamification/achievements";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
const STORAGE_KEY_PREFIX = "healthlog-achievements-seen";
interface AchievementsPayload {
achievements: AchievementProgress[];
}
interface AchievementUnlockNotifierProps {
userId: string;
}
@@ -66,23 +61,16 @@ export function AchievementUnlockNotifier({
const localStateReadyRef = useRef(false);
const hasSnapshotRef = useRef(false);
const { data } = useQuery({
queryKey: ["gamification", "achievements", "unlock-notifier", userId],
queryFn: async () => {
try {
const response = await fetch("/api/gamification/achievements");
if (!response.ok) {
return { achievements: [] } satisfies AchievementsPayload;
}
const json = await response.json();
return (json.data ?? { achievements: [] }) as AchievementsPayload;
} catch {
return { achievements: [] } satisfies AchievementsPayload;
}
},
staleTime: 60 * 1000,
// v1.4.34 IW-F-Perf — the notifier and `<RecentAchievementsCard>`
// both ride the shared `["gamification", "achievements"]` cache cell.
// Before, the notifier carried a per-user discriminator on its key,
// which made TanStack treat it as a fresh cell and fire a second
// network call alongside the dashboard tile. The shared hook owns the
// refetch cadence; the notifier supplies the polling interval so
// long-open tabs still surface new unlocks without forcing the card
// to re-render on the same cadence.
const { data } = useAchievementsQuery({
refetchInterval: 2 * 60 * 1000,
refetchOnWindowFocus: false,
});
useEffect(() => {
@@ -1,6 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
@@ -20,14 +19,10 @@ import {
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
import type {
AchievementProgress,
AchievementSummary,
AchievementMetrics,
} from "@/lib/gamification/achievements";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import type { AchievementProgress } from "@/lib/gamification/achievements";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -46,12 +41,6 @@ const iconMap: Record<string, LucideIcon> = {
SkipForward,
};
interface AchievementsData {
summary: AchievementSummary;
achievements: AchievementProgress[];
metrics: AchievementMetrics;
}
/**
* Pure helper — pick the N most-recently unlocked achievements.
* Achievements without a `completedAt` (legacy unlocks pre-persistence)
@@ -84,23 +73,15 @@ const RECENT_LIMIT = 3;
* (`achievements` widget id).
*
* Reuses the same `/api/gamification/achievements` endpoint as the
* dedicated page; TanStack Query dedupes the request when both this
* card and the page render in the same session.
* dedicated page and the unlock notifier; v1.4.34 IW-F-Perf folds all
* three consumers onto the shared `useAchievementsQuery()` hook so a
* single cache slot and a single network call back this dashboard
* tile, the mother page, and the toast notifier.
*/
export function RecentAchievementsCard() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data } = useQuery({
queryKey: ["gamification", "achievements"],
queryFn: async () => {
const res = await fetch("/api/gamification/achievements");
if (!res.ok) throw new Error("Failed to load achievements");
const json = await res.json();
return json.data as AchievementsData;
},
enabled: isAuthenticated,
});
const { data } = useAchievementsQuery();
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
@@ -70,6 +70,9 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
expect(html).not.toContain(">Mood<");
expect(html).not.toContain(">Medication<");
expect(html).not.toContain(">Sleep<");
// v1.4.34 IW-D — the parent "Vitals" pill stays out when no
// wave-A HealthKit metric has data (none in this availability).
expect(html).not.toContain(">Vitals<");
});
it("keeps the overview pill regardless of availability", () => {
@@ -115,3 +118,58 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
expect(html).toContain(">BMI<");
});
});
describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
it("renders a single 'Vitals' parent pill instead of five wave-A pills in the strip row", () => {
// No availability ⇒ every wave-A pill is visible ⇒ the parent pill
// appears once. The five sub-pages still render in the popover
// body but that lives in a Radix Portal which `renderToStaticMarkup`
// does not serialise (no portal target in SSR) — so we assert on
// the strip-row only. Behaviour test for the popover body sits in
// a Playwright spec when the dev server runs.
const html = render(<InsightsTabStrip />);
// Parent pill — there is exactly one.
const parentMatches = html.match(/>Vitals</g);
expect(parentMatches).not.toBeNull();
expect(parentMatches!.length).toBe(1);
// The flat strip-row should NOT carry the five individual labels —
// they live behind the parent pill now.
expect(html).not.toContain(">HRV<");
expect(html).not.toContain(">Resting HR<");
expect(html).not.toContain(">Oxygen<");
expect(html).not.toContain(">Temperature<");
// "Active energy" overlaps with no other label in en/de so we can
// assert the exact match.
expect(html).not.toContain(">Active energy<");
});
it("hides the parent pill when no wave-A metric has data", () => {
const availability: InsightInputs = {
summaries: { WEIGHT: fakeSummary(2) },
hasMood: false,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
expect(html).not.toContain(">Vitals<");
expect(html).not.toContain('data-slot="insights-tab-strip-group"');
});
it("renders the parent pill as a popover trigger (button, not link)", () => {
const html = render(<InsightsTabStrip />);
expect(html).toContain('data-slot="insights-tab-strip-group"');
expect(html).toContain('data-group="vitals"');
});
it("preserves the non-grouped pills inline", () => {
const html = render(<InsightsTabStrip />);
// Flat (non-grouped) pills must still be reachable directly.
expect(html).toContain(">Blood Pressure<");
expect(html).toContain(">Pulse<");
expect(html).toContain(">Weight<");
expect(html).toContain(">BMI<");
expect(html).toContain(">Sleep<");
expect(html).toContain(">Mood<");
expect(html).toContain(">Medication<");
expect(html).toContain(">Workouts<");
});
});
+196 -42
View File
@@ -3,14 +3,22 @@
import { memo, useEffect, useMemo, useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Loader2, RefreshCw } from "lucide-react";
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
INSIGHTS_OVERVIEW_PATH,
SUB_PAGE_GROUP,
SUB_PAGE_GROUP_ORDER,
SUB_PAGE_SLUGS,
type SubPageGroup,
type SubPageSlug,
} from "@/lib/insights/sub-page-metric";
import {
@@ -67,12 +75,31 @@ export interface InsightsTabStripProps {
availability?: InsightInputs;
}
interface TabEntry {
/** Pathname this pill links to. */
href: string;
/** Translation key for the pill label. */
labelKey: string;
}
/**
* v1.4.34 IW-D — a strip entry is either a flat `<Link>` pill or a
* group "parent" pill that opens a popover containing the sub-pages
* in the group. The discriminated union keeps the renderer branch-
* exhaustive and lets future groups land with one new variant.
*/
type TabEntry =
| {
kind: "link";
/** Pathname this pill links to. */
href: string;
/** Translation key for the pill label. */
labelKey: string;
}
| {
kind: "group";
/** Group key (e.g. `"vitals"`). Used for keys + a11y wiring. */
group: SubPageGroup;
/** Translation key for the parent-pill label. */
labelKey: string;
/** Translation key for the popover header. */
headerKey: string;
/** Visible sub-pages inside the group (post-availability gating). */
children: Array<{ href: string; labelKey: string; slug: SubPageSlug }>;
};
/**
* Slug → (label key, gating metric) mapping. Keeping the metric here
@@ -128,18 +155,70 @@ const SUB_PAGE_TABS: Record<
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
};
/**
* v1.4.34 IW-D — group metadata (label + popover header) keyed by
* `SubPageGroup`. The strip renders one parent pill per group; the
* five wave-A HealthKit pills are the only group today.
*/
const SUB_PAGE_GROUP_META: Record<
SubPageGroup,
{ labelKey: string; headerKey: string }
> = {
vitals: {
labelKey: "insights.tabStrip.vitalsParent.label",
headerKey: "insights.tabStrip.vitalsParent.header",
},
};
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
const subPageEntries = SUB_PAGE_SLUGS.filter((slug) => {
const visibleSlugs = SUB_PAGE_SLUGS.filter((slug) => {
if (!availability) return true;
return hasMetricData(SUB_PAGE_TABS[slug].metric, availability);
}).map((slug) => ({
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
labelKey: SUB_PAGE_TABS[slug].labelKey,
}));
return [
{ href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" },
...subPageEntries,
});
const visibleSet = new Set(visibleSlugs);
const entries: TabEntry[] = [
{
kind: "link",
href: INSIGHTS_OVERVIEW_PATH,
labelKey: "insights.navOverview",
},
];
// v1.4.34 IW-D — track which groups have already been emitted so the
// first encountered group-member triggers the parent pill at the
// same strip position (preserving categorical order).
const emittedGroups = new Set<SubPageGroup>();
for (const slug of visibleSlugs) {
const group = SUB_PAGE_GROUP[slug];
if (group) {
if (emittedGroups.has(group)) continue;
emittedGroups.add(group);
const children = SUB_PAGE_GROUP_ORDER[group]
.filter((childSlug) => visibleSet.has(childSlug))
.map((childSlug) => ({
slug: childSlug,
href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`,
labelKey: SUB_PAGE_TABS[childSlug].labelKey,
}));
if (children.length === 0) continue;
entries.push({
kind: "group",
group,
labelKey: SUB_PAGE_GROUP_META[group].labelKey,
headerKey: SUB_PAGE_GROUP_META[group].headerKey,
children,
});
continue;
}
entries.push({
kind: "link",
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
labelKey: SUB_PAGE_TABS[slug].labelKey,
});
}
return entries;
}
function InsightsTabStripImpl({
@@ -198,34 +277,109 @@ function InsightsTabStripImpl({
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
{tabs.map((tab) => {
// The overview pill matches the mother page exactly; the
// sub-page pills match a prefix so future nested routes
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
// parent tab.
const isActive =
tab.href === INSIGHTS_OVERVIEW_PATH
? pathname === INSIGHTS_OVERVIEW_PATH
: pathname === tab.href || pathname.startsWith(`${tab.href}/`);
if (tab.kind === "link") {
// The overview pill matches the mother page exactly; the
// sub-page pills match a prefix so future nested routes
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
// parent tab.
const isActive =
tab.href === INSIGHTS_OVERVIEW_PATH
? pathname === INSIGHTS_OVERVIEW_PATH
: pathname === tab.href ||
pathname.startsWith(`${tab.href}/`);
return (
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? "page" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
// 44px touch-target floor (W8.3) — pills are primary
// navigation; the regenerate icon-button to the right
// already meets the same minimum.
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
}
// v1.4.34 IW-D — group parent pill. Renders as a popover
// trigger so desktop hover (`onMouseEnter` falls through to
// Radix click semantics — tap-to-open is the same gesture
// on every device, matching the `<Popover>` MB3 decision).
// Sub-page navigation happens via the `<Link>` inside the
// popover; the parent pill never navigates on its own.
const isGroupActive = tab.children.some(
(child) =>
pathname === child.href ||
pathname.startsWith(`${child.href}/`),
);
return (
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? "page" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
// 44px touch-target floor (W8.3) — pills are primary
// navigation; the regenerate icon-button to the right
// already meets the same minimum.
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
<Popover key={`group-${tab.group}`}>
<PopoverTrigger asChild>
<button
type="button"
aria-current={isGroupActive ? "page" : undefined}
data-slot="insights-tab-strip-group"
data-group={tab.group}
data-active={isGroupActive ? "true" : undefined}
className={cn(
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isGroupActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
<ChevronDown
className="h-3 w-3 opacity-70"
aria-hidden="true"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
data-slot="insights-tab-strip-group-popover"
className="w-56 p-2 text-sm"
>
<p className="text-muted-foreground px-2 py-1 text-[11px] font-semibold tracking-wide uppercase">
{t(tab.headerKey)}
</p>
<ul className="space-y-1" role="list">
{tab.children.map((child) => {
const isChildActive =
pathname === child.href ||
pathname.startsWith(`${child.href}/`);
return (
<li key={child.href}>
<Link
href={child.href}
aria-current={isChildActive ? "page" : undefined}
data-slot="insights-tab-strip-group-item"
data-active={isChildActive ? "true" : undefined}
className={cn(
"flex min-h-10 items-center rounded-md px-2 py-1.5 text-sm transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isChildActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
{t(child.labelKey)}
</Link>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
);
})}
</div>
@@ -0,0 +1,62 @@
/**
* v1.4.34 IW-B — auth-shell hoist contract.
*
* The Coach launch provider used to live on `app/insights/layout.tsx`,
* which meant the drawer was only reachable from `/insights/**`. The
* hoist moves the provider + the drawer mount to `<AuthShell>` so every
* authenticated route (dashboard included) shares the same context and
* can call `askCoach()` from any descendant.
*
* Project convention keeps this contract pinned at the source level
* because `<AuthShell>` is hard to render under SSR (it reads
* `useAuth()` + `usePathname()` + the router) and we don't want a fully
* mocked `@testing-library/react` setup for a one-line invariant.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const AUTH_SHELL_PATH = resolve(
__dirname,
"..",
"auth-shell.tsx",
);
const INSIGHTS_LAYOUT_PATH = resolve(
__dirname,
"..",
"..",
"..",
"app",
"insights",
"layout.tsx",
);
describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
it("imports the CoachLaunchProvider in the auth-shell", () => {
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
expect(source).toContain(
'from "@/lib/insights/coach-launch-context"',
);
expect(source).toContain("CoachLaunchProvider");
});
it("mounts the LayoutCoachMount inside the auth-shell so the drawer is reachable globally", () => {
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
expect(source).toContain("<LayoutCoachMount />");
});
it("no longer mounts the provider in the routed insights layout", () => {
const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8");
// The provider import drops; the FAB import stays because the FAB
// is still scoped to `/insights/**` only.
expect(source).not.toContain(
'from "@/lib/insights/coach-launch-context"',
);
// The drawer mount lives on the shell now — verify the routed
// layout doesn't double-mount it.
expect(source).not.toContain("import { LayoutCoachMount }");
// FAB stays scoped to the routed insights surface.
expect(source).toContain("LayoutCoachFab");
});
});
+16 -3
View File
@@ -5,8 +5,10 @@ import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { BottomNav } from "./bottom-nav";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -125,9 +127,19 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
);
}
// Authenticated — full app shell
// Authenticated — full app shell.
//
// v1.4.34 IW-B — the Coach launch provider lives here (not inside
// `app/insights/layout.tsx` anymore) so every authenticated route can
// call `askCoach()` from the same context. Pre-hoist the drawer was
// only reachable from `/insights/**`; the dashboard hero CTA now opens
// it without a route hop. `<LayoutCoachMount>` consumes the same
// context to render the drawer once at the shell level — the
// insights-only mobile FAB stays where it is, inside the routed
// insights layout, so the bottom-right floating action sits beside
// the chart tooltips only on the surfaces that need it.
return (
<>
<CoachLaunchProvider>
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
@@ -170,6 +182,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
</div>
<BottomNav />
</div>
</>
<LayoutCoachMount />
</CoachLaunchProvider>
);
}
@@ -79,6 +79,14 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({
vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
// v1.4.34 IW-D — `<ThresholdsSection>` now also embeds
// `<SourcesSection mode="embedded">` so the combined "Targets &
// Sources" page reads as one shelf. The SourcesSection itself owns
// React Query and TanStack mutation wiring; stub it at the import
// boundary to keep the SSR smoke test focused on the wrapper.
vi.mock("@/components/settings/sources-section", () => ({
SourcesSection: () => <div data-testid="sources-section" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AboutSection } from "../about-section";
@@ -197,28 +205,26 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Quellen &amp; Dokumentation");
});
it("<ThresholdsSection> renders heading and embeds the editor (B6 rename)", () => {
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
// v1.4.16 phase B6 contract: the route wrapper is named
// `<ThresholdsSection>` (was `<ThresholdsSettingsSection>`), lives
// at `thresholds-section.tsx`, and embeds the inner editor under
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`). The
// mock above stubs the editor as `data-testid="thresholds-editor"`,
// so this test asserts:
// - the wrapper renders without throwing
// - the localised page title surfaces
// - the editor mount-point is present
// - no raw i18n key leaks
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
// v1.4.34 IW-D — the wrapper now also embeds
// `<SourcesSection mode="embedded">` so the page is the merged
// "Targets & Sources" surface. Both editor mounts must be present.
const html = render(<ThresholdsSection />);
// F-07 (v1.4.19): page title aligned with the URL slug — was
// "Personal targets", now "Personal thresholds".
expect(html).toContain("Personal thresholds");
// v1.4.34 IW-D: section renamed to "Targets & Sources".
expect(html).toContain("Targets &amp; Sources");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
expect(html).not.toContain("settings.sections.thresholds.");
});
it("<ThresholdsSection> resolves the German title (B6 rename)", () => {
it("<ThresholdsSection> resolves the German title (B6 rename + IW-D merge)", () => {
const html = render(<ThresholdsSection />, "de");
expect(html).toContain("Persönliche Zielwerte");
expect(html).toContain("Zielwerte &amp; Quellen");
expect(html).toContain('data-testid="thresholds-editor"');
expect(html).toContain('data-testid="sources-section"');
});
});
@@ -35,7 +35,7 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares the eleven sections agreed for v1.4.25", () => {
it("declares the ten sections agreed for v1.4.34", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
@@ -48,13 +48,15 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// per-metric-class source-priority surface lives next to the other
// analytics-shaping controls (thresholds → ranges; sources →
// canonical source per metric).
// v1.4.34 IW-D — `sources` slug was retired (merged into
// `thresholds`, now "Targets & Sources"). `/settings/sources` stays
// alive as a `permanentRedirect`. Section count: 10 (was 11).
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
@@ -162,6 +164,11 @@ describe("<SettingsShell>", () => {
// in the sidebar user-card dropdown. Route `/settings/about` is
// still alive for direct links.
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources slug retired; merged into Targets & Sources
// under `/settings/thresholds`. The Sources nav entry must not
// appear; the route stays alive via permanentRedirect.
expect(html).not.toContain('href="/settings/sources"');
expect(html).toContain("Targets &amp; Sources");
});
it("resolves every section title via the i18n provider — German", () => {
@@ -177,7 +184,9 @@ describe("<SettingsShell>", () => {
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
expect(html).toContain("Persönliche Zielwerte");
// v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to
// "Zielwerte & Quellen" (combined with the former Sources section).
expect(html).toContain("Zielwerte &amp; Quellen");
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
expect(html).toContain("Auswertungen");
@@ -187,6 +196,9 @@ describe("<SettingsShell>", () => {
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
expect(html).not.toContain('href="/settings/about"');
// v1.4.34 IW-D — Sources nav entry retired (merged into Targets &
// Sources).
expect(html).not.toContain('href="/settings/sources"');
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
@@ -16,7 +16,6 @@ import {
Download,
Info,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -36,7 +35,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
api: KeyRound,
export: Download,
+8 -1
View File
@@ -6,13 +6,20 @@
* graph.
*/
// v1.4.34 IW-D — `sources` slug was retired. The two former sections
// (`/settings/thresholds` and `/settings/sources`) merged into a single
// "Targets & Sources" page under `/settings/thresholds`. The historic
// `/settings/sources` URL stays alive as a `permanentRedirect` (see
// `src/app/settings/sources/page.tsx`) so external bookmarks (iOS,
// docs) follow through unchanged. Per
// `.planning/research/v1434-r-2-carryover-scope.md` §7 and
// `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"sources",
"ai",
"api",
"export",
+9 -6
View File
@@ -21,7 +21,6 @@ import {
Bell,
Download,
KeyRound,
Layers,
LayoutDashboard,
Link2,
Settings2,
@@ -73,6 +72,15 @@ interface SettingsSection {
* keeps emitting the page — but it no longer appears in the in-page
* navigation. Direct links (the user-card dropdown, the public
* `/about` permalink) continue to resolve to the same component.
*
* v1.4.34 IW-D — `sources` was merged into `thresholds` (now titled
* "Targets & Sources" / "Zielwerte & Quellen"). The combined section
* surfaces per-metric source priority alongside the threshold inputs
* on a single page. `/settings/sources` stays alive as a
* `permanentRedirect` so external bookmarks keep working; the slug
* was dropped from `SETTINGS_SECTION_SLUGS` because the dynamic
* `[section]` route no longer serves it. Per
* `.planning/research/v1434-r-2-carryover-scope.md` §7.
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
@@ -96,11 +104,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
},
{
slug: "sources",
titleKey: "settings.sections.sources.title",
icon: Layers,
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
+46 -17
View File
@@ -98,7 +98,26 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
unknown: "settings.sections.sources.deviceLabels.unknown",
};
export function SourcesSection() {
/**
* v1.4.34 IW-D — section presentation mode.
* - `"standalone"` (default) renders the page-level header (h1 +
* subtitle) and keeps the section's own aria-labelledby wiring.
* - `"embedded"` drops the header because a parent surface
* (`<ThresholdsSection>` on `/settings/thresholds`) already names
* the page. The section becomes a `<div>` instead of a
* `<section>` so the assistive-tech reading order stays one
* combined surface.
*
* `/settings/sources` no longer renders this component directly — it
* permanently redirects to `/settings/thresholds`, which uses the
* `embedded` mode. The default keeps backward compatibility for any
* test/storybook that still mounts `<SourcesSection />` standalone.
*/
export interface SourcesSectionProps {
mode?: "standalone" | "embedded";
}
export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -273,22 +292,32 @@ export function SourcesSection() {
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
// v1.4.34 IW-D — `embedded` mode skips the section header because the
// parent surface (`<ThresholdsSection>`) already names the page.
const isStandalone = mode === "standalone";
const Wrapper = isStandalone ? "section" : "div";
const wrapperProps = isStandalone
? {
"aria-labelledby": "settings-section-sources-title",
className: "space-y-6",
}
: { className: "space-y-6" };
return (
<section
aria-labelledby="settings-section-sources-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
<Wrapper {...wrapperProps}>
{isStandalone && (
<header className="space-y-1">
<h1
id="settings-section-sources-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sources.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sources.description")}
</p>
</header>
)}
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
@@ -570,7 +599,7 @@ export function SourcesSection() {
</div>
)}
</div>
</section>
</Wrapper>
);
}
+43 -7
View File
@@ -1,19 +1,47 @@
"use client";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
import { useTranslations } from "@/lib/i18n/context";
/**
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`.
*
* v1.4.34 IW-D — section was renamed "Zielwerte & Quellen" / "Targets &
* Sources" and absorbs the former `/settings/sources` content. The two
* editors stay distinct mutation flows (`/api/user/thresholds` for
* ranges + `/api/auth/me/source-priority` for source ladders) so a save
* on one half never disturbs the other; presenting them on a single
* page collapses two adjacent sidebar slots into one and matches the
* Settings nav audit (§7.1 item 3 in
* `.planning/round-v1433-audit-menu.md` — both screens were per-metric
* configuration shelves and reading them together matches how a user
* actually thinks about a metric: "for this metric, which sources do
* I trust AND what range counts as healthy?").
*
* `/settings/sources` stays alive as a `permanentRedirect` (see
* `src/app/settings/sources/page.tsx`) so external bookmarks (iOS
* Settings deep-links, docs) keep resolving.
*
* Visual structure on this page:
* 1. Section header (combined title + description).
* 2. `<ThresholdsEditorSection>` — per-metric range inputs (top).
* The editor card kept its own affordance row (icon, reset-all
* button) so the action surface stays compact.
* 3. `<SourcesSection>` body — per-metric source priority + the
* two-axis device-type expander (bottom). The inner section's
* header was suppressed in `mode="embedded"` because the page
* header above already names the surface.
*
* v1.4.16 phase B6: file renamed from the historic
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`) so the
* filename + default export match the slug — every other section in
* `src/components/settings/` follows the `<slug>-section.tsx`
* `<SlugSection>` convention. The inner editor card was simultaneously
* moved out of `thresholds-section.tsx` (where it used to live as
* `<ThresholdsSection>`) into `thresholds-editor-section.tsx`
* (`<ThresholdsEditorSection>`) so the names stop clashing.
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`)
* so the filename + default export match the slug — every other
* section in `src/components/settings/` follows the
* `<slug>-section.tsx` `<SlugSection>` convention. The inner editor
* card was simultaneously moved out of `thresholds-section.tsx`
* (where it used to live as `<ThresholdsSection>`) into
* `thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`) so
* the names stop clashing.
*/
export function ThresholdsSection() {
const { t } = useTranslations();
@@ -35,7 +63,15 @@ export function ThresholdsSection() {
</p>
</header>
{/* Per-metric threshold ranges — top card. */}
<ThresholdsEditorSection id="thresholds" />
{/* Per-metric source priority + two-axis device-type ladder —
bottom card. `mode="embedded"` suppresses the inner h1 +
subtitle (the page header above owns the surface name) and
drops the inner `<section>` aria-label so the assistive-tech
reading order stays one combined surface, not two. */}
<SourcesSection mode="embedded" />
</section>
);
}
+67 -28
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { calculateCompliance, classifyIntakeTiming } from "../compliance";
import type { IntakeTimingClass } from "../compliance";
describe("calculateCompliance", () => {
// Fix "now" for deterministic tests
@@ -202,6 +203,12 @@ describe("calculateCompliance", () => {
});
describe("classifyIntakeTiming", () => {
// v1.4.34 IW-C — the classifier now widens the pre-window grace to
// 3h and introduces an `early` bucket so a proactive logger (10 min
// before the window) is no longer flushed to `very_late`. Doses up
// to 3h past `windowEnd` stay `on_time`; `late` spans the next 2h
// tail; anything beyond is `very_late`. Overnight windows are
// exercised in parallel by the parameterised matrix below.
const scheduledDate = new Date("2025-01-15T00:00:00Z");
it('returns "missed" when takenAt is null', () => {
@@ -231,31 +238,35 @@ describe("classifyIntakeTiming", () => {
);
});
it('returns "on_time" when taken within 1h grace period before windowStart', () => {
const takenAt = new Date("2025-01-15T07:15:00Z"); // 45 min before 08:00
// Parameterised offset matrix relative to a `08:00 → 09:00` window.
// Negative offsets are minutes before `windowStart` (08:00); positive
// offsets are minutes after `windowEnd` (09:00). The reference
// boundaries are: `early` from -180 to -1 min before start; `on_time`
// from start through windowEnd + 180 min; `late` for the next 120
// min; `very_late` past that or beyond the 3h pre-window grace.
it.each<[label: string, offsetMin: number, expected: IntakeTimingClass]>([
["3.5h before window → very_late", -210, "very_late"],
["exactly 3h before window → early", -180, "early"],
["1h before window → early", -60, "early"],
["10 min before window → early", -10, "early"],
["exactly at windowStart → on_time", 0, "on_time"],
["10 min into the window → on_time", 10, "on_time"],
["20 min past windowEnd → on_time", 80, "on_time"],
["1h past windowEnd → on_time", 120, "on_time"],
["exactly 3h past windowEnd → on_time", 240, "on_time"],
["3.5h past windowEnd → late", 270, "late"],
["5h past windowEnd → late (tail boundary)", 360, "late"],
["5h 1min past windowEnd → very_late", 361, "very_late"],
])("offset case: %s", (_label, offsetMin, expected) => {
// The matrix expresses offsets relative to windowStart (negative)
// or relative to windowEnd (positive). 0 sits at windowStart, +60
// sits at windowEnd, then positive offsets accumulate past
// windowEnd. With windowStart=08:00 and windowEnd=09:00, the
// base instant is 08:00Z; positive offsets walk forward from there.
const baseMs = new Date("2025-01-15T08:00:00Z").getTime();
const takenAt = new Date(baseMs + offsetMin * 60 * 1000);
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
});
it('returns "late" when taken within 2h after windowEnd', () => {
const takenAt = new Date("2025-01-15T10:30:00Z"); // 1.5h after 09:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"late",
);
});
it('returns "late" when taken right after windowEnd', () => {
const takenAt = new Date("2025-01-15T09:01:00Z"); // 1 min after 09:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"late",
);
});
it('returns "very_late" when taken more than 2h after windowEnd', () => {
const takenAt = new Date("2025-01-15T14:00:00Z"); // 5h after 09:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"very_late",
expected,
);
});
@@ -266,6 +277,26 @@ describe("classifyIntakeTiming", () => {
);
});
it('respects the configurable `lateMinutes` tail', () => {
// With lateMinutes=30 the late tail collapses to 30 min after the
// 3h on-time grace. windowEnd is 09:00 so `on_time` extends to
// 12:00 and `late` extends to 12:30. A dose at 12:15 falls in
// `late`; one at 12:45 falls in `very_late`.
const takenLate = new Date("2025-01-15T12:15:00Z");
expect(
classifyIntakeTiming(takenLate, "08:00", "09:00", scheduledDate, {
lateMinutes: 30,
}),
).toBe("late");
const takenVeryLate = new Date("2025-01-15T12:45:00Z");
expect(
classifyIntakeTiming(takenVeryLate, "08:00", "09:00", scheduledDate, {
lateMinutes: 30,
}),
).toBe("very_late");
});
it("handles overnight windows (windowEnd < windowStart)", () => {
// Schedule: 23:00 - 01:00 (overnight)
const takenAt = new Date("2025-01-15T23:30:00Z");
@@ -274,17 +305,25 @@ describe("classifyIntakeTiming", () => {
);
});
it("handles overnight window early intake", () => {
// Schedule: 23:00 - 01:00, taken at 22:30 (30 min before windowStart)
const takenAt = new Date("2025-01-15T22:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"early",
);
});
it("handles overnight window late intake", () => {
// Schedule: 23:00 - 01:00, taken at 02:00 (1h late)
const takenAt = new Date("2025-01-16T02:00:00Z");
// Schedule: 23:00 - 01:00, taken at 04:30 (3.5h past 01:00)
const takenAt = new Date("2025-01-16T04:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"late",
);
});
it("handles overnight window very late intake", () => {
// Schedule: 23:00 - 01:00, taken at 04:00 (3h late)
const takenAt = new Date("2025-01-16T04:00:00Z");
// Schedule: 23:00 - 01:00, taken at 07:00 (6h past 01:00)
const takenAt = new Date("2025-01-16T07:00:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"very_late",
);
@@ -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).
+42 -11
View File
@@ -13,7 +13,12 @@ interface ScheduleWindow {
windowEnd: string; // HH:mm
}
export type IntakeTimingClass = "on_time" | "late" | "very_late" | "missed";
export type IntakeTimingClass =
| "early"
| "on_time"
| "late"
| "very_late"
| "missed";
export interface ComplianceResult {
totalExpected: number;
@@ -24,7 +29,13 @@ export interface ComplianceResult {
streak: number; // consecutive days with all taken
}
/** Daily compliance data including timing breakdown. */
/**
* Daily compliance data including timing breakdown.
*
* `early` is the v1.4.34 bucket for doses taken before the window's
* 3-hour grace start. Consumers that group by compliance status
* should treat `early` as compliant (alongside `onTime`).
*/
export interface DailyComplianceEntry {
expected: number;
taken: number;
@@ -32,6 +43,7 @@ export interface DailyComplianceEntry {
onTime: number;
late: number;
veryLate: number;
early?: number;
}
/**
@@ -55,12 +67,25 @@ function toDateOnDay(time: string, day: Date): Date {
/**
* Classify how punctual an intake was relative to a schedule window.
*
* - "on_time": within windowStart-1h .. windowEnd (1h early grace)
* - "late": within windowEnd .. windowEnd+2h
* - "very_late": after windowEnd+2h
* v1.4.34 IW-C — widened pre-window grace from 1h to 3h and introduced
* a dedicated `early` bucket so a proactive logger (e.g. 10 min before
* the window) no longer gets flushed into `very_late`. The post-window
* grace likewise grew to 3h so a "20 minutes late" dose stays `on_time`.
* Only doses beyond a 3-hour late tolerance fall into `late`, and only
* doses beyond a further `lateMinutes` tail fall into `very_late`.
*
* Buckets:
* - "early": within (windowStart - 3h) .. windowStart (compliant)
* - "on_time": windowStart .. (windowEnd + 3h) (compliant)
* - "late": (windowEnd + 3h) .. (windowEnd + 3h + lateMinutes) (default 2h tail)
* - "very_late": before windowStart - 3h, or after the late tail
* - "missed": takenAt is null
*
* Handles overnight windows (windowEnd < windowStart means next day).
*
* @param options.lateMinutes Width of the `late` band beyond the 3-hour
* on-time tolerance, in minutes. Defaults to 120. Doses past this
* tail land in `very_late`.
*/
export function classifyIntakeTiming(
takenAt: Date | null,
@@ -79,16 +104,22 @@ export function classifyIntakeTiming(
end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
}
// 1h grace period before windowStart
const graceStart = new Date(start.getTime() - 60 * 60 * 1000);
// Configurable tolerance after windowEnd (default 120 min)
const HOUR_MS = 60 * 60 * 1000;
// 3h pre-window grace: doses inside this window are `early` (still
// compliant). Doses before it are `very_late`.
const earlyStart = new Date(start.getTime() - 3 * HOUR_MS);
// 3h post-window grace: doses up to here are still `on_time`.
const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS);
// Configurable `late` tail past the on-time grace (default 120 min).
const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000;
const lateEnd = new Date(end.getTime() + lateTolerance);
const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance);
const t = takenAt.getTime();
if (t >= graceStart.getTime() && t <= end.getTime()) return "on_time";
if (t > end.getTime() && t <= lateEnd.getTime()) return "late";
if (t < earlyStart.getTime()) return "very_late";
if (t < start.getTime()) return "early";
if (t <= onTimeEnd.getTime()) return "on_time";
if (t <= lateEnd.getTime()) return "late";
return "very_late";
}
+44 -2
View File
@@ -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 };
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Unit tests for the per-user invalidation helpers — every helper
* evicts the matching `caches.*` bucket and leaves unrelated entries
* intact.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
__resetAllCachesForTests,
cached,
caches,
} from "../server-cache";
import {
invalidateAppSettings,
invalidateUserDashboardWidgets,
invalidateUserMeasurements,
invalidateUserMedications,
invalidateUserMood,
} from "../invalidate";
const USER_A = "user-a";
const USER_B = "user-b";
beforeEach(() => {
__resetAllCachesForTests();
});
afterEach(() => {
__resetAllCachesForTests();
});
async function primeAllCaches(): Promise<void> {
// Analytics keys carry a slice suffix; the prefix-match must include
// the pipe so user-1 doesn't match user-10 in production traffic.
await cached(caches.analytics, `${USER_A}|default`, async () => ({ a: 1 }));
await cached(caches.analytics, `${USER_A}|summaries`, async () => ({ a: 2 }));
await cached(caches.analytics, `${USER_B}|default`, async () => ({ b: 1 }));
await cached(caches.medications, USER_A, async () => ({ m: 1 }));
await cached(caches.medications, USER_B, async () => ({ m: 2 }));
await cached(caches.medicationsIntake, `${USER_A}|compliance|30`, async () => ({
c: 1,
}));
await cached(caches.medicationsIntake, `${USER_B}|compliance|30`, async () => ({
c: 2,
}));
await cached(caches.achievements, USER_A, async () => ({ ach: 1 }));
await cached(caches.achievements, USER_B, async () => ({ ach: 2 }));
await cached(caches.workouts, `${USER_A}|3|0||`, async () => ({ w: 1 }));
await cached(caches.workouts, `${USER_B}|3|0||`, async () => ({ w: 2 }));
await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 }));
await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 }));
await cached(caches.dashboardWidgets, USER_A, async () => ({ d: 1 }));
await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 }));
await cached(caches.bugreportStatus, "singleton", async () => ({ s: 1 }));
}
describe("invalidateUserMeasurements", () => {
it("evicts analytics + achievements + workouts for the target user only", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A);
expect(caches.analytics.get(`${USER_A}|default`)).toBeNull();
expect(caches.analytics.get(`${USER_A}|summaries`)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
// User B's caches untouched.
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
expect(caches.achievements.get(USER_B)).not.toBeNull();
expect(caches.workouts.get(`${USER_B}|3|0||`)).not.toBeNull();
// Mood-analytics not touched by a measurement write.
expect(caches.moodAnalytics.get(USER_A)).not.toBeNull();
});
});
describe("invalidateUserMood", () => {
it("evicts mood-analytics + achievements + analytics", async () => {
await primeAllCaches();
invalidateUserMood(USER_A);
expect(caches.moodAnalytics.get(USER_A)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.analytics.get(`${USER_A}|default`)).toBeNull();
expect(caches.moodAnalytics.get(USER_B)).not.toBeNull();
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
});
});
describe("invalidateUserMedications", () => {
it("evicts medications + medications-intake + achievements", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A);
expect(caches.medications.get(USER_A)).toBeNull();
expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.medications.get(USER_B)).not.toBeNull();
expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull();
});
});
describe("invalidateUserDashboardWidgets", () => {
it("evicts only the dashboardWidgets bucket for the target user", async () => {
await primeAllCaches();
invalidateUserDashboardWidgets(USER_A);
expect(caches.dashboardWidgets.get(USER_A)).toBeNull();
expect(caches.dashboardWidgets.get(USER_B)).not.toBeNull();
// Sibling caches untouched.
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
});
});
describe("invalidateAppSettings", () => {
it("evicts the entire bug-report status cache (singleton row)", async () => {
await primeAllCaches();
invalidateAppSettings();
expect(caches.bugreportStatus.get("singleton")).toBeNull();
// Per-user caches untouched.
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* Unit tests for the `ServerCache<T>` primitive — LRU ordering, TTL
* expiry, capacity-cap eviction, prefix delete, single-flight
* coalescing, builder-rejection cleanup, and the `cached()` wrapper's
* observability annotation.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__resetAllCachesForTests,
cached,
caches,
hashCacheKey,
ServerCache,
} from "../server-cache";
afterEach(() => {
__resetAllCachesForTests();
vi.useRealTimers();
});
describe("ServerCache.get / set", () => {
it("returns the stored value within TTL", () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
cache.set("k", "v");
expect(cache.get("k")).toBe("v");
});
it("returns null when the key is absent", () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
expect(cache.get("missing")).toBeNull();
});
it("expires entries past the TTL", () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 1000 });
cache.set("k", "v");
vi.advanceTimersByTime(999);
expect(cache.get("k")).toBe("v");
vi.advanceTimersByTime(2);
expect(cache.get("k")).toBeNull();
});
});
describe("ServerCache LRU eviction", () => {
it("evicts the oldest entry on capacity overflow", () => {
const cache = new ServerCache<number>({ maxEntries: 3, ttlMs: 60_000 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
cache.set("d", 4); // evicts "a"
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBe(2);
expect(cache.get("c")).toBe(3);
expect(cache.get("d")).toBe(4);
expect(cache.stats().evictions).toBe(1);
});
it("touches on read so least-recently-used is evicted", () => {
const cache = new ServerCache<number>({ maxEntries: 3, ttlMs: 60_000 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
// Touch "a" — now "b" is the oldest.
expect(cache.get("a")).toBe(1);
cache.set("d", 4); // evicts "b"
expect(cache.get("a")).toBe(1);
expect(cache.get("b")).toBeNull();
expect(cache.get("c")).toBe(3);
expect(cache.get("d")).toBe(4);
});
it("re-setting an existing key does not count toward eviction", () => {
const cache = new ServerCache<number>({ maxEntries: 2, ttlMs: 60_000 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("a", 99); // overwrite, no eviction
expect(cache.stats().evictions).toBe(0);
expect(cache.get("a")).toBe(99);
expect(cache.get("b")).toBe(2);
});
});
describe("ServerCache.deleteByPrefix", () => {
it("removes every key with a matching prefix", () => {
const cache = new ServerCache<number>({ maxEntries: 16, ttlMs: 60_000 });
cache.set("user-1|analytics", 1);
cache.set("user-1|workouts", 2);
cache.set("user-2|analytics", 3);
const removed = cache.deleteByPrefix("user-1|");
expect(removed).toBe(2);
expect(cache.get("user-1|analytics")).toBeNull();
expect(cache.get("user-1|workouts")).toBeNull();
expect(cache.get("user-2|analytics")).toBe(3);
});
it("returns 0 when no keys match", () => {
const cache = new ServerCache<number>({ maxEntries: 16, ttlMs: 60_000 });
cache.set("user-1|analytics", 1);
expect(cache.deleteByPrefix("user-99|")).toBe(0);
});
});
describe("ServerCache.wrap single-flight", () => {
it("calls the builder once for two concurrent reads of the same key", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let callCount = 0;
let resolveBuilder: ((value: string) => void) | null = null;
const builder = vi.fn(
() =>
new Promise<string>((resolve) => {
callCount += 1;
resolveBuilder = resolve;
}),
);
const first = cache.wrap("k", builder);
const second = cache.wrap("k", builder);
// Both reads have landed before the builder resolves.
expect(callCount).toBe(1);
resolveBuilder!("built");
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.value).toBe("built");
expect(firstResult.outcome).toBe("miss");
expect(secondResult.value).toBe("built");
expect(secondResult.outcome).toBe("stampede");
expect(builder).toHaveBeenCalledTimes(1);
expect(cache.stats().stampedes).toBe(1);
});
it("returns the cached value as a hit on the third read", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
const builder = vi.fn(async () => "built");
await cache.wrap("k", builder);
const third = await cache.wrap("k", builder);
expect(third.value).toBe("built");
expect(third.outcome).toBe("hit");
expect(builder).toHaveBeenCalledTimes(1);
expect(cache.stats().hits).toBe(1);
});
it("clears the pending slot on builder rejection so the next call retries", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
let attempt = 0;
const builder = vi.fn(async () => {
attempt += 1;
if (attempt === 1) throw new Error("transient");
return "ok";
});
await expect(cache.wrap("k", builder)).rejects.toThrow("transient");
const next = await cache.wrap("k", builder);
expect(next.value).toBe("ok");
expect(next.outcome).toBe("miss");
expect(builder).toHaveBeenCalledTimes(2);
});
});
describe("cached() observability wrapper", () => {
it("annotates hit / miss outcome and a hashed key", async () => {
const cache = new ServerCache<string>({
name: "test",
maxEntries: 4,
ttlMs: 60_000,
});
const annotate = vi.fn();
const builder = vi.fn(async () => "v");
await cached(cache, "user-1|default", builder, annotate);
expect(annotate).toHaveBeenCalledWith({
meta: {
"cache.test.outcome": "miss",
"cache.test.key_hash": hashCacheKey("user-1|default"),
},
});
annotate.mockClear();
await cached(cache, "user-1|default", builder, annotate);
expect(annotate).toHaveBeenCalledWith({
meta: expect.objectContaining({
"cache.test.outcome": "hit",
}),
});
});
it("no-ops the annotation when no annotator is passed", async () => {
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
const value = await cached(cache, "k", async () => "v");
expect(value).toBe("v");
});
});
describe("global cache registry", () => {
it("exposes the eight blueprint caches as module-scope singletons", () => {
expect(caches.analytics).toBeDefined();
expect(caches.medications).toBeDefined();
expect(caches.achievements).toBeDefined();
expect(caches.dashboardWidgets).toBeDefined();
expect(caches.bugreportStatus).toBeDefined();
expect(caches.workouts).toBeDefined();
expect(caches.medicationsIntake).toBeDefined();
expect(caches.moodAnalytics).toBeDefined();
});
it("__resetAllCachesForTests clears every registry instance", async () => {
await cached(caches.analytics, "user-1|default", async () => ({ ok: 1 }));
expect(caches.analytics.stats().size).toBe(1);
__resetAllCachesForTests();
expect(caches.analytics.stats().size).toBe(0);
expect(caches.analytics.stats().hits).toBe(0);
expect(caches.analytics.stats().misses).toBe(0);
});
});
describe("hashCacheKey", () => {
it("is deterministic and unsigned 32-bit", () => {
const h1 = hashCacheKey("user-1|default");
const h2 = hashCacheKey("user-1|default");
expect(h1).toBe(h2);
expect(h1).toBeGreaterThanOrEqual(0);
expect(h1).toBeLessThanOrEqual(0xffffffff);
});
it("distinguishes near-identical keys", () => {
expect(hashCacheKey("user-1|default")).not.toBe(
hashCacheKey("user-2|default"),
);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* v1.4.34 IW-G — per-user cache invalidation helpers.
*
* Each write endpoint that touches a measurement / mood / medication /
* dashboard-widget row calls the matching helper after its successful
* database commit. The helpers walk the affected `caches.*` maps and
* delete every key whose `userId` prefix matches.
*
* The "evict the whole user-bucket" stance is intentional: a redundant
* eviction costs the next reader one cache miss (cheap, the row's
* already paginated and the snapshot LRU sits behind), but an
* under-eviction surfaces stale data which is the directive's failure
* mode. The full per-write → per-cache fan-out is documented in the
* blueprint §6 (`.planning/research/v1434-r-cache-aggregation.md`).
*
* The helpers are intentionally synchronous — they do `Map.delete()`
* over a bounded entry set, which is cheap enough to run inline before
* the write endpoint's `apiSuccess()` return. No `await` overhead.
*/
import { caches } from "./server-cache";
/**
* Invalidate every cache that may reflect a user's measurement set.
*
* Covers: analytics aggregate, achievement progress, workouts cache.
* Mood-analytics is invalidated separately via `invalidateUserMood`
* because mood writes don't change measurement rows.
*/
export function invalidateUserMeasurements(userId: string): void {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
}
/**
* Invalidate every cache that may reflect a user's mood entries.
*
* Mood writes also dirty the analytics aggregate (correlations runner
* pairs mood × pulse) and achievement progress (consistent-month / mood
* badges).
*/
export function invalidateUserMood(userId: string): void {
caches.moodAnalytics.deleteByPrefix(userId);
caches.achievements.deleteByPrefix(userId);
caches.analytics.deleteByPrefix(`${userId}|`);
}
/**
* Invalidate every cache that may reflect a user's medication state.
*
* Covers the medications cache, the medication-intake compliance cache
* (cached daily), and achievement progress.
*/
export function invalidateUserMedications(userId: string): void {
caches.medications.deleteByPrefix(userId);
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
}
/**
* Invalidate the dashboard-widget layout cache for a single user.
*
* Called from the dashboard-widgets PUT / DELETE endpoints when the
* user reorders / disables a tile.
*/
export function invalidateUserDashboardWidgets(userId: string): void {
caches.dashboardWidgets.deleteByPrefix(userId);
}
/**
* Global eviction of the bug-report-status cache.
*
* The cache is keyed on the singleton `"singleton"` slot because the
* underlying data is a row in `AppSettings`. Any admin write to the
* GitHub-token / bug-report-enabled fields invalidates the entire
* cache; the per-user `isAdmin` flag is layered on at request time, so
* the cache itself only stores role-agnostic shape.
*/
export function invalidateAppSettings(): void {
caches.bugreportStatus.deleteByPrefix("");
}
+299
View File
@@ -0,0 +1,299 @@
/**
* v1.4.34 IW-G — in-process LRU + single-flight server cache.
*
* Generalises the v1.4.33 Coach snapshot cache shape
* (`src/lib/ai/coach/snapshot.ts:292-336`, commit `af17db5d`) into a
* reusable primitive. Same recipe: `Map<string, { expiresAt, value }>`
* gives us insertion-order iteration for LRU touch, a hard cap on
* entries to bound memory, and a `pending` map so concurrent reads of
* the same cold key fan into a single builder call.
*
* Design notes:
*
* - LRU touch is `delete + set` so re-insertion moves the key to the
* end of the Map's iteration order. Eviction reads the first key
* (least-recently-touched) via `map.keys().next().value`.
*
* - `wrap(key, builder)` is the only public read path. Misses register
* the in-flight promise on `pending` before awaiting the builder so
* a second caller for the same key inside the build window awaits
* the same promise instead of starting a duplicate read. Rejected
* promises remove themselves from `pending` so a transient failure
* doesn't poison the key.
*
* - Metrics are per-instance: hits, misses, evictions, stampedes (a
* caller that hit the single-flight join), and capacity (current
* entry count). Read these via `stats()` for the wide-event
* annotations the route handlers attach to each request.
*
* - The cache is process-local. A multi-instance Coolify deploy keeps
* each container's cache isolated; the TTL bounds the staleness
* window. The Redis migration path is sketched in the blueprint
* (`.planning/research/v1434-r-cache-aggregation.md` §7).
*
* - `__resetForTests()` matches the snapshot pattern's escape hatch so
* vitest's afterEach can clear every instance between tests without
* reaching into the private `Map`.
*/
export interface ServerCacheOptions {
/** Hard cap on entries. Oldest entry is evicted on overflow. */
readonly maxEntries: number;
/** Time-to-live in milliseconds. Expired entries miss on read. */
readonly ttlMs: number;
/**
* Optional name for observability — surfaced in `stats().name` so the
* wide-event meta keys can carry the cache identity without the
* caller having to remember which `caches.*` instance it pulled.
*/
readonly name?: string;
}
export interface ServerCacheStats {
readonly name: string;
readonly size: number;
readonly hits: number;
readonly misses: number;
readonly evictions: number;
readonly stampedes: number;
}
export class ServerCache<T> {
private readonly map = new Map<string, { expiresAt: number; value: T }>();
private readonly pending = new Map<string, Promise<T>>();
private hits = 0;
private misses = 0;
private evictions = 0;
private stampedes = 0;
constructor(private readonly opts: ServerCacheOptions) {}
/**
* Read a key. Returns null on miss, expired entry, or absent entry.
* Side effect: an expired entry is removed; a live entry is
* LRU-touched (re-inserted) so subsequent reads keep it warm.
*
* Does NOT increment `hits`/`misses` — those live on the
* `wrap()` path because the bare `get` is also called from tests
* and from the test escape hatch.
*/
get(key: string): T | null {
const entry = this.map.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
this.map.delete(key);
return null;
}
// LRU touch — re-insertion moves the key to the end of the Map's
// iteration order (which is the most-recently-used end).
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
/** Insert or overwrite. Evicts the oldest entry if the cap is hit. */
set(key: string, value: T): void {
// The key might already exist (eviction-by-set is a write through
// the same slot — no cap eviction needed). Delete-then-set to keep
// the LRU ordering predictable.
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.opts.maxEntries) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) {
this.map.delete(oldest);
this.evictions += 1;
}
}
this.map.set(key, {
expiresAt: Date.now() + this.opts.ttlMs,
value,
});
}
/** Single-key eviction. Returns true when the key was present. */
delete(key: string): boolean {
return this.map.delete(key);
}
/**
* Bulk eviction. Removes every entry whose key starts with `prefix`.
* Returns the count of removed entries.
*
* The invalidation helpers in `./invalidate.ts` use this to flush an
* entire user-bucket from a cache without having to enumerate every
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
this.map.delete(key);
removed += 1;
}
}
return removed;
}
/**
* Read-through + single-flight. On a live hit, returns the cached
* value. On a miss, either joins an in-flight builder (single-flight)
* or kicks off a new builder, stores the result, and returns it.
*
* Builder rejections do NOT poison the cache — the `pending` entry
* is removed on reject so the next caller retries.
*/
async wrap(key: string, builder: () => Promise<T>): Promise<{
value: T;
outcome: "hit" | "miss" | "stampede";
}> {
const live = this.get(key);
if (live !== null) {
this.hits += 1;
return { value: live, outcome: "hit" };
}
const inFlight = this.pending.get(key);
if (inFlight) {
this.stampedes += 1;
const value = await inFlight;
return { value, outcome: "stampede" };
}
this.misses += 1;
const promise = builder()
.then((value) => {
this.set(key, value);
return value;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
const value = await promise;
return { value, outcome: "miss" };
}
/** Snapshot of per-instance counters for the wide-event annotation. */
stats(): ServerCacheStats {
return {
name: this.opts.name ?? "unnamed",
size: this.map.size,
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
stampedes: this.stampedes,
};
}
/** Reset every counter + entry. Test-only escape hatch. */
__resetForTests(): void {
this.map.clear();
this.pending.clear();
this.hits = 0;
this.misses = 0;
this.evictions = 0;
this.stampedes = 0;
}
}
/**
* Per-route cache instances. Module-scope so every request lands in the
* same `Map`. Sizes match the blueprint §5 table.
*
* The blueprint's full eight-cache roster is provisioned so future
* rounds can wire the remaining routes (`/api/medications`,
* `/api/dashboard/widgets`, `/api/bugreport/status`, `/api/workouts`,
* `/api/mood/analytics`) without churning the helper module.
*/
export const caches = {
analytics: new ServerCache<unknown>({
name: "analytics",
maxEntries: 1000,
ttlMs: 60_000,
}),
medications: new ServerCache<unknown>({
name: "medications",
maxEntries: 1000,
ttlMs: 60_000,
}),
achievements: new ServerCache<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
}),
dashboardWidgets: new ServerCache<unknown>({
name: "dashboardWidgets",
maxEntries: 500,
ttlMs: 300_000,
}),
bugreportStatus: new ServerCache<unknown>({
name: "bugreportStatus",
maxEntries: 10,
ttlMs: 600_000,
}),
workouts: new ServerCache<unknown>({
name: "workouts",
maxEntries: 1000,
ttlMs: 60_000,
}),
medicationsIntake: new ServerCache<unknown>({
name: "medicationsIntake",
maxEntries: 1000,
ttlMs: 900_000,
}),
moodAnalytics: new ServerCache<unknown>({
name: "moodAnalytics",
maxEntries: 1000,
ttlMs: 60_000,
}),
} as const;
/**
* Non-reversible 32-bit hash of the cache key. Used in wide-event meta
* so we can correlate hit / miss patterns without leaking the raw
* userId portion of the key into logs.
*
* djb2 — public domain, two lines, matches the blueprint §8 sketch.
*/
export function hashCacheKey(key: string): number {
let hash = 5381;
for (let i = 0; i < key.length; i += 1) {
hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
}
return hash >>> 0;
}
/**
* Read-through wrapper that also annotates the active wide-event with
* the cache hit / miss / stampede outcome and a key hash. Matches the
* observability contract in the blueprint §8.
*
* Falls back gracefully when no logging context is active (the
* `annotate()` call is a no-op outside of `apiHandler`).
*/
export async function cached<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
): Promise<T> {
const { value, outcome } = await cache.wrap(key, builder);
if (annotateFn) {
const name = cache.stats().name;
annotateFn({
meta: {
[`cache.${name}.outcome`]: outcome,
[`cache.${name}.key_hash`]: hashCacheKey(key),
},
});
}
return value;
}
/** Test helper — reset every cache in the registry. */
export function __resetAllCachesForTests(): void {
for (const cache of Object.values(caches)) {
cache.__resetForTests();
}
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import {
NO_STORE_BUT_BFCACHE,
SHORT_LIVED_PUBLIC,
applyAuthedHeaders,
} from "../cache-headers";
describe("cache-headers presets", () => {
it("NO_STORE_BUT_BFCACHE is bfcache-eligible (no `no-store` token)", () => {
expect(NO_STORE_BUT_BFCACHE).toBe("private, max-age=0, must-revalidate");
expect(NO_STORE_BUT_BFCACHE).not.toMatch(/no-store/);
// Must keep proxies out of the loop for authed content.
expect(NO_STORE_BUT_BFCACHE).toMatch(/private/);
// Force revalidation on every navigation so stale state never sticks.
expect(NO_STORE_BUT_BFCACHE).toMatch(/max-age=0/);
expect(NO_STORE_BUT_BFCACHE).toMatch(/must-revalidate/);
});
it("SHORT_LIVED_PUBLIC carries a one-hour public TTL", () => {
expect(SHORT_LIVED_PUBLIC).toBe("public, max-age=3600");
});
});
describe("applyAuthedHeaders", () => {
it("stamps NO_STORE_BUT_BFCACHE onto a fresh Response", () => {
const res = new Response("payload", { status: 200 });
const out = applyAuthedHeaders(res);
expect(out.headers.get("Cache-Control")).toBe(NO_STORE_BUT_BFCACHE);
});
it("overwrites any pre-existing Cache-Control directive", () => {
const res = new Response("payload", {
status: 200,
headers: { "Cache-Control": "no-store, must-revalidate" },
});
applyAuthedHeaders(res);
expect(res.headers.get("Cache-Control")).toBe(NO_STORE_BUT_BFCACHE);
});
it("returns the same response for chaining", () => {
const res = new Response("payload");
const returned = applyAuthedHeaders(res);
expect(returned).toBe(res);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Centralised `Cache-Control` directives for HealthLog HTTP responses.
*
* Two presets cover the load-bearing cases for the web tree:
*
* - {@link NO_STORE_BUT_BFCACHE} — the default for every authenticated page
* response. The directive replaces the framework's stock
* `no-store, must-revalidate` (which Chromium counts as a hard bfcache
* breaker per the eligibility matrix) with `private, max-age=0,
* must-revalidate`. `private` keeps shared caches (proxies, CDNs) from
* storing personal data, `max-age=0` forces revalidation on every
* navigation so cookie + session swaps still detect on the wire, and
* `must-revalidate` honours the staleness contract. The combination is
* bfcache-eligible — Chromium admits the page on back-forward navigation,
* restoring scroll position and DOM state instead of paying a full
* reload.
*
* - {@link SHORT_LIVED_PUBLIC} — for static-ish public payloads (manifest
* metadata, the AASA blob, etc.) where a one-hour shared-cache TTL is
* appropriate. Not for authenticated content.
*
* {@link applyAuthedHeaders} stamps `NO_STORE_BUT_BFCACHE` onto an existing
* `Response` (or `NextResponse`) in-place. Use it inside a route handler
* that needs the bfcache-friendly directive on a non-HTML payload (the
* `next.config.ts` `headers()` rule handles the HTML default; this helper
* covers the API-route edges that opt into the same posture).
*/
export const NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate";
export const SHORT_LIVED_PUBLIC = "public, max-age=3600";
/**
* Stamp the bfcache-friendly authed `Cache-Control` directive onto a
* response. Returns the same response for chaining.
*/
export function applyAuthedHeaders<T extends Response>(res: T): T {
res.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return res;
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { deflateRawSync } from "node:zlib";
import { extractExportXml, readCentralDirectory } from "../unzip-export-xml";
/**
* Hand-build a single-entry deflate-compressed ZIP archive with the
* supplied filename + payload. Enough to exercise the central-directory
* walker + extractor without pulling in a full zip library.
*/
function buildMinimalZip(filename: string, payload: Buffer): Buffer {
const compressed = deflateRawSync(payload);
const crc32 = (() => {
// Pre-computed lookup table for CRC-32. node:zlib has crc32 in 22+
// but we keep the function inline to stay version-agnostic.
let c: number;
const table: number[] = [];
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c;
}
let crc = 0 ^ -1;
for (let i = 0; i < payload.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ payload[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
})();
const nameBuf = Buffer.from(filename, "utf8");
// Local file header
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4); // version
localHeader.writeUInt16LE(0, 6); // flags
localHeader.writeUInt16LE(8, 8); // method: deflate
localHeader.writeUInt16LE(0, 10); // time
localHeader.writeUInt16LE(0, 12); // date
localHeader.writeUInt32LE(crc32, 14);
localHeader.writeUInt32LE(compressed.length, 18);
localHeader.writeUInt32LE(payload.length, 22);
localHeader.writeUInt16LE(nameBuf.length, 26);
localHeader.writeUInt16LE(0, 28); // extra len
// Central directory file header
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0);
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0, 8); // flags
cdh.writeUInt16LE(8, 10); // method
cdh.writeUInt16LE(0, 12); // time
cdh.writeUInt16LE(0, 14); // date
cdh.writeUInt32LE(crc32, 16);
cdh.writeUInt32LE(compressed.length, 20);
cdh.writeUInt32LE(payload.length, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra
cdh.writeUInt16LE(0, 32); // comment
cdh.writeUInt16LE(0, 34); // disk num
cdh.writeUInt16LE(0, 36); // internal attrs
cdh.writeUInt32LE(0, 38); // external attrs
cdh.writeUInt32LE(0, 42); // local header offset
// End of Central Directory
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4); // disk
eocd.writeUInt16LE(0, 6); // disk-with-cd
eocd.writeUInt16LE(1, 8); // entries-on-disk
eocd.writeUInt16LE(1, 10); // total-entries
eocd.writeUInt32LE(46 + nameBuf.length, 12); // cd size
// cd offset = local-header + name + data
eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16);
eocd.writeUInt16LE(0, 20); // comment len
return Buffer.concat([
localHeader,
nameBuf,
compressed,
cdh,
nameBuf,
eocd,
]);
}
describe("readCentralDirectory", () => {
it("walks a single-entry archive", () => {
const payload = Buffer.from("<HealthData/>");
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
const entries = readCentralDirectory(zip);
expect(entries).toHaveLength(1);
expect(entries[0].fileName).toBe("apple_health_export/export.xml");
expect(entries[0].compressionMethod).toBe(8);
expect(entries[0].uncompressedSize).toBe(payload.length);
});
});
describe("extractExportXml", () => {
it("extracts the export.xml member to a temp file", () => {
const payload = Buffer.from(
`<?xml version="1.0"?><HealthData><ExportDate value="2026-05-15"/></HealthData>`,
);
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
const zipPath = join(tmp, "export.zip");
writeFileSync(zipPath, zip);
const out = extractExportXml(zipPath);
expect(out.xmlBytes).toBe(payload.length);
expect(out.otherMembers).toHaveLength(0);
const extracted = readFileSync(out.xmlPath);
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
});
it("throws when the export.xml member is missing", () => {
const payload = Buffer.from("noop");
const zip = buildMinimalZip("other-file.txt", payload);
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
const zipPath = join(tmp, "export.zip");
writeFileSync(zipPath, zip);
expect(() => extractExportXml(zipPath)).toThrow(/missing/);
});
});
+254
View File
@@ -0,0 +1,254 @@
/**
* Minimal `export.zip` extractor — pulls `apple_health_export/export.xml`
* out of an Apple Health export archive without spawning a third-party
* dependency.
*
* Why hand-rolled: every iOS Apple Health export lands as a single
* deflate-compressed ZIP archive. Pulling in `yauzl` / `adm-zip` / etc.
* for one file we only ever read once would balloon the build graph
* (and `yauzl` ships a non-trivial number of transitive deps). Node 22
* ships `node:zlib` `inflateRawSync` already; the only missing piece
* is a tiny ZIP central-directory walker.
*
* Coverage scope:
* - Stored entries (compression method 0) — copy bytes verbatim.
* - Deflated entries (compression method 8) — `inflateRawSync`.
* - Encrypted entries → unsupported (Apple does not encrypt the
* export.zip; reject with a clear error).
* - Zip64 — supported for the central directory record (Apple
* exports easily push past the 4 GB limit on multi-year accounts).
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §6.1.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { inflateRawSync } from "node:zlib";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Signature bytes for the End-Of-Central-Directory record. */
const EOCD_SIGNATURE = 0x06054b50;
const ZIP64_EOCD_LOCATOR = 0x07064b50;
const ZIP64_EOCD_RECORD = 0x06064b50;
const CENTRAL_FILE_HEADER = 0x02014b50;
const LOCAL_FILE_HEADER = 0x04034b50;
/** A single entry within the archive's central directory. */
interface CentralDirectoryEntry {
fileName: string;
compressionMethod: number;
compressedSize: number;
uncompressedSize: number;
localHeaderOffset: number;
}
/**
* Result of `extractExportXml()`. The XML is written to a temp file
* because the streaming SAX parser expects a path it can `createReadStream`
* against.
*/
export interface UnzipResult {
/** Filesystem path the extracted `export.xml` lives at. */
xmlPath: string;
/** Uncompressed size in bytes of the extracted XML. */
xmlBytes: number;
/** Members the parser ignored (everything other than `export.xml`). */
otherMembers: { name: string; bytes: number }[];
}
/**
* Walk the central directory of `archivePath` and write the
* `apple_health_export/export.xml` member out to a temp file.
* Throws when the member is missing, encrypted, or compressed with
* an unsupported method.
*/
export function extractExportXml(archivePath: string): UnzipResult {
const buf = readFileSync(archivePath);
const entries = readCentralDirectory(buf);
const exportXmlEntry = entries.find(
(e) =>
e.fileName.endsWith("/export.xml") || e.fileName === "export.xml",
);
if (!exportXmlEntry) {
throw new Error(
"Archive is missing the `apple_health_export/export.xml` member"
+ " — is this a valid Apple Health export.zip?",
);
}
if (
exportXmlEntry.compressionMethod !== 0
&& exportXmlEntry.compressionMethod !== 8
) {
throw new Error(
`Unsupported ZIP compression method ${exportXmlEntry.compressionMethod}`
+ " for export.xml (expected 0=stored or 8=deflate)",
);
}
const xmlBytes = extractEntry(buf, exportXmlEntry);
const xmlPath = join(
tmpdir(),
`healthlog-import-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.xml`,
);
writeFileSync(xmlPath, xmlBytes);
const otherMembers = entries
.filter((e) => e !== exportXmlEntry)
.map((e) => ({ name: e.fileName, bytes: e.uncompressedSize }));
return {
xmlPath,
xmlBytes: xmlBytes.length,
otherMembers,
};
}
/**
* Walk the ZIP central directory and return one descriptor per member.
* Exported for unit testing the central-directory parser independently
* of the file-extraction path.
*/
export function readCentralDirectory(
buf: Buffer,
): CentralDirectoryEntry[] {
const eocdOffset = locateEocd(buf);
if (eocdOffset === -1) {
throw new Error("Could not locate ZIP End-Of-Central-Directory record");
}
let centralDirSize = buf.readUInt32LE(eocdOffset + 12);
let centralDirOffset = buf.readUInt32LE(eocdOffset + 16);
let entryCount = buf.readUInt16LE(eocdOffset + 10);
// Zip64 handling — when any of the three count/offset fields is the
// 0xFFFFFFFF / 0xFFFF sentinel, the real value lives in the Zip64
// EOCD record located by walking back through the locator.
if (
centralDirOffset === 0xffffffff
|| centralDirSize === 0xffffffff
|| entryCount === 0xffff
) {
const locatorOffset = eocdOffset - 20;
if (locatorOffset < 0
|| buf.readUInt32LE(locatorOffset) !== ZIP64_EOCD_LOCATOR) {
throw new Error("Zip64 sentinels present but Zip64 locator missing");
}
const zip64EocdOffset = Number(buf.readBigUInt64LE(locatorOffset + 8));
if (buf.readUInt32LE(zip64EocdOffset) !== ZIP64_EOCD_RECORD) {
throw new Error("Zip64 EOCD record signature mismatch");
}
entryCount = Number(buf.readBigUInt64LE(zip64EocdOffset + 32));
centralDirSize = Number(buf.readBigUInt64LE(zip64EocdOffset + 40));
centralDirOffset = Number(buf.readBigUInt64LE(zip64EocdOffset + 48));
}
const entries: CentralDirectoryEntry[] = [];
let cursor = centralDirOffset;
const end = centralDirOffset + centralDirSize;
while (cursor < end && entries.length < entryCount) {
if (buf.readUInt32LE(cursor) !== CENTRAL_FILE_HEADER) {
throw new Error(
`Central directory entry ${entries.length} has wrong signature`,
);
}
const compressionMethod = buf.readUInt16LE(cursor + 10);
const flags = buf.readUInt16LE(cursor + 8);
if (flags & 0x0001) {
throw new Error("Encrypted ZIP entries are not supported");
}
const compressedSize32 = buf.readUInt32LE(cursor + 20);
const uncompressedSize32 = buf.readUInt32LE(cursor + 24);
const fileNameLen = buf.readUInt16LE(cursor + 28);
const extraLen = buf.readUInt16LE(cursor + 30);
const commentLen = buf.readUInt16LE(cursor + 32);
const localHeaderOffset32 = buf.readUInt32LE(cursor + 42);
const fileName = buf
.slice(cursor + 46, cursor + 46 + fileNameLen)
.toString("utf8");
let compressedSize = compressedSize32;
let uncompressedSize = uncompressedSize32;
let localHeaderOffset = localHeaderOffset32;
// Zip64 extra-field walk if any of the three values is sentinel.
if (
compressedSize === 0xffffffff
|| uncompressedSize === 0xffffffff
|| localHeaderOffset === 0xffffffff
) {
const extraStart = cursor + 46 + fileNameLen;
let extraCursor = extraStart;
const extraEnd = extraStart + extraLen;
while (extraCursor + 4 <= extraEnd) {
const headerId = buf.readUInt16LE(extraCursor);
const dataSize = buf.readUInt16LE(extraCursor + 2);
if (headerId === 0x0001) {
// Zip64 extended-info extra field
let dCursor = extraCursor + 4;
if (uncompressedSize === 0xffffffff) {
uncompressedSize = Number(buf.readBigUInt64LE(dCursor));
dCursor += 8;
}
if (compressedSize === 0xffffffff) {
compressedSize = Number(buf.readBigUInt64LE(dCursor));
dCursor += 8;
}
if (localHeaderOffset === 0xffffffff) {
localHeaderOffset = Number(buf.readBigUInt64LE(dCursor));
dCursor += 8;
}
break;
}
extraCursor += 4 + dataSize;
}
}
entries.push({
fileName,
compressionMethod,
compressedSize,
uncompressedSize,
localHeaderOffset,
});
cursor += 46 + fileNameLen + extraLen + commentLen;
}
return entries;
}
/** Extract the bytes of a single ZIP entry into a Buffer. */
function extractEntry(buf: Buffer, entry: CentralDirectoryEntry): Buffer {
const local = entry.localHeaderOffset;
if (buf.readUInt32LE(local) !== LOCAL_FILE_HEADER) {
throw new Error(
`Local file header for ${entry.fileName} has wrong signature`,
);
}
const localFileNameLen = buf.readUInt16LE(local + 26);
const localExtraLen = buf.readUInt16LE(local + 28);
const dataStart = local + 30 + localFileNameLen + localExtraLen;
const compressed = buf.slice(dataStart, dataStart + entry.compressedSize);
if (entry.compressionMethod === 0) {
return compressed;
}
// Method 8 — DEFLATE.
return inflateRawSync(compressed);
}
/**
* Scan backwards from the end of the buffer for the EOCD signature.
* The EOCD lives in the last 22 + comment bytes; a comment is rare in
* the Health export but we tolerate up to 64 KB just in case.
*/
function locateEocd(buf: Buffer): number {
const searchStart = Math.max(0, buf.length - (22 + 0x10000));
for (let i = buf.length - 22; i >= searchStart; i--) {
if (buf.readUInt32LE(i) === EOCD_SIGNATURE) return i;
}
return -1;
}
+39 -9
View File
@@ -29,15 +29,17 @@
//
// vitals → body → activity → sleep → cardiovascular → mood → events
//
// The wave-A HealthKit entries added in v1.4.32 (HRV, resting HR, SpO2,
// body temperature, active energy) used to stack at the end of the
// strip in insertion order; the regrouping below interleaves them into
// their categorical neighbours so a user reading left-to-right scans
// each metric domain as a block. Heavier regrouping (collapse five
// wave-A pills under a single "Apple Health" pill, fold BMI into
// Gewicht, fold Sauerstoff/Körpertemperatur into a single Vitals pill)
// is deferred to v1.4.34 — that needs a screenshot review per
// .planning/round-v1433-audit-menu.md §7.
// v1.4.34 IW-D — the five wave-A HealthKit entries (HRV, resting HR,
// SpO2, body temperature, active energy) added in v1.4.32 share a
// presentation group on the routed tab strip. Each sub-page keeps its
// own URL and own page component — only the strip presentation changes
// so the five pills collapse behind one "Vitalwerte" / "Vitals" parent
// pill with a popover sub-list. The `group: "vitals"` field below
// drives that grouping; the canonical order inside the popover follows
// the strip order. The earlier inline pills (`puls`, `blutdruck`,
// `gewicht`, `bmi`, `schlaf`, `stimmung`, `medikamente`, `workouts`)
// stay flat — they predate v1.4.32 and the maintainer's audit kept
// them as direct entries.
export const SUB_PAGE_METRIC = {
// ── vitals ──
blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
@@ -69,6 +71,34 @@ export type SubPageSlug = keyof typeof SUB_PAGE_METRIC;
export const SUB_PAGE_SLUGS = Object.keys(SUB_PAGE_METRIC) as SubPageSlug[];
/**
* v1.4.34 IW-D — strip-presentation grouping. Only `"vitals"` is in
* use today; every other slug renders as a top-level pill. The group
* key is rendered through a single parent pill that opens a popover
* containing the grouped sub-pages. Adding a new group is a one-row
* change here plus a matching label key under
* `insights.tabStrip.<group>Parent.*` in every locale.
*/
export type SubPageGroup = "vitals";
export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
hrv: "vitals",
ruhepuls: "vitals",
sauerstoff: "vitals",
koerpertemperatur: "vitals",
"aktive-energie": "vitals",
};
/**
* v1.4.34 IW-D — canonical order of the sub-pages inside each parent
* popover. The strip iterates `SUB_PAGE_SLUGS`, so a vitals member's
* position in the popover follows its position in that array.
*/
export const SUB_PAGE_GROUP_ORDER: Record<SubPageGroup, readonly SubPageSlug[]> =
{
vitals: SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_GROUP[slug] === "vitals"),
};
/**
* Mother-page route. Kept here as a named constant so call sites
* never have to hard-code the string.
+249
View File
@@ -0,0 +1,249 @@
/**
* v1.4.34 — pg-boss handler for the `apple-health-import` queue.
*
* The kick-off endpoint (`POST /api/import/apple-health-export`,
* `POST /api/admin/import-apple-health-export`) writes the upload to
* `/tmp`, creates an `ImportJob` row in `queued`, and sends the
* payload below to this queue. The handler:
*
* 1. Resolves the mirror `ImportJob` row by `pgBossJobId`.
* 2. Extracts `export.xml` from the upload's ZIP archive.
* 3. Streams the XML through `streamParseExportXml()`, which
* UPSERTs `Measurement` and `Workout` rows while feeding a
* live progress snapshot back onto the `ImportJob` row.
* 4. Marks the row `done` with the terminal `ImportJobResult`
* envelope on success, or `failed` with a reason string on
* throw.
* 5. Cleans up the upload + extracted XML so `/tmp` does not
* accumulate gigabyte tails on the worker host.
*
* Concurrency: 1 per host. The parse loop is CPU-bound and a
* concurrent second import would race the first for RSS; the
* pg-boss `boss.work` registration in `reminder-worker.ts` caps
* `localConcurrency: 1`.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1.
*/
import { unlinkSync } from "node:fs";
import { PrismaClient, Prisma } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import type { Job } from "pg-boss";
import { extractExportXml } from "@/lib/import/unzip-export-xml";
import {
streamParseExportXml,
type ImportJobProgress,
type ImportJobResult,
} from "@/lib/measurements/import-apple-health-export";
/** Queue name for the Apple Health export ingest. */
export const APPLE_HEALTH_IMPORT_QUEUE = "apple-health-import";
/** Concurrency cap per worker host. */
export const APPLE_HEALTH_IMPORT_CONCURRENCY = 1;
/** Payload `boss.send` carries onto the queue. */
export interface AppleHealthImportPayload {
/** Owner of the imported rows. */
userId: string;
/** Admin who triggered the import (admin variant only). */
triggeredByAdminId?: string;
/** Absolute path on the worker filesystem where the upload landed. */
uploadPath: string;
/** Bytes count surfaced to the audit log. */
uploadBytes: number;
/** Wall-clock kick-off so duration is computable even with queue lag. */
enqueuedAt: string;
}
let workerPrismaSingleton: PrismaClient | null = null;
function getWorkerPrisma(): PrismaClient {
if (!workerPrismaSingleton) {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
workerPrismaSingleton = new PrismaClient({ adapter });
}
return workerPrismaSingleton;
}
/**
* Persist a progress snapshot onto the mirror `ImportJob` row. The
* worker calls this every `PROGRESS_TICK_RECORDS` records parsed +
* once on terminal `done`.
*/
async function writeProgress(
prisma: PrismaClient,
importJobId: string,
status: string,
progress: ImportJobProgress,
): Promise<void> {
await prisma.importJob.update({
where: { id: importJobId },
data: {
status,
progress: progress as unknown as Prisma.InputJsonValue,
},
});
}
/**
* Top-level handler. Thrown errors land the job in `failed` with the
* error message recorded on `ImportJob.failureReason` and re-thrown
* so pg-boss sees the failure.
*/
export async function handleAppleHealthImport(
job: Job<AppleHealthImportPayload>,
): Promise<void> {
const { userId, uploadPath, uploadBytes, triggeredByAdminId } = job.data;
const prisma = getWorkerPrisma();
// Resolve the mirror ImportJob row. The kick-off endpoint may have
// raced the worker — if the row hasn't landed yet, retry once.
let importJob = await prisma.importJob.findUnique({
where: { pgBossJobId: job.id },
});
if (!importJob) {
await new Promise((r) => setTimeout(r, 250));
importJob = await prisma.importJob.findUnique({
where: { pgBossJobId: job.id },
});
}
if (!importJob) {
// No mirror row — log and exit; we cannot surface progress.
console.warn(
`[apple-health-import] No ImportJob row for pgBossJobId=${job.id};`
+ " creating a stand-in",
);
importJob = await prisma.importJob.create({
data: {
userId,
triggeredByAdminId: triggeredByAdminId ?? null,
pgBossJobId: job.id,
status: "queued",
uploadBytes,
},
});
}
const importJobId = importJob.id;
try {
// Resolve the user's timezone — required for the cumulative
// `stats:` day-key bucketing. Default to Europe/Berlin if the
// user has no preference set.
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const userTimezone =
userRow?.timezone && userRow.timezone.length > 0
? userRow.timezone
: "Europe/Berlin";
// Phase 1: unpacking
await writeProgress(prisma, importJobId, "unpacking", {
currentPhase: "unpacking",
recordsRead: 0,
rowsUpserted: 0,
percent: null,
elapsedMs: 0,
});
const unzip = extractExportXml(uploadPath);
// Phase 2 + 3: parsing + upserting (the parser tracks both
// phases internally via the onProgress hook).
await writeProgress(prisma, importJobId, "parsing", {
currentPhase: "parsing",
recordsRead: 0,
rowsUpserted: 0,
percent: null,
elapsedMs: 0,
});
const result: ImportJobResult = await streamParseExportXml({
xmlPath: unzip.xmlPath,
userId,
userTimezone,
prisma,
onProgress: async (snapshot) => {
// The phase label here is what the polling endpoint surfaces;
// map "parsing" / "upserting" through verbatim.
await prisma.importJob.update({
where: { id: importJobId },
data: {
status: snapshot.currentPhase,
progress: snapshot as unknown as Prisma.InputJsonValue,
},
});
},
});
// Done. Persist the terminal envelope.
await prisma.importJob.update({
where: { id: importJobId },
data: {
status: "done",
completedAt: new Date(),
progress: {
currentPhase: "upserting",
recordsRead: result.totals.recordsRead,
rowsUpserted: result.totals.rowsUpserted,
percent: 100,
elapsedMs: result.totals.durationMs,
} as unknown as Prisma.InputJsonValue,
result: result as unknown as Prisma.InputJsonValue,
},
});
// Best-effort cleanup. A failed unlink is not fatal — `/tmp` is
// periodically swept on the host.
safeUnlink(unzip.xmlPath);
safeUnlink(uploadPath);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await prisma.importJob.update({
where: { id: importJobId },
data: {
status: "failed",
failureReason: reason.slice(0, 1000),
completedAt: new Date(),
},
});
safeUnlink(uploadPath);
throw err;
}
}
function safeUnlink(path: string): void {
try {
unlinkSync(path);
} catch {
// ignore — file may already be gone
}
}
/**
* Reconcile orphan `ImportJob` rows on worker startup. A row stuck
* in `parsing` or `upserting` whose `pgBossJobId` is no longer alive
* on the boss side means the worker was killed mid-run; flip it to
* `failed` with `interrupted_by_restart` so the operator can re-run
* the upload. Idempotent — re-running this on a clean startup is a
* no-op.
*
* Exported so the worker boot path in `reminder-worker.ts` can wire
* it into the start-up sequence right after `boss.start()`.
*/
export async function reconcileOrphanImportJobs(): Promise<void> {
const prisma = getWorkerPrisma();
await prisma.importJob.updateMany({
where: {
status: { in: ["unpacking", "parsing", "upserting"] },
},
data: {
status: "failed",
failureReason: "interrupted_by_restart",
completedAt: new Date(),
},
});
}
+37
View File
@@ -45,6 +45,13 @@ import {
MEDICATION_INVENTORY_EXPIRE_CRON,
type MedicationInventoryExpirePayload,
} from "@/lib/jobs/medication-inventory-expire";
import {
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_CONCURRENCY,
handleAppleHealthImport,
reconcileOrphanImportJobs,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
import { deleteMessage } from "@/lib/telegram";
@@ -1451,12 +1458,27 @@ export async function startReminderWorker() {
FEEDBACK_AGGREGATOR_QUEUE,
PR_DETECTION_QUEUE,
MEDICATION_INVENTORY_EXPIRE_QUEUE,
APPLE_HEALTH_IMPORT_QUEUE,
];
for (const q of allQueues) {
await boss.createQueue(q);
}
// v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when
// the worker last shut down. Flips orphaned rows to `failed` so the
// operator can re-upload without leaving the polling endpoint
// stuck on `parsing` / `upserting`.
try {
await reconcileOrphanImportJobs();
} catch (err) {
workerLog(
"error",
"Failed to reconcile orphan ImportJob rows",
err,
);
}
// Schedule recurring cron jobs
const schedules: [string, string][] = [
[QUEUE_NAME, CHECK_INTERVAL_CRON],
@@ -1600,6 +1622,21 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleMedicationInventoryExpire,
);
// v1.4.34 — Apple Health export.zip ingest worker. localConcurrency
// caps at 1 because the parse loop is CPU-bound and a concurrent
// second 1 GB import would race the first for RSS.
await boss.work<AppleHealthImportPayload>(
APPLE_HEALTH_IMPORT_QUEUE,
{ localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY },
async (jobs) => {
// pg-boss v12 work callbacks always receive an array (batched
// worker mode); for our concurrency-1 + batchSize-1 case we just
// process each job sequentially.
for (const job of jobs) {
await handleAppleHealthImport(job);
}
},
);
return boss;
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
HK_WORKOUT_ACTIVITY_TYPE_MAP,
resolveHkWorkoutSportType,
} from "../hk-workout-activity-type-map";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
describe("HK_WORKOUT_ACTIVITY_TYPE_MAP", () => {
it("only references sport types that exist in the canonical enum", () => {
const valid = new Set<string>(workoutSportTypeEnum.options);
for (const [hkType, sport] of Object.entries(
HK_WORKOUT_ACTIVITY_TYPE_MAP,
)) {
expect(
valid.has(sport),
`${hkType} maps to '${sport}', not in workoutSportTypeEnum`,
).toBe(true);
}
});
it("covers the iOS 18 canonical roster", () => {
// Spot-check the most commonly observed identifiers; full
// exhaustiveness is impractical because Apple silently adds
// identifiers between iOS releases.
const canonical = [
"HKWorkoutActivityTypeRunning",
"HKWorkoutActivityTypeWalking",
"HKWorkoutActivityTypeCycling",
"HKWorkoutActivityTypeHiking",
"HKWorkoutActivityTypeSwimming",
"HKWorkoutActivityTypeYoga",
"HKWorkoutActivityTypeHighIntensityIntervalTraining",
"HKWorkoutActivityTypeFunctionalStrengthTraining",
"HKWorkoutActivityTypeOther",
];
for (const hkType of canonical) {
expect(
HK_WORKOUT_ACTIVITY_TYPE_MAP[hkType],
`${hkType} missing from map`,
).toBeDefined();
}
});
});
describe("resolveHkWorkoutSportType", () => {
it("returns the mapped sport and a `known` flag for a known identifier", () => {
expect(resolveHkWorkoutSportType("HKWorkoutActivityTypeRunning")).toEqual({
sportType: "running",
known: true,
});
});
it("falls back to 'other' with a falsy known flag", () => {
const out = resolveHkWorkoutSportType("HKWorkoutActivityTypeFutureTypeXYZ");
expect(out.sportType).toBe("other");
expect(out.known).toBe(false);
});
});
@@ -0,0 +1,432 @@
import { describe, expect, it } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
hashSampleKey,
parseRecordValue,
streamParseExportXml,
SLEEP_STAGE_NAME_TO_CODEPOINT,
APPLE_HEALTH_SLEEP_STAGE_MAP,
type ImportJobProgress,
} from "../import-apple-health-export";
/**
* Build a minimal hand-authored `export.xml` covering the surface area
* the parser must handle end-to-end: spot quantity (weight), cumulative
* quantity (steps), sleep stage, audio-exposure event, workout,
* deferred identifier, unknown identifier, clinical record.
*/
function tinyExportXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE HealthData [<!ELEMENT HealthData (Record|Workout|ClinicalRecord)*>]>
<HealthData locale="en_US">
<ExportDate value="2026-05-15 14:32:01 +0200"/>
<Me HKCharacteristicTypeIdentifierDateOfBirth="1985-06-12"/>
<Record type="HKQuantityTypeIdentifierBodyMass"
unit="kg"
startDate="2026-05-14 08:13:00 +0200"
endDate="2026-05-14 08:14:00 +0200"
value="78.4"
sourceName="Withings Scale"
sourceVersion="2.4.1"/>
<Record type="HKQuantityTypeIdentifierStepCount"
unit="count"
startDate="2026-05-14 08:00:00 +0200"
endDate="2026-05-14 08:30:00 +0200"
value="1500"
sourceName="Marc's iPhone"
sourceVersion="17.4.1"/>
<Record type="HKQuantityTypeIdentifierStepCount"
unit="count"
startDate="2026-05-14 12:00:00 +0200"
endDate="2026-05-14 12:30:00 +0200"
value="3500"
sourceName="Marc's iPhone"
sourceVersion="17.4.1"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis"
unit=""
startDate="2026-05-14 23:00:00 +0200"
endDate="2026-05-15 06:30:00 +0200"
value="HKCategoryValueSleepAnalysisAsleepDeep"
sourceName="Apple Watch"
sourceVersion="11.2"/>
<Workout workoutActivityType="HKWorkoutActivityTypeRunning"
duration="42.0"
durationUnit="min"
totalDistance="6.5"
totalDistanceUnit="km"
totalEnergyBurned="412"
totalEnergyBurnedUnit="kcal"
startDate="2026-05-14 18:00:00 +0200"
endDate="2026-05-14 18:42:00 +0200"
sourceName="Apple Watch"/>
<Record type="HKQuantityTypeIdentifierDietaryWater"
unit="L"
startDate="2026-05-14 10:00:00 +0200"
endDate="2026-05-14 10:00:00 +0200"
value="0.5"
sourceName="Health app"/>
<Record type="HKQuantityTypeIdentifierFutureTypeXYZ"
unit="??"
startDate="2026-05-14 10:00:00 +0200"
endDate="2026-05-14 10:00:00 +0200"
value="1"/>
<ClinicalRecord type="HKClinicalTypeIdentifierLabResultRecord"
identifier="lab-1"
fhirResource="{}"/>
</HealthData>`;
}
/**
* In-memory Prisma stand-in. The parser calls only the `measurement`
* and `workout` model methods documented on `StreamParseInput.prisma`;
* we mimic just those.
*/
function makeFakePrisma() {
type Row = Record<string, unknown>;
const measurements: Row[] = [];
const workouts: Row[] = [];
return {
_measurements: measurements,
_workouts: workouts,
measurement: {
findMany: async ({
where,
}: {
where: { userId: string; source: string; OR: Array<Record<string, unknown>> };
}) => {
return measurements.filter((m) => {
if (m.userId !== where.userId || m.source !== where.source) return false;
return where.OR.some(
(clause) => m.type === clause.type && m.externalId === clause.externalId,
);
}).map((m) => ({ type: m.type, externalId: m.externalId }));
},
findUnique: async ({
where,
}: {
where: { userId_type_source_externalId: Record<string, unknown> };
}) => {
const key = where.userId_type_source_externalId;
return measurements.find(
(m) =>
m.userId === key.userId
&& m.type === key.type
&& m.source === key.source
&& m.externalId === key.externalId,
) ?? null;
},
upsert: async ({
where,
create,
update,
}: {
where: { userId_type_source_externalId: Record<string, unknown> };
create: Row;
update: Row;
}) => {
const key = where.userId_type_source_externalId;
const idx = measurements.findIndex(
(m) =>
m.userId === key.userId
&& m.type === key.type
&& m.source === key.source
&& m.externalId === key.externalId,
);
if (idx >= 0) {
measurements[idx] = { ...measurements[idx], ...update };
return measurements[idx];
}
measurements.push(create);
return create;
},
},
workout: {
findMany: async ({
where,
}: {
where: { userId: string; source: string; externalId: { in: string[] } };
}) => {
return workouts.filter(
(w) =>
w.userId === where.userId
&& w.source === where.source
&& where.externalId.in.includes(w.externalId as string),
).map((w) => ({ externalId: w.externalId }));
},
upsert: async ({
where,
create,
update,
}: {
where: { userId_source_externalId: Record<string, unknown> };
create: Row;
update: Row;
}) => {
const key = where.userId_source_externalId;
const idx = workouts.findIndex(
(w) =>
w.userId === key.userId
&& w.source === key.source
&& w.externalId === key.externalId,
);
if (idx >= 0) {
workouts[idx] = { ...workouts[idx], ...update };
return workouts[idx];
}
workouts.push(create);
return create;
},
},
};
}
describe("parseRecordValue", () => {
it("parses a quantity-record float value", () => {
expect(
parseRecordValue(
"HKQuantityTypeIdentifierBodyMass",
"78.4",
"2026-05-14 08:13:00 +0200",
"2026-05-14 08:14:00 +0200",
),
).toEqual({ value: 78.4 });
});
it("maps a sleep-stage symbolic name to its integer codepoint", () => {
const result = parseRecordValue(
"HKCategoryTypeIdentifierSleepAnalysis",
"HKCategoryValueSleepAnalysisAsleepDeep",
"2026-05-14 23:00:00 +0200",
"2026-05-15 06:30:00 +0200",
);
expect(result?.sleepStage).toBe(4);
// duration should be (6:30 + 1 day - 23:00) = 7h30m = 450 minutes
expect(result?.value).toBeGreaterThan(449);
expect(result?.value).toBeLessThan(451);
});
it("returns null for an unparseable quantity", () => {
expect(
parseRecordValue(
"HKQuantityTypeIdentifierBodyMass",
"not-a-number",
"2026-05-14 08:13:00 +0200",
"2026-05-14 08:14:00 +0200",
),
).toBeNull();
});
it("treats audio-exposure events as 1-count categoricals", () => {
const result = parseRecordValue(
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
"",
"2026-05-14 12:00:00 +0200",
"2026-05-14 12:00:00 +0200",
);
expect(result).toEqual({ value: 1 });
});
});
describe("SLEEP_STAGE_NAME_TO_CODEPOINT", () => {
it("round-trips with APPLE_HEALTH_SLEEP_STAGE_MAP", () => {
// Every value in the name map must resolve via the canonical
// codepoint map back to a SleepStage.
for (const [name, codepoint] of Object.entries(
SLEEP_STAGE_NAME_TO_CODEPOINT,
)) {
const stage = APPLE_HEALTH_SLEEP_STAGE_MAP[codepoint];
expect(stage, `${name} → ${codepoint}`).toBeDefined();
}
});
});
describe("hashSampleKey", () => {
it("returns a stable short hash for the same inputs", () => {
const a = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.4",
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
const b = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.4",
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
expect(a).toBe(b);
expect(a).toMatch(/^sample:[0-9a-f]{28}$/);
});
it("differs when any input differs", () => {
const a = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.4",
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
const b = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.5",
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
expect(a).not.toBe(b);
});
});
describe("streamParseExportXml — end-to-end", () => {
it("ingests the tiny fixture into per-type / workout / deferred / unknown buckets", async () => {
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
const xmlPath = join(tmp, "export.xml");
writeFileSync(xmlPath, tinyExportXml());
const prisma = makeFakePrisma();
const progressSnapshots: ImportJobProgress[] = [];
const result = await streamParseExportXml({
xmlPath,
userId: "user-1",
userTimezone: "Europe/Berlin",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
onProgress: (snap) => {
progressSnapshots.push(snap);
},
spotBatchSize: 2,
workoutBatchSize: 1,
});
// Spot row — weight upsert landed
expect(result.perType.WEIGHT?.inserted).toBe(1);
expect(result.perType.WEIGHT?.updated).toBe(0);
// Cumulative — both step records collapsed into one daily bucket
expect(result.perType.ACTIVITY_STEPS?.inserted).toBe(1);
expect(result.perType.ACTIVITY_STEPS?.read).toBe(2);
// The flushed daily-stats row should sum the two records:
const stepsRow = prisma._measurements.find(
(m) => m.type === "ACTIVITY_STEPS",
);
expect(stepsRow?.value).toBe(5000);
expect(stepsRow?.externalId).toMatch(
/^stats:HKQuantityTypeIdentifierStepCount:\d{4}-\d{2}-\d{2}$/,
);
// Sleep — translated through the sleep-stage table
expect(result.perType.SLEEP_DURATION?.inserted).toBe(1);
// Workout — single running entry
expect(result.workouts.read).toBe(1);
expect(result.workouts.inserted).toBe(1);
expect(result.workouts.unknownActivityType).toBe(0);
const workoutRow = prisma._workouts[0];
expect(workoutRow?.sportType).toBe("running");
// 6.5 km → 6500 m
expect(workoutRow?.totalDistanceM).toBe(6500);
// Deferred identifier — DietaryWater landed in deferred, not unknown
expect(result.deferred.HKQuantityTypeIdentifierDietaryWater).toBe(1);
expect(result.unknown.HKQuantityTypeIdentifierDietaryWater).toBeUndefined();
// Unknown identifier — surfaced under unknown
expect(result.unknown.HKQuantityTypeIdentifierFutureTypeXYZ).toBe(1);
// Clinical record — skipped
expect(result.clinical.skipped).toBe(1);
// Progress hook fired at least once
expect(progressSnapshots.length).toBeGreaterThan(0);
// Totals consistent
expect(result.totals.recordsRead).toBeGreaterThanOrEqual(7);
});
it("re-importing the same export reports 0 inserts and N updates", async () => {
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
const xmlPath = join(tmp, "export.xml");
writeFileSync(xmlPath, tinyExportXml());
const prisma = makeFakePrisma();
await streamParseExportXml({
xmlPath,
userId: "user-1",
userTimezone: "Europe/Berlin",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
const secondRun = await streamParseExportXml({
xmlPath,
userId: "user-1",
userTimezone: "Europe/Berlin",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
// Second run — every row that landed the first time should
// update rather than insert.
expect(secondRun.perType.WEIGHT?.inserted).toBe(0);
expect(secondRun.perType.WEIGHT?.updated).toBe(1);
expect(secondRun.perType.ACTIVITY_STEPS?.inserted).toBe(0);
expect(secondRun.perType.ACTIVITY_STEPS?.updated).toBe(1);
expect(secondRun.workouts.inserted).toBe(0);
expect(secondRun.workouts.updated).toBe(1);
});
});
/**
* Streaming-memory ceiling: synthesise a ~5 MB export and assert peak
* heap stays bounded. Smaller than the §11 §5 fixture goal (50 MB) to
* keep CI iteration fast; the property under test is identical.
*/
describe("streamParseExportXml — memory ceiling", () => {
it("bounded peak heap on a multi-thousand-row synthetic export", async () => {
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
const xmlPath = join(tmp, "export.xml");
// ~10 000 step records — each ~250 bytes → ~2.5 MB. Synthetic
// timestamps walk seconds within a single day so every record has
// a valid wall-clock and lands in the same cumulative bucket.
const header =
`<?xml version="1.0" encoding="UTF-8"?>\n`
+ `<!DOCTYPE HealthData [<!ELEMENT HealthData (Record)*>]>\n`
+ `<HealthData locale="en_US">\n`;
const footer = `</HealthData>\n`;
const rows: string[] = [];
for (let i = 0; i < 10_000; i++) {
const hour = Math.floor(i / 3600) % 24;
const minute = Math.floor((i % 3600) / 60);
const second = i % 60;
const ts = `2026-05-14 ${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")} +0200`;
rows.push(
`<Record type="HKQuantityTypeIdentifierStepCount"`
+ ` unit="count"`
+ ` startDate="${ts}"`
+ ` endDate="${ts}"`
+ ` value="${(i % 200) + 1}"/>\n`,
);
}
writeFileSync(xmlPath, header + rows.join("") + footer);
const bytes = readFileSync(xmlPath).length;
expect(bytes).toBeGreaterThan(1_000_000);
const beforeHeap = process.memoryUsage().heapUsed;
const prisma = makeFakePrisma();
const result = await streamParseExportXml({
xmlPath,
userId: "user-1",
userTimezone: "Europe/Berlin",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
const afterHeap = process.memoryUsage().heapUsed;
const heapDelta = afterHeap - beforeHeap;
expect(result.totals.recordsRead).toBe(10_000);
expect(result.perType.ACTIVITY_STEPS?.read).toBe(10_000);
// The cumulative fold collapses all 10 000 records into one daily
// bucket (they all land on the same date in the fixture).
expect(result.perType.ACTIVITY_STEPS?.inserted).toBe(1);
// Heap delta should be modest — we tolerate a generous 100 MB so a
// GC-related spike doesn't flake the test, but a regression that
// buffers per-record would push us above this ceiling fast.
expect(heapDelta).toBeLessThan(100 * 1024 * 1024);
});
});
@@ -0,0 +1,141 @@
/**
* `HKWorkoutActivityType` → HealthLog `WorkoutSportType` lookup table.
*
* Apple's `HKWorkoutActivityType` is an `NS_ENUM(NSUInteger, ...)` with
* 75+ members at iOS 18. Only the canonical roster surfaced by the
* Health app export (`workoutActivityType="HKWorkoutActivityType*"`)
* gets a first-class entry here; anything outside the table falls
* through to `"other"` — the escape hatch already on the
* `workoutSportTypeEnum` union in `src/lib/validations/workout.ts`.
*
* Adding a new sport: append the literal to `workoutSportTypeEnum`,
* then add the HK activity-type entry here. The unit test under
* `__tests__/hk-workout-activity-type-map.test.ts` enforces that every
* value referenced here exists in the canonical union.
*
* Source: Apple's `HealthKit/HKWorkoutActivityType.h` header (iOS 18
* SDK) cross-checked against the activity strings observed in
* open-source Apple Health export corpora — `apple-health-grafana`,
* `healthkit-to-sqlite`, `apple-health-parser`. Locked per
* `.planning/research/v1434-r-1-xml-import.md` §7.
*/
import type { WorkoutSportType } from "@/lib/validations/workout";
/**
* Map every known `HKWorkoutActivityType` value (the literal string
* Apple writes to `Workout.workoutActivityType` in `export.xml`) to a
* HealthLog sport-type union member. Unknown identifiers fall through
* to `"other"` at the call site; the parser increments the
* `workouts.unknownActivityType` counter so operators can spot new
* iOS releases that introduce a sport this table does not know yet.
*/
export const HK_WORKOUT_ACTIVITY_TYPE_MAP: Record<string, WorkoutSportType> = {
// ── Endurance ───────────────────────────────────────────────
HKWorkoutActivityTypeWalking: "walking",
HKWorkoutActivityTypeRunning: "running",
HKWorkoutActivityTypeCycling: "cycling",
HKWorkoutActivityTypeHiking: "hiking",
HKWorkoutActivityTypeSwimming: "swimming",
HKWorkoutActivityTypeSwimBikeRun: "crossTraining",
HKWorkoutActivityTypeRowing: "rowing",
HKWorkoutActivityTypeElliptical: "elliptical",
HKWorkoutActivityTypeStairClimbing: "stairClimber",
HKWorkoutActivityTypeStairs: "stairClimber",
HKWorkoutActivityTypeStepTraining: "stairClimber",
// ── Mind & body ─────────────────────────────────────────────
HKWorkoutActivityTypeYoga: "yoga",
HKWorkoutActivityTypeMindAndBody: "mindAndBody",
HKWorkoutActivityTypeFlexibility: "mindAndBody",
HKWorkoutActivityTypePilates: "mindAndBody",
HKWorkoutActivityTypeTaiChi: "mindAndBody",
HKWorkoutActivityTypeBarre: "mindAndBody",
HKWorkoutActivityTypeCooldown: "mindAndBody",
HKWorkoutActivityTypePreparationAndRecovery: "mindAndBody",
// ── Strength ────────────────────────────────────────────────
HKWorkoutActivityTypeFunctionalStrengthTraining: "strength",
HKWorkoutActivityTypeTraditionalStrengthTraining: "strength",
HKWorkoutActivityTypeCoreTraining: "strength",
HKWorkoutActivityTypeKickboxing: "strength",
HKWorkoutActivityTypeMartialArts: "strength",
// ── High intensity / mixed ─────────────────────────────────
HKWorkoutActivityTypeHighIntensityIntervalTraining: "hiit",
HKWorkoutActivityTypeCrossTraining: "crossTraining",
HKWorkoutActivityTypeMixedCardio: "mixedCardio",
HKWorkoutActivityTypeMixedMetabolicCardioTraining: "mixedCardio",
HKWorkoutActivityTypeJumpRope: "hiit",
// ── Dance ───────────────────────────────────────────────────
HKWorkoutActivityTypeDance: "dance",
HKWorkoutActivityTypeDanceInspiredTraining: "dance",
HKWorkoutActivityTypeSocialDance: "dance",
HKWorkoutActivityTypeCardioDance: "dance",
// ── Field & ball sports ─────────────────────────────────────
HKWorkoutActivityTypeGolf: "golf",
HKWorkoutActivityTypeTennis: "tennis",
HKWorkoutActivityTypeBasketball: "basketball",
HKWorkoutActivityTypeSoccer: "soccer",
HKWorkoutActivityTypeAmericanFootball: "soccer",
HKWorkoutActivityTypeAustralianFootball: "soccer",
HKWorkoutActivityTypeRugby: "soccer",
HKWorkoutActivityTypeBaseball: "tennis",
HKWorkoutActivityTypeCricket: "tennis",
HKWorkoutActivityTypeBadminton: "tennis",
HKWorkoutActivityTypeRacquetball: "tennis",
HKWorkoutActivityTypeSquash: "tennis",
HKWorkoutActivityTypeTableTennis: "tennis",
HKWorkoutActivityTypeVolleyball: "tennis",
HKWorkoutActivityTypeHandball: "tennis",
HKWorkoutActivityTypeHockey: "tennis",
HKWorkoutActivityTypeLacrosse: "tennis",
HKWorkoutActivityTypeSoftball: "tennis",
HKWorkoutActivityTypePaddleSports: "rowing",
HKWorkoutActivityTypePickleball: "tennis",
// ── Snow / ice / water ──────────────────────────────────────
HKWorkoutActivityTypeDownhillSkiing: "other",
HKWorkoutActivityTypeCrossCountrySkiing: "other",
HKWorkoutActivityTypeSnowboarding: "other",
HKWorkoutActivityTypeSnowSports: "other",
HKWorkoutActivityTypeSkatingSports: "other",
HKWorkoutActivityTypeWaterFitness: "swimming",
HKWorkoutActivityTypeWaterPolo: "swimming",
HKWorkoutActivityTypeWaterSports: "swimming",
HKWorkoutActivityTypeSurfingSports: "other",
HKWorkoutActivityTypeSailing: "other",
HKWorkoutActivityTypeFishing: "other",
// ── Wheelchair ──────────────────────────────────────────────
HKWorkoutActivityTypeWheelchairWalkPace: "walking",
HKWorkoutActivityTypeWheelchairRunPace: "running",
HKWorkoutActivityTypeHandCycling: "cycling",
// ── Misc / catch-all ───────────────────────────────────────
HKWorkoutActivityTypeBoxing: "strength",
HKWorkoutActivityTypeFencing: "other",
HKWorkoutActivityTypeArchery: "other",
HKWorkoutActivityTypeBowling: "other",
HKWorkoutActivityTypeClimbing: "other",
HKWorkoutActivityTypeEquestrianSports: "other",
HKWorkoutActivityTypeGymnastics: "other",
HKWorkoutActivityTypeHunting: "other",
HKWorkoutActivityTypePlay: "other",
HKWorkoutActivityTypeOther: "other",
};
/**
* Resolve a `WorkoutSportType` for the given `HKWorkoutActivityType`
* identifier string. Returns `"other"` and a falsy `known` flag for
* identifiers outside the table so the call site can increment its
* `unknownActivityType` counter without losing the row.
*/
export function resolveHkWorkoutSportType(
activityType: string,
): { sportType: WorkoutSportType; known: boolean } {
const sportType = HK_WORKOUT_ACTIVITY_TYPE_MAP[activityType];
if (sportType) return { sportType, known: true };
return { sportType: "other", known: false };
}
@@ -0,0 +1,767 @@
/**
* Streaming Apple Health `export.xml` parser + ingest mapper.
*
* Reads an `export.xml` byte-stream via `sax` (event-driven SAX, no
* DOM) and folds every `<Record>`, `<Workout>`, and `<Correlation>`
* element into the row shape the existing `Measurement` and
* `Workout` models expect. Cumulative-quantity `<Record>` rows
* (steps, energy, distance, flights, daylight) collapse into one
* `stats:<HKType>:<YYYY-MM-DD>` `Measurement` per user-local day
* — mirroring iOS's `HKStatisticsCollectionQuery` daily-aggregation
* convention locked in v1.4.30. Spot rows (BP, weight, HRV, …)
* survive verbatim, keyed by `HKSample.uuid` when present.
*
* Memory profile: SAX callbacks fire as the byte cursor advances,
* so peak RSS stays bounded regardless of the input file size. The
* cumulative-bucket map holds at most one `(type, day)` entry per
* observed day per cumulative type — a 10-year export with five
* cumulative types lands at ~18 000 entries (~1 MB).
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
*/
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import { Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import sax from "sax";
import { Prisma } from "@/generated/prisma/client";
import type {
MeasurementType,
PrismaClient,
} from "@/generated/prisma/client";
import {
APPLE_HEALTH_SLEEP_STAGE_MAP,
APPLE_HEALTH_TYPE_MAP,
CUMULATIVE_HK_TYPES,
HK_QUANTITY_TYPE_DEFERRED,
dailyStatsExternalId,
mapAppleHealthEntry,
} from "@/lib/measurements/apple-health-mapping";
import {
dayKeyForUserTz,
canonicalDailyTimestamp,
} from "@/lib/measurements/drain-per-sample-cumulative";
import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map";
import { validateMeasurementRange } from "@/lib/validations/measurement";
/**
* Reverse-lookup for the symbolic `HKCategoryValueSleepAnalysis*`
* names Apple writes to `Record[type="HKCategoryTypeIdentifierSleepAnalysis"]`
* `value` attributes. The web batch endpoint receives the integer
* codepoint pre-resolved by iOS; the XML export carries the symbolic
* name. Mirrors `APPLE_HEALTH_SLEEP_STAGE_MAP` inverted by stage
* label so the existing `mapAppleHealthEntry()` path picks up the
* integer codepoint without further translation.
*/
const SLEEP_STAGE_NAME_TO_CODEPOINT: Record<string, number> = {
HKCategoryValueSleepAnalysisInBed: 0,
HKCategoryValueSleepAnalysisAsleep: 1,
HKCategoryValueSleepAnalysisAsleepUnspecified: 1,
HKCategoryValueSleepAnalysisAwake: 2,
HKCategoryValueSleepAnalysisAsleepCore: 3,
HKCategoryValueSleepAnalysisAsleepDeep: 4,
HKCategoryValueSleepAnalysisAsleepREM: 5,
};
/** Phase the worker is currently in. */
export type ImportJobPhase =
| "queued"
| "unpacking"
| "parsing"
| "upserting"
| "done"
| "failed";
/**
* Live snapshot the worker writes to `ImportJob.progress` every
* `PROGRESS_TICK_RECORDS` records parsed. The polling endpoint
* returns it verbatim.
*/
export interface ImportJobProgress {
currentPhase: Exclude<ImportJobPhase, "queued" | "done" | "failed">;
recordsRead: number;
rowsUpserted: number;
/** Percent is best-effort and may stay null until the parser sees
* `</HealthData>` (we don't know the total record count up-front). */
percent: number | null;
elapsedMs: number;
}
/** Final outcome carried on `ImportJob.result` once terminal. */
export interface ImportJobResult {
perType: Record<
string,
{ read: number; inserted: number; updated: number; durationMs: number }
>;
workouts: {
read: number;
inserted: number;
updated: number;
unknownActivityType: number;
routesAttached: number;
durationMs: number;
};
clinical: { skipped: number };
deferred: Record<string, number>;
unknown: Record<string, number>;
totals: {
recordsRead: number;
rowsUpserted: number;
durationMs: number;
};
}
/** Flush row written to the `Measurement` table. */
interface PreparedMeasurement {
userId: string;
type: MeasurementType;
value: number;
unit: string;
measuredAt: Date;
externalId: string;
externalSourceVersion: string | null;
sleepStage: Prisma.MeasurementCreateInput["sleepStage"];
deviceType: string | null;
}
/** Flush row written to the `Workout` table. */
interface PreparedWorkout {
userId: string;
sportType: string;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
externalId: string;
externalSourceVersion: string | null;
metadata: Prisma.JsonValue | null;
}
/** Per-type running stats accumulator. */
interface MutableTypeStat {
read: number;
inserted: number;
updated: number;
durationMs: number;
}
/** Tick frequency for the live `ImportJob.progress` write. */
export const PROGRESS_TICK_RECORDS = 1_000;
const SPOT_FLUSH_BATCH = 500;
const WORKOUT_FLUSH_BATCH = 100;
/**
* Hash the upload to a deterministic `externalId` when no
* `HKMetadataKeyExternalUUID` is present. Truncated to 28 hex chars
* (~112 bits) — well inside the 120-char Zod cap on `externalId`,
* and orders of magnitude more collision budget than any reasonable
* export could need.
*/
export function hashSampleKey(
hkIdentifier: string,
value: number | string,
startDate: string,
endDate: string,
): string {
return (
"sample:" +
createHash("sha256")
.update(`${hkIdentifier}|${value}|${startDate}|${endDate}`)
.digest("hex")
.slice(0, 28)
);
}
/**
* Parse the Apple `Record` `value` attribute. Quantity records carry
* a numeric string; sleep-analysis records carry the symbolic name.
* Returns `null` for unparseable values so the caller can skip the
* row rather than poisoning the batch.
*/
export function parseRecordValue(
hkIdentifier: string,
rawValue: string | undefined,
startDate: string,
endDate: string,
): { value: number; sleepStage?: number } | null {
if (rawValue === undefined) return null;
if (hkIdentifier === "HKCategoryTypeIdentifierSleepAnalysis") {
const stage = SLEEP_STAGE_NAME_TO_CODEPOINT[rawValue];
if (stage === undefined) return null;
// For sleep, our `mapAppleHealthEntry` expects the value to be the
// duration in minutes; derive it from start/end so the row carries
// the canonical reading downstream.
const start = new Date(startDate);
const end = new Date(endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return null;
}
const minutes = Math.max(0, (end.getTime() - start.getTime()) / 60_000);
return { value: minutes, sleepStage: stage };
}
if (hkIdentifier === "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent"
|| hkIdentifier === "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent") {
// Apple writes these category-type events with empty / sentinel
// value strings. `mapAppleHealthEntry()` ignores the inbound
// number and always converts to a 1.0 count.
return { value: 1 };
}
const parsed = Number.parseFloat(rawValue);
if (!Number.isFinite(parsed)) return null;
return { value: parsed };
}
/**
* Input to `streamParseExportXml()`.
*/
export interface StreamParseInput {
/** Filesystem path to an unzipped `export.xml`. */
xmlPath: string;
/** Owner of the imported rows. */
userId: string;
/** IANA timezone, used to anchor cumulative-type day-keys. */
userTimezone: string;
/** Prisma client to flush rows through. */
prisma: Pick<PrismaClient, "measurement" | "workout">;
/**
* Live progress hook. Called every `PROGRESS_TICK_RECORDS` records
* read, and once on terminal `done`. Best-effort; the parser
* continues even if the hook throws.
*/
onProgress?: (snapshot: ImportJobProgress) => Promise<void> | void;
/**
* Optional override of the spot-row flush batch size. Defaults to
* `SPOT_FLUSH_BATCH`. Lower values make the upsert path more
* granular for tests; production runs should keep the default.
*/
spotBatchSize?: number;
/**
* Optional override of the workout flush batch size. Defaults to
* `WORKOUT_FLUSH_BATCH`.
*/
workoutBatchSize?: number;
}
/**
* Streaming parse of an Apple Health `export.xml`. Returns the
* terminal `ImportJobResult` envelope; mid-run updates flow through
* `onProgress`. Throws on a fatal SAX error.
*
* Idempotency contract: every row UPSERTs against the existing
* `(userId, type, source, externalId)` compound unique on
* `Measurement` and `(userId, source, externalId)` on `Workout`. A
* re-import of the exact same file reports 0 inserts and N updates.
*/
export async function streamParseExportXml(
input: StreamParseInput,
): Promise<ImportJobResult> {
const {
xmlPath,
userId,
userTimezone,
prisma,
onProgress,
spotBatchSize = SPOT_FLUSH_BATCH,
workoutBatchSize = WORKOUT_FLUSH_BATCH,
} = input;
const startedAt = Date.now();
const perType: Record<string, MutableTypeStat> = {};
const workouts = {
read: 0,
inserted: 0,
updated: 0,
unknownActivityType: 0,
routesAttached: 0,
durationMs: 0,
};
const clinical = { skipped: 0 };
const deferred: Record<string, number> = {};
const unknown: Record<string, number> = {};
// Cumulative-type fold: type -> dayKey -> running sum.
const cumulativeBucket = new Map<MeasurementType, Map<string, number>>();
// Spot-row batch awaiting flush.
const spotBatch: PreparedMeasurement[] = [];
// Workout-row batch awaiting flush.
const workoutBatch: PreparedWorkout[] = [];
let recordsRead = 0;
let rowsUpserted = 0;
// Per R-1 §8 the percent stays best-effort and may remain null
// until the parser sees the closing `</HealthData>` tag. We don't
// currently mutate this in v1.4.34 — the iOS app keeps polling on
// the elapsed-ms instead — but keep the field around so the
// progress envelope stays additive for future percent backfill.
const totalRecords: number | null = null;
const bumpStat = (type: MeasurementType): MutableTypeStat => {
const key = type as string;
let stat = perType[key];
if (!stat) {
stat = { read: 0, inserted: 0, updated: 0, durationMs: 0 };
perType[key] = stat;
}
return stat;
};
const emitProgress = async (
phase: ImportJobProgress["currentPhase"],
): Promise<void> => {
if (!onProgress) return;
const snapshot: ImportJobProgress = {
currentPhase: phase,
recordsRead,
rowsUpserted,
percent:
totalRecords && totalRecords > 0
? Math.min(99, Math.round((recordsRead / totalRecords) * 100))
: null,
elapsedMs: Date.now() - startedAt,
};
try {
await onProgress(snapshot);
} catch {
// best-effort; do not poison the parse loop
}
};
// ── Flush helpers ──────────────────────────────────────────
const flushSpotBatch = async (): Promise<void> => {
if (spotBatch.length === 0) return;
const chunk = spotBatch.splice(0, spotBatch.length);
// Look up existing rows by the compound unique key so we can
// attribute inserts vs updates accurately for the per-type stats.
const existingRows = await prisma.measurement.findMany({
where: {
userId,
source: "APPLE_HEALTH",
OR: chunk.map((r) => ({
type: r.type,
externalId: r.externalId,
})),
},
select: { type: true, externalId: true },
});
const existingKey = new Set(
existingRows.map((r) => `${r.type}::${r.externalId}`),
);
for (const row of chunk) {
const stat = bumpStat(row.type);
const rowStart = Date.now();
const exists = existingKey.has(`${row.type}::${row.externalId}`);
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: row.type,
source: "APPLE_HEALTH",
externalId: row.externalId,
},
},
create: {
userId,
type: row.type,
value: row.value,
unit: row.unit,
source: "APPLE_HEALTH",
measuredAt: row.measuredAt,
externalId: row.externalId,
externalSourceVersion: row.externalSourceVersion,
sleepStage: row.sleepStage ?? null,
deviceType: row.deviceType,
},
update: {
value: row.value,
measuredAt: row.measuredAt,
externalSourceVersion: row.externalSourceVersion,
sleepStage: row.sleepStage ?? null,
deviceType: row.deviceType,
},
});
stat.durationMs += Date.now() - rowStart;
if (exists) stat.updated += 1;
else stat.inserted += 1;
rowsUpserted += 1;
}
};
const flushWorkoutBatch = async (): Promise<void> => {
if (workoutBatch.length === 0) return;
const chunk = workoutBatch.splice(0, workoutBatch.length);
const existingRows = await prisma.workout.findMany({
where: {
userId,
source: "APPLE_HEALTH",
externalId: { in: chunk.map((r) => r.externalId) },
},
select: { externalId: true },
});
const existingKey = new Set(existingRows.map((r) => r.externalId));
for (const row of chunk) {
const rowStart = Date.now();
const exists = existingKey.has(row.externalId);
await prisma.workout.upsert({
where: {
userId_source_externalId: {
userId,
source: "APPLE_HEALTH",
externalId: row.externalId,
},
},
create: {
userId,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
totalEnergyKcal: row.totalEnergyKcal,
totalDistanceM: row.totalDistanceM,
source: "APPLE_HEALTH",
externalId: row.externalId,
externalSourceVersion: row.externalSourceVersion,
metadata: row.metadata ?? undefined,
},
update: {
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
totalEnergyKcal: row.totalEnergyKcal,
totalDistanceM: row.totalDistanceM,
externalSourceVersion: row.externalSourceVersion,
metadata: row.metadata ?? undefined,
},
});
workouts.durationMs += Date.now() - rowStart;
if (exists) workouts.updated += 1;
else workouts.inserted += 1;
rowsUpserted += 1;
}
};
const flushCumulativeBuckets = async (): Promise<void> => {
for (const [type, byDay] of cumulativeBucket.entries()) {
// Re-resolve the HK identifier from the mapping table — the
// bucket keys are MeasurementType, but the externalId carries
// the HK identifier so a future re-import collides on the
// same string.
const mapping = Object.values(APPLE_HEALTH_TYPE_MAP).find(
(m) => m.measurementType === type,
);
if (!mapping) continue;
const stat = bumpStat(type);
for (const [dayKey, sum] of byDay.entries()) {
const externalId = dailyStatsExternalId(mapping.hkIdentifier, dayKey);
const measuredAt = canonicalDailyTimestamp(dayKey, userTimezone);
const rowStart = Date.now();
const existing = await prisma.measurement.findUnique({
where: {
userId_type_source_externalId: {
userId,
type,
source: "APPLE_HEALTH",
externalId,
},
},
select: { id: true },
});
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type,
source: "APPLE_HEALTH",
externalId,
},
},
create: {
userId,
type,
value: sum,
unit: mapping.dbUnit,
source: "APPLE_HEALTH",
measuredAt,
externalId,
},
update: {
value: sum,
measuredAt,
},
});
stat.durationMs += Date.now() - rowStart;
if (existing) stat.updated += 1;
else stat.inserted += 1;
rowsUpserted += 1;
}
}
};
// ── SAX parser configuration ────────────────────────────────
const parser = sax.parser(true, { trim: true });
let pendingError: Error | null = null;
let currentWorkout: PreparedWorkout | null = null;
parser.onerror = (err) => {
pendingError = err instanceof Error ? err : new Error(String(err));
};
parser.onopentag = (node) => {
const name = node.name;
const attrs = node.attributes as Record<string, string>;
if (name === "Record") {
const hkType = attrs.type;
if (!hkType) return;
recordsRead += 1;
if (HK_QUANTITY_TYPE_DEFERRED.has(hkType)) {
deferred[hkType] = (deferred[hkType] ?? 0) + 1;
return;
}
const mapping = APPLE_HEALTH_TYPE_MAP[hkType];
if (!mapping) {
unknown[hkType] = (unknown[hkType] ?? 0) + 1;
return;
}
const parsedValue = parseRecordValue(
hkType,
attrs.value,
attrs.startDate,
attrs.endDate,
);
if (!parsedValue) {
unknown[`${hkType}::unparseable`] =
(unknown[`${hkType}::unparseable`] ?? 0) + 1;
return;
}
const mapped = mapAppleHealthEntry({
hkIdentifier: hkType,
value: parsedValue.value,
unit: attrs.unit ?? mapping.hkUnit,
startDate: attrs.startDate,
endDate: attrs.endDate,
sleepStage: parsedValue.sleepStage,
});
if (!mapped) {
unknown[`${hkType}::map_failed`] =
(unknown[`${hkType}::map_failed`] ?? 0) + 1;
return;
}
// Plausibility-range guard. A single rogue sample shouldn't
// poison the import — record under `unknown` with the
// explicit reason tag so operators can spot ingest pathologies.
const rangeError = validateMeasurementRange(mapped.type, mapped.value);
if (rangeError !== null) {
unknown[`${hkType}::out_of_range`] =
(unknown[`${hkType}::out_of_range`] ?? 0) + 1;
return;
}
const stat = bumpStat(mapped.type);
stat.read += 1;
if (CUMULATIVE_HK_TYPES.has(mapped.type)) {
const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone);
let byDay = cumulativeBucket.get(mapped.type);
if (!byDay) {
byDay = new Map();
cumulativeBucket.set(mapped.type, byDay);
}
byDay.set(dayKey, (byDay.get(dayKey) ?? 0) + mapped.value);
} else {
// Spot row: derive a stable externalId, queue for flush.
const externalId = hashSampleKey(
hkType,
attrs.value ?? "",
attrs.startDate,
attrs.endDate,
);
spotBatch.push({
userId,
type: mapped.type,
value: mapped.value,
unit: mapped.unit,
measuredAt: mapped.takenAt,
externalId,
externalSourceVersion: attrs.sourceVersion ?? null,
sleepStage: mapped.sleepStage ?? null,
deviceType: null,
});
}
return;
}
if (name === "Workout") {
recordsRead += 1;
workouts.read += 1;
const activityType = attrs.workoutActivityType ?? "";
const { sportType, known } = resolveHkWorkoutSportType(activityType);
if (!known) workouts.unknownActivityType += 1;
const startDate = new Date(attrs.startDate);
const endDate = new Date(attrs.endDate);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
unknown[`Workout::bad_dates`] =
(unknown[`Workout::bad_dates`] ?? 0) + 1;
return;
}
const durationSec = Math.max(
0,
Math.round((endDate.getTime() - startDate.getTime()) / 1000),
);
const totalDistance = Number.parseFloat(attrs.totalDistance ?? "");
const totalEnergy = Number.parseFloat(attrs.totalEnergyBurned ?? "");
const distanceUnit = attrs.totalDistanceUnit ?? "";
// Apple ships km for HKWorkout.totalDistance by default; metres
// is the canonical DB unit. Convert when the unit is km.
const distanceM = Number.isFinite(totalDistance)
? distanceUnit === "km"
? totalDistance * 1000
: distanceUnit === "mi"
? totalDistance * 1609.344
: totalDistance
: null;
const externalId = hashSampleKey(
activityType || "Workout",
attrs.duration ?? "",
attrs.startDate,
attrs.endDate,
);
currentWorkout = {
userId,
sportType,
startedAt: startDate,
endedAt: endDate,
durationSec,
totalEnergyKcal: Number.isFinite(totalEnergy) ? totalEnergy : null,
totalDistanceM: distanceM,
externalId,
externalSourceVersion: attrs.sourceVersion ?? null,
metadata: {
activityType,
sourceName: attrs.sourceName ?? null,
durationUnit: attrs.durationUnit ?? null,
totalDistanceUnit: distanceUnit || null,
totalEnergyBurnedUnit: attrs.totalEnergyBurnedUnit ?? null,
} as Prisma.JsonValue,
};
return;
}
if (name === "ClinicalRecord") {
recordsRead += 1;
clinical.skipped += 1;
return;
}
if (name === "ExportDate" || name === "Me" || name === "Correlation"
|| name === "ActivitySummary" || name === "MetadataEntry"
|| name === "WorkoutEvent" || name === "WorkoutRoute"
|| name === "FileReference" || name === "HealthData") {
// Known elements we intentionally ignore at the open-tag stage.
// Correlation envelopes flatten naturally because their child
// `<Record>` elements still fire their own `onopentag`.
return;
}
// Any other element name lands as a structural unknown — log
// once per name so operators can spot future schema additions.
unknown[`element::${name}`] = (unknown[`element::${name}`] ?? 0) + 1;
};
parser.onclosetag = async (tagName) => {
if (tagName === "Workout" && currentWorkout) {
workoutBatch.push(currentWorkout);
currentWorkout = null;
}
};
// ── Drive the parser from a node read stream ────────────────
const readable = createReadStream(xmlPath, { highWaterMark: 64 * 1024 });
let lastProgressEmitAt = 0;
const PROGRESS_EMIT_INTERVAL_MS = 250;
const sink = new Writable({
write(chunk: Buffer, _enc, callback) {
try {
parser.write(chunk.toString("utf8"));
if (pendingError) {
callback(pendingError);
return;
}
// Drain any batches the parser filled to keep memory bounded.
const shouldFlush =
spotBatch.length >= spotBatchSize
|| workoutBatch.length >= workoutBatchSize;
const drain = async (): Promise<void> => {
if (spotBatch.length >= spotBatchSize) await flushSpotBatch();
if (workoutBatch.length >= workoutBatchSize) await flushWorkoutBatch();
const now = Date.now();
if (
(recordsRead > 0 && recordsRead % PROGRESS_TICK_RECORDS === 0)
|| now - lastProgressEmitAt > PROGRESS_EMIT_INTERVAL_MS
) {
lastProgressEmitAt = now;
await emitProgress("parsing");
}
};
if (shouldFlush || recordsRead % PROGRESS_TICK_RECORDS === 0) {
drain().then(() => callback()).catch((err) => callback(err));
} else {
callback();
}
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
},
final(callback) {
try {
parser.close();
callback();
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
},
});
await pipeline(readable, sink);
if (pendingError) throw pendingError;
await emitProgress("upserting");
// Drain any partial spot/workout batches that did not hit the flush
// threshold during the parse.
await flushSpotBatch();
await flushWorkoutBatch();
await flushCumulativeBuckets();
await emitProgress("upserting");
return {
perType,
workouts,
clinical,
deferred,
unknown,
totals: {
recordsRead,
rowsUpserted,
durationMs: Date.now() - startedAt,
},
};
}
// Re-export the SLEEP_STAGE table for unit tests.
export { SLEEP_STAGE_NAME_TO_CODEPOINT, APPLE_HEALTH_SLEEP_STAGE_MAP };
+2 -2
View File
@@ -70,8 +70,8 @@ const WEEK_MS = 7 * DAY_MS;
* Match window for pairing an actual intake event to an expected
* dose: +/- 12 hours around the slot's center.
*
* Rationale: the existing classifyIntakeTiming uses a 1-hour grace
* before the window + ~2-hour late tolerance after. For cadence
* Rationale: the existing classifyIntakeTiming uses a 3-hour grace
* around the window + a configurable late tolerance after. For cadence
* visualisation we want a wider matching radius so users who logged a
* weekly shot a day late still see it paired with that week's slot
* (and the slot reads `taken`, not `missed` followed by an "extra").
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { readFileSync, unlinkSync } from "node:fs";
import {
parseBoundary,
streamMultipartToDisk,
} from "../stream-to-disk";
const BOUNDARY = "----HealthLogTestBoundary";
/**
* Build a multipart body covering the canonical Apple Health import
* upload shape: one named `file` field + one text `userId` field.
*/
function buildMultipartBody(
filename: string,
fileContent: Buffer,
textFields: Record<string, string> = {},
): Buffer {
const parts: Buffer[] = [];
for (const [name, value] of Object.entries(textFields)) {
parts.push(
Buffer.from(
`--${BOUNDARY}\r\n`
+ `Content-Disposition: form-data; name="${name}"\r\n\r\n`
+ value
+ `\r\n`,
),
);
}
parts.push(
Buffer.from(
`--${BOUNDARY}\r\n`
+ `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n`
+ `Content-Type: application/octet-stream\r\n\r\n`,
),
);
parts.push(fileContent);
parts.push(Buffer.from(`\r\n--${BOUNDARY}--\r\n`));
return Buffer.concat(parts);
}
function toWebStream(buf: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buf));
controller.close();
},
});
}
describe("parseBoundary", () => {
it("extracts a quoted boundary", () => {
expect(parseBoundary(`multipart/form-data; boundary="abc-123"`)).toBe(
"abc-123",
);
});
it("extracts an unquoted boundary", () => {
expect(parseBoundary(`multipart/form-data; boundary=abc-123`)).toBe(
"abc-123",
);
});
it("returns null on a missing header", () => {
expect(parseBoundary(null)).toBeNull();
expect(parseBoundary("application/json")).toBeNull();
});
});
describe("streamMultipartToDisk", () => {
it("streams the file field to disk + captures text fields + sha256", async () => {
const payload = Buffer.from("the quick brown fox jumps over the lazy dog");
const body = buildMultipartBody("export.zip", payload, { userId: "u-1" });
const result = await streamMultipartToDisk(
toWebStream(body),
`multipart/form-data; boundary=${BOUNDARY}`,
{
maxBytes: 1024,
fieldName: "file",
},
);
try {
expect(result.bytes).toBe(payload.length);
expect(result.originalFilename).toBe("export.zip");
expect(result.textFields.userId).toBe("u-1");
const written = readFileSync(result.filePath);
expect(written.equals(payload)).toBe(true);
// SHA-256 of the well-known phrase
expect(result.sha256).toBe(
"05c6e08f1d9fdafa03147fcb8f82f124c76d2f70e3d989dc8aadb5e7d7450bec",
);
} finally {
try { unlinkSync(result.filePath); } catch { /* ignore */ }
}
});
it("throws when the upload exceeds the size cap", async () => {
const payload = Buffer.alloc(2048, 0x41);
const body = buildMultipartBody("export.zip", payload);
await expect(
streamMultipartToDisk(
toWebStream(body),
`multipart/form-data; boundary=${BOUNDARY}`,
{ maxBytes: 100, fieldName: "file" },
),
).rejects.toThrow(/size cap/);
});
it("throws when the named field is missing", async () => {
const body = buildMultipartBody("export.zip", Buffer.from("x"), {
something: "else",
});
// Override the filename to a field name that's NOT the captured one
// by re-building without a file field at all.
const onlyText = Buffer.from(
`--${BOUNDARY}\r\n`
+ `Content-Disposition: form-data; name="userId"\r\n\r\n`
+ `u-1`
+ `\r\n`
+ `--${BOUNDARY}--\r\n`,
);
void body; // referenced above for shape demo
await expect(
streamMultipartToDisk(
toWebStream(onlyText),
`multipart/form-data; boundary=${BOUNDARY}`,
{ maxBytes: 1024, fieldName: "file" },
),
).rejects.toThrow(/file/);
});
it("throws when the boundary is missing from the header", async () => {
const body = buildMultipartBody("export.zip", Buffer.from("x"));
await expect(
streamMultipartToDisk(
toWebStream(body),
`application/json`,
{ maxBytes: 1024, fieldName: "file" },
),
).rejects.toThrow(/boundary/);
});
});
+289
View File
@@ -0,0 +1,289 @@
/**
* Stream a `multipart/form-data` body to disk without buffering the
* whole payload in memory.
*
* Next.js 16's `request.formData()` is fine up to ~10 MB but allocates
* the full body on a 1 GB upload. The Apple Health export ingest
* therefore reads `request.body` directly (a `ReadableStream<Uint8Array>`
* per Web Streams) and walks the multipart boundary by hand.
*
* Only the named `file` field of the upload is captured; any other
* field-name parts are read and discarded so the stream drains
* cleanly. The captured field's bytes are written to a temp file and
* a SHA-256 digest is computed in the same pass — the digest powers
* the content-hash idempotency check in the kick-off endpoint.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.4.
*/
import { createHash, randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Writable } from "node:stream";
export interface MultipartFileResult {
/** Absolute path the captured file landed at. */
filePath: string;
/** Total bytes streamed. */
bytes: number;
/** SHA-256 hex digest of the captured bytes. */
sha256: string;
/** Optional original filename pulled from `Content-Disposition`. */
originalFilename: string | null;
/** Other named text fields encountered in the multipart body. */
textFields: Record<string, string>;
}
export interface MultipartStreamOptions {
/** Maximum allowed file size in bytes (hard cap; throws on overflow). */
maxBytes: number;
/** Required multipart field name to capture. */
fieldName: string;
/** Temp directory; defaults to OS temp dir. */
tmpDir?: string;
/** Optional filename prefix; defaults to `healthlog-upload`. */
tmpPrefix?: string;
}
const CRLF = Buffer.from("\r\n");
const DOUBLE_CRLF = Buffer.from("\r\n\r\n");
/**
* Parse the multipart boundary from a `Content-Type` header. Returns
* `null` if the header is missing or unparseable.
*/
export function parseBoundary(contentType: string | null): string | null {
if (!contentType) return null;
const match = contentType.match(/boundary=(?:"([^"]+)"|([^;,\s]+))/i);
if (!match) return null;
return match[1] ?? match[2] ?? null;
}
/**
* Stream a multipart body from `req.body` to disk, returning the
* captured file's path + a SHA-256 digest. Throws on:
* - Missing / unparseable boundary.
* - Missing required field.
* - Size cap exceeded.
*/
export async function streamMultipartToDisk(
body: ReadableStream<Uint8Array>,
contentType: string | null,
opts: MultipartStreamOptions,
): Promise<MultipartFileResult> {
const boundary = parseBoundary(contentType);
if (!boundary) {
throw new Error("Multipart body missing boundary parameter");
}
const boundaryMarker = Buffer.from(`--${boundary}`);
const tmpDirectory = opts.tmpDir ?? tmpdir();
const filePath = join(
tmpDirectory,
`${opts.tmpPrefix ?? "healthlog-upload"}-${randomUUID()}.bin`,
);
const hash = createHash("sha256");
let bytesWritten = 0;
let originalFilename: string | null = null;
const textFields: Record<string, string> = {};
// Lazily created sink — opens once we have entered the matching part.
let fileSink: Writable | null = null;
let fileSinkOpen = false;
const ensureFileSink = (): Writable => {
if (!fileSink) {
fileSink = createWriteStream(filePath);
fileSinkOpen = true;
}
return fileSink;
};
// Buffer holding bytes awaiting boundary scan. Capped at twice the
// boundary length so we never accumulate more than necessary.
let buffer = Buffer.alloc(0);
// Current parser state:
// "preamble" — before the first boundary
// "headers" — inside a part's header block
// "body-file" — inside the captured file's body
// "body-text" — inside a text field's body
// "epilogue" — after the closing boundary
type ParserState = "preamble" | "headers" | "body-file" | "body-text" | "epilogue";
let state: ParserState = "preamble";
let currentFieldName: string | null = null;
let currentTextBuffer: Buffer[] = [];
const writeFileChunk = (chunk: Buffer): Promise<void> => {
return new Promise((resolve, reject) => {
if (bytesWritten + chunk.length > opts.maxBytes) {
reject(
new Error(
`Upload exceeds size cap of ${opts.maxBytes} bytes`,
),
);
return;
}
bytesWritten += chunk.length;
hash.update(chunk);
const sink = ensureFileSink();
sink.write(chunk, (err) => {
if (err) reject(err);
else resolve();
});
});
};
const closeFileSink = (): Promise<void> => {
return new Promise((resolve) => {
if (!fileSink || !fileSinkOpen) {
resolve();
return;
}
fileSinkOpen = false;
fileSink.end(() => resolve());
});
};
const reader = body.getReader();
let streamDone = false;
// The classic SAX-style boundary scanner: accumulate into `buffer`,
// search for the boundary marker, emit bytes up to it.
const processBuffer = async (): Promise<void> => {
while (true) {
if (state === "preamble") {
const idx = buffer.indexOf(boundaryMarker);
if (idx === -1) {
// Need more bytes to find a boundary; retain a small tail
// so a boundary split across chunks is still detectable.
if (buffer.length > boundaryMarker.length) {
buffer = buffer.slice(buffer.length - boundaryMarker.length);
}
return;
}
// Drop everything up to and including the boundary marker.
buffer = buffer.slice(idx + boundaryMarker.length);
// After the first boundary we expect "\r\n" or "--" (closing).
if (buffer.length < 2) return;
if (buffer[0] === 0x2d && buffer[1] === 0x2d) {
state = "epilogue";
return;
}
if (buffer[0] === 0x0d && buffer[1] === 0x0a) {
buffer = buffer.slice(2);
state = "headers";
continue;
}
return;
}
if (state === "headers") {
const eoh = buffer.indexOf(DOUBLE_CRLF);
if (eoh === -1) return;
const headerBlock = buffer.slice(0, eoh).toString("utf8");
buffer = buffer.slice(eoh + DOUBLE_CRLF.length);
const disposition = headerBlock
.split(/\r?\n/)
.find((l) => l.toLowerCase().startsWith("content-disposition:"));
if (!disposition) {
// Unparseable part — skip until next boundary as text.
state = "body-text";
currentFieldName = null;
continue;
}
const nameMatch = disposition.match(/name="([^"]+)"/i);
const filenameMatch = disposition.match(/filename="([^"]*)"/i);
currentFieldName = nameMatch?.[1] ?? null;
if (currentFieldName === opts.fieldName) {
state = "body-file";
originalFilename = filenameMatch?.[1] ?? null;
} else {
state = "body-text";
currentTextBuffer = [];
}
continue;
}
// body-file / body-text: emit bytes up to the next CRLF--boundary.
const sep = Buffer.concat([CRLF, boundaryMarker]);
const idx = buffer.indexOf(sep);
if (idx === -1) {
// Emit everything except a trailing slice that might contain
// the start of the next boundary marker.
const keep = Math.min(buffer.length, sep.length - 1);
const emit = buffer.slice(0, buffer.length - keep);
if (emit.length > 0) {
if (state === "body-file") {
await writeFileChunk(emit);
} else {
currentTextBuffer.push(emit);
}
}
buffer = buffer.slice(buffer.length - keep);
return;
}
const emit = buffer.slice(0, idx);
if (emit.length > 0) {
if (state === "body-file") {
await writeFileChunk(emit);
} else {
currentTextBuffer.push(emit);
}
}
// Finalise the text field if applicable.
if (state === "body-text" && currentFieldName) {
textFields[currentFieldName] = Buffer.concat(
currentTextBuffer,
).toString("utf8");
currentTextBuffer = [];
}
if (state === "body-file") {
await closeFileSink();
}
buffer = buffer.slice(idx + sep.length);
// After the boundary we expect "\r\n" or "--".
if (buffer.length < 2) {
state = "preamble";
return;
}
if (buffer[0] === 0x2d && buffer[1] === 0x2d) {
state = "epilogue";
return;
}
if (buffer[0] === 0x0d && buffer[1] === 0x0a) {
buffer = buffer.slice(2);
state = "headers";
continue;
}
return;
}
};
while (!streamDone) {
const { value, done } = await reader.read();
if (done) {
streamDone = true;
break;
}
buffer = Buffer.concat([buffer, Buffer.from(value)]);
await processBuffer();
}
await processBuffer();
await closeFileSink();
if (bytesWritten === 0) {
throw new Error(
`Multipart body did not include a '${opts.fieldName}' file field`,
);
}
return {
filePath,
bytes: bytesWritten,
sha256: hash.digest("hex"),
originalFilename,
textFields,
};
}
@@ -0,0 +1,126 @@
/**
* v1.4.34 IW-F-Perf — consumer-collapse contract.
*
* Three independent consumers
* (`<RecentAchievementsCard>`, `/achievements` mother page,
* `<AchievementUnlockNotifier>`) all read through
* `useAchievementsQuery()`. The shared hook is the only seam where the
* queryKey is centralised; if a future drift reintroduces a per-user
* discriminator or a literal `["gamification", "achievements"]` shadow
* key, this test surfaces the regression before HAR proves it again
* in production.
*
* Two contracts pinned:
*
* 1. Mounting all three consumers under one TanStack QueryClient
* produces exactly one network call. The shared cache cell wins;
* the divergent v1.4.33 keys are gone.
* 2. The unlock notifier's polling interval rides the shared cache,
* so the card never re-renders on the 2-minute cadence by itself
* — the only refetch fires through the notifier's hook instance
* and updates the single cache cell that both consumers read.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { I18nProvider } from "@/lib/i18n/context";
import { useAchievementsQuery } from "../use-achievements-query";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
const samplePayload = {
summary: { unlockedCount: 0, totalCount: 0 },
achievements: [],
metrics: {},
};
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ data: samplePayload }),
});
// `fetch` is part of the Node global in vitest's node environment; the
// hook reaches for it via `fetch(...)` directly.
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
function ConsumerA() {
useAchievementsQuery();
return null;
}
function ConsumerB() {
useAchievementsQuery();
return null;
}
function ConsumerC() {
useAchievementsQuery({ refetchInterval: 2 * 60 * 1000 });
return null;
}
function withQueryClient(node: ReactNode): string {
const client = new QueryClient({
defaultOptions: {
queries: {
// Force the query to actually fire on mount so the test can
// count the network calls — the hook's `refetchOnMount: false`
// governs subsequent mounts within the staleTime window, not
// the first mount.
retry: false,
},
},
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("useAchievementsQuery — consumer collapse", () => {
it("fires a single network call when three consumers mount together", async () => {
withQueryClient(
<>
<ConsumerA />
<ConsumerB />
<ConsumerC />
</>,
);
// SSR renders are synchronous, so the queryFn is enqueued but not
// awaited inside renderToStaticMarkup. The microtask flush below
// mirrors what react-query does between mount and the network
// call — one tick is enough to see the fetch attempt.
await Promise.resolve();
await Promise.resolve();
// The three consumers share `queryKeys.gamificationAchievements()`
// so TanStack hands every subscriber the same cache slot; one cell
// = one in-flight queryFn = one fetch. Pre-fix the notifier carried
// a `userId` discriminator and fired its own request.
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(1);
});
it("uses /api/gamification/achievements without query params", async () => {
withQueryClient(<ConsumerA />);
await Promise.resolve();
await Promise.resolve();
if (fetchMock.mock.calls.length > 0) {
const [url] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/gamification/achievements");
}
});
});
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import type {
AchievementMetrics,
AchievementProgress,
AchievementSummary,
} from "@/lib/gamification/achievements";
/**
* v1.4.34 IW-F-Perf — single TanStack-Query wrapper for
* `/api/gamification/achievements`.
*
* Pre-fix, three consumers each declared their own `useQuery` block:
* - `<RecentAchievementsCard>` with `["gamification", "achievements"]`,
* - the `/achievements` mother page with the same literal key, and
* - `<AchievementUnlockNotifier>` with a different key
* (`["gamification", "achievements", "unlock-notifier", userId]`)
* plus a 2-minute refetch interval.
*
* TanStack treats the divergent key as a fresh cache cell, so the
* dashboard fired two network calls instead of one on every cold
* mount — once for the card, once for the notifier. The
* `round-v1434-prod-slowness-investigation.md` HAR catches the
* duplicate landing inside the same ~3 s pool-starvation tail.
*
* This hook mirrors the v1.4.33 `use-analytics-query.ts` shape:
* - centralises the queryKey via `queryKeys.gamificationAchievements()`,
* - sets `staleTime: 60_000` so a route swing within a minute is a
* free cache hit,
* - keeps `refetchOnMount: false` + `refetchOnWindowFocus: false` so
* route transitions never trigger a refetch storm,
* - defaults to `enabled: isAuthenticated` so the unauthenticated
* surfaces don't spin up a 401-bound request,
* - lets the notifier opt into the 2-minute `refetchInterval` so its
* "fresh unlock" toast still fires for users who keep the tab open
* without forcing the card to refetch on the same cadence — the
* cache slot is shared, so a single refetch updates both.
*/
export interface AchievementsPayload {
summary: AchievementSummary;
achievements: AchievementProgress[];
metrics: AchievementMetrics;
}
export interface UseAchievementsQueryOptions {
/**
* Optional override for the auth gate. Defaults to
* `isAuthenticated`. Callers that need to defer the fetch behind a
* stricter precondition (e.g. notifier waiting for a known userId)
* can pass a composed flag here.
*/
enabled?: boolean;
/**
* Optional refetch cadence in milliseconds. Defaults to `false`
* (no polling). The unlock notifier opts into a 2-minute interval
* so users who keep the tab open still see new-unlock toasts; the
* card stays passive because both consumers share the same cache
* cell.
*/
refetchInterval?: number | false;
}
async function fetchAchievements(): Promise<AchievementsPayload> {
const res = await fetch("/api/gamification/achievements");
if (!res.ok) {
throw new Error(`Failed to load achievements (${res.status})`);
}
const json = (await res.json()) as { data: AchievementsPayload };
return json.data;
}
/**
* Shared hook. Returns the unwrapped `data` payload typed as
* `AchievementsPayload`. Consumers narrow at the call site.
*/
export function useAchievementsQuery(
options: UseAchievementsQueryOptions = {},
): UseQueryResult<AchievementsPayload, Error> {
const { isAuthenticated } = useAuth();
const enabled =
options.enabled !== undefined ? options.enabled : isAuthenticated;
const refetchInterval = options.refetchInterval ?? false;
return useQuery({
queryKey: queryKeys.gamificationAchievements(),
queryFn: fetchAchievements,
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval,
});
}
+11
View File
@@ -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;