From ba99551faae3bcf35f4307200fac2941149c5307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:22:49 +0200 Subject: [PATCH 1/6] feat(apple-health): show when each type last arrived and how The Apple Health card listed each type's newest sample. A type the phone only hands over on "Sync all" can carry a reading from this morning and never have arrived in the background, so the sample time could not answer whether a type flows on its own. The batch route's per-type ledger now records, in one upsert per batch, when each Apple Health type last arrived, the trigger that carried it (including the new `manual`), and when a batch last brought a new or updated sample of it. Duplicate-only re-sends move the arrival but keep the last new value; scoped credentials and MANUAL rows write nothing, the same rule as the sync checkpoint. A ledger failure leaves a warn line and never fails the ingest. `GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and `lastNewSampleAt` over each `metricFreshness` entry (null for a type no live sync has carried, absent on the workouts entry and when the ledger read failed), and the card shows them as a second line per type. The per-type newest-sample read walks live types through `listLiveMeasurementTypes` and probes each type's newest row instead of grouping every live row of the source. --- docs/api/openapi.yaml | 69 +++- prisma/schema.prisma | 6 +- src/__tests__/i18n-reverse-coverage.test.ts | 2 - .../healthkit/__tests__/route.test.ts | 109 +++++-- src/app/api/integrations/healthkit/route.ts | 19 +- .../status/__tests__/route.test.ts | 14 +- .../__tests__/apple-health-card.test.tsx | 76 ++++- .../integrations/apple-health-card.tsx | 70 +++- .../metric-freshness-disclosure.tsx | 83 +++-- .../__tests__/metric-freshness.test.ts | 86 ++++- src/lib/integrations/healthkit-type-sync.ts | 138 +++++++- src/lib/integrations/metric-freshness.ts | 78 +++-- src/lib/openapi/routes/integrations.ts | 36 ++- src/lib/openapi/routes/measurements.ts | 2 +- tests/integration/healthkit-type-sync.test.ts | 302 ++++++++++++++++++ 15 files changed, 976 insertions(+), 114 deletions(-) create mode 100644 tests/integration/healthkit-type-sync.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f7350a170..eb8af506b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24873,7 +24873,9 @@ components: description: 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both - readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is + readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the + batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on + each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.' type: string enum: @@ -72802,11 +72804,11 @@ components: description: Present on the GET read; omitted from the PATCH echo. $ref: "#/components/schemas/SyncHealth" metricFreshness: - description: Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH - echo. + description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the + GET read; omitted from the PATCH echo. type: array items: - $ref: "#/components/schemas/MetricFreshnessEntry" + $ref: "#/components/schemas/AppleHealthMetricFreshnessEntry" syncProgress: description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo. @@ -72874,7 +72876,7 @@ components: additionalProperties: false description: Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'. - MetricFreshnessEntry: + AppleHealthMetricFreshnessEntry: type: object properties: type: @@ -72889,13 +72891,44 @@ components: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. + lastReceivedAt: + description: "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null + when no live sync has carried it yet: a type that so far came only from the export import, or from before + v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. + Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read + failed." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lastTrigger: + description: The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever + `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`. + anyOf: + - type: string + enum: + - foreground + - background + - push + - manual + description: What triggered a HealthKit batch. + - type: "null" + lastNewSampleAt: + description: When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. + Null when none has. Same omission rules as `lastReceivedAt`. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - type - lastSeenAt - stale additionalProperties: false - description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually - delivered appear — absence is absence, never an invented row. + description: "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type + arrival facts recorded by `POST /api/measurements/batch` (v1.42)." AppleHealthSyncProgress: type: object properties: @@ -74763,6 +74796,28 @@ components: entry carries, plus the extras that provider has. The optional fields are not optional per request — each is present exactly for the providers named in its description and absent for the rest, so branch on `integration`, not on presence." + MetricFreshnessEntry: + type: object + properties: + type: + type: string + description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg. + lastSeenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Newest recorded reading for this type from this source. + stale: + type: boolean + description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single + revoked HealthKit permission). Only ever true when the verdict is `fresh`. + required: + - type + - lastSeenAt + - stale + additionalProperties: false + description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually + delivered appear — absence is absence, never an invented row. FitbitResumeEnvelope: type: object properties: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d95bceefb..2aec5f66e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3947,12 +3947,12 @@ model ManagedProfileHandover { model HealthKitTypeSync { userId String @map("user_id") type MeasurementType - /// @pending-consumer(#1173) Last batch that carried this type. + /// Last batch that carried this type. lastReceivedAt DateTime @map("last_received_at") - /// @pending-consumer(#1173) `syncTrigger` of that batch + /// `syncTrigger` of that batch /// (`foreground` / `background` / `push` / `manual`), NULL when absent. lastTrigger String? @map("last_trigger") - /// @pending-consumer(#1173) Last batch that inserted or updated a sample of + /// Last batch that inserted or updated a sample of /// this type (as opposed to sending only duplicates). lastNewSampleAt DateTime? @map("last_new_sample_at") diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index e3bda6a41..4a2083a39 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -91,8 +91,6 @@ const RELEASE_PENDING_PREFIXES: Record = { "environment.fields.europeanAqi": "v1.42 air-quality field label (#615)", "environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)", "environment.fields.dustMax": "v1.42 air-quality field label (#615)", - "settings.appleHealth.freshness": - "v1.42 per-type HealthKit freshness list (#1173)", "insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)", "insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)", "insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)", diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 26ac0eae7..fd6b34138 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -8,12 +8,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { create: vi.fn() }, // Per-metric freshness + first-run sync progress for the APPLE_HEALTH // source (#778). - measurement: { groupBy: vi.fn(), aggregate: vi.fn() }, + measurement: { aggregate: vi.fn() }, workout: { groupBy: vi.fn(), aggregate: vi.fn() }, + // v1.42 — the newest sample per live type is one raw probe per type, and + // the per-type arrival ledger (#1173) is read beside it. + $queryRaw: vi.fn(), + healthKitTypeSync: { findMany: vi.fn() }, }, toJson: (v: T) => v, })); +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -38,6 +46,7 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const SESSION_OK = { @@ -53,7 +62,9 @@ beforeEach(() => { } as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([] as never); vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ _count: { _all: 0 }, @@ -281,26 +292,20 @@ describe("GET /api/integrations/healthkit — sync health", () => { healthKitConfigJson: null, healthKitLastSyncedAt: ago(60 * 60 * 1000), } as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ - { - source: "APPLE_HEALTH", - type: "RESPIRATORY_RATE", - _max: { measuredAt: ago(30 * DAY) }, - }, - { - source: "APPLE_HEALTH", - type: "PULSE", - _max: { measuredAt: ago(60 * 60 * 1000) }, - }, + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "RESPIRATORY_RATE", + "PULSE", + ]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) }, + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, ] as never); const body = await read(); // Only the Apple Health source is read. - const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock - .calls[0]?.[0] as unknown as { - where: { source: { in: string[] } }; - }; - expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]); + expect(vi.mocked(listLiveMeasurementTypes).mock.calls[0]?.[1]).toEqual({ + source: "APPLE_HEALTH", + }); const byType = Object.fromEntries( body.metricFreshness.map((entry) => [entry.type, entry.stale]), ); @@ -382,11 +387,75 @@ describe("GET /api/integrations/healthkit — sync health", () => { healthKitConfigJson: null, healthKitLastSyncedAt: ago(DAY), } as never); - vi.mocked(prisma.measurement.groupBy).mockRejectedValue( - new Error("groupBy hiccup"), + vi.mocked(listLiveMeasurementTypes).mockRejectedValue( + new Error("live-type hiccup"), ); const body = await read(); expect(body.metricFreshness).toEqual([]); expect(body.syncHealth.verdict).toBe("fresh"); }); + + // #1173 — when a type last ARRIVED and under which trigger, beside when its + // newest sample was taken. A type that only ever comes with "Sync all" is + // visible as `manual`; a type no live sync carried yet reads null. + it("lays the per-type arrival facts over the freshness entries", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "RESPIRATORY_RATE", + "PULSE", + ]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) }, + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, + ] as never); + vi.mocked(prisma.workout.groupBy).mockResolvedValue([ + { source: "APPLE_HEALTH", _max: { startedAt: ago(DAY) } }, + ] as never); + const receivedAt = ago(3 * 60 * 60 * 1000); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "RESPIRATORY_RATE", + lastReceivedAt: receivedAt, + lastTrigger: "manual", + lastNewSampleAt: null, + }, + ] as never); + + const body = await read(); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry]), + ); + expect(byType.RESPIRATORY_RATE).toMatchObject({ + lastReceivedAt: receivedAt.toISOString(), + lastTrigger: "manual", + lastNewSampleAt: null, + }); + expect(byType.PULSE).toMatchObject({ + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + }); + // Workouts ride their own batch route, which the ledger does not cover. + expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt"); + }); + + it("leaves the arrival facts off when the ledger read fails", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "PULSE", last_seen: ago(DAY) }, + ] as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue( + new Error("ledger hiccup"), + ); + const body = await read(); + expect(body.metricFreshness).toHaveLength(1); + expect(body.metricFreshness[0]).not.toHaveProperty("lastReceivedAt"); + }); }); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e9428e4c1..e104168ca 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -21,6 +21,10 @@ import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; +import { + getHealthKitTypeArrivals, + withHealthKitArrivals, +} from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; import { classifyMetricFreshness, @@ -166,9 +170,13 @@ export const GET = apiHandler(async () => { legacyLastSyncedAt: lastSyncedAt, cadence: PUSH_CADENCE, }); - const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( - () => [], - ); + // #1173 — when each type last ARRIVED and under which trigger, beside when + // its newest sample was taken. Fail-soft to null, which leaves the entries + // without arrival fields rather than reporting every type as never received. + const [samples, arrivals] = await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getHealthKitTypeArrivals(user.id).catch(() => null), + ]); // Issue #778 — a first-run backfill used to be invisible from the web: the // card said "last sync 2 minutes ago" while giving no sense of how much had // arrived or how far back it reached. These two figures (row count + oldest @@ -186,7 +194,10 @@ export const GET = apiHandler(async () => { lastSyncTrigger, lastBackgroundSyncAt, syncHealth, - metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), + metricFreshness: withHealthKitArrivals( + classifyMetricFreshness(samples, syncHealth.verdict), + arrivals, + ), syncProgress, }); }); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 75f54a607..1c0dde365 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -13,11 +13,16 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, - measurement: { groupBy: vi.fn(async () => []) }, + $queryRaw: vi.fn(async () => []), workout: { groupBy: vi.fn(async () => []) }, }, })); +// v1.42 — the per-source type walk that replaced the measurement groupBy. +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(async () => []), +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/api-response", () => ({ @@ -53,6 +58,7 @@ vi.mock("@/lib/oura/credentials", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const userFind = prisma.user.findUnique as ReturnType; @@ -315,7 +321,7 @@ describe("/api/integrations/status — the freshness read failed", () => { }); it("reports the degradation instead of passing an empty list off as an answer", async () => { - (prisma.measurement.groupBy as ReturnType).mockRejectedValue( + vi.mocked(listLiveMeasurementTypes).mockRejectedValue( new Error("statement timeout"), ); @@ -331,9 +337,7 @@ describe("/api/integrations/status — the freshness read failed", () => { }); it("reports no degradation when the read simply found nothing", async () => { - (prisma.measurement.groupBy as ReturnType).mockResolvedValue( - [], - ); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]); (prisma.workout.groupBy as ReturnType).mockResolvedValue([]); const envelope = await fetchEnvelope(); diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index fdc7b43e2..1a483c1d5 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -38,7 +38,7 @@ vi.mock("@tanstack/react-query", () => ({ }), })); -import { AppleHealthCard } from "../apple-health-card"; +import { AppleHealthCard, ArrivalDetail } from "../apple-health-card"; function render(locale: Locale = "en") { return renderToStaticMarkup( @@ -404,3 +404,77 @@ describe(" — sync progress (#778)", () => { } }); }); + +describe(" — per-type arrivals (#1173)", () => { + const now = new Date("2026-10-08T12:00:00.000Z"); + const hoursAgo = (h: number) => + new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); + const base = { + type: "RESPIRATORY_RATE", + lastSeenAt: hoursAgo(5), + stale: false, + }; + const t = (key: string, params?: Record) => { + const value = key + .split(".") + .reduce( + (node, part) => (node as Record)?.[part], + en, + ); + if (typeof value !== "string") return key; + return value.replace(/\{(\w+)\}/g, (_, name: string) => + String(params?.[name] ?? `{${name}}`), + ); + }; + const html = (entry: Parameters[0]["entry"]) => + renderToStaticMarkup(); + + it("names when a type last arrived and that only Sync all carried it", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(2), + lastTrigger: "manual", + lastNewSampleAt: hoursAgo(2), + }); + expect(out).toContain('data-trigger="manual"'); + expect(out).toContain("Last received 2 h ago"); + expect(out).toContain("from Sync all"); + // The last arrival brought something new, so no second date is needed. + expect(out).not.toContain("Last new value"); + }); + + it("adds the last new value when the latest arrival held duplicates only", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(1), + lastTrigger: "background", + lastNewSampleAt: hoursAgo(72), + }); + expect(out).toContain("in the background"); + expect(out).toContain("Last new value 3 d ago"); + }); + + it("says a type has not arrived through live sync yet", () => { + const out = html({ + ...base, + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + }); + expect(out).toContain('data-state="never"'); + expect(out).toContain("Not received yet"); + }); + + it("renders nothing when the server sent no arrival facts", () => { + expect(html(base)).toBe(""); + }); + + it("titles the list by data type", () => { + statusPayload = { + lastSyncedAt: hoursAgo(1), + syncHealth: { verdict: "fresh", since: null }, + metricFreshness: [{ ...base }], + }; + expect(render()).toContain("By data type"); + }); +}); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index a65f522fa..403594b6f 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -25,10 +25,8 @@ import { import { Button } from "@/components/ui/button"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { - MetricFreshnessEntry, - SyncHealth, -} from "@/lib/integrations/sync-verdict"; +import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync"; +import type { SyncHealth } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; @@ -43,7 +41,8 @@ interface HealthKitStatus { /** When data last arrived without the app being opened. */ lastBackgroundSyncAt?: string | null; syncHealth?: SyncHealth; - metricFreshness?: MetricFreshnessEntry[]; + /** Per type: newest sample, plus when it last arrived and how (#1173). */ + metricFreshness?: HealthKitFreshnessEntry[]; /** Server-side backfill progress (#778); null when the read failed. */ syncProgress?: { recordsAccepted: number; @@ -135,6 +134,65 @@ function DeliveryDiagnostic({ ); } +/** + * The second line of a type's row: when a live sync last carried the type, + * under which trigger, and when one last brought a new value. The newest + * sample's time on the right of the row says when the reading was TAKEN; this + * says when the server RECEIVED it, which is what tells a type that only + * arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing + * renders when the server sent no arrival facts (the workouts row, or a failed + * ledger read), so absence is never painted as "not received". + */ +export function ArrivalDetail({ + entry, + t, + now, +}: { + entry: HealthKitFreshnessEntry; + t: (key: string, params?: Record) => string; + now?: Date; +}) { + if (entry.lastReceivedAt === undefined) return null; + if (entry.lastReceivedAt === null) { + return ( + + {t("settings.appleHealth.freshness.never")} + + ); + } + const reference = (now ?? new Date()).getTime(); + const relative = (iso: string) => + formatRelative(Math.max(0, reference - new Date(iso).getTime()), t); + const trigger = entry.lastTrigger ?? null; + const parts = [ + t("settings.appleHealth.freshness.lastReceived", { + relative: relative(entry.lastReceivedAt), + }), + t( + trigger + ? TRIGGER_KEYS[trigger] + : "settings.appleHealth.delivery.trigger.unknown", + ), + ]; + // Only worth a mention when the last arrival brought duplicates alone. + if (entry.lastNewSampleAt && entry.lastNewSampleAt !== entry.lastReceivedAt) { + parts.push( + t("settings.appleHealth.freshness.lastNewValue", { + relative: relative(entry.lastNewSampleAt), + }), + ); + } + return ( + + {parts.join(" · ")} + + ); +} + /** * First-run backfill progress (#778). During the initial HealthKit backfill * the card said nothing about how much had arrived or how far back it @@ -269,6 +327,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { } />
diff --git a/src/components/settings/integrations/metric-freshness-disclosure.tsx b/src/components/settings/integrations/metric-freshness-disclosure.tsx index 31f2d2b08..ae9fba69a 100644 --- a/src/components/settings/integrations/metric-freshness-disclosure.tsx +++ b/src/components/settings/integrations/metric-freshness-disclosure.tsx @@ -20,7 +20,7 @@ * "expected but missing" row is invented. */ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { AlertTriangle, ChevronDown } from "lucide-react"; import { formatRelative } from "@/components/settings/integration-status-pill"; @@ -42,16 +42,27 @@ function humanise(type: string): string { return words.charAt(0).toUpperCase() + words.slice(1); } -export function MetricFreshnessDisclosure({ +export function MetricFreshnessDisclosure< + Entry extends MetricFreshnessEntry = MetricFreshnessEntry, +>({ entries, idPrefix, now, + title, + detail, }: { - entries: MetricFreshnessEntry[] | undefined; + entries: Entry[] | undefined; /** Namespaces the disclosure's ids so several cards can coexist. */ idPrefix: string; /** Override "now" for deterministic testing. */ now?: Date; + /** Replaces the shared "received data" heading. */ + title?: string; + /** + * A second line under a row, for a card that knows more per type than the + * newest sample (the Apple Health card's arrival facts, #1173). + */ + detail?: (entry: Entry) => ReactNode; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); @@ -101,7 +112,7 @@ export function MetricFreshnessDisclosure({ )} /> - {t("settings.integrationFreshness.title")} + {title ?? t("settings.integrationFreshness.title")} {t("settings.integrationFreshness.summary", { @@ -119,34 +130,44 @@ export function MetricFreshnessDisclosure({ {open && (
    - {sorted.map((entry) => ( -
  • - - {entry.stale ? ( - -
  • - {relative(entry.lastSeenAt)} - -
  • - ))} +
    + + {entry.stale ? ( + + +
    + {extra ? ( +
    + {extra} +
    + ) : null} + + ); + })}
)}
diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts index c5f3c0a5d..635efde2f 100644 --- a/src/lib/integrations/__tests__/metric-freshness.test.ts +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -8,14 +8,35 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const groupByMock = vi.hoisted(() => vi.fn()); +/** + * The fixtures keep the grouped-row shape (`source`, `type`, `_max`) the read + * used to take. Since v1.42 the module asks `listLiveMeasurementTypes` per + * source and then probes each type's newest row in one raw query; the two + * mocks below answer both from the same fixture rows. + */ +type GroupedRow = { + source: string; + type: string; + _max: { measuredAt: Date | null }; +}; +let measurementRows: GroupedRow[] = []; +const groupByMock = { + mockResolvedValue(rows: GroupedRow[]) { + measurementRows = rows; + }, +}; +const liveTypesMock = vi.hoisted(() => vi.fn()); +const queryRawMock = vi.hoisted(() => vi.fn()); const workoutGroupByMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/db", () => ({ prisma: { - measurement: { groupBy: groupByMock }, + $queryRaw: queryRawMock, workout: { groupBy: workoutGroupByMock }, }, })); +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: liveTypesMock, +})); import { getSourceFreshness, @@ -28,7 +49,32 @@ import { } from "../sync-verdict"; beforeEach(() => { - groupByMock.mockReset(); + measurementRows = []; + liveTypesMock.mockReset(); + liveTypesMock.mockImplementation( + async (_userId: string, options: { source: string }) => + measurementRows + .filter((row) => row.source === options.source) + .map((row) => row.type), + ); + queryRawMock.mockReset(); + queryRawMock.mockImplementation( + async (_strings: TemplateStringsArray, ...values: unknown[]) => { + const types = values.find(Array.isArray) as string[]; + const source = values.find( + (value) => + typeof value === "string" && + measurementRows.some((row) => row.source === value), + ); + return types.map((type) => ({ + type, + last_seen: + measurementRows.find( + (row) => row.source === source && row.type === type, + )?._max.measuredAt ?? null, + })); + }, + ); workoutGroupByMock.mockReset(); workoutGroupByMock.mockResolvedValue([]); }); @@ -65,11 +111,10 @@ describe("getSourceMetricFreshness", () => { expect(result.withings).toEqual([ { type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" }, ]); - // Only the sync sources are queried. - expect(groupByMock.mock.calls[0][0].where.source.in).toEqual( - Object.values(INTEGRATION_MEASUREMENT_SOURCE), - ); - expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull(); + // Only the sync sources are asked about, one live-type walk each. + expect( + liveTypesMock.mock.calls.map((call) => call[1].source).sort(), + ).toEqual([...Object.values(INTEGRATION_MEASUREMENT_SOURCE)].sort()); }); it("skips rows with no reading and unmapped sources", async () => { @@ -175,9 +220,30 @@ describe("getSourceFreshness — one source, for Apple Health", () => { expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([ { type: "RESPIRATORY_RATE", lastSeenAt: "2026-07-01T00:00:00.000Z" }, ]); - expect(groupByMock.mock.calls[0][0].where.source.in).toEqual([ - "APPLE_HEALTH", + expect(liveTypesMock).toHaveBeenCalledTimes(1); + expect(liveTypesMock.mock.calls[0][1]).toEqual({ source: "APPLE_HEALTH" }); + }); + + it("issues no per-type probe when the source has no live types", async () => { + expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([]); + expect(queryRawMock).not.toHaveBeenCalled(); + }); + + it("probes live rows of the one source only", async () => { + groupByMock.mockResolvedValue([ + { + source: "APPLE_HEALTH", + type: "PULSE", + _max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") }, + }, ]); + await getSourceFreshness("u1", "APPLE_HEALTH"); + const sql = (queryRawMock.mock.calls[0][0] as TemplateStringsArray).join( + "?", + ); + expect(sql).toContain('"deleted_at" IS NULL'); + expect(sql).toContain('"source" = ?::"measurement_source"'); + expect(sql).toContain("LIMIT 1"); }); }); diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index daef60a49..1945b8131 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -7,13 +7,30 @@ * it to show which types are actually flowing. A scoped ingest credential * does not write it, the same rule as `lastSyncedAt`. * - * Contract stub: the signature is final; the body records nothing yet. + * Sample time (`max(measuredAt)`, what the freshness list showed before) and + * arrival time are different questions. A type the phone only hands over + * when someone taps "Sync all" can carry a reading from this morning and + * still never have arrived in the background; only the arrival time and the + * trigger that carried it can show that. */ import type { MeasurementType, Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { logCaught } from "@/lib/logging/signal"; +import { + WORKOUT_FRESHNESS_TYPE, + type MetricFreshnessEntry, +} from "./sync-verdict"; export type HealthKitSyncTrigger = "foreground" | "background" | "push" | "manual"; +const TRIGGERS: ReadonlySet = new Set([ + "foreground", + "background", + "push", + "manual", +]); + /** What one batch did for one type. */ export interface HealthKitTypeArrival { /** Entries of this type the batch accepted (inserted, updated or duplicate). */ @@ -22,6 +39,16 @@ export interface HealthKitTypeArrival { newSamples: number; } +/** + * One statement for the whole batch (`unnest` over at most a few dozen + * types). `last_received_at` and `last_trigger` describe the latest batch + * that carried the type, so they are overwritten; `last_new_sample_at` only + * moves when this batch brought something new, so a duplicates-only re-send + * keeps the earlier date standing. + * + * Best effort: the ledger is a diagnostic beside rows that are already + * written, so a failure here leaves a `warn` line and never fails the ingest. + */ export async function recordHealthKitTypeArrivals( tx: Prisma.TransactionClient, input: { @@ -31,6 +58,111 @@ export async function recordHealthKitTypeArrivals( at?: Date; }, ): Promise { - void tx; - void input; + const types: string[] = []; + const brought: boolean[] = []; + for (const [type, arrival] of input.counts) { + if (arrival.accepted <= 0) continue; + types.push(type); + brought.push(arrival.newSamples > 0); + } + if (types.length === 0) return; + // An ISO string cast in SQL, not a JS `Date`: a Date parameter is + // serialised in the process's local zone and these are zone-less UTC + // columns (same reasoning as the access-token sunset in refresh-token.ts). + const at = (input.at ?? new Date()).toISOString(); + try { + await tx.$executeRaw` + INSERT INTO "healthkit_type_syncs" + ("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at") + SELECT ${input.userId}, u.t::"measurement_type", + ${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger}, + CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END + FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n) + ON CONFLICT ("user_id", "type") DO UPDATE SET + "last_received_at" = EXCLUDED."last_received_at", + "last_trigger" = EXCLUDED."last_trigger", + "last_new_sample_at" = COALESCE( + EXCLUDED."last_new_sample_at", + "healthkit_type_syncs"."last_new_sample_at" + ) + `; + } catch (error) { + logCaught("healthkit.type_sync.record_failed", error, { + types: types.length, + }); + } +} + +/** One type's arrival facts, as the Apple Health status read serves them. */ +export interface HealthKitTypeArrivalRecord { + lastReceivedAt: string; + lastTrigger: HealthKitSyncTrigger | null; + lastNewSampleAt: string | null; +} + +/** + * Every recorded type for one account, keyed by `MeasurementType`. A stored + * trigger outside the known set (none is written, but the column is text) + * reads as null rather than reaching the client as an unknown label. + */ +export async function getHealthKitTypeArrivals( + userId: string, +): Promise> { + const rows = await prisma.healthKitTypeSync.findMany({ + where: { userId }, + select: { + type: true, + lastReceivedAt: true, + lastTrigger: true, + lastNewSampleAt: true, + }, + }); + const out = new Map(); + for (const row of rows) { + out.set(row.type, { + lastReceivedAt: row.lastReceivedAt.toISOString(), + lastTrigger: + row.lastTrigger && TRIGGERS.has(row.lastTrigger) + ? (row.lastTrigger as HealthKitSyncTrigger) + : null, + lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null, + }); + } + return out; +} + +/** A freshness entry with the arrival facts beside the newest sample. */ +export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { + /** When a live sync last carried this type; null when none has. */ + lastReceivedAt?: string | null; + /** The trigger of that sync; null when absent or not reported. */ + lastTrigger?: HealthKitSyncTrigger | null; + /** When a live sync last brought a new or updated sample of it. */ + lastNewSampleAt?: string | null; +} + +/** + * Lay the arrival ledger over the per-type freshness entries. Every + * measurement type gets the three fields, null when no live sync has carried + * it yet (a type that so far came only from the export import, or from before + * the ledger existed). The `WORKOUTS` entry is left without them: workouts + * arrive through their own batch route, which the ledger does not cover, and + * "not received yet" would be false. A null ledger (the read failed) leaves + * every entry as it was rather than claiming nothing arrived. + */ +export function withHealthKitArrivals( + entries: readonly MetricFreshnessEntry[], + arrivals: ReadonlyMap | null, +): HealthKitFreshnessEntry[] { + if (!arrivals) return [...entries]; + return entries.map((entry) => { + if (entry.type === WORKOUT_FRESHNESS_TYPE) return entry; + const arrival = arrivals.get(entry.type); + return { + ...entry, + lastReceivedAt: arrival?.lastReceivedAt ?? null, + lastTrigger: arrival?.lastTrigger ?? null, + lastNewSampleAt: arrival?.lastNewSampleAt ?? null, + }; + }); } diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index b5f6e80c2..d45eb557d 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -10,7 +10,7 @@ * but idle". * * This computes the last-value timestamp per `(source, type)` straight from the - * `Measurement` table (no schema change, no migration) so a caller can surface + * `Measurement` table so a caller can surface * per-metric liveness honestly: a metric whose newest reading is frozen weeks in * the past is visibly distinct from one that is genuinely current. The * classification of "quiet" vs "current" is a pure function next to the verdict @@ -22,7 +22,11 @@ * pseudo-entry so those pipes are visible too. */ import { prisma } from "@/lib/db"; -import type { MeasurementSource } from "@/generated/prisma/client"; +import type { + MeasurementSource, + MeasurementType, +} from "@/generated/prisma/client"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import type { IntegrationKey } from "./status"; import { WORKOUT_FRESHNESS_TYPE, @@ -70,34 +74,68 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial< }; /** - * Newest measurement per `(source, type)` for the given sources. One grouped - * query; a `(source, type)` pair with no rows simply has no entry, which is how - * honest absence stays structural — a metric a provider never delivered is - * never invented. + * Newest measurement per `(source, type)` for the given sources. A `(source, + * type)` pair with no rows simply has no entry, which is how honest absence + * stays structural — a metric a provider never delivered is never invented. + * + * v1.42: this used to be one `groupBy … _max(measuredAt)` over every live row + * of the sources, which on a multi-year Apple Health history reads six figures + * of rows for a two-digit answer on every settings visit. It now asks which + * types are live per source (`listLiveMeasurementTypes`, a loose index scan) + * and then probes each type's newest row through the same partial covering + * index, one `LIMIT 1` per type. */ export async function getMeasurementFreshnessBySource( userId: string, sources: readonly MeasurementSource[], ): Promise> { const out = new Map(); - if (sources.length === 0) return out; - - const grouped = await prisma.measurement.groupBy({ - by: ["source", "type"], - where: { userId, deletedAt: null, source: { in: [...sources] } }, - _max: { measuredAt: true }, - }); - - for (const row of grouped) { - const lastSeen = row._max.measuredAt; - if (!lastSeen) continue; - const list = out.get(row.source) ?? []; - list.push({ type: row.type, lastSeenAt: lastSeen.toISOString() }); - out.set(row.source, list); + const perSource = await Promise.all( + [...new Set(sources)].map(async (source) => { + const types = await listLiveMeasurementTypes(userId, { source }); + return { source, samples: await newestPerType(userId, source, types) }; + }), + ); + for (const { source, samples } of perSource) { + if (samples.length > 0) out.set(source, samples); } return out; } +async function newestPerType( + userId: string, + source: MeasurementSource, + types: readonly MeasurementType[], +): Promise { + if (types.length === 0) return []; + // Parameter-bound throughout; the enum casts are literal SQL. + const rows = await prisma.$queryRaw< + Array<{ type: string; last_seen: Date | null }> + >` + SELECT live."type"::text AS "type", + ( + SELECT m."measured_at" + FROM "measurements" m + WHERE m."user_id" = ${userId} + AND m."type" = live."type" + AND m."deleted_at" IS NULL + AND m."source" = ${source}::"measurement_source" + ORDER BY m."measured_at" DESC + LIMIT 1 + ) AS "last_seen" + FROM unnest(${[...types]}::"measurement_type"[]) AS live("type") + `; + const samples: MetricFreshnessSample[] = []; + for (const row of rows) { + if (!row.last_seen) continue; + samples.push({ + type: row.type, + lastSeenAt: new Date(row.last_seen).toISOString(), + }); + } + return samples; +} + /** * Newest workout per source. `Workout` rows are hard-deleted, so there is no * tombstone predicate to apply. diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 320520843..ad7793b75 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -117,6 +117,38 @@ const metricFreshnessEntry = z "Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.", }); +// #1173 — the Apple Health read adds when each type last ARRIVED, under which +// trigger, and when a sync last brought something new for it. Mirrors +// `HealthKitFreshnessEntry` in `src/lib/integrations/healthkit-type-sync.ts`. +const appleHealthMetricFreshnessEntry = metricFreshnessEntry + .extend({ + lastReceivedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null when no live sync has carried it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed.", + ), + lastTrigger: healthKitSyncTriggerEnum + .nullable() + .optional() + .describe( + "The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`.", + ), + lastNewSampleAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.", + ), + }) + .meta({ + id: "AppleHealthMetricFreshnessEntry", + description: + "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42).", + }); + // Issue #778 — the two backfill-progress figures the server genuinely holds. // The iOS app drives the backfill; its queue, throttle state, and any ETA // live on the device and are deliberately absent here. @@ -167,10 +199,10 @@ const healthKitConfigResponse = z .optional() .describe("Present on the GET read; omitted from the PATCH echo."), metricFreshness: z - .array(metricFreshnessEntry) + .array(appleHealthMetricFreshnessEntry) .optional() .describe( - "Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.", + "Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the GET read; omitted from the PATCH echo.", ), syncProgress: appleHealthSyncProgress .nullable() diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b063609c8..cbc3efa0d 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -130,7 +130,7 @@ const batchPayloadSchema = z .enum(["foreground", "background", "push", "manual"]) .optional() .describe( - 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', + 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', ), }) .meta({ diff --git a/tests/integration/healthkit-type-sync.test.ts b/tests/integration/healthkit-type-sync.test.ts new file mode 100644 index 000000000..7074a5e5b --- /dev/null +++ b/tests/integration/healthkit-type-sync.test.ts @@ -0,0 +1,302 @@ +/** + * Per-type HealthKit arrival ledger (#1173), against real Postgres. + * + * The Apple Health card used to show only when each type's newest SAMPLE was + * taken. A type the phone hands over only on "Sync all" can carry a reading + * from this morning and still never arrive in the background, so the card + * needs when the server RECEIVED each type and under which trigger. These + * cases pin the batch route's write, the duplicate rule for + * `lastNewSampleAt`, the scoped-credential exclusion, and the status read + * that serves it, including the newest-sample probe that replaced the + * grouped read. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-healthkit-type-sync-32-bytes-minimum-123456"; + +const { hashToken } = await import("@/lib/auth/hmac"); + +const USER_ID = "user-healthkit-type-sync"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "healthkit-type-sync", + email: "healthkit-type-sync@example.test", + timezone: "UTC", + }, + }); +}); + +async function armSession(): Promise { + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function entry( + hkIdentifier: string, + value: number, + unit: string, + at: string, + externalId: string, +) { + return { hkIdentifier, value, unit, startDate: at, endDate: at, externalId }; +} + +async function postBatch( + body: Record, + headers: Record = {}, +): Promise { + const { POST } = await import("@/app/api/measurements/batch/route"); + return POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }), + ); +} + +const T1 = "2026-10-01T06:00:00.000Z"; +const T2 = "2026-10-01T07:00:00.000Z"; + +describe("per-type arrival ledger (#1173)", () => { + it("records each type a batch carried, with its trigger", async () => { + await armSession(); + const res = await postBatch({ + syncTrigger: "manual", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + entry( + "HKQuantityTypeIdentifierHeartRate", + 61, + "count/min", + T1, + "uuid-p1", + ), + entry( + "HKQuantityTypeIdentifierHeartRate", + 63, + "count/min", + T2, + "uuid-p2", + ), + ], + }); + expect(res.status).toBe(200); + + const rows = await getPrismaClient().healthKitTypeSync.findMany({ + where: { userId: USER_ID }, + orderBy: { type: "asc" }, + }); + expect(rows.map((row) => row.type).sort()).toEqual(["PULSE", "WEIGHT"]); + for (const row of rows) { + expect(row.lastTrigger).toBe("manual"); + expect(row.lastNewSampleAt?.toISOString()).toBe( + row.lastReceivedAt.toISOString(), + ); + // Stored as the instant it is, whatever zone the process runs in. + expect(Math.abs(row.lastReceivedAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + } + }); + + it("moves the arrival but keeps the last new value on a duplicates-only re-send", async () => { + await armSession(); + const weight = entry( + "HKQuantityTypeIdentifierBodyMass", + 70, + "kg", + T1, + "uuid-w1", + ); + await postBatch({ syncTrigger: "foreground", entries: [weight] }); + const first = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + await postBatch({ syncTrigger: "background", entries: [weight] }); + const second = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + + expect(second.lastTrigger).toBe("background"); + expect(second.lastReceivedAt.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + expect(second.lastNewSampleAt?.toISOString()).toBe( + first.lastNewSampleAt?.toISOString(), + ); + }); + + it("stores a missing trigger as null rather than keeping the previous one", async () => { + await armSession(); + await postBatch({ + syncTrigger: "push", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + ], + }); + await postBatch({ + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 71, "kg", T2, "uuid-w2"), + ], + }); + const row = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + expect(row.lastTrigger).toBeNull(); + }); + + it("records nothing for MANUAL rows or for a scoped credential", async () => { + await armSession(); + await postBatch({ + syncTrigger: "foreground", + entries: [ + { + ...entry( + "HKQuantityTypeIdentifierBodyMass", + 70, + "kg", + T1, + "manual-w1", + ), + source: "MANUAL", + }, + ], + }); + cookieJar.clear(); + + const raw = `hlk_typesync_${"0".repeat(48)}`; + await getPrismaClient().apiToken.create({ + data: { + userId: USER_ID, + name: "bridge", + tokenHash: hashToken(raw), + permissions: ["measurements:write"], + }, + }); + headerJar.set("authorization", `Bearer ${raw}`); + const res = await postBatch( + { + syncTrigger: "background", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 72, "kg", T2, "uuid-b1"), + ], + }, + { authorization: `Bearer ${raw}` }, + ); + expect(res.status).toBe(200); + + expect( + await getPrismaClient().healthKitTypeSync.count({ + where: { userId: USER_ID }, + }), + ).toBe(0); + }); + + it("serves the arrival facts beside the newest live sample", async () => { + await armSession(); + await postBatch({ + syncTrigger: "manual", + entries: [ + entry( + "HKQuantityTypeIdentifierRespiratoryRate", + 14, + "count/min", + T1, + "uuid-r1", + ), + entry( + "HKQuantityTypeIdentifierRespiratoryRate", + 15, + "count/min", + T2, + "uuid-r2", + ), + ], + }); + // A type that came only from the export import: live rows, no arrival. + await getPrismaClient().measurement.create({ + data: { + userId: USER_ID, + type: "WEIGHT", + value: 70, + unit: "kg", + measuredAt: new Date(T1), + source: "APPLE_HEALTH", + externalId: "xml-w1", + }, + }); + // The newest respiratory row is a tombstone; the read must skip it. + await getPrismaClient().measurement.updateMany({ + where: { userId: USER_ID, externalId: "uuid-r2" }, + data: { deletedAt: new Date() }, + }); + + const { GET } = await import("@/app/api/integrations/healthkit/route"); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/integrations/healthkit"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metricFreshness: Array<{ + type: string; + lastSeenAt: string; + lastReceivedAt?: string | null; + lastTrigger?: string | null; + }>; + }; + }; + const byType = Object.fromEntries( + body.data.metricFreshness.map((row) => [row.type, row]), + ); + expect(byType.RESPIRATORY_RATE.lastSeenAt).toBe(T1); + expect(byType.RESPIRATORY_RATE.lastTrigger).toBe("manual"); + expect(byType.RESPIRATORY_RATE.lastReceivedAt).toEqual(expect.any(String)); + expect(byType.WEIGHT).toMatchObject({ + lastSeenAt: T1, + lastReceivedAt: null, + lastTrigger: null, + }); + }); +}); From 2731c4639932deeb0b63dc4c15e13d85105f22a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:28:56 +0200 Subject: [PATCH 2/6] feat(workouts): edit a hand-entered workout from its detail page A workout logged by hand could only be deleted and logged again. The detail page now offers an edit control beside delete for a MANUAL workout carrying the form's `manual:` id, in one's own record. It opens the log sheet on the stored values and saves by re-posting that id through `POST /api/workouts/batch`, which already applies such a re-post as an overwrite of that one row. No new route. The overwrite nulls every overwritable column a re-post leaves out, and the form has no fields for heart rate, steps, elevation or pauses. The edit therefore sends those back as stored. A field left as shown sends the stored value rather than one re-parsed from its display, so saving an untouched edit keeps the start's seconds and a distance shown in miles at its exact metres, and answers `duplicate`. The detail read's `avgHr` / `maxHr` can come from a same-session twin; the read now also serves the row's own `storedAvgHr` / `storedMaxHr`, which is what the edit sends back. --- docs/api/openapi.yaml | 16 ++ src/__tests__/i18n-reverse-coverage.test.ts | 3 - src/app/api/workouts/[id]/route.ts | 6 + src/app/insights/workouts/[id]/page.tsx | 18 +- .../__tests__/manual-workout-form.test.tsx | 41 +++- .../workouts/edit-workout-button.tsx | 83 ++++++++ .../workouts/manual-workout-form.tsx | 41 +++- .../workouts/manual-workout-sheet.tsx | 27 ++- src/hooks/use-workouts.ts | 7 + src/lib/openapi/routes/workouts.ts | 16 ++ .../workouts/__tests__/manual-entry.test.ts | 132 ++++++++++++ src/lib/workouts/manual-entry.ts | 157 +++++++++++++- tests/integration/manual-workout-edit.test.ts | 192 ++++++++++++++++++ 13 files changed, 717 insertions(+), 22 deletions(-) create mode 100644 src/components/workouts/edit-workout-button.tsx create mode 100644 tests/integration/manual-workout-edit.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eb8af506b..21d03505e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -45064,6 +45064,22 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + storedAvgHr: + description: The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the + canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's + value is not written onto it. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + storedMaxHr: + description: The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" stepCount: anyOf: - type: integer diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 4a2083a39..ecf56219c 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -91,9 +91,6 @@ const RELEASE_PENDING_PREFIXES: Record = { "environment.fields.europeanAqi": "v1.42 air-quality field label (#615)", "environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)", "environment.fields.dustMax": "v1.42 air-quality field label (#615)", - "insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)", - "insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)", - "insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)", "vaccinations.custom": "v1.42 user-defined vaccines (#1005)", "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", }; diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index a5ff667fa..033cebee8 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -335,6 +335,12 @@ export const GET = apiHandler( avgHr: effectiveAvgHeartRate, maxHr: effectiveMaxHeartRate, minHr: row.minHeartRate, + // The row's own heart rate. `avgHr` / `maxHr` above may be filled in + // from a twin by the canonical picker, which is right for display and + // wrong for an edit: re-posting them would store the twin's values on + // the hand-entered row (#1162). + storedAvgHr: row.avgHeartRate, + storedMaxHr: row.maxHeartRate, stepCount: row.stepCount, elevationM: row.elevationM, pauseDurationSec: row.pauseDurationSec, diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 4d8d66d64..338d55b20 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route"; import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button"; +import { EditWorkoutButton } from "@/components/workouts/edit-workout-button"; import { WorkoutDetailHeader, WorkoutDetailStats, @@ -65,10 +66,21 @@ export default function InsightsWorkoutDetailPage({ // No description. The one this page used to carry belongs to the LIST // ("Recent runs, rides, walks…, deduped…") and describes a surface // this is not — a detail page must not claim to be the list. - // A workout entered by hand can be deleted here; the control renders - // nothing for a synced one. + // A workout entered by hand can be edited (#1162) and deleted here; + // both controls render nothing for a synced one. headerAction={ - data && !canonicalHref ? : null + data && !canonicalHref ? ( +
+ + +
+ ) : null } backLink={ { }); }); }); + +describe(" — editing a stored workout (#1162)", () => { + const original = manualWorkoutOriginalFromRow( + { + sportType: "cycling", + startedAt: "2026-09-15T06:00:00.000Z", + endedAt: "2026-09-15T07:30:00.000Z", + durationSec: 5400, + distanceM: 32000, + activeEnergyKcal: 640, + minHr: null, + stepCount: null, + elevationM: null, + pauseDurationSec: null, + storedAvgHr: 131, + storedMaxHr: null, + }, + { timezone: "Europe/Berlin", unitPreference: "metric" }, + ); + + it("opens on the stored values", () => { + const html = render( + , + ); + expect(html).toContain('value="2026-09-15T08:00"'); + expect(html).toContain('value="1"'); + expect(html).toContain('value="30"'); + expect(html).toContain('value="32"'); + expect(html).toContain('value="640"'); + }); + + it("opens empty when it is not editing", () => { + const html = render(); + expect(html).not.toContain('value="640"'); + }); +}); diff --git a/src/components/workouts/edit-workout-button.tsx b/src/components/workouts/edit-workout-button.tsx new file mode 100644 index 000000000..ba9333959 --- /dev/null +++ b/src/components/workouts/edit-workout-button.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Pencil } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet"; +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { useTranslations } from "@/lib/i18n/context"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { cn } from "@/lib/utils"; +import { + canEditWorkout, + manualWorkoutOriginalFromRow, + type StoredManualWorkout, +} from "@/lib/workouts/manual-entry"; + +/** + * The detail page's edit control (#1162): a ghost icon beside delete that + * opens the "Log workout" sheet on the stored values. Renders nothing for a + * workout it may not edit (a synced one, or somebody else's record). + * + * Saving goes through the same `POST /api/workouts/batch` the form always + * used, re-posting the row's own `manual:` id, which the route applies as an + * overwrite of that one row. No separate edit route exists. + */ +export function EditWorkoutButton({ + workout, +}: { + workout: StoredManualWorkout & { + source: string; + externalId: string | null; + }; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const { preference } = useUnitDisplay(); + const capabilities = useRecordCapabilities(); + const [open, setOpen] = useState(false); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + + const original = useMemo( + () => + manualWorkoutOriginalFromRow(workout, { + timezone, + unitPreference: preference, + }), + [workout, timezone, preference], + ); + + if (!canEditWorkout(workout, capabilities) || !workout.externalId) { + return null; + } + + return ( + <> + + {/* The sheet mounts its form only while open, so each opening starts + from the values the page shows at that moment. */} + + + ); +} diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index 4455c83ae..b3f3a40e5 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -1,9 +1,9 @@ "use client"; -import { useId, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; -import { Loader2, Plus } from "lucide-react"; +import { Check, Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -33,6 +33,7 @@ import { type ManualWorkoutDraft, type ManualWorkoutEntry, type ManualWorkoutField, + type ManualWorkoutOriginal, } from "@/lib/workouts/manual-entry"; import { workoutSportTypeEnum, @@ -49,10 +50,23 @@ import { * `onSuccess` / `onCancel` close the sheet. */ +/** + * Edit mode (#1162): the stored workout's own `manual:` id and the values it + * opens with. Saving re-posts that id, which the batch route applies as an + * overwrite of that one row. + */ +export interface ManualWorkoutEdit { + externalId: string; + original: ManualWorkoutOriginal; +} + interface ManualWorkoutFormProps { onSuccess?: () => void; onCancel?: () => void; footerSlot?: HTMLElement | null; + edit?: ManualWorkoutEdit; + /** Whether the fields differ from what the form opened with. */ + onDirtyChange?: (dirty: boolean) => void; } interface BatchResult { @@ -104,6 +118,8 @@ export function ManualWorkoutForm({ onSuccess, onCancel, footerSlot, + edit, + onDirtyChange, }: ManualWorkoutFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -114,10 +130,20 @@ export function ManualWorkoutForm({ // Minted once per opened form and sent with every submit, so a second tap // or a retried request lands as a duplicate rather than a second row, and // a submit after an edit updates the row the first submit stored. - const [externalId] = useState(newManualWorkoutExternalId); - const [draft, setDraft] = useState(() => - emptyManualWorkoutDraft(), + // An edit sends the stored row's own id instead. + const [externalId] = useState( + () => edit?.externalId ?? newManualWorkoutExternalId(), ); + const [initialDraft] = useState( + () => edit?.original.draft ?? emptyManualWorkoutDraft(), + ); + const [draft, setDraft] = useState(initialDraft); + const dirty = ( + Object.keys(initialDraft) as Array + ).some((key) => draft[key] !== initialDraft[key]); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); const [errors, setErrors] = useState< Partial> >({}); @@ -158,6 +184,7 @@ export function ManualWorkoutForm({ unitPreference: preference, externalId, now: new Date(), + original: edit?.original, }); if (!built.ok) { setErrors(built.errors); @@ -174,6 +201,8 @@ export function ManualWorkoutForm({ "empty", t("insights.workouts.manual.alreadySaved"), ); + } else if (outcome === "updated") { + toastWrittenOutcome("success", t("insights.workouts.manual.updated")); } else { toastWrittenOutcome("success", t("common.saved")); } @@ -216,6 +245,8 @@ export function ManualWorkoutForm({ > {saving ? ( + ) : edit ? ( + ) : ( )} diff --git a/src/components/workouts/manual-workout-sheet.tsx b/src/components/workouts/manual-workout-sheet.tsx index 47903471f..106fbb0e7 100644 --- a/src/components/workouts/manual-workout-sheet.tsx +++ b/src/components/workouts/manual-workout-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -14,7 +14,10 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets"; -import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form"; +import { + ManualWorkoutForm, + type ManualWorkoutEdit, +} from "@/components/workouts/manual-workout-form"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -23,21 +26,31 @@ import { useTranslations } from "@/lib/i18n/context"; * `ManualWorkoutForm` inside their own sheets, which already carry the same * confirm-before-discard guard; this one carries it too, so a half-filled * form is never dropped by a stray swipe. + * + * With `edit` it opens on a stored workout (#1162). Its fields start filled, + * so "has input" says nothing there; the guard asks the form whether anything + * differs from what it opened with. */ export function ManualWorkoutSheet({ open, onOpenChange, + edit, }: { open: boolean; onOpenChange: (open: boolean) => void; + edit?: ManualWorkoutEdit; }) { const { t } = useTranslations(); const [footerEl, setFooterEl] = useState(null); const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + const editDirty = useRef(false); + const handleDirtyChange = useCallback((dirty: boolean) => { + editDirty.current = dirty; + }, []); function handleOpenChange(next: boolean) { if (next) return onOpenChange(true); - if (sheetBodyHasUnsavedInput()) { + if (edit ? editDirty.current : sheetBodyHasUnsavedInput()) { setConfirmDiscardOpen(true); return; } @@ -49,7 +62,11 @@ export function ManualWorkoutSheet({ } > @@ -58,6 +75,8 @@ export function ManualWorkoutSheet({ onSuccess={() => onOpenChange(false)} onCancel={() => onOpenChange(false)} footerSlot={footerEl} + edit={edit} + onDirtyChange={edit ? handleDirtyChange : undefined} /> )} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index f37d0ec33..9b08342d2 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -305,6 +305,13 @@ export interface WorkoutDetailPayload extends WorkoutListEntry { */ dayKey: string; minHr: number | null; + /** + * The row's own heart rate, before the canonical picker filled any of it + * in from a twin. An edit of a hand-entered workout sends these back + * (#1162); `avgHr` / `maxHr` are for display. + */ + storedAvgHr?: number | null; + storedMaxHr?: number | null; stepCount: number | null; elevationM: number | null; pauseDurationSec: number | null; diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 037b49bb5..e3c4678a1 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -221,6 +221,22 @@ const workoutDetailResponse = z avgHr: z.number().int().nullable(), maxHr: z.number().int().nullable(), minHr: z.number().int().nullable(), + storedAvgHr: z + .number() + .int() + .nullable() + .optional() + .describe( + "The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's value is not written onto it.", + ), + storedMaxHr: z + .number() + .int() + .nullable() + .optional() + .describe( + "The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`.", + ), stepCount: z.number().int().nullable(), elevationM: z.number().nullable(), pauseDurationSec: z.number().int().nullable(), diff --git a/src/lib/workouts/__tests__/manual-entry.test.ts b/src/lib/workouts/__tests__/manual-entry.test.ts index 001142455..be9902419 100644 --- a/src/lib/workouts/__tests__/manual-entry.test.ts +++ b/src/lib/workouts/__tests__/manual-entry.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { buildManualWorkoutEntry, + canEditWorkout, canLogWorkout, + manualWorkoutOriginalFromRow, + type StoredManualWorkout, emptyManualWorkoutDraft, newManualWorkoutExternalId, wallClockNow, @@ -283,3 +286,132 @@ describe("canLogWorkout — who is offered the entry", () => { expect(canLogWorkout(SHARED, { workouts: true })).toBe(false); }); }); + +describe("editing a stored workout (#1162)", () => { + // 07:12:34 in Berlin, 47 min 20 s, 5 000 m, with values the form has no + // field for (written by a client other than this form). + const ROW: StoredManualWorkout = { + sportType: "running", + startedAt: "2026-09-15T05:12:34.000Z", + endedAt: "2026-09-15T05:59:54.000Z", + durationSec: 2840, + distanceM: 5000, + activeEnergyKcal: 410, + minHr: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + storedAvgHr: 142, + storedMaxHr: 171, + }; + const EDIT_ID = "manual:7c1d2e3f-aaaa-4bbb-8ccc-dddddddddddd"; + + function original(unitPreference: "metric" | "imperial" = "metric") { + return manualWorkoutOriginalFromRow(ROW, { + timezone: "Europe/Berlin", + unitPreference, + }); + } + + function save( + edit: Partial, + unitPreference: "metric" | "imperial" = "metric", + ) { + const o = original(unitPreference); + const result = buildManualWorkoutEntry( + { ...o.draft, ...edit }, + { ...CTX, unitPreference, externalId: EDIT_ID, original: o }, + ); + if (!result.ok) throw new Error(JSON.stringify(result.errors)); + return result.entry; + } + + it("opens on the stored values, in the profile zone and the reader's unit", () => { + expect(original().draft).toEqual({ + sportType: "running", + start: "2026-09-15T07:12", + hours: "0", + minutes: "47", + distance: "5", + energyKcal: "410", + }); + expect(original("imperial").draft.distance).toBe("3.11"); + }); + + it("saves an untouched edit as exactly the stored row", () => { + expect(save({}, "imperial")).toEqual({ + sportType: "running", + startedAt: ROW.startedAt, + endedAt: ROW.endedAt, + source: "MANUAL", + externalId: EDIT_ID, + totalDistanceM: 5000, + totalEnergyKcal: 410, + avgHeartRate: 142, + maxHeartRate: 171, + minHeartRate: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + }); + }); + + // The batch route nulls every overwritable column a re-post leaves out, so + // an edit of one field must carry the rest or it erases them. + it("keeps heart rate, steps and the rest when only the energy changes", () => { + const entry = save({ energyKcal: "450" }); + expect(entry.totalEnergyKcal).toBe(450); + expect(entry).toMatchObject({ + avgHeartRate: 142, + maxHeartRate: 171, + minHeartRate: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + totalDistanceM: 5000, + startedAt: ROW.startedAt, + }); + }); + + it("re-derives start and end from the fields once either changes", () => { + const entry = save({ minutes: "50" }); + // 07:12 Berlin = 05:12Z, now to the minute, plus 50 minutes. + expect(entry.startedAt).toBe("2026-09-15T05:12:00.000Z"); + expect(entry.endedAt).toBe("2026-09-15T06:02:00.000Z"); + }); + + it("clears a distance the person emptied", () => { + expect(save({ distance: "" })).not.toHaveProperty("totalDistanceM"); + }); + + it("is accepted by the batch route's own schema", () => { + const parsed = createBatchWorkoutSchema.safeParse({ + workouts: [save({ energyKcal: "450" })], + }); + expect(parsed.success).toBe(true); + }); + + it("is offered for a form-entered workout in one's own record only", () => { + const own = { inSharedRecord: false }; + expect(canEditWorkout({ source: "MANUAL", externalId: EDIT_ID }, own)).toBe( + true, + ); + expect( + canEditWorkout( + { source: "MANUAL", externalId: EDIT_ID }, + { inSharedRecord: true }, + ), + ).toBe(false); + expect( + canEditWorkout({ source: "APPLE_HEALTH", externalId: EDIT_ID }, own), + ).toBe(false); + // A MANUAL row without the form's id would not be overwritten by a + // re-post, so it is not offered. + expect(canEditWorkout({ source: "MANUAL", externalId: null }, own)).toBe( + false, + ); + expect( + canEditWorkout({ source: "MANUAL", externalId: "uuid-123" }, own), + ).toBe(false); + }); +}); diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index d672ae8ea..09e87eca6 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -19,6 +19,7 @@ import { z } from "zod/v4"; import { + applyDisplayTransform, getQuantityTransform, invertDisplayTransform, type UnitPreference, @@ -59,8 +60,24 @@ export interface ManualWorkoutDraft { export type ManualWorkoutField = "sportType" | "start" | "duration" | "distance" | "energyKcal"; +/** + * The columns of a hand-entered workout the form has no field for. The batch + * route's overwrite of a `manual:` row replaces EVERY overwritable column, + * nulling the ones a re-post leaves out, so an edit sends these back exactly + * as stored. A MANUAL row can carry them when a client other than this form + * wrote it (a phone shortcut posting heart rate or steps). + */ +export interface ManualWorkoutCarried { + avgHeartRate?: number; + maxHeartRate?: number; + minHeartRate?: number; + stepCount?: number; + elevationM?: number; + pauseDurationSec?: number; +} + /** The one entry of the `POST /api/workouts/batch` body this form sends. */ -export interface ManualWorkoutEntry { +export interface ManualWorkoutEntry extends ManualWorkoutCarried { sportType: WorkoutSportType; startedAt: string; endedAt: string; @@ -70,6 +87,23 @@ export interface ManualWorkoutEntry { totalEnergyKcal?: number; } +/** + * The stored row an edit starts from (#1162): the draft it was shown as, and + * the stored values behind it. A field whose text the person left as shown + * sends the stored value, not the value re-parsed from that text, so opening + * and saving an edit never moves a start by its seconds or a distance by the + * rounding of its display (5 000 m shown as 3.11 mi would come back as + * 5 005.1 m). + */ +export interface ManualWorkoutOriginal { + draft: ManualWorkoutDraft; + startedAt: string; + endedAt: string; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + carried: ManualWorkoutCarried; +} + export interface ManualWorkoutContext { /** The profile timezone the start is read in. */ timezone: string; @@ -77,6 +111,8 @@ export interface ManualWorkoutContext { /** The id this form instance sends with every submit. */ externalId: string; now: Date; + /** Set when the form edits a stored workout rather than logging one. */ + original?: ManualWorkoutOriginal; } export type ManualWorkoutResult = @@ -106,6 +142,90 @@ export function canLogWorkout( return !caps.inSharedRecord && isSurfaceVisible("capture:workout", modules); } +/** + * Whether a workout can be edited from its detail page (#1162): one entered + * by hand through the form, in one's own record. The edit is a re-post of the + * row's own `manual:` id through `POST /api/workouts/batch`, which the route + * treats as an overwrite of that row and of nothing else, so a MANUAL row + * without such an id (none is expected; the form has always minted one) can + * still be deleted but not edited. + */ +export function canEditWorkout( + workout: { source: string; externalId: string | null }, + caps: { inSharedRecord: boolean }, +): boolean { + return ( + workout.source === "MANUAL" && + typeof workout.externalId === "string" && + workout.externalId.startsWith("manual:") && + !caps.inSharedRecord + ); +} + +/** The stored workout an edit opens from, in the detail read's field names. */ +export interface StoredManualWorkout { + sportType: string; + startedAt: string; + endedAt: string; + durationSec: number; + distanceM: number | null; + activeEnergyKcal: number | null; + minHr: number | null; + stepCount: number | null; + elevationM: number | null; + pauseDurationSec: number | null; + /** The row's own heart rate, before any twin filled it in. */ + storedAvgHr: number | null; + storedMaxHr: number | null; +} + +/** + * The draft a stored workout is shown as, and the stored values behind it. + * The start reads in the profile timezone to the minute, the duration in + * whole minutes, the distance in the reader's unit at its display precision. + */ +export function manualWorkoutOriginalFromRow( + row: StoredManualWorkout, + opts: { timezone: string; unitPreference: UnitPreference }, +): ManualWorkoutOriginal { + const sport = workoutSportTypeEnum.safeParse(row.sportType); + const totalMinutes = Math.max(0, Math.round(row.durationSec / 60)); + const distance = + row.distanceM === null + ? "" + : String( + applyDisplayTransform( + row.distanceM, + getQuantityTransform("distance", opts.unitPreference), + ), + ); + const carried: ManualWorkoutCarried = {}; + if (row.storedAvgHr !== null) carried.avgHeartRate = row.storedAvgHr; + if (row.storedMaxHr !== null) carried.maxHeartRate = row.storedMaxHr; + if (row.minHr !== null) carried.minHeartRate = row.minHr; + if (row.stepCount !== null) carried.stepCount = row.stepCount; + if (row.elevationM !== null) carried.elevationM = row.elevationM; + if (row.pauseDurationSec !== null) { + carried.pauseDurationSec = row.pauseDurationSec; + } + return { + draft: { + sportType: sport.success ? sport.data : "", + start: wallClockNow(new Date(row.startedAt), opts.timezone), + hours: String(Math.floor(totalMinutes / 60)), + minutes: String(totalMinutes % 60), + distance, + energyKcal: + row.activeEnergyKcal === null ? "" : String(row.activeEnergyKcal), + }, + startedAt: new Date(row.startedAt).toISOString(), + endedAt: new Date(row.endedAt).toISOString(), + totalDistanceM: row.distanceM, + totalEnergyKcal: row.activeEnergyKcal, + carried, + }; +} + /** A fresh id for one opened form. The prefix says where the row came from. */ export function newManualWorkoutExternalId(): string { return `manual:${randomId()}`; @@ -306,26 +426,51 @@ export function buildManualWorkoutEntry( } // Every branch below was proven by the schema above. + const original = ctx.original; const durationSec = ((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) * 60; - const startedAt = - draft.start.trim() === "" + // An edit whose start and duration read as shown keeps the stored instants + // to the second; changing either re-derives both from the fields. + const timesAsShown = + original !== undefined && + draft.start === original.draft.start && + draft.hours === original.draft.hours && + draft.minutes === original.draft.minutes; + const startedAt = timesAsShown + ? new Date(original.startedAt) + : draft.start.trim() === "" ? new Date(ctx.now.getTime() - durationSec * 1000) : startToUtc(draft.start, ctx.timezone)!; + const endedAt = timesAsShown + ? new Date(original.endedAt) + : new Date(startedAt.getTime() + durationSec * 1000); const distance = parseDecimal(draft.distance); const energy = parseDecimal(draft.energyKcal); const entry: ManualWorkoutEntry = { sportType: draft.sportType as WorkoutSportType, startedAt: startedAt.toISOString(), - endedAt: new Date(startedAt.getTime() + durationSec * 1000).toISOString(), + endedAt: endedAt.toISOString(), source: "MANUAL", externalId: ctx.externalId, + // Everything the form has no field for goes back as stored, because the + // overwrite nulls whatever a re-post leaves out. + ...(original?.carried ?? {}), }; - if (distance !== null) { + if (original && draft.distance === original.draft.distance) { + if (original.totalDistanceM !== null) { + entry.totalDistanceM = original.totalDistanceM; + } + } else if (distance !== null) { entry.totalDistanceM = toMetres(distance, ctx.unitPreference); } - if (energy !== null) entry.totalEnergyKcal = energy; + if (original && draft.energyKcal === original.draft.energyKcal) { + if (original.totalEnergyKcal !== null) { + entry.totalEnergyKcal = original.totalEnergyKcal; + } + } else if (energy !== null) { + entry.totalEnergyKcal = energy; + } return { ok: true, entry }; } diff --git a/tests/integration/manual-workout-edit.test.ts b/tests/integration/manual-workout-edit.test.ts new file mode 100644 index 000000000..1abf71e2a --- /dev/null +++ b/tests/integration/manual-workout-edit.test.ts @@ -0,0 +1,192 @@ +/** + * Editing a hand-entered workout (#1162), end to end against real Postgres: + * the detail read the page opens the edit sheet from, the entry the form + * builds from it, and the batch route's overwrite of the `manual:` row. + * + * The overwrite replaces every overwritable column and nulls the ones a + * re-post leaves out. The form has no heart-rate or step fields, so an edit + * that did not carry them back would erase them; and the detail read's + * `avgHr` can be filled in from a same-session twin, which must not end up + * stored on the hand-entered row. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + buildManualWorkoutEntry, + manualWorkoutOriginalFromRow, +} from "@/lib/workouts/manual-entry"; + +const USER_ID = "user-manual-workout-edit"; +const EXTERNAL_ID = "manual:0f9e8d7c-1111-4222-8333-444455556666"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "manual-workout-edit", + email: "manual-workout-edit@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +async function postWorkouts(workouts: unknown[]) { + const { POST } = await import("@/app/api/workouts/batch/route"); + const res = await POST( + new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workouts }), + }), + ); + expect(res.status).toBe(200); + return ( + (await res.json()) as { data: { entries: Array<{ status: string }> } } + ).data.entries; +} + +async function readDetail(id: string) { + const { GET } = await import("@/app/api/workouts/[id]/route"); + const res = await ( + GET as unknown as ( + r: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )(new NextRequest(`http://localhost/api/workouts/${id}?compact=1`), { + params: Promise.resolve({ id }), + }); + expect(res.status).toBe(200); + return ((await res.json()) as { data: Record }).data; +} + +describe("editing a hand-entered workout (#1162)", () => { + it("changes the edited field and keeps every field the form has no input for", async () => { + // A MANUAL row carrying heart rate and steps, as a client other than the + // web form can write it. + const [created] = await postWorkouts([ + { + sportType: "running", + startedAt: "2026-09-15T05:12:34.000Z", + endedAt: "2026-09-15T05:59:54.000Z", + source: "MANUAL", + externalId: EXTERNAL_ID, + totalDistanceM: 5000, + totalEnergyKcal: 410, + avgHeartRate: 142, + maxHeartRate: 171, + minHeartRate: 98, + stepCount: 6400, + elevationM: 42.5, + pauseDurationSec: 60, + }, + ]); + expect(created?.status).toBe("inserted"); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: USER_ID, externalId: EXTERNAL_ID }, + }); + + const detail = await readDetail(row.id); + expect(detail.storedAvgHr).toBe(142); + const original = manualWorkoutOriginalFromRow( + detail as unknown as Parameters[0], + { timezone: "Europe/Berlin", unitPreference: "imperial" }, + ); + const built = buildManualWorkoutEntry( + { ...original.draft, energyKcal: "455" }, + { + timezone: "Europe/Berlin", + unitPreference: "imperial", + externalId: detail.externalId as string, + now: new Date("2026-09-16T12:00:00.000Z"), + original, + }, + ); + if (!built.ok) throw new Error(JSON.stringify(built.errors)); + + const [edited] = await postWorkouts([built.entry]); + expect(edited?.status).toBe("updated"); + + const after = await getPrismaClient().workout.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(after.totalEnergyKcal).toBe(455); + expect(after.avgHeartRate).toBe(142); + expect(after.maxHeartRate).toBe(171); + expect(after.minHeartRate).toBe(98); + expect(after.stepCount).toBe(6400); + expect(after.elevationM).toBe(42.5); + expect(after.pauseDurationSec).toBe(60); + // Shown as 3.11 mi and left alone: the stored metres, not 5 005.1. + expect(after.totalDistanceM).toBe(5000); + expect(after.startedAt.toISOString()).toBe("2026-09-15T05:12:34.000Z"); + expect(after.endedAt.toISOString()).toBe("2026-09-15T05:59:54.000Z"); + expect( + await getPrismaClient().workout.count({ where: { userId: USER_ID } }), + ).toBe(1); + }); + + it("answers duplicate, and writes nothing, for an edit saved unchanged", async () => { + await postWorkouts([ + { + sportType: "cycling", + startedAt: "2026-09-15T06:00:00.000Z", + endedAt: "2026-09-15T07:30:00.000Z", + source: "MANUAL", + externalId: EXTERNAL_ID, + totalDistanceM: 32000, + }, + ]); + const row = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: USER_ID }, + }); + const detail = await readDetail(row.id); + const original = manualWorkoutOriginalFromRow( + detail as unknown as Parameters[0], + { timezone: "Europe/Berlin", unitPreference: "metric" }, + ); + const built = buildManualWorkoutEntry(original.draft, { + timezone: "Europe/Berlin", + unitPreference: "metric", + externalId: EXTERNAL_ID, + now: new Date("2026-09-16T12:00:00.000Z"), + original, + }); + if (!built.ok) throw new Error(JSON.stringify(built.errors)); + const [result] = await postWorkouts([built.entry]); + expect(result?.status).toBe("duplicate"); + }); +}); From 370270bbc1622f2fa81bdc8969a42c31ff07da01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:37:04 +0200 Subject: [PATCH 3/6] feat(apple-health): ingest RMSSD heart-rate variability from iOS 27 iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD` (Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`, available from 27.0). The live batch and the export.zip import now store it as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The accepted range is 1 to 300 ms. RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a day holding it from Apple Health and a strap resolves to one source instead of a name tiebreak. SDNN and RMSSD are different statistics and are kept apart once one account holds both. The HRV page charts each on its own, titled with its measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section carries one timeline per measure under `byMeasure` (same section key, so every reader still finds it) rather than one daily series averaging the two; the budget pass condenses and sheds each measure's timeline like a glucose context. --- docs/integrations/apple-health.md | 11 ++ src/app/insights/hrv/page.tsx | 6 +- ...ealthkit-metric-page-hrv-measures.test.tsx | 114 ++++++++++++ .../insights/healthkit-metric-page.tsx | 48 ++++- .../__tests__/hrv-measures-snapshot.test.ts | 90 +++++++++ src/lib/ai/coach/series-condense.ts | 11 +- .../snapshot-blocks/value-series-blocks.ts | 175 +++++++++++++----- src/lib/ai/coach/snapshot.ts | 19 +- src/lib/analytics/source-rank-sql.ts | 2 + .../__tests__/apple-health-mapping.test.ts | 34 +++- .../__tests__/cumulative-day-sum.test.ts | 8 + src/lib/measurements/apple-health-mapping.ts | 15 ++ src/lib/measurements/cumulative-day-sum.ts | 5 + tests/integration/hrv-rmssd-ingest.test.ts | 138 ++++++++++++++ 14 files changed, 618 insertions(+), 58 deletions(-) create mode 100644 src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx create mode 100644 src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts create mode 100644 tests/integration/hrv-rmssd-ingest.test.ts diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 456dd96c3..dc7a3d615 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -120,6 +120,14 @@ The HK quantity types that map to HealthLog metric types live in mapped set (for example workout routes) is counted as unsupported and not stored. +Heart-rate variability comes in two measures, and they stay two types. +`HeartRateVariabilitySDNN` becomes `HEART_RATE_VARIABILITY`; +`HeartRateVariabilityRMSSD`, which iOS and watchOS 27 added, becomes +`HRV_RMSSD`, the same type WHOOP, Oura and Polar write. RMSSD readings +up to 300 ms are accepted. When both arrive, the HRV page charts each on +its own and the Coach reads them apart, because one is not a stand-in for +the other. + ### ECG archive safety, privacy, and idempotency ECG support is source-agnostic: if HealthKit includes a valid @@ -284,6 +292,9 @@ Concrete consequences: source tag — the lowest rank — is reserved for legacy CSV/JSON imports that pre-date the Apple Health passthrough. +RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**, +separate from the SDNN one. + Override per-user via Settings → Sources (`/settings/sources`). ## Failure modes diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index fca82fbe7..0ab1e33da 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -15,7 +15,9 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; * SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP * store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present * and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a - * ring / strap user's stored HRV always surfaces. + * ring / strap user's stored HRV always surfaces. Since iOS 27 an Apple + * Watch writes both (#1110); with rows of both the page charts each on its + * own, labelled, because the two are not one measure. */ export default function InsightsHrvPage() { return ( @@ -23,6 +25,8 @@ export default function InsightsHrvPage() { measurementType="HEART_RATE_VARIABILITY" fallbackMeasurementType="HRV_RMSSD" fallbackMeasureLabel="RMSSD" + primaryMeasureLabel="SDNN" + fallbackColor="var(--chart-2)" statusMetric="HEART_RATE_VARIABILITY" insightMetric="HEART_RATE_VARIABILITY" chartKey="hrv" diff --git a/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx new file mode 100644 index 000000000..2f4273ac4 --- /dev/null +++ b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * #1110 — since iOS 27 one Apple Watch writes both HRV measures, SDNN + * (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). The page used to chart + * RMSSD only when there was no SDNN at all, so a watch writing both showed + * SDNN and hid RMSSD. With both present each now gets its own chart, titled + * with its measure; they are never drawn as one line. + * + * The chart is `next/dynamic`; the stub echoes the props that matter. + */ +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), +})); +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = (props: { types?: string[]; title?: string }) => ( +
+ ); + Stub.displayName = "HealthChartStub"; + return Stub; + }, +})); +vi.mock("next/navigation", () => ({ usePathname: () => "/insights/hrv" })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), +})); +const analyticsMock = vi.fn(); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => analyticsMock(), +})); + +import InsightsHrvPage from "@/app/insights/hrv/page"; + +function render() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +function charts(html: string): Array<{ types: string; title: string }> { + return [...html.matchAll(/data-types="([^"]*)" data-title="([^"]*)"/g)].map( + (m) => ({ types: m[1]!, title: m[2]! }), + ); +} + +beforeEach(() => analyticsMock.mockReset()); + +describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => { + it("charts each measure on its own when both have readings", () => { + analyticsMock.mockReturnValue({ + data: { + summaries: { + HEART_RATE_VARIABILITY: { count: 30 }, + HRV_RMSSD: { count: 28 }, + }, + }, + isEmpty: false, + }); + const found = charts(render()); + expect(found).toHaveLength(2); + expect(found[0]).toMatchObject({ types: "HEART_RATE_VARIABILITY" }); + expect(found[0]!.title).toMatch(/SDNN$/); + expect(found[1]).toMatchObject({ types: "HRV_RMSSD" }); + expect(found[1]!.title).toMatch(/RMSSD$/); + }); + + it("keeps one unlabelled SDNN chart when there is no RMSSD", () => { + analyticsMock.mockReturnValue({ + data: { summaries: { HEART_RATE_VARIABILITY: { count: 30 } } }, + isEmpty: false, + }); + const found = charts(render()); + expect(found).toHaveLength(1); + expect(found[0]!.types).toBe("HEART_RATE_VARIABILITY"); + expect(found[0]!.title).not.toMatch(/SDNN|RMSSD/); + }); + + it("charts RMSSD alone, labelled, for a ring or strap user", () => { + analyticsMock.mockReturnValue({ + data: { summaries: { HRV_RMSSD: { count: 28 } } }, + isEmpty: false, + }); + const found = charts(render()); + expect(found).toHaveLength(1); + expect(found[0]!.types).toBe("HRV_RMSSD"); + expect(found[0]!.title).toMatch(/RMSSD$/); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 321fddb30..5acc7c3bb 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -73,6 +73,16 @@ export interface HealthKitMetricPageProps { * `fallbackMeasurementType` is set. */ fallbackMeasureLabel?: string; + /** + * v1.42 (#1110) — short label naming the PRIMARY measure (e.g. "SDNN"). + * Since iOS 27 one account can hold both HRV measures from the same + * watch. They are different statistics on different scales, so when + * both have rows the page charts each on its own, each titled with its + * measure, instead of hiding the second behind the first. + */ + primaryMeasureLabel?: string; + /** Line colour of the fallback measure's own chart; defaults to `color`. */ + fallbackColor?: `var(--${string})`; /** The InsightMetric key used by `useInsightsAnalytics()`. */ insightMetric: InsightMetric; /** The chart-overlay slot id. */ @@ -215,6 +225,8 @@ export function HealthKitMetricPage({ measurementType, fallbackMeasurementType, fallbackMeasureLabel, + primaryMeasureLabel, + fallbackColor, insightMetric, chartKey, i18nPrefix, @@ -269,6 +281,11 @@ export function HealthKitMetricPage({ const effectiveType = usingFallback ? (fallbackMeasurementType as string) : measurementType; + // v1.42 (#1110) — both measures present: the primary keeps the strip, the + // coach read and the value list, and the fallback measure gets a chart of + // its own beneath it. Never one line, never one hidden. + const bothMeasures = + !!fallbackMeasurementType && primaryCount > 0 && fallbackCount > 0; // v1.32.26 — resolve the display unit + scale from the user's preference for // a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved, @@ -421,7 +438,11 @@ export function HealthKitMetricPage({ summary={summary} unit={resolvedYAxisUnit ?? resolvedUnit ?? ""} fractionDigits={resolvedFractionDigits} - seriesLabel={title} + seriesLabel={ + bothMeasures && primaryMeasureLabel + ? `${title} · ${primaryMeasureLabel}` + : title + } icon={statIcon} windowStats={statsByType?.[effectiveType] ?? null} medianLabel={statMedianLabel} @@ -455,7 +476,9 @@ export function HealthKitMetricPage({ title={ usingFallback && fallbackMeasureLabel ? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}` - : t(`${i18nPrefix}.chartTitle`) + : bothMeasures && primaryMeasureLabel + ? `${t(`${i18nPrefix}.chartTitle`)} · ${primaryMeasureLabel}` + : t(`${i18nPrefix}.chartTitle`) } titleIcon={statIcon} colors={[color]} @@ -469,6 +492,27 @@ export function HealthKitMetricPage({ onVisibleStats={onVisibleStats} showDataTable /> + {bothMeasures ? ( + + ) : null} {targetSummarySlug ? ( ) : null} diff --git a/src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts b/src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts new file mode 100644 index 000000000..3b31a2424 --- /dev/null +++ b/src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts @@ -0,0 +1,90 @@ +/** + * #1110 — since iOS 27 one Apple Watch writes both HRV measures: SDNN + * (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). They are different + * statistics on different scales. The Coach's HRV block used to fold every + * row of both into one daily series, so a day with both averaged an SDNN + * value with an RMSSD value. With both present the block now carries one + * timeline per measure under `byMeasure`, under the same section key, and + * the budget pass condenses each as its own series. + */ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; +import { buildValueSeriesBlocks } from "@/lib/ai/coach/snapshot-blocks/value-series-blocks"; +import { + condenseSeriesBlock, + isCondensable, + type DailySeries, +} from "@/lib/ai/coach/series-condense"; +import type { CoachProvenance } from "@/lib/ai/coach/types"; + +const NOW = new Date("2026-10-08T12:00:00.000Z"); +const day = (n: number) => new Date(NOW.getTime() - n * 86_400_000); + +function build(rows: Array<{ type: string; value: number; measuredAt: Date }>) { + const snapshot: Record = {}; + const registered = new Map DailySeries>(); + const counts: NonNullable = {}; + buildValueSeriesBlocks({ + sources: new Set(["hrv"]), + measurementRows: rows, + additiveCutoff: () => day(400), + recentCutoff: day(14), + userTz: "UTC", + snapshot, + metrics: new Set(), + counts, + registerBlock: (key, _source, daily) => { + if (daily) registered.set(key, daily); + }, + groundingValues: new Map(), + units: DEFAULT_UNIT_PREFERENCES, + }); + return { snapshot, registered, counts }; +} + +const both = [ + { type: "HEART_RATE_VARIABILITY", value: 40, measuredAt: day(1) }, + { type: "HRV_RMSSD", value: 70, measuredAt: day(1) }, + { type: "HEART_RATE_VARIABILITY", value: 44, measuredAt: day(2) }, + { type: "HRV_RMSSD", value: 76, measuredAt: day(2) }, + { type: "HEART_RATE_VARIABILITY", value: 42, measuredAt: day(30) }, +]; + +describe("Coach HRV block — SDNN and RMSSD kept apart (#1110)", () => { + it("carries one timeline per measure when both are present", () => { + const { snapshot, counts } = build(both); + const block = snapshot.heartRateVariability as { + unit?: string; + timeline?: unknown; + byMeasure: Record }>; + }; + expect(block.timeline).toBeUndefined(); + expect(block.unit).toBe("ms"); + expect(Object.keys(block.byMeasure)).toEqual(["SDNN", "RMSSD"]); + const sdnn = block.byMeasure.SDNN!.recent.map((r) => r.value); + const rmssd = block.byMeasure.RMSSD!.recent.map((r) => r.value); + // No day mixes the two: SDNN days read 40s, RMSSD days 70s. + expect(sdnn.every((v) => v < 50)).toBe(true); + expect(rmssd.every((v) => v > 60)).toBe(true); + expect(counts.hrv).toBe(5); + }); + + it("registers each measure as its own series, and condenses each", () => { + const { snapshot, registered } = build(both); + const daily = registered.get("heartRateVariability")!(); + expect(Object.keys(daily).sort()).toEqual(["RMSSD", "SDNN"]); + const block = snapshot.heartRateVariability; + expect(isCondensable("heartRateVariability", block)).toBe(true); + condenseSeriesBlock(block, 1, "heartRateVariability", daily); + const summary = (block as { summary?: Record }).summary; + expect(Object.keys(summary ?? {}).sort()).toEqual(["RMSSD", "SDNN"]); + }); + + it("keeps the single-timeline shape when only one measure is present", () => { + const { snapshot } = build(both.filter((row) => row.type === "HRV_RMSSD")); + const block = snapshot.heartRateVariability as Record; + expect(block.byMeasure).toBeUndefined(); + expect(block.timeline).toBeDefined(); + }); +}); diff --git a/src/lib/ai/coach/series-condense.ts b/src/lib/ai/coach/series-condense.ts index e068e9019..428aa2f8a 100644 --- a/src/lib/ai/coach/series-condense.ts +++ b/src/lib/ai/coach/series-condense.ts @@ -238,8 +238,17 @@ function tracksOf(kind: SeriesBlockKind, block: Rec): Track[] { ] : []; switch (kind) { - case "value": + case "value": { + // A metric with several measures (HRV: SDNN and RMSSD, #1110) keeps + // one timeline per measure, each condensed as its own series. + const byMeasure = block.byMeasure; + if (isRecord(byMeasure)) { + return Object.keys(byMeasure).flatMap((measure) => + single(byMeasure[measure], "value", measure), + ); + } return single(block.timeline, "value", null); + } case "sleep": return single(block.timeline, "minutes", null); case "compliance": diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts index 948f636e1..f3ea65239 100644 --- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts +++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts @@ -28,6 +28,15 @@ interface ValueBlock { source: CoachScopeSource; snapshotKey: string; type: string | readonly string[]; + /** + * For a block whose types are different statistics of one thing (HRV: + * SDNN and RMSSD). With rows of one measure the block is built as before; + * with rows of several, the block carries one timeline per measure under + * `byMeasure`, named by measure, instead of one daily series averaging the + * two together (#1110: since iOS 27 one watch writes both). Same section + * key, so every reader that asks for the section still finds it. + */ + measures?: readonly { type: string; label: string }[]; } const VALUE_BLOCKS: readonly ValueBlock[] = [ @@ -36,6 +45,10 @@ const VALUE_BLOCKS: readonly ValueBlock[] = [ source: "hrv", snapshotKey: "heartRateVariability", type: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"], + measures: [ + { type: "HEART_RATE_VARIABILITY", label: "SDNN" }, + { type: "HRV_RMSSD", label: "RMSSD" }, + ], }, { metric: "resting_hr", @@ -271,14 +284,13 @@ export function buildValueSeriesBlocks( const wanted = Array.isArray(block.type) ? new Set(block.type) : null; const blockCutoff = ctx.additiveCutoff(block.source); - const rows = ctx.measurementRows - .filter((row) => - wanted ? wanted.has(row.type) : row.type === block.type, - ) - .map((row) => ({ measuredAt: row.measuredAt, value: row.value })) - .filter((row) => row.measuredAt >= blockCutoff); + const inWindow = ctx.measurementRows.filter( + (row) => + (wanted ? wanted.has(row.type) : row.type === block.type) && + row.measuredAt >= blockCutoff, + ); - if (rows.length === 0) { + if (inWindow.length === 0) { const cluster = sourceCluster(block.source); if (cluster) { annotate({ @@ -289,43 +301,120 @@ export function buildValueSeriesBlocks( continue; } - const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz); - // Aggregated on the canonical value (the grounding below reads that), - // stated in the reader's unit. The two HRV estimators share one unit. - const transform = getReadingTransform( - typeof block.type === "string" ? block.type : block.type[0], - ctx.units, + const present = block.measures?.filter((measure) => + inWindow.some((row) => row.type === measure.type), ); - ctx.snapshot[block.snapshotKey] = { - ...(UNLABELLED_UNITS.has(transform.displayUnit) - ? {} - : { unit: transform.displayUnit }), - timeline: timelineInUnit( - { - recent: recentRows, - weekly: bucketWeekly( - rows.filter((row) => row.measuredAt < ctx.recentCutoff), - ctx.userTz, - ), - }, - transform, - ), - }; - ctx.metrics.add(block.metric); - ctx.counts[block.metric] = rows.length; - ctx.registerBlock(block.snapshotKey, block.source, () => ({ - value: dailyPoints(rows, ctx.userTz, undefined, transform), - })); - - const referenceMetric = - typeof block.type === "string" - ? TYPE_TO_REFERENCE_METRIC[block.type] - : undefined; - if (referenceMetric && recentRows.length > 0) { - const values = recentRows.slice(-14); - const mean = - values.reduce((sum, row) => sum + row.value, 0) / values.length; - ctx.groundingValues.set(referenceMetric, mean); + if (present && present.length > 1) { + writeMeasuresSection(ctx, block, present, inWindow); + } else { + writeValueSection( + ctx, + block, + typeof block.type === "string" ? block.type : block.type[0]!, + inWindow, + ); } + ctx.metrics.add(block.metric); + ctx.counts[block.metric] = inWindow.length; + } +} + +type ValueRows = ValueSeriesBlocksContext["measurementRows"]; + +function toPoints(rows: ValueRows) { + return rows.map((row) => ({ measuredAt: row.measuredAt, value: row.value })); +} + +function valueTimeline( + ctx: Readonly, + rows: ReturnType, + transform: ReturnType, +) { + return timelineInUnit( + { + recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz), + weekly: bucketWeekly( + rows.filter((row) => row.measuredAt < ctx.recentCutoff), + ctx.userTz, + ), + }, + transform, + ); +} + +/** + * Several measures of one metric, each its own timeline under `byMeasure`, + * and each its own series should the budget pass condense the block. + */ +function writeMeasuresSection( + ctx: Readonly, + block: ValueBlock, + measures: readonly { type: string; label: string }[], + inWindow: ValueRows, +): void { + // The measures of one block share a unit (HRV: milliseconds). + const transform = getReadingTransform(measures[0]!.type, ctx.units); + const byMeasure: Record = {}; + const pointsByMeasure = new Map>(); + for (const measure of measures) { + const rows = toPoints(inWindow.filter((row) => row.type === measure.type)); + pointsByMeasure.set(measure.label, rows); + byMeasure[measure.label] = valueTimeline(ctx, rows, transform); + } + ctx.snapshot[block.snapshotKey] = { + ...(UNLABELLED_UNITS.has(transform.displayUnit) + ? {} + : { unit: transform.displayUnit }), + byMeasure, + }; + ctx.registerBlock(block.snapshotKey, block.source, () => + Object.fromEntries( + [...pointsByMeasure].map(([label, rows]) => [ + label, + dailyPoints(rows, ctx.userTz, undefined, transform), + ]), + ), + ); +} + +function writeValueSection( + ctx: Readonly, + block: ValueBlock, + unitType: string, + sourceRows: ValueRows, +): void { + const rows = toPoints(sourceRows); + const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz); + // Aggregated on the canonical value (the grounding below reads that), + // stated in the reader's unit. + const transform = getReadingTransform(unitType, ctx.units); + ctx.snapshot[block.snapshotKey] = { + ...(UNLABELLED_UNITS.has(transform.displayUnit) + ? {} + : { unit: transform.displayUnit }), + timeline: timelineInUnit( + { + recent: recentRows, + weekly: bucketWeekly( + rows.filter((row) => row.measuredAt < ctx.recentCutoff), + ctx.userTz, + ), + }, + transform, + ), + }; + ctx.registerBlock(block.snapshotKey, block.source, () => ({ + value: dailyPoints(rows, ctx.userTz, undefined, transform), + })); + + const referenceMetric = + typeof block.type === "string" + ? TYPE_TO_REFERENCE_METRIC[block.type] + : undefined; + if (referenceMetric && recentRows.length > 0) { + const values = recentRows.slice(-14); + const mean = + values.reduce((sum, row) => sum + row.value, 0) / values.length; + ctx.groundingValues.set(referenceMetric, mean); } } diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 3ab1ff58a..2dd46bbc6 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1223,10 +1223,12 @@ export function degradeToBudget( delete timeline.coarse; changed = true; } - const byContext = asRecord(block.byContext); - if (byContext) { - for (const ctx of Object.keys(byContext)) { - const c = asRecord(byContext[ctx]); + // Glucose keeps a timeline per context, HRV one per measure (#1110). + for (const nested of [block.byContext, block.byMeasure]) { + const byKey = asRecord(nested); + if (!byKey) continue; + for (const ctx of Object.keys(byKey)) { + const c = asRecord(byKey[ctx]); if (c && "recent" in c) { delete c.recent; changed = true; @@ -1258,10 +1260,11 @@ export function degradeToBudget( delete block.timeline; } } - const byContext = asRecord(block.byContext); - if (byContext) { - for (const ctx of Object.keys(byContext)) { - const c = asRecord(byContext[ctx]); + for (const nested of [block.byContext, block.byMeasure]) { + const byKey = asRecord(nested); + if (!byKey) continue; + for (const ctx of Object.keys(byKey)) { + const c = asRecord(byKey[ctx]); if (c && "weekly" in c) { delete c.weekly; changed = true; diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts index a0f02cec4..c53485983 100644 --- a/src/lib/analytics/source-rank-sql.ts +++ b/src/lib/analytics/source-rank-sql.ts @@ -34,6 +34,8 @@ import { export const RANKED_TYPES: readonly MeasurementType[] = [ "RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", + // v1.42 (#1110) — the `hrvRmssd` ladder. + "HRV_RMSSD", "RESPIRATORY_RATE", "OXYGEN_SATURATION", "BODY_TEMPERATURE", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 61892652c..48cac12e4 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -45,9 +45,8 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // v1.11.0 — WHOOP-native score classes. These ingest server-side from the // WHOOP API (source = WHOOP), never from HealthKit; Apple ships no // identifier for day/workout strain, the WHOOP sleep-quality indices, sleep - // need, RMSSD HRV (Apple ships only the SDNN variant), or kJ energy. They - // have no HK mapping by design. - "HRV_RMSSD", + // need, or kJ energy. They have no HK mapping by design. (RMSSD HRV left + // this list in v1.42: iOS 27 added an identifier for it, #1110.) "DAY_STRAIN", "WORKOUT_STRAIN", "SLEEP_PERFORMANCE", @@ -124,6 +123,8 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { [ "HKCategoryTypeIdentifierSleepAnalysis", "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + // v1.42 (#1110) — the RMSSD variant, same consent as SDNN. + "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD", "HKQuantityTypeIdentifierVO2Max", // v1.10.0 — overnight wrist temperature + the sleep-breathing // index ride behind an explicit Health-share consent, same as @@ -1081,3 +1082,30 @@ describe("percent-shaped HealthKit types take a fraction or an already-scaled pe expect(mapping.convertToDbUnit(1.02)).toBeCloseTo(102); }); }); + +describe("RMSSD HRV from HealthKit (#1110, iOS 27)", () => { + it("maps the RMSSD identifier onto HRV_RMSSD in milliseconds, never onto SDNN", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD", + value: 64, + unit: "ms", + startDate: "2026-10-01T03:00:00.000Z", + endDate: "2026-10-01T03:00:00.000Z", + }); + expect(out).toMatchObject({ type: "HRV_RMSSD", value: 64, unit: "ms" }); + expect(hkIdentifierForType("HRV_RMSSD")).toBe( + "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD", + ); + expect(hkIdentifierForType("HEART_RATE_VARIABILITY")).toBe( + "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + ); + }); + + it("accepts a trained person's 250 ms and refuses 301 ms", () => { + // RMSSD runs higher than SDNN; an out-of-range value is a terminal skip, + // so a 200 ms ceiling would have lost real readings for good. + expect(validateMeasurementRange("HRV_RMSSD", 250)).toBeNull(); + expect(validateMeasurementRange("HRV_RMSSD", 300)).toBeNull(); + expect(validateMeasurementRange("HRV_RMSSD", 301)).not.toBeNull(); + }); +}); diff --git a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts index eb993ce5d..e9c5fb8b9 100644 --- a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts +++ b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts @@ -176,4 +176,12 @@ describe("metricKeyForType — source-priority ladder coverage (v1.18.10 I-5)", expect(metricKeyForType("STRESS_SCORE")).toBe("stress"); expect(RANKED_TYPES).toContain("STRESS_SCORE"); }); + + it("ranks RMSSD HRV on its own ladder, never on SDNN's (#1110)", () => { + // Since iOS 27 Apple Health writes RMSSD next to WHOOP / Oura / Polar, + // so a day can hold it from two sources. + expect(metricKeyForType("HRV_RMSSD")).toBe("hrvRmssd"); + expect(metricKeyForType("HEART_RATE_VARIABILITY")).toBe("hrv"); + expect(RANKED_TYPES).toContain("HRV_RMSSD"); + }); }); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index b368c805a..60824d9c8 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -337,6 +337,21 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { aggregation: "mean", isPrivacySensitive: true, }, + // v1.42 (#1110) — RMSSD HRV, new in iOS / watchOS 27.0. Apple documents it + // as `HKQuantityTypeIdentifier.heartRateVariabilityRMSSD`, symbol + // `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD` + // (developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/ + // heartratevariabilityrmssd). It lands on `HRV_RMSSD` beside WHOOP, Oura + // and Polar, never on the SDNN type: the two are different statistics. + HKQuantityTypeIdentifierHeartRateVariabilityRMSSD: { + hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD", + measurementType: "HRV_RMSSD", + hkUnit: "ms", + dbUnit: "ms", + convertToDbUnit: (v) => v, + aggregation: "mean", + isPrivacySensitive: true, + }, // ── Activity (cumulative) ─────────────────────────────────── HKQuantityTypeIdentifierStepCount: { diff --git a/src/lib/measurements/cumulative-day-sum.ts b/src/lib/measurements/cumulative-day-sum.ts index c4c55b1d1..239aefcd8 100644 --- a/src/lib/measurements/cumulative-day-sum.ts +++ b/src/lib/measurements/cumulative-day-sum.ts @@ -151,6 +151,11 @@ export function metricKeyForType( return "restingHeartRate"; case "HEART_RATE_VARIABILITY": return "hrv"; + // v1.42 (#1110) — RMSSD has its own ladder: WHOOP, Oura, Polar and, + // since iOS 27, Apple Health all write it, so one day can hold it from + // two sources. + case "HRV_RMSSD": + return "hrvRmssd"; case "RESPIRATORY_RATE": return "respiratoryRate"; case "OXYGEN_SATURATION": diff --git a/tests/integration/hrv-rmssd-ingest.test.ts b/tests/integration/hrv-rmssd-ingest.test.ts new file mode 100644 index 000000000..47a932972 --- /dev/null +++ b/tests/integration/hrv-rmssd-ingest.test.ts @@ -0,0 +1,138 @@ +/** + * RMSSD HRV from HealthKit (#1110), against real Postgres: iOS / watchOS 27 + * added `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`. Both ingest + * paths, the live batch and the `export.zip` import, read the one mapping; + * each must store it as `HRV_RMSSD`, apart from SDNN, and keep a reading + * above the old 200 ms ceiling instead of skipping it for good. + */ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export"; + +const USER_ID = "user-hrv-rmssd-ingest"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "hrv-rmssd", + email: "hrv-rmssd@example.test", + timezone: "UTC", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +describe("RMSSD HRV ingest (#1110)", () => { + it("stores a batch entry as HRV_RMSSD, a 250 ms reading included", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + const at = "2026-10-01T03:00:00.000Z"; + const res = await POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD", + value: 250, + unit: "ms", + startDate: at, + endDate: at, + externalId: "uuid-rmssd-1", + }, + { + hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + value: 48, + unit: "ms", + startDate: at, + endDate: at, + externalId: "uuid-sdnn-1", + }, + ], + }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries.map((e) => e.status)).toEqual([ + "inserted", + "inserted", + ]); + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER_ID }, + orderBy: { type: "asc" }, + select: { type: true, value: true, unit: true }, + }); + expect(rows).toEqual([ + { type: "HEART_RATE_VARIABILITY", value: 48, unit: "ms" }, + { type: "HRV_RMSSD", value: 250, unit: "ms" }, + ]); + }); + + it("imports an RMSSD record from export.zip as HRV_RMSSD", async () => { + const dir = mkdtempSync(join(tmpdir(), "healthlog-rmssd-")); + const xmlPath = join(dir, "export.xml"); + writeFileSync( + xmlPath, + ` + + +`, + ); + await streamParseExportXml({ + xmlPath, + userId: USER_ID, + userTimezone: "UTC", + prisma: getPrismaClient(), + }); + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER_ID }, + select: { type: true, value: true, source: true }, + }); + expect(rows).toEqual([ + { type: "HRV_RMSSD", value: 71.5, source: "APPLE_HEALTH" }, + ]); + }); +}); From b8c7eda89cc71a5987e1f3964faf1b987d9ce3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:40:11 +0200 Subject: [PATCH 4/6] test(insights): reset the HRV page mock without returning a teardown --- .../__tests__/healthkit-metric-page-hrv-measures.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx index 2f4273ac4..3064ffb45 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx @@ -69,7 +69,9 @@ function charts(html: string): Array<{ types: string; title: string }> { ); } -beforeEach(() => analyticsMock.mockReset()); +beforeEach(() => { + analyticsMock.mockReset(); +}); describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => { it("charts each measure on its own when both have readings", () => { From 196c331068e24c58abcfefaca8b773ba20d0c960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:40:12 +0200 Subject: [PATCH 5/6] fix(mcp): make the metric and lab deep links land on their pages MCP `search` and `fetch` link a metric as `/insights?metric=` and a lab value as `/labs?analyte=`. Neither page read its query, so every link opened the overview or the full list. `/insights` now resolves the id server-side, in whichever spelling the result carries (a Coach source key such as `hrv` or `bp`, a signal or metric-status key such as `GRIP_STRENGTH`, a measurement type, or a sub-page slug), and redirects to the metric's page, or to its value list when no page focuses it. An id it cannot place renders the overview as before. `/labs` asks for the newest reading stored under the exact analyte name and opens its marker's page; a name with no reading keeps the list and drops the query. --- .../__tests__/page-metric-deep-link.test.tsx | 44 ++++++++++++ src/app/insights/page.tsx | 17 ++++- src/app/labs/page.tsx | 30 +++++++- .../__tests__/metric-deep-link.test.ts | 68 +++++++++++++++++++ src/lib/insights/metric-deep-link.ts | 66 ++++++++++++++++++ .../labs/__tests__/analyte-deep-link.test.ts | 43 ++++++++++++ src/lib/labs/analyte-deep-link.ts | 26 +++++++ src/lib/query-keys/labs.ts | 7 ++ 8 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/app/insights/__tests__/page-metric-deep-link.test.tsx create mode 100644 src/lib/insights/__tests__/metric-deep-link.test.ts create mode 100644 src/lib/insights/metric-deep-link.ts create mode 100644 src/lib/labs/__tests__/analyte-deep-link.test.ts create mode 100644 src/lib/labs/analyte-deep-link.ts diff --git a/src/app/insights/__tests__/page-metric-deep-link.test.tsx b/src/app/insights/__tests__/page-metric-deep-link.test.tsx new file mode 100644 index 000000000..0a0e86216 --- /dev/null +++ b/src/app/insights/__tests__/page-metric-deep-link.test.tsx @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * MCP deep links (`/insights?metric=`) used to land on the overview, + * whatever the id. The RSC now redirects a placeable id to the metric's page + * and renders the overview for anything else. + */ +const redirect = vi.hoisted(() => + vi.fn((href: string) => { + throw new Error(`REDIRECT ${href}`); + }), +); +vi.mock("next/navigation", () => ({ redirect })); +vi.mock("@/lib/auth/acting-carrier", () => ({ + getUnswitchedSession: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: vi.fn(), +})); +vi.mock("../page-client", () => ({ default: () => null })); + +import InsightsPage from "../page"; + +beforeEach(() => { + redirect.mockClear(); +}); + +describe("/insights?metric=", () => { + it("redirects a metric id to its page", async () => { + await expect( + InsightsPage({ searchParams: Promise.resolve({ metric: "hrv" }) }), + ).rejects.toThrow("REDIRECT /insights/hrv"); + }); + + it("renders the overview for an id it cannot place, or none", async () => { + await expect( + InsightsPage({ searchParams: Promise.resolve({ metric: "nonsense" }) }), + ).resolves.toBeTruthy(); + await expect( + InsightsPage({ searchParams: Promise.resolve({}) }), + ).resolves.toBeTruthy(); + expect(redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 880f4b2d7..b79fec77d 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -4,7 +4,10 @@ import { QueryClient, } from "@tanstack/react-query"; +import { redirect } from "next/navigation"; + import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; +import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { queryKeys } from "@/lib/query-keys"; @@ -51,7 +54,19 @@ import InsightsPageClient from "./page-client"; * - Fail-soft: no session or a builder hiccup renders the page exactly as * before this wrapper existed — the client cells own the fetch. */ -export default async function InsightsPage() { +export default async function InsightsPage({ + searchParams, +}: { + searchParams?: Promise>; +} = {}) { + // MCP deep links (`/insights?metric=`) go to the metric's own page. An + // id that names no metric leaves the overview as it was. + const { metric } = (await searchParams) ?? {}; + const target = insightsHrefForMetric( + typeof metric === "string" ? metric : null, + ); + if (target) redirect(target); + // Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e // server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which // only see CLIENT fetches — keep governing what every prefetched page paints. diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 69ca8f41e..86faac75d 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, FolderOpen, @@ -38,6 +38,12 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import type { LabResultListResponse } from "@/components/labs/types"; +import { + analyteFromQuery, + labHrefForAnalyteReading, +} from "@/lib/labs/analyte-deep-link"; export default function LabsPage() { const { user, isAuthenticated, isLoading } = useAuth(); @@ -89,6 +95,26 @@ export default function LabsPage() { disabled: dialogOpen, }); + // MCP deep links (`/labs?analyte=`) open the marker's own page. The + // newest reading under that exact name says which marker it is; a name + // with no reading keeps the list and drops the query. + const searchParams = useSearchParams(); + const linkedAnalyte = analyteFromQuery(searchParams.get("analyte")); + const analyteLink = useQuery({ + queryKey: queryKeys.labAnalyteLink(linkedAnalyte ?? ""), + queryFn: () => + apiGet( + `/api/labs?analyte=${encodeURIComponent(linkedAnalyte ?? "")}&limit=1&sortDir=desc`, + ), + enabled: !!linkedAnalyte && isAuthenticated && enabled, + }); + useEffect(() => { + if (!linkedAnalyte || !analyteLink.isFetched) return; + router.replace( + labHrefForAnalyteReading(analyteLink.data?.results?.[0]) ?? "/labs", + ); + }, [linkedAnalyte, analyteLink.isFetched, analyteLink.data, router]); + useEffect(() => { if (isLoading) return; if (!isAuthenticated) { diff --git a/src/lib/insights/__tests__/metric-deep-link.test.ts b/src/lib/insights/__tests__/metric-deep-link.test.ts new file mode 100644 index 000000000..155b9f109 --- /dev/null +++ b/src/lib/insights/__tests__/metric-deep-link.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link"; +import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric"; +import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * The MCP `search` / `fetch` results link a metric as `/insights?metric=` + * in several spellings. Every one must land on the metric's own page; an id + * that names nothing leaves the overview alone. + */ +describe("insightsHrefForMetric", () => { + it("routes the Coach source keys the data inventory links carry", () => { + expect(insightsHrefForMetric("hrv")).toBe("/insights/hrv"); + expect(insightsHrefForMetric("resting_hr")).toBe("/insights/resting-pulse"); + expect(insightsHrefForMetric("bp")).toBe("/insights/blood-pressure"); + expect(insightsHrefForMetric("weight")).toBe("/insights/weight"); + expect(insightsHrefForMetric("glucose")).toBe("/insights/blood-glucose"); + expect(insightsHrefForMetric("mood")).toBe("/insights/mood"); + expect(insightsHrefForMetric("workouts")).toBe("/insights/workouts"); + }); + + it("routes signal keys and measurement types", () => { + expect(insightsHrefForMetric("GRIP_STRENGTH")).toBe( + "/insights/grip-strength", + ); + expect(insightsHrefForMetric("CARDIO_RECOVERY")).toBe( + "/insights/cardio-recovery", + ); + expect(insightsHrefForMetric("HRV_RMSSD")).toBe("/insights/hrv"); + }); + + it("falls back to the value list for a type no page focuses", () => { + expect(insightsHrefForMetric("DAY_STRAIN")).toBe( + "/insights/values/DAY_STRAIN", + ); + }); + + it("routes a sub-page slug in either separator", () => { + expect(insightsHrefForMetric("walking-speed")).toBe( + "/insights/walking-speed", + ); + expect(insightsHrefForMetric("walking_speed")).toBe( + "/insights/walking-speed", + ); + }); + + it("leaves the overview for an id it cannot place", () => { + for (const id of [null, "", " ", "nonsense", "../admin", "a".repeat(80)]) { + expect(insightsHrefForMetric(id)).toBeNull(); + } + }); + + it("only ever points at a page that exists", () => { + const ids = [ + ...SUB_PAGE_SLUGS, + ...Object.keys(COACH_SOURCE_MEASUREMENT_TYPES), + ]; + for (const id of ids) { + const href = insightsHrefForMetric(id); + if (!href || href.startsWith("/insights/values/")) continue; + const dir = join(process.cwd(), "src/app", href); + expect(existsSync(join(dir, "page.tsx")), `${id} → ${href}`).toBe(true); + } + }); +}); diff --git a/src/lib/insights/metric-deep-link.ts b/src/lib/insights/metric-deep-link.ts new file mode 100644 index 000000000..544525c7a --- /dev/null +++ b/src/lib/insights/metric-deep-link.ts @@ -0,0 +1,66 @@ +/** + * Where `/insights?metric=` goes (MCP deep links). + * + * The MCP `search` and `fetch` results link a metric as + * `/insights?metric=`, with the id in whichever spelling that result + * carries: a Coach source key (`hrv`, `resting_hr`, `bp`), a signal or + * metric-status key (`GRIP_STRENGTH`, `CARDIO_RECOVERY`), or a sub-page slug. + * The overview read none of them, so every such link landed on the overview. + * This resolves the id to the metric's own page, or to its value list when no + * page focuses it, and to nothing for an id it cannot place (the overview + * then renders as before). Pure: no I/O. + */ +import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import { + SUB_PAGE_SLUGS, + subPageSlugForType, + type SubPageSlug, +} from "@/lib/insights/sub-page-metric"; +import { SIGNALS } from "@/lib/signals/registry"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +/** Coach sources that read a model other than `Measurement`. */ +const NON_MEASUREMENT_SOURCES: Readonly> = { + mood: "/insights/mood", + workouts: "/insights/workouts", + compliance: "/insights/medications", +}; + +const SLUGS: ReadonlySet = new Set(SUB_PAGE_SLUGS); + +function hrefForType(type: string): string { + const slug = subPageSlugForType(type); + return slug + ? `/insights/${slug}` + : `/insights/values/${encodeURIComponent(type)}`; +} + +export function insightsHrefForMetric( + raw: string | null | undefined, +): string | null { + const id = raw?.trim() ?? ""; + if (id === "" || id.length > 64 || !/^[A-Za-z0-9_-]+$/.test(id)) { + return null; + } + const lower = id.toLowerCase(); + + // A sub-page slug, in either separator. + const slug = lower.replace(/_/g, "-"); + if (SLUGS.has(slug)) return `/insights/${slug as SubPageSlug}`; + + // A Coach source key, the spelling the data inventory links carry. + if (lower in NON_MEASUREMENT_SOURCES) return NON_MEASUREMENT_SOURCES[lower]!; + const sourceTypes = + COACH_SOURCE_MEASUREMENT_TYPES[lower as CoachScopeSource] ?? []; + if (sourceTypes.length > 0) return hrefForType(sourceTypes[0]!); + + // A signal key, then a measurement type itself. + const upper = id.toUpperCase(); + const signal = SIGNALS[upper]; + if (signal && (signal.kind === "measurement" || signal.kind === "score")) { + return hrefForType(signal.source.measurementType); + } + if (measurementTypeEnum.safeParse(upper).success) return hrefForType(upper); + return null; +} diff --git a/src/lib/labs/__tests__/analyte-deep-link.test.ts b/src/lib/labs/__tests__/analyte-deep-link.test.ts new file mode 100644 index 000000000..524570e5b --- /dev/null +++ b/src/lib/labs/__tests__/analyte-deep-link.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + analyteFromQuery, + labHrefForAnalyteReading, +} from "@/lib/labs/analyte-deep-link"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * `/labs?analyte=` (MCP deep links) opens the marker page of the newest + * reading stored under that name, and keeps the list otherwise. + */ +describe("labs analyte deep link", () => { + it("follows a named analyte and ignores an empty or oversized one", () => { + expect(analyteFromQuery(" Ferritin ")).toBe("Ferritin"); + expect(analyteFromQuery(null)).toBeNull(); + expect(analyteFromQuery(" ")).toBeNull(); + expect(analyteFromQuery("x".repeat(121))).toBeNull(); + }); + + it("opens the marker page of the reading, and nothing without one", () => { + expect(labHrefForAnalyteReading({ biomarkerId: "bm_1" })).toBe( + "/labs/bm_1", + ); + expect(labHrefForAnalyteReading({ biomarkerId: null })).toBeNull(); + expect(labHrefForAnalyteReading(undefined)).toBeNull(); + }); + + it("keeps its read under the lab-results prefix, apart from the list", () => { + const key = queryKeys.labAnalyteLink("Ferritin"); + expect(key[0]).toBe("lab-results"); + expect(key).not.toEqual( + queryKeys.labResultsList({ + analyte: "Ferritin", + panel: undefined, + from: undefined, + to: undefined, + page: 0, + sortDir: "desc", + }), + ); + }); +}); diff --git a/src/lib/labs/analyte-deep-link.ts b/src/lib/labs/analyte-deep-link.ts new file mode 100644 index 000000000..0a8b47531 --- /dev/null +++ b/src/lib/labs/analyte-deep-link.ts @@ -0,0 +1,26 @@ +/** + * Where `/labs?analyte=` goes (MCP deep links). + * + * MCP `search` and `fetch` link a lab value as `/labs?analyte=`, the + * analyte name exactly as stored on the reading. The labs page read nothing + * from the query, so every such link showed the whole list. The page now asks + * for the newest reading under that name and opens its marker's page; a name + * with no reading, or one whose reading carries no marker, keeps the list. + */ + +/** The analyte named in the query, or null when there is none to follow. */ +export function analyteFromQuery( + raw: string | null | undefined, +): string | null { + const analyte = raw?.trim() ?? ""; + return analyte.length > 0 && analyte.length <= 120 ? analyte : null; +} + +/** The marker page for the newest reading under that name, or null. */ +export function labHrefForAnalyteReading( + reading: { biomarkerId: string | null } | undefined, +): string | null { + return reading?.biomarkerId + ? `/labs/${encodeURIComponent(reading.biomarkerId)}` + : null; +} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index cbb3f7031..45cdf949a 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -29,6 +29,13 @@ export const labKeys = { params.sortDir, ] as const, + // `/labs?analyte=` (MCP deep links): the newest reading stored under + // that exact analyte name, one row, to find its marker's page. Its own key + // because the page size differs from every list read; the `lab-results` + // prefix lets a result mutation evict it too. + labAnalyteLink: (analyte: string) => + ["lab-results", "analyte-link", analyte] as const, + // The visit form's lab picker: newest results, its own page size. Its own // key because the Labs list reads a different page size, and a shared key // would serve one read's page to the other. From 1fe4a7af951a57a508db2179663be41e16f289ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:52:51 +0200 Subject: [PATCH 6/6] fix: settle the new edit and HRV surfaces after a look at them The HRV page's second chart reported its visible window into the same stat-strip slot as the first, so the two replaced each other on every render until React stopped it and the chart failed to load. Only the primary chart reports the window now; the strip describes that measure. The workout edit sheet no longer shows the "leave empty if you just finished" hint, which is about logging and not about a stored start. On the Apple Health card a type's name reads as the row's content above its arrival line, with room between rows. --- ...ealthkit-metric-page-hrv-measures.test.tsx | 25 ++++++++++++++++++- .../insights/healthkit-metric-page.tsx | 4 ++- .../metric-freshness-disclosure.tsx | 13 ++++++++-- .../workouts/manual-workout-form.tsx | 12 ++++++--- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx index 3064ffb45..455a59224 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx @@ -22,11 +22,16 @@ vi.mock("@/hooks/use-ai-capability", () => ({ })); vi.mock("next/dynamic", () => ({ default: () => { - const Stub = (props: { types?: string[]; title?: string }) => ( + const Stub = (props: { + types?: string[]; + title?: string; + onVisibleStats?: unknown; + }) => (
); Stub.displayName = "HealthChartStub"; @@ -92,6 +97,24 @@ describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => { expect(found[1]!.title).toMatch(/RMSSD$/); }); + it("lets only the primary chart write the stat strip's window", () => { + // Two charts writing the one stats slot replaced each other's window on + // every render: React's update-depth error, and the chart failed to load. + analyticsMock.mockReturnValue({ + data: { + summaries: { + HEART_RATE_VARIABILITY: { count: 30 }, + HRV_RMSSD: { count: 28 }, + }, + }, + isEmpty: false, + }); + const flags = [...render().matchAll(/data-reports-stats="(yes|no)"/g)].map( + (m) => m[1], + ); + expect(flags).toEqual(["yes", "no"]); + }); + it("keeps one unlabelled SDNN chart when there is no RMSSD", () => { analyticsMock.mockReturnValue({ data: { summaries: { HEART_RATE_VARIABILITY: { count: 30 } } }, diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 5acc7c3bb..3863e9062 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -509,7 +509,9 @@ export function HealthKitMetricPage({ userTimezone={user?.timezone} valueScale={resolvedScale} valueOffset={resolvedOffset} - onVisibleStats={onVisibleStats} + // No `onVisibleStats`: the strip describes the primary measure, and + // two charts writing the one stats slot would replace each other's + // window on every render without end. showDataTable /> ) : null} diff --git a/src/components/settings/integrations/metric-freshness-disclosure.tsx b/src/components/settings/integrations/metric-freshness-disclosure.tsx index ae9fba69a..54a23864e 100644 --- a/src/components/settings/integrations/metric-freshness-disclosure.tsx +++ b/src/components/settings/integrations/metric-freshness-disclosure.tsx @@ -129,7 +129,7 @@ export function MetricFreshnessDisclosure< {open && ( -
    +
      {sorted.map((entry) => { const extra = detail?.(entry); return ( @@ -151,7 +151,16 @@ export function MetricFreshnessDisclosure< className="size-3 shrink-0" /> ) : null} - {label(entry.type)} + {/* With a detail line under it, the type name is the + row's content and the line its meta. */} + + {label(entry.type)} +