diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84a9a1758..e5b232871 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25044,7 +25044,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: @@ -45258,6 +45260,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 @@ -73189,11 +73207,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. @@ -73261,7 +73279,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: @@ -73276,13 +73294,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: @@ -75150,6 +75199,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/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/prisma/schema.prisma b/prisma/schema.prisma index 9b23caf54..877ce6d27 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3952,12 +3952,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 92917bc04..27d9efc4e 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -78,11 +78,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ * before the release is tagged. It is NOT a place for keys nobody will wire. */ const RELEASE_PENDING_PREFIXES: Record = { - "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)", "vaccinations.custom": "v1.42 user-defined vaccines (#1005)", "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", }; 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/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/__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/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/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/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={ `) 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/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..455a59224 --- /dev/null +++ b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx @@ -0,0 +1,139 @@ +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; + onVisibleStats?: unknown; + }) => ( +
+ ); + 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("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 } } }, + 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..3863e9062 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,29 @@ export function HealthKitMetricPage({ onVisibleStats={onVisibleStats} showDataTable /> + {bothMeasures ? ( + + ) : null} {targetSummarySlug ? ( ) : null} 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..54a23864e 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", { @@ -118,35 +129,54 @@ export function MetricFreshnessDisclosure({ {open && ( -
    - {sorted.map((entry) => ( -
  • - - {entry.stale ? ( - -
  • - {relative(entry.lastSeenAt)} - -
  • - ))} +
    + + {entry.stale ? ( + + +
    + {extra ? ( +
    + {extra} +
    + ) : null} + + ); + })}
)}
diff --git a/src/components/workouts/__tests__/manual-workout-form.test.tsx b/src/components/workouts/__tests__/manual-workout-form.test.tsx index 15f0d3818..ff947bbf6 100644 --- a/src/components/workouts/__tests__/manual-workout-form.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-form.test.tsx @@ -47,7 +47,10 @@ import { canDeleteWorkout, deleteManualWorkout, } from "../delete-workout-button"; -import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry"; +import { + manualWorkoutOriginalFromRow, + type ManualWorkoutEntry, +} from "@/lib/workouts/manual-entry"; const ENTRY: ManualWorkoutEntry = { sportType: "cycling", @@ -251,3 +254,39 @@ describe("delete — a hand-entered workout in one's own record only", () => { }); }); }); + +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..d4ced34cf 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 ? ( + ) : ( )} @@ -278,16 +309,20 @@ export function ManualWorkoutForm({ max={wallClockNow(new Date(), timezone)} aria-invalid={!!errors.start || undefined} aria-describedby={[ - `${ids.start}-hint`, + edit ? undefined : `${ids.start}-hint`, describedBy("start", `${ids.start}-error`), ] .filter(Boolean) .join(" ")} data-testid="manual-workout-start" /> -

- {t("insights.workouts.manual.startHint")} -

+ {/* "Leave empty if you just finished" is about logging; an edit + opens with the stored start in place. */} + {edit ? null : ( +

+ {t("insights.workouts.manual.startHint")} +

+ )}
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/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/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/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/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/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/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 bc54361ad..b1d15d410 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/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/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. 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/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, + }); + }); +}); 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" }, + ]); + }); +}); 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"); + }); +});