Merge tickets part 1 into release/v1.42.0

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

No files matched your search

@@ -78,11 +78,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
* before the release is tagged. It is NOT a place for keys nobody will wire.
*/
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"settings.appleHealth.freshness":
"v1.42 per-type HealthKit freshness list (#1173)",
"insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)",
"insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)",
"insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)",
"vaccinations.custom": "v1.42 user-defined vaccines (#1005)",
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
};
@@ -8,12 +8,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { create: vi.fn() },
// Per-metric freshness + first-run sync progress for the APPLE_HEALTH
// source (#778).
measurement: { groupBy: vi.fn(), aggregate: vi.fn() },
measurement: { aggregate: vi.fn() },
workout: { groupBy: vi.fn(), aggregate: vi.fn() },
// v1.42 — the newest sample per live type is one raw probe per type, and
// the per-type arrival ledger (#1173) is read beside it.
$queryRaw: vi.fn(),
healthKitTypeSync: { findMany: vi.fn() },
},
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/measurements/live-types", () => ({
listLiveMeasurementTypes: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -38,6 +46,7 @@ vi.mock("next/headers", () => ({
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
const SESSION_OK = {
@@ -53,7 +62,9 @@ beforeEach(() => {
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
_count: { _all: 0 },
@@ -281,26 +292,20 @@ describe("GET /api/integrations/healthkit — sync health", () => {
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
source: "APPLE_HEALTH",
type: "RESPIRATORY_RATE",
_max: { measuredAt: ago(30 * DAY) },
},
{
source: "APPLE_HEALTH",
type: "PULSE",
_max: { measuredAt: ago(60 * 60 * 1000) },
},
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
"RESPIRATORY_RATE",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) },
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
] as never);
const body = await read();
// Only the Apple Health source is read.
const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock
.calls[0]?.[0] as unknown as {
where: { source: { in: string[] } };
};
expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]);
expect(vi.mocked(listLiveMeasurementTypes).mock.calls[0]?.[1]).toEqual({
source: "APPLE_HEALTH",
});
const byType = Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry.stale]),
);
@@ -382,11 +387,75 @@ describe("GET /api/integrations/healthkit — sync health", () => {
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(prisma.measurement.groupBy).mockRejectedValue(
new Error("groupBy hiccup"),
vi.mocked(listLiveMeasurementTypes).mockRejectedValue(
new Error("live-type hiccup"),
);
const body = await read();
expect(body.metricFreshness).toEqual([]);
expect(body.syncHealth.verdict).toBe("fresh");
});
// #1173 — when a type last ARRIVED and under which trigger, beside when its
// newest sample was taken. A type that only ever comes with "Sync all" is
// visible as `manual`; a type no live sync carried yet reads null.
it("lays the per-type arrival facts over the freshness entries", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
"RESPIRATORY_RATE",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) },
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
] as never);
vi.mocked(prisma.workout.groupBy).mockResolvedValue([
{ source: "APPLE_HEALTH", _max: { startedAt: ago(DAY) } },
] as never);
const receivedAt = ago(3 * 60 * 60 * 1000);
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
{
type: "RESPIRATORY_RATE",
lastReceivedAt: receivedAt,
lastTrigger: "manual",
lastNewSampleAt: null,
},
] as never);
const body = await read();
const byType = Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry]),
);
expect(byType.RESPIRATORY_RATE).toMatchObject({
lastReceivedAt: receivedAt.toISOString(),
lastTrigger: "manual",
lastNewSampleAt: null,
});
expect(byType.PULSE).toMatchObject({
lastReceivedAt: null,
lastTrigger: null,
lastNewSampleAt: null,
});
// Workouts ride their own batch route, which the ledger does not cover.
expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt");
});
it("leaves the arrival facts off when the ledger read fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "PULSE", last_seen: ago(DAY) },
] as never);
vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue(
new Error("ledger hiccup"),
);
const body = await read();
expect(body.metricFreshness).toHaveLength(1);
expect(body.metricFreshness[0]).not.toHaveProperty("lastReceivedAt");
});
});
+15 -4
View File
@@ -21,6 +21,10 @@ import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
import {
getHealthKitTypeArrivals,
withHealthKitArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
import {
classifyMetricFreshness,
@@ -166,9 +170,13 @@ export const GET = apiHandler(async () => {
legacyLastSyncedAt: lastSyncedAt,
cadence: PUSH_CADENCE,
});
const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch(
() => [],
);
// #1173 — when each type last ARRIVED and under which trigger, beside when
// its newest sample was taken. Fail-soft to null, which leaves the entries
// without arrival fields rather than reporting every type as never received.
const [samples, arrivals] = await Promise.all([
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
getHealthKitTypeArrivals(user.id).catch(() => null),
]);
// Issue #778 — a first-run backfill used to be invisible from the web: the
// card said "last sync 2 minutes ago" while giving no sense of how much had
// arrived or how far back it reached. These two figures (row count + oldest
@@ -186,7 +194,10 @@ export const GET = apiHandler(async () => {
lastSyncTrigger,
lastBackgroundSyncAt,
syncHealth,
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
metricFreshness: withHealthKitArrivals(
classifyMetricFreshness(samples, syncHealth.verdict),
arrivals,
),
syncProgress,
});
});
@@ -13,11 +13,16 @@ vi.mock("@/lib/db", () => ({
fitbitConnection: { findUnique: vi.fn(async () => null) },
googleHealthConnection: { findUnique: vi.fn(async () => null) },
moodEntry: { count: vi.fn(async () => 0) },
measurement: { groupBy: vi.fn(async () => []) },
$queryRaw: vi.fn(async () => []),
workout: { groupBy: vi.fn(async () => []) },
},
}));
// v1.42 — the per-source type walk that replaced the measurement groupBy.
vi.mock("@/lib/measurements/live-types", () => ({
listLiveMeasurementTypes: vi.fn(async () => []),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
@@ -53,6 +58,7 @@ vi.mock("@/lib/oura/credentials", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
@@ -315,7 +321,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
});
it("reports the degradation instead of passing an empty list off as an answer", async () => {
(prisma.measurement.groupBy as ReturnType<typeof vi.fn>).mockRejectedValue(
vi.mocked(listLiveMeasurementTypes).mockRejectedValue(
new Error("statement timeout"),
);
@@ -331,9 +337,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
});
it("reports no degradation when the read simply found nothing", async () => {
(prisma.measurement.groupBy as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]);
(prisma.workout.groupBy as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const envelope = await fetchEnvelope();
+6
View File
@@ -335,6 +335,12 @@ export const GET = apiHandler(
avgHr: effectiveAvgHeartRate,
maxHr: effectiveMaxHeartRate,
minHr: row.minHeartRate,
// The row's own heart rate. `avgHr` / `maxHr` above may be filled in
// from a twin by the canonical picker, which is right for display and
// wrong for an edit: re-posting them would store the twin's values on
// the hand-entered row (#1162).
storedAvgHr: row.avgHeartRate,
storedMaxHr: row.maxHeartRate,
stepCount: row.stepCount,
elevationM: row.elevationM,
pauseDurationSec: row.pauseDurationSec,
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* MCP deep links (`/insights?metric=<id>`) used to land on the overview,
* whatever the id. The RSC now redirects a placeable id to the metric's page
* and renders the overview for anything else.
*/
const redirect = vi.hoisted(() =>
vi.fn((href: string) => {
throw new Error(`REDIRECT ${href}`);
}),
);
vi.mock("next/navigation", () => ({ redirect }));
vi.mock("@/lib/auth/acting-carrier", () => ({
getUnswitchedSession: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/dashboard/snapshot-read", () => ({
readDashboardSnapshotCached: vi.fn(),
}));
vi.mock("../page-client", () => ({ default: () => null }));
import InsightsPage from "../page";
beforeEach(() => {
redirect.mockClear();
});
describe("/insights?metric=", () => {
it("redirects a metric id to its page", async () => {
await expect(
InsightsPage({ searchParams: Promise.resolve({ metric: "hrv" }) }),
).rejects.toThrow("REDIRECT /insights/hrv");
});
it("renders the overview for an id it cannot place, or none", async () => {
await expect(
InsightsPage({ searchParams: Promise.resolve({ metric: "nonsense" }) }),
).resolves.toBeTruthy();
await expect(
InsightsPage({ searchParams: Promise.resolve({}) }),
).resolves.toBeTruthy();
expect(redirect).not.toHaveBeenCalled();
});
});
+5 -1
View File
@@ -15,7 +15,9 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
* SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP
* store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present
* and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a
* ring / strap user's stored HRV always surfaces.
* ring / strap user's stored HRV always surfaces. Since iOS 27 an Apple
* Watch writes both (#1110); with rows of both the page charts each on its
* own, labelled, because the two are not one measure.
*/
export default function InsightsHrvPage() {
return (
@@ -23,6 +25,8 @@ export default function InsightsHrvPage() {
measurementType="HEART_RATE_VARIABILITY"
fallbackMeasurementType="HRV_RMSSD"
fallbackMeasureLabel="RMSSD"
primaryMeasureLabel="SDNN"
fallbackColor="var(--chart-2)"
statusMetric="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
+16 -1
View File
@@ -4,7 +4,10 @@ import {
QueryClient,
} from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { queryKeys } from "@/lib/query-keys";
@@ -51,7 +54,19 @@ import InsightsPageClient from "./page-client";
* - Fail-soft: no session or a builder hiccup renders the page exactly as
* before this wrapper existed — the client cells own the fetch.
*/
export default async function InsightsPage() {
export default async function InsightsPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
} = {}) {
// MCP deep links (`/insights?metric=<id>`) go to the metric's own page. An
// id that names no metric leaves the overview as it was.
const { metric } = (await searchParams) ?? {};
const target = insightsHrefForMetric(
typeof metric === "string" ? metric : null,
);
if (target) redirect(target);
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
// only see CLIENT fetches — keep governing what every prefetched page paints.
+15 -3
View File
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route";
import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button";
import { EditWorkoutButton } from "@/components/workouts/edit-workout-button";
import {
WorkoutDetailHeader,
WorkoutDetailStats,
@@ -65,10 +66,21 @@ export default function InsightsWorkoutDetailPage({
// No description. The one this page used to carry belongs to the LIST
// ("Recent runs, rides, walks…, deduped…") and describes a surface
// this is not — a detail page must not claim to be the list.
// A workout entered by hand can be deleted here; the control renders
// nothing for a synced one.
// A workout entered by hand can be edited (#1162) and deleted here;
// both controls render nothing for a synced one.
headerAction={
data && !canonicalHref ? <DeleteWorkoutButton workout={data} /> : null
data && !canonicalHref ? (
<div className="flex items-center gap-1">
<EditWorkoutButton
workout={{
...data,
storedAvgHr: data.storedAvgHr ?? null,
storedMaxHr: data.storedMaxHr ?? null,
}}
/>
<DeleteWorkoutButton workout={data} />
</div>
) : null
}
backLink={
<BackLink
+28 -2
View File
@@ -2,8 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
FolderOpen,
@@ -38,6 +38,12 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import type { LabResultListResponse } from "@/components/labs/types";
import {
analyteFromQuery,
labHrefForAnalyteReading,
} from "@/lib/labs/analyte-deep-link";
export default function LabsPage() {
const { user, isAuthenticated, isLoading } = useAuth();
@@ -89,6 +95,26 @@ export default function LabsPage() {
disabled: dialogOpen,
});
// MCP deep links (`/labs?analyte=<name>`) open the marker's own page. The
// newest reading under that exact name says which marker it is; a name
// with no reading keeps the list and drops the query.
const searchParams = useSearchParams();
const linkedAnalyte = analyteFromQuery(searchParams.get("analyte"));
const analyteLink = useQuery({
queryKey: queryKeys.labAnalyteLink(linkedAnalyte ?? ""),
queryFn: () =>
apiGet<LabResultListResponse>(
`/api/labs?analyte=${encodeURIComponent(linkedAnalyte ?? "")}&limit=1&sortDir=desc`,
),
enabled: !!linkedAnalyte && isAuthenticated && enabled,
});
useEffect(() => {
if (!linkedAnalyte || !analyteLink.isFetched) return;
router.replace(
labHrefForAnalyteReading(analyteLink.data?.results?.[0]) ?? "/labs",
);
}, [linkedAnalyte, analyteLink.isFetched, analyteLink.data, router]);
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* #1110 — since iOS 27 one Apple Watch writes both HRV measures, SDNN
* (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). The page used to chart
* RMSSD only when there was no SDNN at all, so a watch writing both showed
* SDNN and hid RMSSD. With both present each now gets its own chart, titled
* with its measure; they are never drawn as one line.
*
* The chart is `next/dynamic`; the stub echoes the props that matter.
*/
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = (props: {
types?: string[];
title?: string;
onVisibleStats?: unknown;
}) => (
<div
data-slot="chart-stub"
data-types={(props.types ?? []).join(",")}
data-title={props.title ?? ""}
data-reports-stats={props.onVisibleStats ? "yes" : "no"}
/>
);
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("next/navigation", () => ({ usePathname: () => "/insights/hrv" }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import InsightsHrvPage from "@/app/insights/hrv/page";
function render() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<InsightsHrvPage />
</I18nProvider>
</QueryClientProvider>,
);
}
function charts(html: string): Array<{ types: string; title: string }> {
return [...html.matchAll(/data-types="([^"]*)" data-title="([^"]*)"/g)].map(
(m) => ({ types: m[1]!, title: m[2]! }),
);
}
beforeEach(() => {
analyticsMock.mockReset();
});
describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => {
it("charts each measure on its own when both have readings", () => {
analyticsMock.mockReturnValue({
data: {
summaries: {
HEART_RATE_VARIABILITY: { count: 30 },
HRV_RMSSD: { count: 28 },
},
},
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(2);
expect(found[0]).toMatchObject({ types: "HEART_RATE_VARIABILITY" });
expect(found[0]!.title).toMatch(/SDNN$/);
expect(found[1]).toMatchObject({ types: "HRV_RMSSD" });
expect(found[1]!.title).toMatch(/RMSSD$/);
});
it("lets only the primary chart write the stat strip's window", () => {
// Two charts writing the one stats slot replaced each other's window on
// every render: React's update-depth error, and the chart failed to load.
analyticsMock.mockReturnValue({
data: {
summaries: {
HEART_RATE_VARIABILITY: { count: 30 },
HRV_RMSSD: { count: 28 },
},
},
isEmpty: false,
});
const flags = [...render().matchAll(/data-reports-stats="(yes|no)"/g)].map(
(m) => m[1],
);
expect(flags).toEqual(["yes", "no"]);
});
it("keeps one unlabelled SDNN chart when there is no RMSSD", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 30 } } },
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(1);
expect(found[0]!.types).toBe("HEART_RATE_VARIABILITY");
expect(found[0]!.title).not.toMatch(/SDNN|RMSSD/);
});
it("charts RMSSD alone, labelled, for a ring or strap user", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HRV_RMSSD: { count: 28 } } },
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(1);
expect(found[0]!.types).toBe("HRV_RMSSD");
expect(found[0]!.title).toMatch(/RMSSD$/);
});
});
@@ -73,6 +73,16 @@ export interface HealthKitMetricPageProps {
* `fallbackMeasurementType` is set.
*/
fallbackMeasureLabel?: string;
/**
* v1.42 (#1110) — short label naming the PRIMARY measure (e.g. "SDNN").
* Since iOS 27 one account can hold both HRV measures from the same
* watch. They are different statistics on different scales, so when
* both have rows the page charts each on its own, each titled with its
* measure, instead of hiding the second behind the first.
*/
primaryMeasureLabel?: string;
/** Line colour of the fallback measure's own chart; defaults to `color`. */
fallbackColor?: `var(--${string})`;
/** The InsightMetric key used by `useInsightsAnalytics()`. */
insightMetric: InsightMetric;
/** The chart-overlay slot id. */
@@ -215,6 +225,8 @@ export function HealthKitMetricPage({
measurementType,
fallbackMeasurementType,
fallbackMeasureLabel,
primaryMeasureLabel,
fallbackColor,
insightMetric,
chartKey,
i18nPrefix,
@@ -269,6 +281,11 @@ export function HealthKitMetricPage({
const effectiveType = usingFallback
? (fallbackMeasurementType as string)
: measurementType;
// v1.42 (#1110) — both measures present: the primary keeps the strip, the
// coach read and the value list, and the fallback measure gets a chart of
// its own beneath it. Never one line, never one hidden.
const bothMeasures =
!!fallbackMeasurementType && primaryCount > 0 && fallbackCount > 0;
// v1.32.26 — resolve the display unit + scale from the user's preference for
// a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved,
@@ -421,7 +438,11 @@ export function HealthKitMetricPage({
summary={summary}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
seriesLabel={title}
seriesLabel={
bothMeasures && primaryMeasureLabel
? `${title} · ${primaryMeasureLabel}`
: title
}
icon={statIcon}
windowStats={statsByType?.[effectiveType] ?? null}
medianLabel={statMedianLabel}
@@ -455,7 +476,9 @@ export function HealthKitMetricPage({
title={
usingFallback && fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
: bothMeasures && primaryMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${primaryMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[color]}
@@ -469,6 +492,29 @@ export function HealthKitMetricPage({
onVisibleStats={onVisibleStats}
showDataTable
/>
{bothMeasures ? (
<HealthChartDynamic
chartKey={chartKey}
types={[fallbackMeasurementType as string]}
title={
fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[fallbackColor ?? color]}
unit={resolvedUnit}
yAxisUnit={resolvedYAxisUnit}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={resolvedScale}
valueOffset={resolvedOffset}
// No `onVisibleStats`: the strip describes the primary measure, and
// two charts writing the one stats slot would replace each other's
// window on every render without end.
showDataTable
/>
) : null}
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
@@ -38,7 +38,7 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
import { AppleHealthCard } from "../apple-health-card";
import { AppleHealthCard, ArrivalDetail } from "../apple-health-card";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
@@ -404,3 +404,77 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
}
});
});
describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
const now = new Date("2026-10-08T12:00:00.000Z");
const hoursAgo = (h: number) =>
new Date(now.getTime() - h * 60 * 60 * 1000).toISOString();
const base = {
type: "RESPIRATORY_RATE",
lastSeenAt: hoursAgo(5),
stale: false,
};
const t = (key: string, params?: Record<string, string | number>) => {
const value = key
.split(".")
.reduce<unknown>(
(node, part) => (node as Record<string, unknown>)?.[part],
en,
);
if (typeof value !== "string") return key;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
String(params?.[name] ?? `{${name}}`),
);
};
const html = (entry: Parameters<typeof ArrivalDetail>[0]["entry"]) =>
renderToStaticMarkup(<ArrivalDetail entry={entry} t={t} now={now} />);
it("names when a type last arrived and that only Sync all carried it", () => {
const out = html({
...base,
lastReceivedAt: hoursAgo(2),
lastTrigger: "manual",
lastNewSampleAt: hoursAgo(2),
});
expect(out).toContain('data-trigger="manual"');
expect(out).toContain("Last received 2 h ago");
expect(out).toContain("from Sync all");
// The last arrival brought something new, so no second date is needed.
expect(out).not.toContain("Last new value");
});
it("adds the last new value when the latest arrival held duplicates only", () => {
const out = html({
...base,
lastReceivedAt: hoursAgo(1),
lastTrigger: "background",
lastNewSampleAt: hoursAgo(72),
});
expect(out).toContain("in the background");
expect(out).toContain("Last new value 3 d ago");
});
it("says a type has not arrived through live sync yet", () => {
const out = html({
...base,
lastReceivedAt: null,
lastTrigger: null,
lastNewSampleAt: null,
});
expect(out).toContain('data-state="never"');
expect(out).toContain("Not received yet");
});
it("renders nothing when the server sent no arrival facts", () => {
expect(html(base)).toBe("");
});
it("titles the list by data type", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [{ ...base }],
};
expect(render()).toContain("By data type");
});
});
@@ -25,10 +25,8 @@ import {
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
} from "@/lib/integrations/sync-verdict";
import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync";
import type { SyncHealth } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
@@ -43,7 +41,8 @@ interface HealthKitStatus {
/** When data last arrived without the app being opened. */
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
/** Per type: newest sample, plus when it last arrived and how (#1173). */
metricFreshness?: HealthKitFreshnessEntry[];
/** Server-side backfill progress (#778); null when the read failed. */
syncProgress?: {
recordsAccepted: number;
@@ -135,6 +134,65 @@ function DeliveryDiagnostic({
);
}
/**
* The second line of a type's row: when a live sync last carried the type,
* under which trigger, and when one last brought a new value. The newest
* sample's time on the right of the row says when the reading was TAKEN; this
* says when the server RECEIVED it, which is what tells a type that only
* arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing
* renders when the server sent no arrival facts (the workouts row, or a failed
* ledger read), so absence is never painted as "not received".
*/
export function ArrivalDetail({
entry,
t,
now,
}: {
entry: HealthKitFreshnessEntry;
t: (key: string, params?: Record<string, string | number>) => string;
now?: Date;
}) {
if (entry.lastReceivedAt === undefined) return null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{t("settings.appleHealth.freshness.never")}
</span>
);
}
const reference = (now ?? new Date()).getTime();
const relative = (iso: string) =>
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
const trigger = entry.lastTrigger ?? null;
const parts = [
t("settings.appleHealth.freshness.lastReceived", {
relative: relative(entry.lastReceivedAt),
}),
t(
trigger
? TRIGGER_KEYS[trigger]
: "settings.appleHealth.delivery.trigger.unknown",
),
];
// Only worth a mention when the last arrival brought duplicates alone.
if (entry.lastNewSampleAt && entry.lastNewSampleAt !== entry.lastReceivedAt) {
parts.push(
t("settings.appleHealth.freshness.lastNewValue", {
relative: relative(entry.lastNewSampleAt),
}),
);
}
return (
<span
data-testid="apple-health-arrival"
data-state="received"
data-trigger={trigger ?? "unknown"}
>
{parts.join(" · ")}
</span>
);
}
/**
* First-run backfill progress (#778). During the initial HealthKit backfill
* the card said nothing about how much had arrived or how far back it
@@ -269,6 +327,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
<div className="space-y-2">
@@ -20,7 +20,7 @@
* "expected but missing" row is invented.
*/
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { AlertTriangle, ChevronDown } from "lucide-react";
import { formatRelative } from "@/components/settings/integration-status-pill";
@@ -42,16 +42,27 @@ function humanise(type: string): string {
return words.charAt(0).toUpperCase() + words.slice(1);
}
export function MetricFreshnessDisclosure({
export function MetricFreshnessDisclosure<
Entry extends MetricFreshnessEntry = MetricFreshnessEntry,
>({
entries,
idPrefix,
now,
title,
detail,
}: {
entries: MetricFreshnessEntry[] | undefined;
entries: Entry[] | undefined;
/** Namespaces the disclosure's ids so several cards can coexist. */
idPrefix: string;
/** Override "now" for deterministic testing. */
now?: Date;
/** Replaces the shared "received data" heading. */
title?: string;
/**
* A second line under a row, for a card that knows more per type than the
* newest sample (the Apple Health card's arrival facts, #1173).
*/
detail?: (entry: Entry) => ReactNode;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -101,7 +112,7 @@ export function MetricFreshnessDisclosure({
)}
/>
<span className="text-sm font-medium">
{t("settings.integrationFreshness.title")}
{title ?? t("settings.integrationFreshness.title")}
</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
{t("settings.integrationFreshness.summary", {
@@ -118,35 +129,54 @@ export function MetricFreshnessDisclosure({
</button>
{open && (
<ul id={panelId} className="space-y-1">
{sorted.map((entry) => (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"flex items-center justify-between gap-3 text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
<span className="truncate">{label(entry.type)}</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
<ul id={panelId} className={detail ? "space-y-2.5" : "space-y-1"}>
{sorted.map((entry) => {
const extra = detail?.(entry);
return (
<li
key={entry.type}
data-slot="metric-freshness-row"
data-metric={entry.type}
data-state={entry.stale ? "stale" : "fresh"}
className={cn(
"text-xs",
entry.stale ? "text-warning" : "text-muted-foreground",
)}
>
{relative(entry.lastSeenAt)}
</time>
</li>
))}
<div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1.5">
{entry.stale ? (
<AlertTriangle
aria-hidden="true"
className="size-3 shrink-0"
/>
) : null}
{/* With a detail line under it, the type name is the
row's content and the line its meta. */}
<span
className={cn(
"truncate",
extra && !entry.stale && "text-foreground",
)}
>
{label(entry.type)}
</span>
</span>
<time
dateTime={entry.lastSeenAt}
className="shrink-0 tabular-nums"
>
{relative(entry.lastSeenAt)}
</time>
</div>
{extra ? (
<div data-slot="metric-freshness-detail" className="mt-0.5">
{extra}
</div>
) : null}
</li>
);
})}
</ul>
)}
</div>
@@ -47,7 +47,10 @@ import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
import {
manualWorkoutOriginalFromRow,
type ManualWorkoutEntry,
} from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
@@ -251,3 +254,39 @@ describe("delete — a hand-entered workout in one's own record only", () => {
});
});
});
describe("<ManualWorkoutForm> — editing a stored workout (#1162)", () => {
const original = manualWorkoutOriginalFromRow(
{
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
durationSec: 5400,
distanceM: 32000,
activeEnergyKcal: 640,
minHr: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
storedAvgHr: 131,
storedMaxHr: null,
},
{ timezone: "Europe/Berlin", unitPreference: "metric" },
);
it("opens on the stored values", () => {
const html = render(
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
);
expect(html).toContain('value="2026-09-15T08:00"');
expect(html).toContain('value="1"');
expect(html).toContain('value="30"');
expect(html).toContain('value="32"');
expect(html).toContain('value="640"');
});
it("opens empty when it is not editing", () => {
const html = render(<ManualWorkoutForm />);
expect(html).not.toContain('value="640"');
});
});
@@ -0,0 +1,83 @@
"use client";
import { useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { cn } from "@/lib/utils";
import {
canEditWorkout,
manualWorkoutOriginalFromRow,
type StoredManualWorkout,
} from "@/lib/workouts/manual-entry";
/**
* The detail page's edit control (#1162): a ghost icon beside delete that
* opens the "Log workout" sheet on the stored values. Renders nothing for a
* workout it may not edit (a synced one, or somebody else's record).
*
* Saving goes through the same `POST /api/workouts/batch` the form always
* used, re-posting the row's own `manual:` id, which the route applies as an
* overwrite of that one row. No separate edit route exists.
*/
export function EditWorkoutButton({
workout,
}: {
workout: StoredManualWorkout & {
source: string;
externalId: string | null;
};
}) {
const { t } = useTranslations();
const { user } = useAuth();
const { preference } = useUnitDisplay();
const capabilities = useRecordCapabilities();
const [open, setOpen] = useState(false);
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const original = useMemo(
() =>
manualWorkoutOriginalFromRow(workout, {
timezone,
unitPreference: preference,
}),
[workout, timezone, preference],
);
if (!canEditWorkout(workout, capabilities) || !workout.externalId) {
return null;
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-edit"
aria-label={t("insights.workouts.manual.editAction")}
title={t("insights.workouts.manual.editTitle")}
onClick={() => setOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
{/* The sheet mounts its form only while open, so each opening starts
from the values the page shows at that moment. */}
<ManualWorkoutSheet
open={open}
onOpenChange={setOpen}
edit={{ externalId: workout.externalId, original }}
/>
</>
);
}
@@ -1,9 +1,9 @@
"use client";
import { useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { Check, Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
@@ -33,6 +33,7 @@ import {
type ManualWorkoutDraft,
type ManualWorkoutEntry,
type ManualWorkoutField,
type ManualWorkoutOriginal,
} from "@/lib/workouts/manual-entry";
import {
workoutSportTypeEnum,
@@ -49,10 +50,23 @@ import {
* `onSuccess` / `onCancel` close the sheet.
*/
/**
* Edit mode (#1162): the stored workout's own `manual:` id and the values it
* opens with. Saving re-posts that id, which the batch route applies as an
* overwrite of that one row.
*/
export interface ManualWorkoutEdit {
externalId: string;
original: ManualWorkoutOriginal;
}
interface ManualWorkoutFormProps {
onSuccess?: () => void;
onCancel?: () => void;
footerSlot?: HTMLElement | null;
edit?: ManualWorkoutEdit;
/** Whether the fields differ from what the form opened with. */
onDirtyChange?: (dirty: boolean) => void;
}
interface BatchResult {
@@ -104,6 +118,8 @@ export function ManualWorkoutForm({
onSuccess,
onCancel,
footerSlot,
edit,
onDirtyChange,
}: ManualWorkoutFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -114,10 +130,20 @@ export function ManualWorkoutForm({
// Minted once per opened form and sent with every submit, so a second tap
// or a retried request lands as a duplicate rather than a second row, and
// a submit after an edit updates the row the first submit stored.
const [externalId] = useState(newManualWorkoutExternalId);
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
emptyManualWorkoutDraft(),
// An edit sends the stored row's own id instead.
const [externalId] = useState(
() => edit?.externalId ?? newManualWorkoutExternalId(),
);
const [initialDraft] = useState<ManualWorkoutDraft>(
() => edit?.original.draft ?? emptyManualWorkoutDraft(),
);
const [draft, setDraft] = useState<ManualWorkoutDraft>(initialDraft);
const dirty = (
Object.keys(initialDraft) as Array<keyof ManualWorkoutDraft>
).some((key) => draft[key] !== initialDraft[key]);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [errors, setErrors] = useState<
Partial<Record<ManualWorkoutField, string>>
>({});
@@ -158,6 +184,7 @@ export function ManualWorkoutForm({
unitPreference: preference,
externalId,
now: new Date(),
original: edit?.original,
});
if (!built.ok) {
setErrors(built.errors);
@@ -174,6 +201,8 @@ export function ManualWorkoutForm({
"empty",
t("insights.workouts.manual.alreadySaved"),
);
} else if (outcome === "updated") {
toastWrittenOutcome("success", t("insights.workouts.manual.updated"));
} else {
toastWrittenOutcome("success", t("common.saved"));
}
@@ -216,6 +245,8 @@ export function ManualWorkoutForm({
>
{saving ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : edit ? (
<Check className="size-4" />
) : (
<Plus className="size-4" />
)}
@@ -278,16 +309,20 @@ export function ManualWorkoutForm({
max={wallClockNow(new Date(), timezone)}
aria-invalid={!!errors.start || undefined}
aria-describedby={[
`${ids.start}-hint`,
edit ? undefined : `${ids.start}-hint`,
describedBy("start", `${ids.start}-error`),
]
.filter(Boolean)
.join(" ")}
data-testid="manual-workout-start"
/>
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
{/* "Leave empty if you just finished" is about logging; an edit
opens with the stored start in place. */}
{edit ? null : (
<p id={`${ids.start}-hint`} className="text-muted-foreground text-xs">
{t("insights.workouts.manual.startHint")}
</p>
)}
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
</div>
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
@@ -14,7 +14,10 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { ManualWorkoutForm } from "@/components/workouts/manual-workout-form";
import {
ManualWorkoutForm,
type ManualWorkoutEdit,
} from "@/components/workouts/manual-workout-form";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -23,21 +26,31 @@ import { useTranslations } from "@/lib/i18n/context";
* `ManualWorkoutForm` inside their own sheets, which already carry the same
* confirm-before-discard guard; this one carries it too, so a half-filled
* form is never dropped by a stray swipe.
*
* With `edit` it opens on a stored workout (#1162). Its fields start filled,
* so "has input" says nothing there; the guard asks the form whether anything
* differs from what it opened with.
*/
export function ManualWorkoutSheet({
open,
onOpenChange,
edit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
edit?: ManualWorkoutEdit;
}) {
const { t } = useTranslations();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
const editDirty = useRef(false);
const handleDirtyChange = useCallback((dirty: boolean) => {
editDirty.current = dirty;
}, []);
function handleOpenChange(next: boolean) {
if (next) return onOpenChange(true);
if (sheetBodyHasUnsavedInput()) {
if (edit ? editDirty.current : sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
@@ -49,7 +62,11 @@ export function ManualWorkoutSheet({
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.workouts.manual.sheetTitle")}
title={t(
edit
? "insights.workouts.manual.editTitle"
: "insights.workouts.manual.sheetTitle",
)}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
@@ -58,6 +75,8 @@ export function ManualWorkoutSheet({
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
edit={edit}
onDirtyChange={edit ? handleDirtyChange : undefined}
/>
)}
</ResponsiveSheet>
+7
View File
@@ -305,6 +305,13 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
*/
dayKey: string;
minHr: number | null;
/**
* The row's own heart rate, before the canonical picker filled any of it
* in from a twin. An edit of a hand-entered workout sends these back
* (#1162); `avgHr` / `maxHr` are for display.
*/
storedAvgHr?: number | null;
storedMaxHr?: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
@@ -0,0 +1,90 @@
/**
* #1110 — since iOS 27 one Apple Watch writes both HRV measures: SDNN
* (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). They are different
* statistics on different scales. The Coach's HRV block used to fold every
* row of both into one daily series, so a day with both averaged an SDNN
* value with an RMSSD value. With both present the block now carries one
* timeline per measure under `byMeasure`, under the same section key, and
* the budget pass condenses each as its own series.
*/
import { describe, expect, it } from "vitest";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { buildValueSeriesBlocks } from "@/lib/ai/coach/snapshot-blocks/value-series-blocks";
import {
condenseSeriesBlock,
isCondensable,
type DailySeries,
} from "@/lib/ai/coach/series-condense";
import type { CoachProvenance } from "@/lib/ai/coach/types";
const NOW = new Date("2026-10-08T12:00:00.000Z");
const day = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
function build(rows: Array<{ type: string; value: number; measuredAt: Date }>) {
const snapshot: Record<string, unknown> = {};
const registered = new Map<string, () => DailySeries>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
buildValueSeriesBlocks({
sources: new Set(["hrv"]),
measurementRows: rows,
additiveCutoff: () => day(400),
recentCutoff: day(14),
userTz: "UTC",
snapshot,
metrics: new Set(),
counts,
registerBlock: (key, _source, daily) => {
if (daily) registered.set(key, daily);
},
groundingValues: new Map(),
units: DEFAULT_UNIT_PREFERENCES,
});
return { snapshot, registered, counts };
}
const both = [
{ type: "HEART_RATE_VARIABILITY", value: 40, measuredAt: day(1) },
{ type: "HRV_RMSSD", value: 70, measuredAt: day(1) },
{ type: "HEART_RATE_VARIABILITY", value: 44, measuredAt: day(2) },
{ type: "HRV_RMSSD", value: 76, measuredAt: day(2) },
{ type: "HEART_RATE_VARIABILITY", value: 42, measuredAt: day(30) },
];
describe("Coach HRV block — SDNN and RMSSD kept apart (#1110)", () => {
it("carries one timeline per measure when both are present", () => {
const { snapshot, counts } = build(both);
const block = snapshot.heartRateVariability as {
unit?: string;
timeline?: unknown;
byMeasure: Record<string, { recent: Array<{ value: number }> }>;
};
expect(block.timeline).toBeUndefined();
expect(block.unit).toBe("ms");
expect(Object.keys(block.byMeasure)).toEqual(["SDNN", "RMSSD"]);
const sdnn = block.byMeasure.SDNN!.recent.map((r) => r.value);
const rmssd = block.byMeasure.RMSSD!.recent.map((r) => r.value);
// No day mixes the two: SDNN days read 40s, RMSSD days 70s.
expect(sdnn.every((v) => v < 50)).toBe(true);
expect(rmssd.every((v) => v > 60)).toBe(true);
expect(counts.hrv).toBe(5);
});
it("registers each measure as its own series, and condenses each", () => {
const { snapshot, registered } = build(both);
const daily = registered.get("heartRateVariability")!();
expect(Object.keys(daily).sort()).toEqual(["RMSSD", "SDNN"]);
const block = snapshot.heartRateVariability;
expect(isCondensable("heartRateVariability", block)).toBe(true);
condenseSeriesBlock(block, 1, "heartRateVariability", daily);
const summary = (block as { summary?: Record<string, unknown> }).summary;
expect(Object.keys(summary ?? {}).sort()).toEqual(["RMSSD", "SDNN"]);
});
it("keeps the single-timeline shape when only one measure is present", () => {
const { snapshot } = build(both.filter((row) => row.type === "HRV_RMSSD"));
const block = snapshot.heartRateVariability as Record<string, unknown>;
expect(block.byMeasure).toBeUndefined();
expect(block.timeline).toBeDefined();
});
});
+10 -1
View File
@@ -238,8 +238,17 @@ function tracksOf(kind: SeriesBlockKind, block: Rec): Track[] {
]
: [];
switch (kind) {
case "value":
case "value": {
// A metric with several measures (HRV: SDNN and RMSSD, #1110) keeps
// one timeline per measure, each condensed as its own series.
const byMeasure = block.byMeasure;
if (isRecord(byMeasure)) {
return Object.keys(byMeasure).flatMap((measure) =>
single(byMeasure[measure], "value", measure),
);
}
return single(block.timeline, "value", null);
}
case "sleep":
return single(block.timeline, "minutes", null);
case "compliance":
@@ -28,6 +28,15 @@ interface ValueBlock {
source: CoachScopeSource;
snapshotKey: string;
type: string | readonly string[];
/**
* For a block whose types are different statistics of one thing (HRV:
* SDNN and RMSSD). With rows of one measure the block is built as before;
* with rows of several, the block carries one timeline per measure under
* `byMeasure`, named by measure, instead of one daily series averaging the
* two together (#1110: since iOS 27 one watch writes both). Same section
* key, so every reader that asks for the section still finds it.
*/
measures?: readonly { type: string; label: string }[];
}
const VALUE_BLOCKS: readonly ValueBlock[] = [
@@ -36,6 +45,10 @@ const VALUE_BLOCKS: readonly ValueBlock[] = [
source: "hrv",
snapshotKey: "heartRateVariability",
type: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
measures: [
{ type: "HEART_RATE_VARIABILITY", label: "SDNN" },
{ type: "HRV_RMSSD", label: "RMSSD" },
],
},
{
metric: "resting_hr",
@@ -271,14 +284,13 @@ export function buildValueSeriesBlocks(
const wanted = Array.isArray(block.type) ? new Set(block.type) : null;
const blockCutoff = ctx.additiveCutoff(block.source);
const rows = ctx.measurementRows
.filter((row) =>
wanted ? wanted.has(row.type) : row.type === block.type,
)
.map((row) => ({ measuredAt: row.measuredAt, value: row.value }))
.filter((row) => row.measuredAt >= blockCutoff);
const inWindow = ctx.measurementRows.filter(
(row) =>
(wanted ? wanted.has(row.type) : row.type === block.type) &&
row.measuredAt >= blockCutoff,
);
if (rows.length === 0) {
if (inWindow.length === 0) {
const cluster = sourceCluster(block.source);
if (cluster) {
annotate({
@@ -289,43 +301,120 @@ export function buildValueSeriesBlocks(
continue;
}
const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz);
// Aggregated on the canonical value (the grounding below reads that),
// stated in the reader's unit. The two HRV estimators share one unit.
const transform = getReadingTransform(
typeof block.type === "string" ? block.type : block.type[0],
ctx.units,
const present = block.measures?.filter((measure) =>
inWindow.some((row) => row.type === measure.type),
);
ctx.snapshot[block.snapshotKey] = {
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
timeline: timelineInUnit(
{
recent: recentRows,
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
),
};
ctx.metrics.add(block.metric);
ctx.counts[block.metric] = rows.length;
ctx.registerBlock(block.snapshotKey, block.source, () => ({
value: dailyPoints(rows, ctx.userTz, undefined, transform),
}));
const referenceMetric =
typeof block.type === "string"
? TYPE_TO_REFERENCE_METRIC[block.type]
: undefined;
if (referenceMetric && recentRows.length > 0) {
const values = recentRows.slice(-14);
const mean =
values.reduce((sum, row) => sum + row.value, 0) / values.length;
ctx.groundingValues.set(referenceMetric, mean);
if (present && present.length > 1) {
writeMeasuresSection(ctx, block, present, inWindow);
} else {
writeValueSection(
ctx,
block,
typeof block.type === "string" ? block.type : block.type[0]!,
inWindow,
);
}
ctx.metrics.add(block.metric);
ctx.counts[block.metric] = inWindow.length;
}
}
type ValueRows = ValueSeriesBlocksContext["measurementRows"];
function toPoints(rows: ValueRows) {
return rows.map((row) => ({ measuredAt: row.measuredAt, value: row.value }));
}
function valueTimeline(
ctx: Readonly<ValueSeriesBlocksContext>,
rows: ReturnType<typeof toPoints>,
transform: ReturnType<typeof getReadingTransform>,
) {
return timelineInUnit(
{
recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz),
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
);
}
/**
* Several measures of one metric, each its own timeline under `byMeasure`,
* and each its own series should the budget pass condense the block.
*/
function writeMeasuresSection(
ctx: Readonly<ValueSeriesBlocksContext>,
block: ValueBlock,
measures: readonly { type: string; label: string }[],
inWindow: ValueRows,
): void {
// The measures of one block share a unit (HRV: milliseconds).
const transform = getReadingTransform(measures[0]!.type, ctx.units);
const byMeasure: Record<string, unknown> = {};
const pointsByMeasure = new Map<string, ReturnType<typeof toPoints>>();
for (const measure of measures) {
const rows = toPoints(inWindow.filter((row) => row.type === measure.type));
pointsByMeasure.set(measure.label, rows);
byMeasure[measure.label] = valueTimeline(ctx, rows, transform);
}
ctx.snapshot[block.snapshotKey] = {
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
byMeasure,
};
ctx.registerBlock(block.snapshotKey, block.source, () =>
Object.fromEntries(
[...pointsByMeasure].map(([label, rows]) => [
label,
dailyPoints(rows, ctx.userTz, undefined, transform),
]),
),
);
}
function writeValueSection(
ctx: Readonly<ValueSeriesBlocksContext>,
block: ValueBlock,
unitType: string,
sourceRows: ValueRows,
): void {
const rows = toPoints(sourceRows);
const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz);
// Aggregated on the canonical value (the grounding below reads that),
// stated in the reader's unit.
const transform = getReadingTransform(unitType, ctx.units);
ctx.snapshot[block.snapshotKey] = {
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
timeline: timelineInUnit(
{
recent: recentRows,
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
),
};
ctx.registerBlock(block.snapshotKey, block.source, () => ({
value: dailyPoints(rows, ctx.userTz, undefined, transform),
}));
const referenceMetric =
typeof block.type === "string"
? TYPE_TO_REFERENCE_METRIC[block.type]
: undefined;
if (referenceMetric && recentRows.length > 0) {
const values = recentRows.slice(-14);
const mean =
values.reduce((sum, row) => sum + row.value, 0) / values.length;
ctx.groundingValues.set(referenceMetric, mean);
}
}
+11 -8
View File
@@ -1223,10 +1223,12 @@ export function degradeToBudget(
delete timeline.coarse;
changed = true;
}
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
const c = asRecord(byContext[ctx]);
// Glucose keeps a timeline per context, HRV one per measure (#1110).
for (const nested of [block.byContext, block.byMeasure]) {
const byKey = asRecord(nested);
if (!byKey) continue;
for (const ctx of Object.keys(byKey)) {
const c = asRecord(byKey[ctx]);
if (c && "recent" in c) {
delete c.recent;
changed = true;
@@ -1258,10 +1260,11 @@ export function degradeToBudget(
delete block.timeline;
}
}
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
const c = asRecord(byContext[ctx]);
for (const nested of [block.byContext, block.byMeasure]) {
const byKey = asRecord(nested);
if (!byKey) continue;
for (const ctx of Object.keys(byKey)) {
const c = asRecord(byKey[ctx]);
if (c && "weekly" in c) {
delete c.weekly;
changed = true;
+2
View File
@@ -34,6 +34,8 @@ import {
export const RANKED_TYPES: readonly MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
// v1.42 (#1110) — the `hrvRmssd` ladder.
"HRV_RMSSD",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import { existsSync } from "node:fs";
import { join } from "node:path";
/**
* The MCP `search` / `fetch` results link a metric as `/insights?metric=<id>`
* in several spellings. Every one must land on the metric's own page; an id
* that names nothing leaves the overview alone.
*/
describe("insightsHrefForMetric", () => {
it("routes the Coach source keys the data inventory links carry", () => {
expect(insightsHrefForMetric("hrv")).toBe("/insights/hrv");
expect(insightsHrefForMetric("resting_hr")).toBe("/insights/resting-pulse");
expect(insightsHrefForMetric("bp")).toBe("/insights/blood-pressure");
expect(insightsHrefForMetric("weight")).toBe("/insights/weight");
expect(insightsHrefForMetric("glucose")).toBe("/insights/blood-glucose");
expect(insightsHrefForMetric("mood")).toBe("/insights/mood");
expect(insightsHrefForMetric("workouts")).toBe("/insights/workouts");
});
it("routes signal keys and measurement types", () => {
expect(insightsHrefForMetric("GRIP_STRENGTH")).toBe(
"/insights/grip-strength",
);
expect(insightsHrefForMetric("CARDIO_RECOVERY")).toBe(
"/insights/cardio-recovery",
);
expect(insightsHrefForMetric("HRV_RMSSD")).toBe("/insights/hrv");
});
it("falls back to the value list for a type no page focuses", () => {
expect(insightsHrefForMetric("DAY_STRAIN")).toBe(
"/insights/values/DAY_STRAIN",
);
});
it("routes a sub-page slug in either separator", () => {
expect(insightsHrefForMetric("walking-speed")).toBe(
"/insights/walking-speed",
);
expect(insightsHrefForMetric("walking_speed")).toBe(
"/insights/walking-speed",
);
});
it("leaves the overview for an id it cannot place", () => {
for (const id of [null, "", " ", "nonsense", "../admin", "a".repeat(80)]) {
expect(insightsHrefForMetric(id)).toBeNull();
}
});
it("only ever points at a page that exists", () => {
const ids = [
...SUB_PAGE_SLUGS,
...Object.keys(COACH_SOURCE_MEASUREMENT_TYPES),
];
for (const id of ids) {
const href = insightsHrefForMetric(id);
if (!href || href.startsWith("/insights/values/")) continue;
const dir = join(process.cwd(), "src/app", href);
expect(existsSync(join(dir, "page.tsx")), `${id} → ${href}`).toBe(true);
}
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Where `/insights?metric=<id>` goes (MCP deep links).
*
* The MCP `search` and `fetch` results link a metric as
* `/insights?metric=<id>`, with the id in whichever spelling that result
* carries: a Coach source key (`hrv`, `resting_hr`, `bp`), a signal or
* metric-status key (`GRIP_STRENGTH`, `CARDIO_RECOVERY`), or a sub-page slug.
* The overview read none of them, so every such link landed on the overview.
* This resolves the id to the metric's own page, or to its value list when no
* page focuses it, and to nothing for an id it cannot place (the overview
* then renders as before). Pure: no I/O.
*/
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
import {
SUB_PAGE_SLUGS,
subPageSlugForType,
type SubPageSlug,
} from "@/lib/insights/sub-page-metric";
import { SIGNALS } from "@/lib/signals/registry";
import { measurementTypeEnum } from "@/lib/validations/measurement";
/** Coach sources that read a model other than `Measurement`. */
const NON_MEASUREMENT_SOURCES: Readonly<Record<string, string>> = {
mood: "/insights/mood",
workouts: "/insights/workouts",
compliance: "/insights/medications",
};
const SLUGS: ReadonlySet<string> = new Set(SUB_PAGE_SLUGS);
function hrefForType(type: string): string {
const slug = subPageSlugForType(type);
return slug
? `/insights/${slug}`
: `/insights/values/${encodeURIComponent(type)}`;
}
export function insightsHrefForMetric(
raw: string | null | undefined,
): string | null {
const id = raw?.trim() ?? "";
if (id === "" || id.length > 64 || !/^[A-Za-z0-9_-]+$/.test(id)) {
return null;
}
const lower = id.toLowerCase();
// A sub-page slug, in either separator.
const slug = lower.replace(/_/g, "-");
if (SLUGS.has(slug)) return `/insights/${slug as SubPageSlug}`;
// A Coach source key, the spelling the data inventory links carry.
if (lower in NON_MEASUREMENT_SOURCES) return NON_MEASUREMENT_SOURCES[lower]!;
const sourceTypes =
COACH_SOURCE_MEASUREMENT_TYPES[lower as CoachScopeSource] ?? [];
if (sourceTypes.length > 0) return hrefForType(sourceTypes[0]!);
// A signal key, then a measurement type itself.
const upper = id.toUpperCase();
const signal = SIGNALS[upper];
if (signal && (signal.kind === "measurement" || signal.kind === "score")) {
return hrefForType(signal.source.measurementType);
}
if (measurementTypeEnum.safeParse(upper).success) return hrefForType(upper);
return null;
}
@@ -8,14 +8,35 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const groupByMock = vi.hoisted(() => vi.fn());
/**
* The fixtures keep the grouped-row shape (`source`, `type`, `_max`) the read
* used to take. Since v1.42 the module asks `listLiveMeasurementTypes` per
* source and then probes each type's newest row in one raw query; the two
* mocks below answer both from the same fixture rows.
*/
type GroupedRow = {
source: string;
type: string;
_max: { measuredAt: Date | null };
};
let measurementRows: GroupedRow[] = [];
const groupByMock = {
mockResolvedValue(rows: GroupedRow[]) {
measurementRows = rows;
},
};
const liveTypesMock = vi.hoisted(() => vi.fn());
const queryRawMock = vi.hoisted(() => vi.fn());
const workoutGroupByMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: groupByMock },
$queryRaw: queryRawMock,
workout: { groupBy: workoutGroupByMock },
},
}));
vi.mock("@/lib/measurements/live-types", () => ({
listLiveMeasurementTypes: liveTypesMock,
}));
import {
getSourceFreshness,
@@ -28,7 +49,32 @@ import {
} from "../sync-verdict";
beforeEach(() => {
groupByMock.mockReset();
measurementRows = [];
liveTypesMock.mockReset();
liveTypesMock.mockImplementation(
async (_userId: string, options: { source: string }) =>
measurementRows
.filter((row) => row.source === options.source)
.map((row) => row.type),
);
queryRawMock.mockReset();
queryRawMock.mockImplementation(
async (_strings: TemplateStringsArray, ...values: unknown[]) => {
const types = values.find(Array.isArray) as string[];
const source = values.find(
(value) =>
typeof value === "string" &&
measurementRows.some((row) => row.source === value),
);
return types.map((type) => ({
type,
last_seen:
measurementRows.find(
(row) => row.source === source && row.type === type,
)?._max.measuredAt ?? null,
}));
},
);
workoutGroupByMock.mockReset();
workoutGroupByMock.mockResolvedValue([]);
});
@@ -65,11 +111,10 @@ describe("getSourceMetricFreshness", () => {
expect(result.withings).toEqual([
{ type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" },
]);
// Only the sync sources are queried.
expect(groupByMock.mock.calls[0][0].where.source.in).toEqual(
Object.values(INTEGRATION_MEASUREMENT_SOURCE),
);
expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull();
// Only the sync sources are asked about, one live-type walk each.
expect(
liveTypesMock.mock.calls.map((call) => call[1].source).sort(),
).toEqual([...Object.values(INTEGRATION_MEASUREMENT_SOURCE)].sort());
});
it("skips rows with no reading and unmapped sources", async () => {
@@ -175,9 +220,30 @@ describe("getSourceFreshness — one source, for Apple Health", () => {
expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([
{ type: "RESPIRATORY_RATE", lastSeenAt: "2026-07-01T00:00:00.000Z" },
]);
expect(groupByMock.mock.calls[0][0].where.source.in).toEqual([
"APPLE_HEALTH",
expect(liveTypesMock).toHaveBeenCalledTimes(1);
expect(liveTypesMock.mock.calls[0][1]).toEqual({ source: "APPLE_HEALTH" });
});
it("issues no per-type probe when the source has no live types", async () => {
expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([]);
expect(queryRawMock).not.toHaveBeenCalled();
});
it("probes live rows of the one source only", async () => {
groupByMock.mockResolvedValue([
{
source: "APPLE_HEALTH",
type: "PULSE",
_max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") },
},
]);
await getSourceFreshness("u1", "APPLE_HEALTH");
const sql = (queryRawMock.mock.calls[0][0] as TemplateStringsArray).join(
"?",
);
expect(sql).toContain('"deleted_at" IS NULL');
expect(sql).toContain('"source" = ?::"measurement_source"');
expect(sql).toContain("LIMIT 1");
});
});
+135 -3
View File
@@ -7,13 +7,30 @@
* it to show which types are actually flowing. A scoped ingest credential
* does not write it, the same rule as `lastSyncedAt`.
*
* Contract stub: the signature is final; the body records nothing yet.
* Sample time (`max(measuredAt)`, what the freshness list showed before) and
* arrival time are different questions. A type the phone only hands over
* when someone taps "Sync all" can carry a reading from this morning and
* still never have arrived in the background; only the arrival time and the
* trigger that carried it can show that.
*/
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logCaught } from "@/lib/logging/signal";
import {
WORKOUT_FRESHNESS_TYPE,
type MetricFreshnessEntry,
} from "./sync-verdict";
export type HealthKitSyncTrigger =
"foreground" | "background" | "push" | "manual";
const TRIGGERS: ReadonlySet<string> = new Set<HealthKitSyncTrigger>([
"foreground",
"background",
"push",
"manual",
]);
/** What one batch did for one type. */
export interface HealthKitTypeArrival {
/** Entries of this type the batch accepted (inserted, updated or duplicate). */
@@ -22,6 +39,16 @@ export interface HealthKitTypeArrival {
newSamples: number;
}
/**
* One statement for the whole batch (`unnest` over at most a few dozen
* types). `last_received_at` and `last_trigger` describe the latest batch
* that carried the type, so they are overwritten; `last_new_sample_at` only
* moves when this batch brought something new, so a duplicates-only re-send
* keeps the earlier date standing.
*
* Best effort: the ledger is a diagnostic beside rows that are already
* written, so a failure here leaves a `warn` line and never fails the ingest.
*/
export async function recordHealthKitTypeArrivals(
tx: Prisma.TransactionClient,
input: {
@@ -31,6 +58,111 @@ export async function recordHealthKitTypeArrivals(
at?: Date;
},
): Promise<void> {
void tx;
void input;
const types: string[] = [];
const brought: boolean[] = [];
for (const [type, arrival] of input.counts) {
if (arrival.accepted <= 0) continue;
types.push(type);
brought.push(arrival.newSamples > 0);
}
if (types.length === 0) return;
// An ISO string cast in SQL, not a JS `Date`: a Date parameter is
// serialised in the process's local zone and these are zone-less UTC
// columns (same reasoning as the access-token sunset in refresh-token.ts).
const at = (input.at ?? new Date()).toISOString();
try {
await tx.$executeRaw`
INSERT INTO "healthkit_type_syncs"
("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at")
SELECT ${input.userId}, u.t::"measurement_type",
${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger},
CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END
FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n)
ON CONFLICT ("user_id", "type") DO UPDATE SET
"last_received_at" = EXCLUDED."last_received_at",
"last_trigger" = EXCLUDED."last_trigger",
"last_new_sample_at" = COALESCE(
EXCLUDED."last_new_sample_at",
"healthkit_type_syncs"."last_new_sample_at"
)
`;
} catch (error) {
logCaught("healthkit.type_sync.record_failed", error, {
types: types.length,
});
}
}
/** One type's arrival facts, as the Apple Health status read serves them. */
export interface HealthKitTypeArrivalRecord {
lastReceivedAt: string;
lastTrigger: HealthKitSyncTrigger | null;
lastNewSampleAt: string | null;
}
/**
* Every recorded type for one account, keyed by `MeasurementType`. A stored
* trigger outside the known set (none is written, but the column is text)
* reads as null rather than reaching the client as an unknown label.
*/
export async function getHealthKitTypeArrivals(
userId: string,
): Promise<Map<string, HealthKitTypeArrivalRecord>> {
const rows = await prisma.healthKitTypeSync.findMany({
where: { userId },
select: {
type: true,
lastReceivedAt: true,
lastTrigger: true,
lastNewSampleAt: true,
},
});
const out = new Map<string, HealthKitTypeArrivalRecord>();
for (const row of rows) {
out.set(row.type, {
lastReceivedAt: row.lastReceivedAt.toISOString(),
lastTrigger:
row.lastTrigger && TRIGGERS.has(row.lastTrigger)
? (row.lastTrigger as HealthKitSyncTrigger)
: null,
lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null,
});
}
return out;
}
/** A freshness entry with the arrival facts beside the newest sample. */
export interface HealthKitFreshnessEntry extends MetricFreshnessEntry {
/** When a live sync last carried this type; null when none has. */
lastReceivedAt?: string | null;
/** The trigger of that sync; null when absent or not reported. */
lastTrigger?: HealthKitSyncTrigger | null;
/** When a live sync last brought a new or updated sample of it. */
lastNewSampleAt?: string | null;
}
/**
* Lay the arrival ledger over the per-type freshness entries. Every
* measurement type gets the three fields, null when no live sync has carried
* it yet (a type that so far came only from the export import, or from before
* the ledger existed). The `WORKOUTS` entry is left without them: workouts
* arrive through their own batch route, which the ledger does not cover, and
* "not received yet" would be false. A null ledger (the read failed) leaves
* every entry as it was rather than claiming nothing arrived.
*/
export function withHealthKitArrivals(
entries: readonly MetricFreshnessEntry[],
arrivals: ReadonlyMap<string, HealthKitTypeArrivalRecord> | null,
): HealthKitFreshnessEntry[] {
if (!arrivals) return [...entries];
return entries.map((entry) => {
if (entry.type === WORKOUT_FRESHNESS_TYPE) return entry;
const arrival = arrivals.get(entry.type);
return {
...entry,
lastReceivedAt: arrival?.lastReceivedAt ?? null,
lastTrigger: arrival?.lastTrigger ?? null,
lastNewSampleAt: arrival?.lastNewSampleAt ?? null,
};
});
}
+58 -20
View File
@@ -10,7 +10,7 @@
* but idle".
*
* This computes the last-value timestamp per `(source, type)` straight from the
* `Measurement` table (no schema change, no migration) so a caller can surface
* `Measurement` table so a caller can surface
* per-metric liveness honestly: a metric whose newest reading is frozen weeks in
* the past is visibly distinct from one that is genuinely current. The
* classification of "quiet" vs "current" is a pure function next to the verdict
@@ -22,7 +22,11 @@
* pseudo-entry so those pipes are visible too.
*/
import { prisma } from "@/lib/db";
import type { MeasurementSource } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import type { IntegrationKey } from "./status";
import {
WORKOUT_FRESHNESS_TYPE,
@@ -70,34 +74,68 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
};
/**
* Newest measurement per `(source, type)` for the given sources. One grouped
* query; a `(source, type)` pair with no rows simply has no entry, which is how
* honest absence stays structural — a metric a provider never delivered is
* never invented.
* Newest measurement per `(source, type)` for the given sources. A `(source,
* type)` pair with no rows simply has no entry, which is how honest absence
* stays structural — a metric a provider never delivered is never invented.
*
* v1.42: this used to be one `groupBy … _max(measuredAt)` over every live row
* of the sources, which on a multi-year Apple Health history reads six figures
* of rows for a two-digit answer on every settings visit. It now asks which
* types are live per source (`listLiveMeasurementTypes`, a loose index scan)
* and then probes each type's newest row through the same partial covering
* index, one `LIMIT 1` per type.
*/
export async function getMeasurementFreshnessBySource(
userId: string,
sources: readonly MeasurementSource[],
): Promise<Map<MeasurementSource, MetricFreshnessSample[]>> {
const out = new Map<MeasurementSource, MetricFreshnessSample[]>();
if (sources.length === 0) return out;
const grouped = await prisma.measurement.groupBy({
by: ["source", "type"],
where: { userId, deletedAt: null, source: { in: [...sources] } },
_max: { measuredAt: true },
});
for (const row of grouped) {
const lastSeen = row._max.measuredAt;
if (!lastSeen) continue;
const list = out.get(row.source) ?? [];
list.push({ type: row.type, lastSeenAt: lastSeen.toISOString() });
out.set(row.source, list);
const perSource = await Promise.all(
[...new Set(sources)].map(async (source) => {
const types = await listLiveMeasurementTypes(userId, { source });
return { source, samples: await newestPerType(userId, source, types) };
}),
);
for (const { source, samples } of perSource) {
if (samples.length > 0) out.set(source, samples);
}
return out;
}
async function newestPerType(
userId: string,
source: MeasurementSource,
types: readonly MeasurementType[],
): Promise<MetricFreshnessSample[]> {
if (types.length === 0) return [];
// Parameter-bound throughout; the enum casts are literal SQL.
const rows = await prisma.$queryRaw<
Array<{ type: string; last_seen: Date | null }>
>`
SELECT live."type"::text AS "type",
(
SELECT m."measured_at"
FROM "measurements" m
WHERE m."user_id" = ${userId}
AND m."type" = live."type"
AND m."deleted_at" IS NULL
AND m."source" = ${source}::"measurement_source"
ORDER BY m."measured_at" DESC
LIMIT 1
) AS "last_seen"
FROM unnest(${[...types]}::"measurement_type"[]) AS live("type")
`;
const samples: MetricFreshnessSample[] = [];
for (const row of rows) {
if (!row.last_seen) continue;
samples.push({
type: row.type,
lastSeenAt: new Date(row.last_seen).toISOString(),
});
}
return samples;
}
/**
* Newest workout per source. `Workout` rows are hard-deleted, so there is no
* tombstone predicate to apply.
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
analyteFromQuery,
labHrefForAnalyteReading,
} from "@/lib/labs/analyte-deep-link";
import { queryKeys } from "@/lib/query-keys";
/**
* `/labs?analyte=<name>` (MCP deep links) opens the marker page of the newest
* reading stored under that name, and keeps the list otherwise.
*/
describe("labs analyte deep link", () => {
it("follows a named analyte and ignores an empty or oversized one", () => {
expect(analyteFromQuery(" Ferritin ")).toBe("Ferritin");
expect(analyteFromQuery(null)).toBeNull();
expect(analyteFromQuery(" ")).toBeNull();
expect(analyteFromQuery("x".repeat(121))).toBeNull();
});
it("opens the marker page of the reading, and nothing without one", () => {
expect(labHrefForAnalyteReading({ biomarkerId: "bm_1" })).toBe(
"/labs/bm_1",
);
expect(labHrefForAnalyteReading({ biomarkerId: null })).toBeNull();
expect(labHrefForAnalyteReading(undefined)).toBeNull();
});
it("keeps its read under the lab-results prefix, apart from the list", () => {
const key = queryKeys.labAnalyteLink("Ferritin");
expect(key[0]).toBe("lab-results");
expect(key).not.toEqual(
queryKeys.labResultsList({
analyte: "Ferritin",
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Where `/labs?analyte=<name>` goes (MCP deep links).
*
* MCP `search` and `fetch` link a lab value as `/labs?analyte=<name>`, the
* analyte name exactly as stored on the reading. The labs page read nothing
* from the query, so every such link showed the whole list. The page now asks
* for the newest reading under that name and opens its marker's page; a name
* with no reading, or one whose reading carries no marker, keeps the list.
*/
/** The analyte named in the query, or null when there is none to follow. */
export function analyteFromQuery(
raw: string | null | undefined,
): string | null {
const analyte = raw?.trim() ?? "";
return analyte.length > 0 && analyte.length <= 120 ? analyte : null;
}
/** The marker page for the newest reading under that name, or null. */
export function labHrefForAnalyteReading(
reading: { biomarkerId: string | null } | undefined,
): string | null {
return reading?.biomarkerId
? `/labs/${encodeURIComponent(reading.biomarkerId)}`
: null;
}
@@ -45,9 +45,8 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// v1.11.0 — WHOOP-native score classes. These ingest server-side from the
// WHOOP API (source = WHOOP), never from HealthKit; Apple ships no
// identifier for day/workout strain, the WHOOP sleep-quality indices, sleep
// need, RMSSD HRV (Apple ships only the SDNN variant), or kJ energy. They
// have no HK mapping by design.
"HRV_RMSSD",
// need, or kJ energy. They have no HK mapping by design. (RMSSD HRV left
// this list in v1.42: iOS 27 added an identifier for it, #1110.)
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
@@ -124,6 +123,8 @@ describe("APPLE_HEALTH_TYPE_MAP", () => {
[
"HKCategoryTypeIdentifierSleepAnalysis",
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
// v1.42 (#1110) — the RMSSD variant, same consent as SDNN.
"HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
"HKQuantityTypeIdentifierVO2Max",
// v1.10.0 — overnight wrist temperature + the sleep-breathing
// index ride behind an explicit Health-share consent, same as
@@ -1081,3 +1082,30 @@ describe("percent-shaped HealthKit types take a fraction or an already-scaled pe
expect(mapping.convertToDbUnit(1.02)).toBeCloseTo(102);
});
});
describe("RMSSD HRV from HealthKit (#1110, iOS 27)", () => {
it("maps the RMSSD identifier onto HRV_RMSSD in milliseconds, never onto SDNN", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
value: 64,
unit: "ms",
startDate: "2026-10-01T03:00:00.000Z",
endDate: "2026-10-01T03:00:00.000Z",
});
expect(out).toMatchObject({ type: "HRV_RMSSD", value: 64, unit: "ms" });
expect(hkIdentifierForType("HRV_RMSSD")).toBe(
"HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
);
expect(hkIdentifierForType("HEART_RATE_VARIABILITY")).toBe(
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
);
});
it("accepts a trained person's 250 ms and refuses 301 ms", () => {
// RMSSD runs higher than SDNN; an out-of-range value is a terminal skip,
// so a 200 ms ceiling would have lost real readings for good.
expect(validateMeasurementRange("HRV_RMSSD", 250)).toBeNull();
expect(validateMeasurementRange("HRV_RMSSD", 300)).toBeNull();
expect(validateMeasurementRange("HRV_RMSSD", 301)).not.toBeNull();
});
});
@@ -176,4 +176,12 @@ describe("metricKeyForType — source-priority ladder coverage (v1.18.10 I-5)",
expect(metricKeyForType("STRESS_SCORE")).toBe("stress");
expect(RANKED_TYPES).toContain("STRESS_SCORE");
});
it("ranks RMSSD HRV on its own ladder, never on SDNN's (#1110)", () => {
// Since iOS 27 Apple Health writes RMSSD next to WHOOP / Oura / Polar,
// so a day can hold it from two sources.
expect(metricKeyForType("HRV_RMSSD")).toBe("hrvRmssd");
expect(metricKeyForType("HEART_RATE_VARIABILITY")).toBe("hrv");
expect(RANKED_TYPES).toContain("HRV_RMSSD");
});
});
@@ -337,6 +337,21 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
aggregation: "mean",
isPrivacySensitive: true,
},
// v1.42 (#1110) — RMSSD HRV, new in iOS / watchOS 27.0. Apple documents it
// as `HKQuantityTypeIdentifier.heartRateVariabilityRMSSD`, symbol
// `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
// (developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/
// heartratevariabilityrmssd). It lands on `HRV_RMSSD` beside WHOOP, Oura
// and Polar, never on the SDNN type: the two are different statistics.
HKQuantityTypeIdentifierHeartRateVariabilityRMSSD: {
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
measurementType: "HRV_RMSSD",
hkUnit: "ms",
dbUnit: "ms",
convertToDbUnit: (v) => v,
aggregation: "mean",
isPrivacySensitive: true,
},
// ── Activity (cumulative) ───────────────────────────────────
HKQuantityTypeIdentifierStepCount: {
@@ -151,6 +151,11 @@ export function metricKeyForType(
return "restingHeartRate";
case "HEART_RATE_VARIABILITY":
return "hrv";
// v1.42 (#1110) — RMSSD has its own ladder: WHOOP, Oura, Polar and,
// since iOS 27, Apple Health all write it, so one day can hold it from
// two sources.
case "HRV_RMSSD":
return "hrvRmssd";
case "RESPIRATORY_RATE":
return "respiratoryRate";
case "OXYGEN_SATURATION":
+34 -2
View File
@@ -117,6 +117,38 @@ const metricFreshnessEntry = z
"Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.",
});
// #1173 — the Apple Health read adds when each type last ARRIVED, under which
// trigger, and when a sync last brought something new for it. Mirrors
// `HealthKitFreshnessEntry` in `src/lib/integrations/healthkit-type-sync.ts`.
const appleHealthMetricFreshnessEntry = metricFreshnessEntry
.extend({
lastReceivedAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe(
"When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null when no live sync has carried it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed.",
),
lastTrigger: healthKitSyncTriggerEnum
.nullable()
.optional()
.describe(
"The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`.",
),
lastNewSampleAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe(
"When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.",
),
})
.meta({
id: "AppleHealthMetricFreshnessEntry",
description:
"Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42).",
});
// Issue #778 — the two backfill-progress figures the server genuinely holds.
// The iOS app drives the backfill; its queue, throttle state, and any ETA
// live on the device and are deliberately absent here.
@@ -167,10 +199,10 @@ const healthKitConfigResponse = z
.optional()
.describe("Present on the GET read; omitted from the PATCH echo."),
metricFreshness: z
.array(metricFreshnessEntry)
.array(appleHealthMetricFreshnessEntry)
.optional()
.describe(
"Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.",
"Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the GET read; omitted from the PATCH echo.",
),
syncProgress: appleHealthSyncProgress
.nullable()
+1 -1
View File
@@ -130,7 +130,7 @@ const batchPayloadSchema = z
.enum(["foreground", "background", "push", "manual"])
.optional()
.describe(
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
),
})
.meta({
+16
View File
@@ -221,6 +221,22 @@ const workoutDetailResponse = z
avgHr: z.number().int().nullable(),
maxHr: z.number().int().nullable(),
minHr: z.number().int().nullable(),
storedAvgHr: z
.number()
.int()
.nullable()
.optional()
.describe(
"The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's value is not written onto it.",
),
storedMaxHr: z
.number()
.int()
.nullable()
.optional()
.describe(
"The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`.",
),
stepCount: z.number().int().nullable(),
elevationM: z.number().nullable(),
pauseDurationSec: z.number().int().nullable(),
+7
View File
@@ -29,6 +29,13 @@ export const labKeys = {
params.sortDir,
] as const,
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
// that exact analyte name, one row, to find its marker's page. Its own key
// because the page size differs from every list read; the `lab-results`
// prefix lets a result mutation evict it too.
labAnalyteLink: (analyte: string) =>
["lab-results", "analyte-link", analyte] as const,
// The visit form's lab picker: newest results, its own page size. Its own
// key because the Labs list reads a different page size, and a shared key
// would serve one read's page to the other.
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import {
buildManualWorkoutEntry,
canEditWorkout,
canLogWorkout,
manualWorkoutOriginalFromRow,
type StoredManualWorkout,
emptyManualWorkoutDraft,
newManualWorkoutExternalId,
wallClockNow,
@@ -283,3 +286,132 @@ describe("canLogWorkout — who is offered the entry", () => {
expect(canLogWorkout(SHARED, { workouts: true })).toBe(false);
});
});
describe("editing a stored workout (#1162)", () => {
// 07:12:34 in Berlin, 47 min 20 s, 5 000 m, with values the form has no
// field for (written by a client other than this form).
const ROW: StoredManualWorkout = {
sportType: "running",
startedAt: "2026-09-15T05:12:34.000Z",
endedAt: "2026-09-15T05:59:54.000Z",
durationSec: 2840,
distanceM: 5000,
activeEnergyKcal: 410,
minHr: 98,
stepCount: 6400,
elevationM: 42.5,
pauseDurationSec: 60,
storedAvgHr: 142,
storedMaxHr: 171,
};
const EDIT_ID = "manual:7c1d2e3f-aaaa-4bbb-8ccc-dddddddddddd";
function original(unitPreference: "metric" | "imperial" = "metric") {
return manualWorkoutOriginalFromRow(ROW, {
timezone: "Europe/Berlin",
unitPreference,
});
}
function save(
edit: Partial<ManualWorkoutDraft>,
unitPreference: "metric" | "imperial" = "metric",
) {
const o = original(unitPreference);
const result = buildManualWorkoutEntry(
{ ...o.draft, ...edit },
{ ...CTX, unitPreference, externalId: EDIT_ID, original: o },
);
if (!result.ok) throw new Error(JSON.stringify(result.errors));
return result.entry;
}
it("opens on the stored values, in the profile zone and the reader's unit", () => {
expect(original().draft).toEqual({
sportType: "running",
start: "2026-09-15T07:12",
hours: "0",
minutes: "47",
distance: "5",
energyKcal: "410",
});
expect(original("imperial").draft.distance).toBe("3.11");
});
it("saves an untouched edit as exactly the stored row", () => {
expect(save({}, "imperial")).toEqual({
sportType: "running",
startedAt: ROW.startedAt,
endedAt: ROW.endedAt,
source: "MANUAL",
externalId: EDIT_ID,
totalDistanceM: 5000,
totalEnergyKcal: 410,
avgHeartRate: 142,
maxHeartRate: 171,
minHeartRate: 98,
stepCount: 6400,
elevationM: 42.5,
pauseDurationSec: 60,
});
});
// The batch route nulls every overwritable column a re-post leaves out, so
// an edit of one field must carry the rest or it erases them.
it("keeps heart rate, steps and the rest when only the energy changes", () => {
const entry = save({ energyKcal: "450" });
expect(entry.totalEnergyKcal).toBe(450);
expect(entry).toMatchObject({
avgHeartRate: 142,
maxHeartRate: 171,
minHeartRate: 98,
stepCount: 6400,
elevationM: 42.5,
pauseDurationSec: 60,
totalDistanceM: 5000,
startedAt: ROW.startedAt,
});
});
it("re-derives start and end from the fields once either changes", () => {
const entry = save({ minutes: "50" });
// 07:12 Berlin = 05:12Z, now to the minute, plus 50 minutes.
expect(entry.startedAt).toBe("2026-09-15T05:12:00.000Z");
expect(entry.endedAt).toBe("2026-09-15T06:02:00.000Z");
});
it("clears a distance the person emptied", () => {
expect(save({ distance: "" })).not.toHaveProperty("totalDistanceM");
});
it("is accepted by the batch route's own schema", () => {
const parsed = createBatchWorkoutSchema.safeParse({
workouts: [save({ energyKcal: "450" })],
});
expect(parsed.success).toBe(true);
});
it("is offered for a form-entered workout in one's own record only", () => {
const own = { inSharedRecord: false };
expect(canEditWorkout({ source: "MANUAL", externalId: EDIT_ID }, own)).toBe(
true,
);
expect(
canEditWorkout(
{ source: "MANUAL", externalId: EDIT_ID },
{ inSharedRecord: true },
),
).toBe(false);
expect(
canEditWorkout({ source: "APPLE_HEALTH", externalId: EDIT_ID }, own),
).toBe(false);
// A MANUAL row without the form's id would not be overwritten by a
// re-post, so it is not offered.
expect(canEditWorkout({ source: "MANUAL", externalId: null }, own)).toBe(
false,
);
expect(
canEditWorkout({ source: "MANUAL", externalId: "uuid-123" }, own),
).toBe(false);
});
});
+151 -6
View File
@@ -19,6 +19,7 @@
import { z } from "zod/v4";
import {
applyDisplayTransform,
getQuantityTransform,
invertDisplayTransform,
type UnitPreference,
@@ -59,8 +60,24 @@ export interface ManualWorkoutDraft {
export type ManualWorkoutField =
"sportType" | "start" | "duration" | "distance" | "energyKcal";
/**
* The columns of a hand-entered workout the form has no field for. The batch
* route's overwrite of a `manual:` row replaces EVERY overwritable column,
* nulling the ones a re-post leaves out, so an edit sends these back exactly
* as stored. A MANUAL row can carry them when a client other than this form
* wrote it (a phone shortcut posting heart rate or steps).
*/
export interface ManualWorkoutCarried {
avgHeartRate?: number;
maxHeartRate?: number;
minHeartRate?: number;
stepCount?: number;
elevationM?: number;
pauseDurationSec?: number;
}
/** The one entry of the `POST /api/workouts/batch` body this form sends. */
export interface ManualWorkoutEntry {
export interface ManualWorkoutEntry extends ManualWorkoutCarried {
sportType: WorkoutSportType;
startedAt: string;
endedAt: string;
@@ -70,6 +87,23 @@ export interface ManualWorkoutEntry {
totalEnergyKcal?: number;
}
/**
* The stored row an edit starts from (#1162): the draft it was shown as, and
* the stored values behind it. A field whose text the person left as shown
* sends the stored value, not the value re-parsed from that text, so opening
* and saving an edit never moves a start by its seconds or a distance by the
* rounding of its display (5 000 m shown as 3.11 mi would come back as
* 5 005.1 m).
*/
export interface ManualWorkoutOriginal {
draft: ManualWorkoutDraft;
startedAt: string;
endedAt: string;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
carried: ManualWorkoutCarried;
}
export interface ManualWorkoutContext {
/** The profile timezone the start is read in. */
timezone: string;
@@ -77,6 +111,8 @@ export interface ManualWorkoutContext {
/** The id this form instance sends with every submit. */
externalId: string;
now: Date;
/** Set when the form edits a stored workout rather than logging one. */
original?: ManualWorkoutOriginal;
}
export type ManualWorkoutResult =
@@ -106,6 +142,90 @@ export function canLogWorkout(
return !caps.inSharedRecord && isSurfaceVisible("capture:workout", modules);
}
/**
* Whether a workout can be edited from its detail page (#1162): one entered
* by hand through the form, in one's own record. The edit is a re-post of the
* row's own `manual:` id through `POST /api/workouts/batch`, which the route
* treats as an overwrite of that row and of nothing else, so a MANUAL row
* without such an id (none is expected; the form has always minted one) can
* still be deleted but not edited.
*/
export function canEditWorkout(
workout: { source: string; externalId: string | null },
caps: { inSharedRecord: boolean },
): boolean {
return (
workout.source === "MANUAL" &&
typeof workout.externalId === "string" &&
workout.externalId.startsWith("manual:") &&
!caps.inSharedRecord
);
}
/** The stored workout an edit opens from, in the detail read's field names. */
export interface StoredManualWorkout {
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
distanceM: number | null;
activeEnergyKcal: number | null;
minHr: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
/** The row's own heart rate, before any twin filled it in. */
storedAvgHr: number | null;
storedMaxHr: number | null;
}
/**
* The draft a stored workout is shown as, and the stored values behind it.
* The start reads in the profile timezone to the minute, the duration in
* whole minutes, the distance in the reader's unit at its display precision.
*/
export function manualWorkoutOriginalFromRow(
row: StoredManualWorkout,
opts: { timezone: string; unitPreference: UnitPreference },
): ManualWorkoutOriginal {
const sport = workoutSportTypeEnum.safeParse(row.sportType);
const totalMinutes = Math.max(0, Math.round(row.durationSec / 60));
const distance =
row.distanceM === null
? ""
: String(
applyDisplayTransform(
row.distanceM,
getQuantityTransform("distance", opts.unitPreference),
),
);
const carried: ManualWorkoutCarried = {};
if (row.storedAvgHr !== null) carried.avgHeartRate = row.storedAvgHr;
if (row.storedMaxHr !== null) carried.maxHeartRate = row.storedMaxHr;
if (row.minHr !== null) carried.minHeartRate = row.minHr;
if (row.stepCount !== null) carried.stepCount = row.stepCount;
if (row.elevationM !== null) carried.elevationM = row.elevationM;
if (row.pauseDurationSec !== null) {
carried.pauseDurationSec = row.pauseDurationSec;
}
return {
draft: {
sportType: sport.success ? sport.data : "",
start: wallClockNow(new Date(row.startedAt), opts.timezone),
hours: String(Math.floor(totalMinutes / 60)),
minutes: String(totalMinutes % 60),
distance,
energyKcal:
row.activeEnergyKcal === null ? "" : String(row.activeEnergyKcal),
},
startedAt: new Date(row.startedAt).toISOString(),
endedAt: new Date(row.endedAt).toISOString(),
totalDistanceM: row.distanceM,
totalEnergyKcal: row.activeEnergyKcal,
carried,
};
}
/** A fresh id for one opened form. The prefix says where the row came from. */
export function newManualWorkoutExternalId(): string {
return `manual:${randomId()}`;
@@ -306,26 +426,51 @@ export function buildManualWorkoutEntry(
}
// Every branch below was proven by the schema above.
const original = ctx.original;
const durationSec =
((parseWhole(draft.hours) ?? 0) * 60 + (parseWhole(draft.minutes) ?? 0)) *
60;
const startedAt =
draft.start.trim() === ""
// An edit whose start and duration read as shown keeps the stored instants
// to the second; changing either re-derives both from the fields.
const timesAsShown =
original !== undefined &&
draft.start === original.draft.start &&
draft.hours === original.draft.hours &&
draft.minutes === original.draft.minutes;
const startedAt = timesAsShown
? new Date(original.startedAt)
: draft.start.trim() === ""
? new Date(ctx.now.getTime() - durationSec * 1000)
: startToUtc(draft.start, ctx.timezone)!;
const endedAt = timesAsShown
? new Date(original.endedAt)
: new Date(startedAt.getTime() + durationSec * 1000);
const distance = parseDecimal(draft.distance);
const energy = parseDecimal(draft.energyKcal);
const entry: ManualWorkoutEntry = {
sportType: draft.sportType as WorkoutSportType,
startedAt: startedAt.toISOString(),
endedAt: new Date(startedAt.getTime() + durationSec * 1000).toISOString(),
endedAt: endedAt.toISOString(),
source: "MANUAL",
externalId: ctx.externalId,
// Everything the form has no field for goes back as stored, because the
// overwrite nulls whatever a re-post leaves out.
...(original?.carried ?? {}),
};
if (distance !== null) {
if (original && draft.distance === original.draft.distance) {
if (original.totalDistanceM !== null) {
entry.totalDistanceM = original.totalDistanceM;
}
} else if (distance !== null) {
entry.totalDistanceM = toMetres(distance, ctx.unitPreference);
}
if (energy !== null) entry.totalEnergyKcal = energy;
if (original && draft.energyKcal === original.draft.energyKcal) {
if (original.totalEnergyKcal !== null) {
entry.totalEnergyKcal = original.totalEnergyKcal;
}
} else if (energy !== null) {
entry.totalEnergyKcal = energy;
}
return { ok: true, entry };
}