mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge tickets part 1 into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
861ba21086
48 files changed
+2654
-202
No files matched your search
+78
-7
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user