Merge tickets part 1 into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:35:07 +02:00
commit 861ba21086
48 files changed
+2654 -202

No files matched your search

+78 -7
View File
@@ -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:
+11
View File
@@ -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
+3 -3
View File
@@ -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")
@@ -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<string, string> = {
"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)",
};
@@ -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: <T>(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");
});
});
+15 -4
View File
@@ -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,
});
});
@@ -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<typeof vi.fn>;
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
[],
);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]);
(prisma.workout.groupBy as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const envelope = await fetchEnvelope();
+6
View File
@@ -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,
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* MCP deep links (`/insights?metric=<id>`) 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();
});
});
+5 -1
View File
@@ -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"
+16 -1
View File
@@ -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<Record<string, string | string[] | undefined>>;
} = {}) {
// MCP deep links (`/insights?metric=<id>`) 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.
+15 -3
View File
@@ -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 ? <DeleteWorkoutButton workout={data} /> : null
data && !canonicalHref ? (
<div className="flex items-center gap-1">
<EditWorkoutButton
workout={{
...data,
storedAvgHr: data.storedAvgHr ?? null,
storedMaxHr: data.storedMaxHr ?? null,
}}
/>
<DeleteWorkoutButton workout={data} />
</div>
) : null
}
backLink={
<BackLink
+28 -2
View File
@@ -2,8 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
FolderOpen,
@@ -38,6 +38,12 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import type { LabResultListResponse } from "@/components/labs/types";
import {
analyteFromQuery,
labHrefForAnalyteReading,
} from "@/lib/labs/analyte-deep-link";
export default function LabsPage() {
const { user, isAuthenticated, isLoading } = useAuth();
@@ -89,6 +95,26 @@ export default function LabsPage() {
disabled: dialogOpen,
});
// MCP deep links (`/labs?analyte=<name>`) 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<LabResultListResponse>(
`/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) {
@@ -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;
}) => (
<div
data-slot="chart-stub"
data-types={(props.types ?? []).join(",")}
data-title={props.title ?? ""}
data-reports-stats={props.onVisibleStats ? "yes" : "no"}
/>
);
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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<InsightsHrvPage />
</I18nProvider>
</QueryClientProvider>,
);
}
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$/);
});
});
@@ -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 ? (
<HealthChartDynamic
chartKey={chartKey}
types={[fallbackMeasurementType as string]}
title={
fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[fallbackColor ?? color]}
unit={resolvedUnit}
yAxisUnit={resolvedYAxisUnit}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={resolvedScale}
valueOffset={resolvedOffset}
// No `onVisibleStats`: the strip describes the primary measure, and
// two charts writing the one stats slot would replace each other's
// window on every render without end.
showDataTable
/>
) : null}
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
@@ -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("<AppleHealthCard> — sync progress (#778)", () => {
}
});
});
describe("<AppleHealthCard> — 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<string, string | number>) => {
const value = key
.split(".")
.reduce<unknown>(
(node, part) => (node as Record<string, unknown>)?.[part],
en,
);
if (typeof value !== "string") return key;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
String(params?.[name] ?? `{${name}}`),
);
};
const html = (entry: Parameters<typeof ArrivalDetail>[0]["entry"]) =>
renderToStaticMarkup(<ArrivalDetail entry={entry} t={t} now={now} />);
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");
});
});
@@ -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, string | number>) => string;
now?: Date;
}) {
if (entry.lastReceivedAt === undefined) return null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{t("settings.appleHealth.freshness.never")}
</span>
);
}
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 (
<span
data-testid="apple-health-arrival"
data-state="received"
data-trigger={trigger ?? "unknown"}
>
{parts.join(" · ")}
</span>
);
}
/**
* 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 }) {
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
<div className="space-y-2">
@@ -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({
)}
/>
<span className="text-sm font-medium">
{t("settings.integrationFreshness.title")}
{title ?? t("settings.integrationFreshness.title")}
</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
{t("settings.integrationFreshness.summary", {
@@ -118,35 +129,54 @@ export function MetricFreshnessDisclosure({
</button>
{open && (
<ul id={panelId} className="space-y-1">
{sorted.map((entry) => (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"flex items-center justify-between gap-3 text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
<span className="truncate">{label(entry.type)}</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
<ul id={panelId} className={detail ? "space-y-2.5" : "space-y-1"}>
{sorted.map((entry) => {
const extra = detail?.(entry);
return (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
{relative(entry.lastSeenAt)}
</time>
</li>
))}
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
{/* With a detail line under it, the type name is the
row's content and the line its meta. */}
<span
className={cn(
"truncate",
extra && !entry.stale && "text-foreground",
)}
>
{label(entry.type)}
</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</div>
{extra ? (
<div data-slot="metric-freshness-detail" className="mt-0.5">
{extra}
</div>
) : null}
</li>
);
})}
</ul>
)}
</div>
@@ -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("<ManualWorkoutForm> — 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(
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
);
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(<ManualWorkoutForm />);
expect(html).not.toContain('value="640"');
});
});
@@ -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 (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-edit"
aria-label={t("insights.workouts.manual.editAction")}
title={t("insights.workouts.manual.editTitle")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
{/* The sheet mounts its form only while open, so each opening starts
from the values the page shows at that moment. */}
<ManualWorkoutSheet
open={open}
onOpenChange={setOpen}
edit={{ externalId: workout.externalId, original }}
/>
</>
);
}
@@ -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<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(),
// An edit sends the stored row's own id instead.
const [externalId] = useState(
() => edit?.externalId ?? newManualWorkoutExternalId(),
);
const [initialDraft] = useState<ManualWorkoutDraft>(
() => edit?.original.draft ?? emptyManualWorkoutDraft(),
);
const [draft, setDraft] = useState<ManualWorkoutDraft>(initialDraft);
const dirty = (
Object.keys(initialDraft) as Array<keyof ManualWorkoutDraft>
).some((key) => draft[key] !== initialDraft[key]);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
@@ -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 ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : edit ? (
<Check className="size-4" />
) : (
<Plus className="size-4" />
)}
@@ -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"
/>
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
{/* "Leave empty if you just finished" is about logging; an edit
opens with the stored start in place. */}
{edit ? null : (
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
)}
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
@@ -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<HTMLDivElement | null>(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({
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
title={t(
edit
? "insights.workouts.manual.editTitle"
: "insights.workouts.manual.sheetTitle",
)}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
@@ -58,6 +75,8 @@ export function ManualWorkoutSheet({
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
edit={edit}
onDirtyChange={edit ? handleDirtyChange : undefined}
/>
)}
</ResponsiveSheet>
+7
View File
@@ -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;
@@ -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<string, unknown> = {};
const registered = new Map<string, () => DailySeries>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
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<string, { recent: Array<{ value: number }> }>;
};
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<string, unknown> }).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<string, unknown>;
expect(block.byMeasure).toBeUndefined();
expect(block.timeline).toBeDefined();
});
});
+10 -1
View File
@@ -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":
@@ -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<ValueSeriesBlocksContext>,
rows: ReturnType<typeof toPoints>,
transform: ReturnType<typeof getReadingTransform>,
) {
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<ValueSeriesBlocksContext>,
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<string, unknown> = {};
const pointsByMeasure = new Map<string, ReturnType<typeof toPoints>>();
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<ValueSeriesBlocksContext>,
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);
}
}
+11 -8
View File
@@ -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;
+2
View File
@@ -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",
@@ -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=<id>`
* 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);
}
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Where `/insights?metric=<id>` goes (MCP deep links).
*
* The MCP `search` and `fetch` results link a metric as
* `/insights?metric=<id>`, 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<Record<string, string>> = {
mood: "/insights/mood",
workouts: "/insights/workouts",
compliance: "/insights/medications",
};
const SLUGS: ReadonlySet<string> = 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;
}
@@ -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");
});
});
+135 -3
View File
@@ -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<string> = new Set<HealthKitSyncTrigger>([
"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> {
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<Map<string, HealthKitTypeArrivalRecord>> {
const rows = await prisma.healthKitTypeSync.findMany({
where: { userId },
select: {
type: true,
lastReceivedAt: true,
lastTrigger: true,
lastNewSampleAt: true,
},
});
const out = new Map<string, HealthKitTypeArrivalRecord>();
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<string, HealthKitTypeArrivalRecord> | 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,
};
});
}
+58 -20
View File
@@ -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<Map<MeasurementSource, MetricFreshnessSample[]>> {
const out = new Map<MeasurementSource, MetricFreshnessSample[]>();
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<MetricFreshnessSample[]> {
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.
@@ -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=<name>` (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",
}),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Where `/labs?analyte=<name>` goes (MCP deep links).
*
* MCP `search` and `fetch` link a lab value as `/labs?analyte=<name>`, 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;
}
@@ -45,9 +45,8 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// 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();
});
});
@@ -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");
});
});
@@ -337,6 +337,21 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
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: {
@@ -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":
+34 -2
View File
@@ -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()
+1 -1
View File
@@ -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({
+16
View File
@@ -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(),
+7
View File
@@ -29,6 +29,13 @@ export const labKeys = {
params.sortDir,
] as const,
// `/labs?analyte=<name>` (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.
@@ -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<ManualWorkoutDraft>,
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);
});
});
+151 -6
View File
@@ -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 };
}
@@ -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<void> {
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<string, unknown>,
headers: Record<string, string> = {},
): Promise<Response> {
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<Response>)(
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,
});
});
});
+138
View File
@@ -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,
`<?xml version="1.0" encoding="UTF-8"?>
<HealthData locale="en_US">
<Record type="HKQuantityTypeIdentifierHeartRateVariabilityRMSSD" unit="ms"
startDate="2026-10-01 05:00:00 +0200"
endDate="2026-10-01 05:00:00 +0200"
value="71.5" sourceName="Watch" sourceVersion="27.0"/>
</HealthData>`,
);
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" },
]);
});
});
@@ -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<Response>
)(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<string, unknown> }).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<typeof manualWorkoutOriginalFromRow>[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<typeof manualWorkoutOriginalFromRow>[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");
});
});