mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
7bef199039
commit
affede31a4
104 files changed
+8050
-404
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)";
|
||||
|
||||
@@ -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<");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Vendored
+82
@@ -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("");
|
||||
}
|
||||
Vendored
+299
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user