Merge branch 'feat/v1472-ketones' into feat/cycle-feed-forecast-style

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:31:31 +01:00
commit 62f4578874
75 files changed
+2133 -94

No files matched your search

@@ -11,7 +11,7 @@
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1086,
"components/insights/insights-tab-strip.tsx": 1087,
"components/measurements/measurement-form.tsx": 815,
"components/medications/detail/medication-detail-tabs.tsx": 1157,
"components/medications/medication-table.tsx": 853,
@@ -39,7 +39,7 @@
"lib/daily/digest.ts": 1317,
"lib/daily/load-digest.ts": 1000,
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 976,
"lib/dashboard-layout.ts": 977,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/export/backup-plan.ts": 838,
@@ -149,6 +149,10 @@ const ALLOWED_SQL: Record<string, string> = {
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
"lib/dashboard/summary-reads.ts::1":
"Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.",
"lib/measurements/read-glucose-ketone-index.ts::1":
"Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.",
"lib/measurements/read-glucose-ketone-index.ts::2":
"Glucose rows only within 15 minutes of one of those ketone readings (`EXISTS`): a handful per fingerstick even under a sensor, never the window's glucose series.",
"lib/dashboard/summary-reads.ts::2":
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
};
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(111);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
expect(ALL_LEAF_IDS.length).toBe(112);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
@@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason: "the onboarding unit picker: its options are the two units",
},
"components/measurements/measurement-form.tsx": {
lines: 11,
lines: 12,
reason:
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
},
@@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason: "the legacy export endpoint's canonical storage-unit contract",
},
"lib/measurements/series/kinds.ts": {
lines: 4,
lines: 5,
reason:
"canonical wire units; glucose is overridden to the reader's unit per request",
"canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice",
},
"lib/doctor-report/stat-display.ts": {
lines: 3,
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { GkiCard } from "@/components/insights/ketones/gki-card";
import { getUnitForType } from "@/lib/measurements/unit-map";
/** mmol/L, from the one canonical unit table; ketones have no unit choice. */
const KETONE_UNIT = getUnitForType("BLOOD_KETONES");
/**
* `/insights/ketones` (#1367).
*
* Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric
* scaffold, with the glucose-ketone index beneath the chart. Readings are
* typed off a meter, alone or together with the glucose from the same
* fingerstick, so the empty state offers manual entry. No band and no
* assessment card: what a ketone reading should be follows the plan a
* clinician sets, and the page shows the readings as recorded.
*/
export default function InsightsKetonesPage() {
return (
<HealthKitMetricPage
measurementType="BLOOD_KETONES"
insightMetric="BLOOD_KETONES"
chartKey="bloodKetones"
i18nPrefix="insights.bloodKetones"
color="var(--chart-3)"
unit={KETONE_UNIT}
yAxisUnit={KETONE_UNIT}
statFractionDigits={1}
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType="BLOOD_KETONES"
captureType="BLOOD_KETONES"
afterChart={<GkiCard />}
/>
);
}
@@ -261,6 +261,21 @@ export function MeasurementGroups({
max: show(type, stat!.max, digits),
})}
/>
{type === "BLOOD_KETONES" &&
report.glucoseKetoneIndex &&
scope.admits("BLOOD_GLUCOSE") ? (
// #1367 — the index the server derived from the ketone
// and glucose readings, beside the ketones; no unit.
<StatRow
label={t("measurements.glucoseKetoneIndex")}
value={statSummary(t, "", {
latest: fmtDigits(report.glucoseKetoneIndex.latest, 1),
avg: fmtDigits(report.glucoseKetoneIndex.avg, 1),
min: fmtDigits(report.glucoseKetoneIndex.min, 1),
max: fmtDigits(report.glucoseKetoneIndex.max, 1),
})}
/>
) : null}
{unvalidated > 0 && (
<p
className="text-muted-foreground text-xs"
+46
View File
@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import type { DayResponse } from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
type DayGki = NonNullable<DayResponse["glucoseKetoneIndex"]>[number];
/**
* The day's glucose-ketone index (#1367), one tile beside the value tiles.
* Every figure is the server's: the index and the two readings it was built
* from. A day with one fingerstick shows its index and the pair; a day with
* several shows the latest index and how many there were, and the ketones page
* lists each. Nothing grades the number.
*/
export function DayGkiTile({ points }: { points: readonly DayGki[] }) {
const { t, tCount } = useTranslations();
const fmt = useFormatters();
const latest = points[points.length - 1];
if (!latest) return null;
return (
<li data-slot="day-gki" className="flex min-w-0">
<Link
href="/insights/ketones"
data-slot="day-value-link"
className="bg-muted/60 hover:bg-muted focus-visible:ring-ring/50 flex min-h-11 w-full min-w-0 flex-col rounded-lg px-3 py-2.5 transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="text-muted-foreground truncate text-xs">
{t("measurements.glucoseKetoneIndex")}
</span>
<span className="truncate text-base font-semibold tabular-nums">
{fmt.number(latest.value, 1)}
</span>
<span className="text-muted-foreground truncate text-xs tabular-nums">
{points.length > 1
? tCount("day.gkiCount", points.length)
: t("day.gkiPair", {
glucose: fmt.number(latest.glucoseMmol, 1),
ketones: fmt.number(latest.ketonesMmol, 1),
})}
</span>
</Link>
</li>
);
}
+7 -3
View File
@@ -29,9 +29,9 @@ import {
type DayEvent,
type DayEventKind,
type DayNotable,
type DayResponse,
type DayRunningItem,
type DayRunningKind,
type DayValue,
} from "@/lib/day/contract";
import { visitKindWords } from "@/components/timeline/item-words";
import {
@@ -62,6 +62,7 @@ import {
WORKOUT_SPORT_KEY,
} from "./label-keys";
import { cycleRunningLine } from "./day-cycle-line";
import { DayGkiTile } from "./day-gki-tile";
import { useDayValueFormat } from "./use-day-value-format";
/**
@@ -508,12 +509,14 @@ function ValueTile({
}
export function DayValues({
values,
day,
focusTypes,
}: {
values: readonly DayValue[];
/** The day's values and its server-derived glucose-ketone index (#1367). */
day: Pick<DayResponse, "values" | "glucoseKetoneIndex">;
focusTypes: readonly string[];
}) {
const { values } = day;
const { t, tCount } = useTranslations();
const [expanded, setExpanded] = useState(false);
if (values.length === 0) return null;
@@ -537,6 +540,7 @@ export function DayValues({
selected={focusKeys.has(tile.key)}
/>
))}
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
</ul>
{rest.length > 0 ? (
<button
+1 -1
View File
@@ -296,7 +296,7 @@ export function DayView({
<DayRunning items={data.running} date={date} />
{data.values.length > 0 ? (
<div className="space-y-2.5">
<DayValues values={data.values} focusTypes={focusTypes} />
<DayValues day={data} focusTypes={focusTypes} />
<DayNotableLines
notables={otherNotables}
labelFor={labelFor}
@@ -380,6 +380,7 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
ketones: { labelKey: "insights.navBloodKetones", metric: "BLOOD_KETONES" },
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
@@ -0,0 +1,109 @@
"use client";
import dynamic from "next/dynamic";
import { Divide } from "lucide-react";
import {
ChartErrorBoundary,
ChartErrorState,
} from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
import { useGlucoseKetoneIndexSeries } from "@/lib/queries/use-glucose-ketone-index";
import { importWithRetry } from "@/lib/retry-import";
// The line chart the custom metrics draw: a value over time with range tabs
// and the day doors. Loaded through the one recharts boundary.
const IndexChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.CustomMetricChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The glucose-ketone index (#1367), beneath the ketone chart. Every point is
* one ketone reading and the glucose reading the server paired with it; the
* value is the server's and is drawn as given. The latest pair is spelled out
* so the number can be traced to the two readings it came from. No band and
* no verdict: what the index should be is the treating clinician's call.
*/
export function GkiCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const query = useGlucoseKetoneIndexSeries();
const points = query.data?.points ?? [];
const latest = points[points.length - 1];
const title = t("measurements.glucoseKetoneIndex");
return (
<Card data-slot="gki-card" aria-busy={query.isFetching || undefined}>
<CardHeader>
<TileHeader icon={Divide} title={title} titleAs="h2" />
<CardDescription>
{t("insights.bloodKetones.gki.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isPending ? (
<ChartSkeleton />
) : query.isError ? (
<ChartErrorState
error={query.error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={title}
onAction={() => void query.refetch()}
/>
) : !latest ? (
<EmptyState
variant="plain"
size="compact"
title={t("insights.bloodKetones.gki.emptyTitle")}
description={t("insights.bloodKetones.gki.emptyDescription")}
/>
) : (
<>
<p className="text-sm" data-slot="gki-latest">
{t("insights.bloodKetones.gki.latest", {
value: fmt.number(latest.value, 1),
glucose: fmt.number(latest.gki.glucoseMmol, 1),
ketones: fmt.number(latest.gki.ketonesMmol, 1),
unit: GKI_COMPONENT_UNIT,
date: fmt.dateTime(latest.at),
})}
</p>
<ChartErrorBoundary>
<IndexChartLazy
name={title}
unit=""
targetLow={null}
targetHigh={null}
decimals={1}
entries={points.map((p) => ({
id: p.id,
customMetricId: "",
value: p.value,
unit: "",
measuredAt: p.at,
note: null,
createdAt: p.at,
}))}
/>
</ChartErrorBoundary>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { ketoneFingerstickBatch } from "@/components/measurements/ketone-fingerstick";
import { createBatchMeasurementSchema } from "@/lib/validations/measurement";
const AT = "2026-11-01T08:00:00.000Z";
function batch(
overrides: Partial<Parameters<typeof ketoneFingerstickBatch>[0]>,
) {
return ketoneFingerstickBatch({
ketones: "1.2",
glucose: "",
glucoseUnit: "mg/dL",
glucoseContext: "FASTING",
measuredAt: AT,
notes: "",
...overrides,
});
}
describe("ketoneFingerstickBatch", () => {
it("posts the ketones alone when the meter gave no glucose", () => {
expect(batch({})).toEqual([
{ type: "BLOOD_KETONES", value: 1.2, measuredAt: AT, notes: undefined },
]);
});
it("posts the glucose and the ketones with one timestamp", () => {
const rows = batch({
glucose: "81",
ketones: "1,5",
notes: "after breakfast",
});
expect(rows).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 81,
measuredAt: AT,
notes: "after breakfast",
glucoseContext: "FASTING",
},
{
type: "BLOOD_KETONES",
value: 1.5,
measuredAt: AT,
notes: "after breakfast",
},
]);
// The batch the route validates: the shape the form sends is accepted.
expect(
createBatchMeasurementSchema.safeParse({ measurements: rows }).success,
).toBe(true);
});
it("stores glucose typed in mmol/L as mg/dL", () => {
const rows = batch({ glucose: "4.5", glucoseUnit: "mmol/L" });
expect(rows?.[0]).toMatchObject({ type: "BLOOD_GLUCOSE", value: 81 });
});
it("refuses a number it cannot read rather than dropping half the pair", () => {
expect(batch({ ketones: "" })).toBeNull();
expect(batch({ ketones: "abc" })).toBeNull();
expect(batch({ glucose: "lots" })).toBeNull();
});
it("is refused by the route's schema above the meters' range", () => {
const rows = batch({ ketones: "12", glucose: "90" });
expect(
createBatchMeasurementSchema.safeParse({ measurements: rows }).success,
).toBe(false);
});
});
@@ -0,0 +1,56 @@
/**
* One fingerstick as one entry (#1367): the ketones, and the glucose from the
* same drop when the meter gave one, as the batch the measurements route
* writes in one transaction. Both readings carry the one timestamp, so the
* server pairs them exactly when it derives the glucose-ketone index.
*
* Pure, so the form's write path can be pinned without rendering it.
*/
import { toCanonicalMgdl, type GlucoseUnit } from "@/lib/glucose";
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
export interface FingerstickRow {
type: "BLOOD_GLUCOSE" | "BLOOD_KETONES";
value: number;
measuredAt: string;
notes?: string;
glucoseContext?: string;
}
/**
* The rows to post, glucose first, or null when a typed number does not read
* as one. An empty glucose field means the meter gave ketones only.
*/
export function ketoneFingerstickBatch(input: {
ketones: string;
glucose: string;
/** The unit the glucose was typed in: the account's glucose unit. */
glucoseUnit: GlucoseUnit;
glucoseContext: string;
measuredAt: string;
notes: string;
}): FingerstickRow[] | null {
const ketones = parseDecimalEntry(input.ketones);
if (ketones === null) return null;
const notes = input.notes || undefined;
const ketoneRow: FingerstickRow = {
type: "BLOOD_KETONES",
value: ketones,
measuredAt: input.measuredAt,
notes,
};
if (input.glucose.trim() === "") return [ketoneRow];
const glucose = parseDecimalEntry(input.glucose);
if (glucose === null) return null;
return [
{
type: "BLOOD_GLUCOSE",
// Stored in mg/dL whatever the reader types in.
value: toCanonicalMgdl(glucose, input.glucoseUnit),
measuredAt: input.measuredAt,
notes,
glucoseContext: input.glucoseContext,
},
ketoneRow,
];
}
@@ -0,0 +1,127 @@
"use client";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { glucoseFractionDigits, type GlucoseUnit } from "@/lib/glucose";
import { useTranslations } from "@/lib/i18n/context";
/**
* The measurement form's action row: the reset menu, Cancel and Save. The
* Save button names the form by id, so it still submits it when the row is
* portalled into a sheet's footer slot.
*/
export function MeasurementFormFooter({
loading,
formId,
onReset,
onCancel,
}: {
loading: boolean;
formId: string;
onReset: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslations();
return (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={loading}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={onReset}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("measurements.formReset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
/**
* #1367 — the glucose a ketone meter reads from the same drop, beside the
* ketones. Optional: a meter that reads ketones only leaves it empty. It asks
* in the account's glucose unit, which the write path converts back to mg/dL.
*/
export function PairedGlucoseField({
value,
onChange,
unit,
invalid,
describedBy,
}: {
value: string;
onChange: (next: string) => void;
unit: GlucoseUnit;
invalid: boolean;
describedBy?: string;
}) {
const { t } = useTranslations();
return (
<FieldGroup
htmlFor="paired-glucose"
label={
<>
{t("measurements.pairedGlucoseLabel", { unit })}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
}
hint={t("measurements.pairedGlucoseHint")}
>
<Input
id="paired-glucose"
// Not `type="number"`, like the value field: a decimal comma must
// survive on every engine.
inputMode="decimal"
enterKeyHint="next"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={glucoseFractionDigits(unit) > 0 ? "5.3" : "95"}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
/>
</FieldGroup>
);
}
@@ -5,13 +5,6 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
@@ -23,7 +16,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
@@ -50,6 +42,11 @@ import {
} from "@/lib/measurements/last-used-type";
import { DurationReadAs } from "./duration-read-as";
import { ketoneFingerstickBatch } from "./ketone-fingerstick";
import {
MeasurementFormFooter,
PairedGlucoseField,
} from "./measurement-form-parts";
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
@@ -104,6 +101,13 @@ export const MEASUREMENT_TYPES = [
unit: "mg/dL",
placeholder: "95",
},
// #1367 — ketones, with the same fingerstick's glucose (one entry).
{
value: "BLOOD_KETONES",
labelKey: "measurements.typeBloodKetones",
unit: "mmol/L",
placeholder: "1.2",
},
{
value: "TOTAL_BODY_WATER",
labelKey: "measurements.typeTotalBodyWater",
@@ -360,6 +364,8 @@ export function MeasurementForm({
const [sysBp, setSysBp] = useState("");
const [diaBp, setDiaBp] = useState("");
const [pulse, setPulse] = useState("");
// The glucose of a fingerstick entered with its ketones; optional.
const [pairedGlucose, setPairedGlucose] = useState("");
const [notes, setNotes] = useState("");
const [measuredAt, setMeasuredAt] = useState(() =>
localDateTimeValue(defaultDate),
@@ -383,6 +389,7 @@ export function MeasurementForm({
const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type);
const isBpMode = type === "BLOOD_PRESSURE";
const isGlucoseMode = type === "BLOOD_GLUCOSE";
const isKetoneMode = type === "BLOOD_KETONES";
// A duration field (sleep, in hours) also reads "7:30" and "7h 30m"; the
// hint under it says how the entry was understood.
const durationUnit = durationEntryUnit(type);
@@ -395,6 +402,7 @@ export function MeasurementForm({
setSysBp("");
setDiaBp("");
setPulse("");
setPairedGlucose("");
setNotes("");
setMeasuredAt(localDateTimeValue(defaultDate));
setError(null);
@@ -440,6 +448,21 @@ export function MeasurementForm({
});
}
await apiPost("/api/measurements", batch);
} else if (isKetoneMode) {
// One fingerstick, posted as one transaction (`ketone-fingerstick.ts`).
const batch = ketoneFingerstickBatch({
ketones: value,
glucose: pairedGlucose,
glucoseUnit,
glucoseContext,
measuredAt: timestamp,
notes,
});
if (!batch) {
setError(t("measurements.saveError"));
return;
}
await apiPost("/api/measurements", batch);
} else {
// Single measurement. Canonical storage stays SI: an imperial user
@@ -503,6 +526,7 @@ export function MeasurementForm({
setSysBp("");
setDiaBp("");
setPulse("");
setPairedGlucose("");
setNotes("");
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
@@ -528,49 +552,12 @@ export function MeasurementForm({
}
const footerNode = (
<div className="flex w-full items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="size-11"
disabled={loading}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("measurements.formReset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
{t("common.cancel")}
</Button>
)}
<Button type="submit" form={formId} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Plus className="h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</div>
<MeasurementFormFooter
loading={loading}
formId={formId}
onReset={resetForm}
onCancel={onCancel}
/>
);
return (
@@ -718,7 +705,17 @@ export function MeasurementForm({
</FieldGroup>
)}
{isGlucoseMode && (
{isKetoneMode && (
<PairedGlucoseField
value={pairedGlucose}
onChange={setPairedGlucose}
unit={glucoseUnit}
invalid={!!error}
describedBy={errorDescriptor}
/>
)}
{(isGlucoseMode || (isKetoneMode && pairedGlucose.trim() !== "")) && (
<FieldGroup
htmlFor="glucose-context"
label={t("measurements.glucoseContext")}
@@ -48,6 +48,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
SLEEP_DURATION: "measurements.typeSleep",
ACTIVITY_STEPS: "measurements.typeSteps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
BLOOD_KETONES: "measurements.typeBloodKetones",
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
BONE_MASS: "measurements.typeBoneMass",
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
@@ -159,6 +160,7 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
SLEEP_DURATION: Moon,
ACTIVITY_STEPS: Footprints,
BLOOD_GLUCOSE: Droplet,
BLOOD_KETONES: Droplet,
TOTAL_BODY_WATER: Droplet,
BONE_MASS: Bone,
OXYGEN_SATURATION: Wind,
@@ -13,6 +13,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
BLOOD_KETONES: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
@@ -131,10 +131,12 @@ const EXPECTED_TYPES = [
"ACUTE_TRAINING_LOAD",
// ── resting energy (#1340) ──
"BASAL_ENERGY_BURNED",
// ── blood ketones (#1367) ──
"BLOOD_KETONES",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 91 canonical measurement types", () => {
it("exposes the 92 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
+1
View File
@@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [
"scoreSleep",
"scoreStress",
"scoreStrain",
"bloodKetones", // #1367 — the chart on the ketones page.
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
+15
View File
@@ -29,6 +29,7 @@ import {
import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores";
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
import { readDayBands, shapeDayValues } from "@/lib/day/values";
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import {
resolveDayReadings,
type DayReadings,
@@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
]);
const presentTypes = [...readings.rowsByType.keys()];
// The index reveals the glucose it was built from, so it needs both
// reading types visible, not only the ketones the day holds.
const gkiReadable =
readings.rowsByType.has("BLOOD_KETONES") &&
typeVisible("BLOOD_KETONES") &&
typeVisible("BLOOD_GLUCOSE");
const glucoseKetoneIndex = gkiReadable
? await readGlucoseKetoneIndex({
userId: recordId,
from: dayStart,
to: dayEnd,
})
: [];
const [bands, notable] =
presentTypes.length === 0
? [new Map(), []]
@@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
counts: { values: values.length, entries: events.length },
running,
values,
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
events,
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
scores,
+24
View File
@@ -117,6 +117,24 @@ const dayValue = z
})
.meta({ id: "DayValue" });
/**
* #1367 — one glucose-ketone index of the day, derived from a ketone reading
* and the glucose reading paired with it; never stored.
*/
const dayGlucoseKetoneIndex = z
.object({
at: instant.describe("The ketone reading's instant."),
value: z.number().describe("The index, to one decimal."),
ketoneId: z.string().describe("The ketone reading's id."),
glucoseId: z.string().describe("The paired glucose reading's id."),
glucoseAt: instant.describe("The paired glucose reading's instant."),
ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."),
glucoseMmol: z
.number()
.describe("The paired glucose in mmol/L, one decimal."),
})
.meta({ id: "DayGlucoseKetoneIndex" });
const dayEvent = z
.object({
at: instant
@@ -232,6 +250,12 @@ export const dayResponseSchema = z
}),
running: z.array(dayRunningItem),
values: z.array(dayValue),
glucoseKetoneIndex: z
.array(dayGlucoseKetoneIndex)
.optional()
.describe(
"#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.",
),
events: z.array(dayEvent),
notable: z.array(dayNotable),
scores: z
@@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow {
}
/**
* Per-context glucose rows. Values are stored canonically in mg/dL and print
* in the owner's unit, in the precision their readings carry.
* The glucose group's own rows: one per meal context, stored canonically in
* mg/dL and printed in the owner's unit in the precision their readings
* carry, then the glucose-ketone index (#1367), a ratio without a unit.
*/
function glucoseContextTableRows(
context: DoctorReportPdfRenderContext,
@@ -209,6 +210,17 @@ function glucoseContextTableRows(
String(s.count),
]);
}
const gki = data.glucoseKetoneIndex;
if (gki) {
rows.push([
t("measurements.glucoseKetoneIndex"),
num(gki.latest, 1),
num(gki.avg, 1),
num(gki.min, 1),
num(gki.max, 1),
String(gki.count),
]);
}
return rows;
}
+7
View File
@@ -118,6 +118,13 @@ export interface DoctorReportData {
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
/**
* #1367 — the glucose-ketone index over the period (count, mean, range,
* latest), derived at read time from each ketone reading and the glucose
* reading paired with it. Null when there is none or when either reading
* type is withheld. Optional so older fixtures still typecheck.
*/
glucoseKetoneIndex?: DoctorReportStats | null;
bmi: number | null;
/** Keyed by medication id; see `DoctorReportCompliance`. */
compliance: Record<string, DoctorReportCompliance>;
+4 -3
View File
@@ -67,6 +67,7 @@ import {
import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { readReportGki } from "./glucose-ketone-index";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import { loadReportWorkouts } from "./workouts";
import { loadReportMedicationEffects } from "./medication-effects";
@@ -249,9 +250,8 @@ export async function collectDoctorReportData(
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
},
orderBy: { measuredAt: "asc" },
// Narrow select: the collector reads exactly these seven fields.
// The full-width read pulled every column — including the encrypted
// notes — across up to 730 days of rows.
// Narrow select: the full-width read pulled every column, encrypted
// notes included, across up to 730 days of rows.
select: {
type: true,
value: true,
@@ -739,6 +739,7 @@ export async function collectDoctorReportData(
glucoseStats,
glucoseRanges,
glucoseClinical,
glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end),
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
@@ -0,0 +1,45 @@
/**
* The glucose-ketone index in the doctor report (#1367): count, mean, range
* and latest over the report window, read by the one pairing rule
* (`read-glucose-ketone-index.ts`) so the report carries the numbers the
* ketones page shows.
*
* Both leaves must admit it. The index is glucose over ketones, so beside the
* ketone series it gives the glucose back exactly: a report that withholds
* glucose withholds the index too.
*/
import type { DoctorReportStats } from "@/lib/doctor-report-types";
import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index";
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import type { ReportGate } from "./selection-gate";
export async function readReportGki(
gate: ReportGate,
/** The report's own stats: no ketone row in the window, no read. */
stats: Readonly<Record<string, DoctorReportStats>>,
userId: string,
/** The report window; `end` is inclusive, as the measurement read has it. */
start: Date,
end: Date,
): Promise<DoctorReportStats | null> {
if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) {
return null;
}
if (!stats.BLOOD_KETONES?.count) return null;
const points = await readGlucoseKetoneIndex({
userId,
from: start,
to: new Date(end.getTime() + 1),
});
const summary = summariseGlucoseKetoneIndex(points);
if (!summary) return null;
return {
avg: summary.mean,
min: summary.min,
max: summary.max,
count: summary.count,
latest: summary.latest,
latestAt: points[points.length - 1]!.at,
};
}
+26
View File
@@ -196,6 +196,32 @@ describe("measurementSeriesObservation", () => {
expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc);
});
it("codes blood ketones as beta-hydroxybutyrate in blood (#1367)", () => {
const o = measurementSeriesObservation(
{
id: "k1",
type: "BLOOD_KETONES",
value: 1.4,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
)!;
assertR4Observation(o);
expect(o.category?.[0]?.coding?.[0]?.code).toBe("laboratory");
expect(o.code.coding).toEqual([
{
system: "http://loinc.org",
code: "104816-4",
display: "Beta hydroxybutyrate [Moles/volume] in Blood",
},
]);
expect(o.valueQuantity).toMatchObject({ value: 1.4, unit: "mmol/L" });
});
it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => {
const o = measurementSeriesObservation(
{
+1
View File
@@ -29,6 +29,7 @@ export const ALLOWED_CHART_TOKENS = [
"metric:SLEEP_DURATION",
"metric:ACTIVITY_STEPS",
"metric:BLOOD_GLUCOSE",
"metric:BLOOD_KETONES",
"metric:TOTAL_BODY_WATER",
"metric:BONE_MASS",
"metric:OXYGEN_SATURATION",
+2
View File
@@ -80,6 +80,8 @@ export type InsightMetric =
// `summaries[metric].count > 0` with no new handler branch (the
// metric auto-lights-up the moment the first reading lands).
| "BLOOD_GLUCOSE"
// #1367 — blood ketones, keyed on its MeasurementType like glucose.
| "BLOOD_KETONES"
| "TOTAL_BODY_WATER"
| "BONE_MASS"
// Body fat percentage, keyed on its MeasurementType like the rest of the
+3
View File
@@ -139,6 +139,8 @@ const SUB_PAGE_METRIC = {
daylight: ["TIME_IN_DAYLIGHT"],
// ── metabolic (v1.7.0) ──
"blood-glucose": ["BLOOD_GLUCOSE"],
// #1367 — blood ketones, with the glucose-ketone index beneath the chart.
ketones: ["BLOOD_KETONES"],
"skin-temperature": ["SKIN_TEMPERATURE"],
// v1.10.0 — overnight wrist temperature joins the metabolic cluster.
"wrist-temperature": ["WRIST_TEMPERATURE"],
@@ -243,6 +245,7 @@ export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
daylight: "environment",
// metabolic (v1.7.0)
"blood-glucose": "metabolic",
ketones: "metabolic",
"skin-temperature": "metabolic",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// activity / mobility cluster
@@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
"WHO5_SCORE",
"SCI_SCORE",
// Blood ketones (#1367): HealthKit has no beta-hydroxybutyrate quantity
// type, so a ketone reading is typed by hand. No HK mapping by design.
"BLOOD_KETONES",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { MGDL_PER_MMOL } from "@/lib/glucose";
import {
GKI_PAIRING_WINDOW_MINUTES,
glucoseKetoneIndex,
pairGlucoseKetoneIndex,
summariseGlucoseKetoneIndex,
type GkiReading,
} from "@/lib/measurements/glucose-ketone-index";
const T0 = Date.parse("2026-11-01T08:00:00.000Z");
const MIN = 60_000;
function reading(
id: string,
offsetMin: number,
value: number,
source = "MANUAL",
): GkiReading {
return { id, measuredAt: new Date(T0 + offsetMin * MIN), value, source };
}
describe("glucoseKetoneIndex", () => {
it("divides glucose in mmol/L by ketones, converting with the one factor", () => {
// 90 mg/dL = 4.995 mmol/L; over 1.5 mmol/L ketones.
expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(90 / MGDL_PER_MMOL / 1.5);
expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(3.33, 2);
});
it("has no index without ketones or with a value that is not positive", () => {
expect(glucoseKetoneIndex(90, 0)).toBeNull();
expect(glucoseKetoneIndex(90, -1)).toBeNull();
expect(glucoseKetoneIndex(0, 1)).toBeNull();
expect(glucoseKetoneIndex(Number.NaN, 1)).toBeNull();
expect(glucoseKetoneIndex(90, Number.POSITIVE_INFINITY)).toBeNull();
});
});
describe("pairGlucoseKetoneIndex", () => {
it("pairs a fingerstick written with one timestamp", () => {
const [point, ...rest] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1.2)],
[reading("g1", 0, 81)],
);
expect(rest).toEqual([]);
expect(point).toEqual({
ketoneId: "k1",
glucoseId: "g1",
at: new Date(T0).toISOString(),
glucoseAt: new Date(T0).toISOString(),
// 81 / 18.0182 = 4.4954 mmol/L; / 1.2 = 3.746 -> 3.7
value: 3.7,
ketonesMmol: 1.2,
glucoseMmol: 4.5,
});
});
it("prefers the same instant over a nearer-looking neighbour", () => {
const [point] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-early", -1, 200), reading("g-same", 0, 90)],
);
expect(point?.glucoseId).toBe("g-same");
});
it("takes the nearest glucose inside the window, before or after", () => {
const [before] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-far", -14, 100), reading("g-near", -3, 90)],
);
expect(before?.glucoseId).toBe("g-near");
const [after] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-after", 4, 90), reading("g-before", -9, 100)],
);
expect(after?.glucoseId).toBe("g-after");
});
it("pairs at the window's edge and never beyond it", () => {
const edge = GKI_PAIRING_WINDOW_MINUTES;
expect(
pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", edge, 90)]),
).toHaveLength(1);
expect(
pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", -edge, 90)]),
).toHaveLength(1);
expect(
pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g1", edge + 0.5, 90), reading("g2", -(edge + 1), 90)],
),
).toEqual([]);
});
it("leaves out a ketone reading of zero or without a partner", () => {
expect(
pairGlucoseKetoneIndex(
[reading("k-zero", 0, 0), reading("k-alone", 120, 1.4)],
[reading("g1", 0, 90)],
),
).toEqual([]);
});
it("breaks a tie by source, then by the earlier reading", () => {
const [bySource] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1, "MANUAL")],
[reading("g-cgm", 0, 140, "APPLE_HEALTH"), reading("g-own", 0, 90)],
);
expect(bySource?.glucoseId).toBe("g-own");
const [byTime] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-later", 5, 100), reading("g-earlier", -5, 90)],
);
expect(byTime?.glucoseId).toBe("g-earlier");
});
it("answers in ketone order whatever order the readings came in", () => {
const points = pairGlucoseKetoneIndex(
[reading("k2", 60, 2), reading("k1", 0, 1)],
[reading("g2", 61, 90), reading("g1", 1, 90)],
);
expect(points.map((p) => [p.ketoneId, p.glucoseId])).toEqual([
["k1", "g1"],
["k2", "g2"],
]);
});
it("lets one glucose reading serve two ketone readings and names it", () => {
const points = pairGlucoseKetoneIndex(
[reading("k1", 0, 1), reading("k2", 6, 2)],
[reading("g1", 3, 90)],
);
expect(points.map((p) => p.glucoseId)).toEqual(["g1", "g1"]);
expect(points[1]!.value).toBeLessThan(points[0]!.value);
});
});
describe("summariseGlucoseKetoneIndex", () => {
it("summarises the indices and is null for none", () => {
expect(summariseGlucoseKetoneIndex([])).toBeNull();
const points = pairGlucoseKetoneIndex(
[reading("k1", 0, 1), reading("k2", 60, 2), reading("k3", 120, 4)],
[reading("g1", 0, 90), reading("g2", 60, 90), reading("g3", 120, 90)],
);
// 90 mg/dL = 4.995 mmol/L: indices 5.0, 2.5, 1.2.
expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.2]);
expect(summariseGlucoseKetoneIndex(points)).toEqual({
count: 3,
mean: 2.9,
min: 1.2,
max: 5,
latest: 1.2,
});
});
});
+1
View File
@@ -128,6 +128,7 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// ── Metabolic ──
["BLOOD_GLUCOSE", "metabolic"],
["BLOOD_KETONES", "metabolic"],
["SKIN_TEMPERATURE", "metabolic"],
// v1.10.0 — wrist temperature is an overnight skin-side reading;
// it sits with skin temperature in the metabolic cluster.
@@ -0,0 +1,189 @@
/**
* The glucose-ketone index (GKI), derived from a ketone reading and the
* glucose reading taken with it (#1367).
*
* GKI = glucose (mmol/L) / beta-hydroxybutyrate (mmol/L)
*
* Glucose is stored in mg/dL and converted with {@link MGDL_PER_MMOL}, the one
* factor the tree uses for that conversion, so the glucose a GKI is built from
* is the same number every glucose surface shows. The index is computed when
* it is read and never stored: a stored ratio drifts the moment either reading
* it came from is edited or deleted, which is the failure a hand-typed GKI has.
*
* Pairing, per ketone reading:
*
* 1. A glucose reading at the same instant wins. A fingerstick entry writes
* both readings with one timestamp, so this is the usual case.
* 2. Otherwise the nearest glucose reading no more than
* {@link GKI_PAIRING_WINDOW_MINUTES} minutes before or after it.
* 3. Nothing further away. A ketone reading with no glucose inside the
* window, or with a value of zero, has no index.
*
* Ties at the same distance go to the reading from the same source as the
* ketone reading (a manual fingerstick pairs with the manual glucose rather
* than with a sensor reading at the same minute), then to the earlier one. One
* glucose reading may serve two ketone readings taken close together; each
* index names the glucose reading it was built from.
*
* The figure is descriptive. Nothing here grades it: what a GKI should be is a
* matter for the plan a clinician sets.
*/
import { MGDL_PER_MMOL } from "@/lib/glucose";
/**
* The widest gap between a ketone and a glucose reading that still pairs.
*
* Fifteen minutes covers two strips taken one after the other, a meter that
* reads ketones from a second drop, a reading typed in a few minutes late, and
* a sensor that samples every five minutes, while staying short enough that
* glucose has not moved on: after a meal it can rise by a few mg/dL a minute,
* so an hour-old reading describes a different moment and the ratio built from
* it would describe neither.
*/
export const GKI_PAIRING_WINDOW_MINUTES = 15;
/**
* The unit both components of the index are in. Fixed, not the reader's
* glucose unit: the index is defined on mmol/L, and a ratio taken over mg/dL
* would be eighteen times larger and mean something else.
*/
export const GKI_COMPONENT_UNIT = "mmol/L";
export const GKI_PAIRING_WINDOW_MS = GKI_PAIRING_WINDOW_MINUTES * 60_000;
/** One stored reading, as the pairing needs it. */
export interface GkiReading {
id: string;
measuredAt: Date;
/** Ketones in mmol/L; glucose in canonical mg/dL. */
value: number;
source: string;
}
/** One derived index, with the two readings it was built from. */
export interface GlucoseKetoneIndexPoint {
/** The ketone reading's id. The index belongs to the ketone reading. */
ketoneId: string;
/** The glucose reading it was paired with. */
glucoseId: string;
/** The ketone reading's instant, ISO 8601. */
at: string;
/** The glucose reading's instant, ISO 8601. */
glucoseAt: string;
/** The index, to one decimal. */
value: number;
/** Beta-hydroxybutyrate as recorded, mmol/L. */
ketonesMmol: number;
/** The paired glucose in mmol/L, to one decimal. */
glucoseMmol: number;
}
/**
* The index of one glucose (mg/dL) and one ketone (mmol/L) value, unrounded,
* or null when it is undefined: no ketones, or a value that is not a positive
* finite number on either side.
*/
export function glucoseKetoneIndex(
glucoseMgdl: number,
ketonesMmol: number,
): number | null {
if (!Number.isFinite(glucoseMgdl) || !Number.isFinite(ketonesMmol)) {
return null;
}
if (ketonesMmol <= 0 || glucoseMgdl <= 0) return null;
return glucoseMgdl / MGDL_PER_MMOL / ketonesMmol;
}
function oneDecimal(value: number): number {
return Math.round(value * 10) / 10;
}
/**
* Pair every ketone reading with its glucose reading and compute the index.
* Pure. Both lists may come in any order; the answer is in ketone-reading
* order (oldest first). Ketone readings without a partner are left out.
*/
export function pairGlucoseKetoneIndex(
ketones: readonly GkiReading[],
glucose: readonly GkiReading[],
): GlucoseKetoneIndexPoint[] {
const sortedGlucose = [...glucose].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
const sortedKetones = [...ketones].sort(
(a, b) =>
a.measuredAt.getTime() - b.measuredAt.getTime() ||
a.id.localeCompare(b.id),
);
const out: GlucoseKetoneIndexPoint[] = [];
let lo = 0;
for (const ketone of sortedKetones) {
if (!(ketone.value > 0)) continue;
const at = ketone.measuredAt.getTime();
// Ketone readings are walked in time order, so the window's lower edge
// only ever moves forward.
while (
lo < sortedGlucose.length &&
sortedGlucose[lo]!.measuredAt.getTime() < at - GKI_PAIRING_WINDOW_MS
) {
lo++;
}
let best: GkiReading | null = null;
let bestRank: [number, number, number] | null = null;
for (let i = lo; i < sortedGlucose.length; i++) {
const candidate = sortedGlucose[i]!;
const t = candidate.measuredAt.getTime();
if (t > at + GKI_PAIRING_WINDOW_MS) break;
const rank: [number, number, number] = [
Math.abs(t - at),
candidate.source === ketone.source ? 0 : 1,
t,
];
if (
bestRank === null ||
rank[0] < bestRank[0] ||
(rank[0] === bestRank[0] &&
(rank[1] < bestRank[1] ||
(rank[1] === bestRank[1] && rank[2] < bestRank[2])))
) {
best = candidate;
bestRank = rank;
}
}
if (best === null) continue;
const index = glucoseKetoneIndex(best.value, ketone.value);
if (index === null) continue;
out.push({
ketoneId: ketone.id,
glucoseId: best.id,
at: ketone.measuredAt.toISOString(),
glucoseAt: best.measuredAt.toISOString(),
value: oneDecimal(index),
ketonesMmol: ketone.value,
glucoseMmol: oneDecimal(best.value / MGDL_PER_MMOL),
});
}
return out;
}
/** Count, mean, min, max and latest of a run of indices, or null when empty. */
export interface GlucoseKetoneIndexSummary {
count: number;
mean: number;
min: number;
max: number;
latest: number;
}
export function summariseGlucoseKetoneIndex(
points: readonly GlucoseKetoneIndexPoint[],
): GlucoseKetoneIndexSummary | null {
if (points.length === 0) return null;
const values = points.map((p) => p.value);
return {
count: values.length,
mean: oneDecimal(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.min(...values),
max: Math.max(...values),
latest: values[values.length - 1]!,
};
}
@@ -0,0 +1,86 @@
/**
* Read the glucose-ketone index of a window (#1367): the ketone readings in
* it and the glucose readings within pairing reach of one, paired by
* `pairGlucoseKetoneIndex`. Every surface that shows the index (the series
* route, the day, the doctor report) reads it here, so the pairing rule has
* one implementation.
*
* Glucose and ketones carry no source ladder (neither is in `RANKED_TYPES`),
* so every live row of every source is a candidate, as on the glucose chart.
* The glucose read is bounded by the ketone readings: only rows within the
* pairing window of one are fetched, so a sensor writing every five minutes
* contributes a handful of rows per fingerstick, not the window's thousands.
*/
import { prisma } from "@/lib/db";
import {
GKI_PAIRING_WINDOW_MINUTES,
GKI_PAIRING_WINDOW_MS,
pairGlucoseKetoneIndex,
type GkiReading,
type GlucoseKetoneIndexPoint,
} from "./glucose-ketone-index";
interface Row {
id: string;
value: number;
measured_at: Date;
source: string;
}
function toReading(row: Row): GkiReading {
return {
id: row.id,
value: row.value,
measuredAt: row.measured_at,
source: row.source,
};
}
/**
* The indices of the ketone readings taken in `[from, to)`, oldest first.
* A glucose reading just outside the window still pairs with a ketone reading
* just inside it.
*/
export async function readGlucoseKetoneIndex(args: {
userId: string;
from: Date;
to: Date;
}): Promise<GlucoseKetoneIndexPoint[]> {
const { userId, from, to } = args;
const ketones = await prisma.$queryRaw<Row[]>`
SELECT k."id", k."value", k."measured_at", k."source"::text AS "source"
FROM measurements k
WHERE k."user_id" = ${userId}
AND k."type" = 'BLOOD_KETONES'::"measurement_type"
AND k."deleted_at" IS NULL
AND k."measured_at" >= ${from}
AND k."measured_at" < ${to}
AND k."value" > 0
ORDER BY k."measured_at" ASC, k."id" ASC
`;
if (ketones.length === 0) return [];
// An interval between two stored instants: no zone enters the arithmetic.
const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`;
const glucose = await prisma.$queryRaw<Row[]>`
SELECT g."id", g."value", g."measured_at", g."source"::text AS "source"
FROM measurements g
WHERE g."user_id" = ${userId}
AND g."type" = 'BLOOD_GLUCOSE'::"measurement_type"
AND g."deleted_at" IS NULL
AND g."measured_at" >= ${new Date(from.getTime() - GKI_PAIRING_WINDOW_MS)}
AND g."measured_at" <= ${new Date(to.getTime() + GKI_PAIRING_WINDOW_MS)}
AND EXISTS (
SELECT 1 FROM measurements k
WHERE k."user_id" = ${userId}
AND k."type" = 'BLOOD_KETONES'::"measurement_type"
AND k."deleted_at" IS NULL
AND k."measured_at" >= ${from}
AND k."measured_at" < ${to}
AND k."value" > 0
AND k."measured_at" BETWEEN g."measured_at" - ${reach}::interval
AND g."measured_at" + ${reach}::interval
)
`;
return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading));
}
@@ -44,7 +44,9 @@ export type SeriesKind =
| "oxygenSaturation"
| "restingHeartRate"
| "heartRateVariability"
| "vo2Max";
| "vo2Max"
| "ketones"
| "glucoseKetoneIndex";
export interface SeriesReferenceLine {
value: number;
+23
View File
@@ -72,6 +72,10 @@ export const kindEnum = z.enum([
"restingHeartRate",
"heartRateVariability",
"vo2Max",
// #1367 — blood ketones, and the glucose-ketone index derived from them and
// the glucose reading taken with each (`glucose-ketone-index.ts`).
"ketones",
"glucoseKetoneIndex",
]);
export type SeriesKind = z.infer<typeof kindEnum>;
@@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record<SeriesKind, MeasurementType> = {
restingHeartRate: "RESTING_HEART_RATE",
heartRateVariability: "HEART_RATE_VARIABILITY",
vo2Max: "VO2_MAX",
ketones: "BLOOD_KETONES",
// The index has no stored type. It belongs to its ketone reading, so the
// change direction and day values read as the ketone series' would; neither
// has a band, so nothing is judged.
glucoseKetoneIndex: "BLOOD_KETONES",
};
/**
@@ -119,6 +128,9 @@ export const SERIES_UNIT: Record<SeriesKind, string> = {
restingHeartRate: "bpm",
heartRateVariability: "ms",
vo2Max: "mL/(kg·min)",
ketones: "mmol/L",
// A ratio of two concentrations in the same unit: dimensionless.
glucoseKetoneIndex: "ratio",
};
export interface SeriesPoint {
@@ -142,6 +154,17 @@ export interface SeriesPoint {
*/
valueMin?: number | null;
valueMax?: number | null;
/**
* #1367 — the two readings a `kind=glucoseKetoneIndex` point was built
* from. Absent for every other kind. `id` and `at` are the ketone
* reading's; this names the glucose reading paired with it.
*/
gki?: {
glucoseId: string;
glucoseAt: string;
glucoseMmol: number;
ketonesMmol: number;
};
}
export function stdDev(values: number[]): number {
@@ -0,0 +1,37 @@
/**
* `kind=glucoseKetoneIndex`: one point per ketone reading that pairs with a
* glucose reading (#1367). The index is derived at read time by the one
* pairing rule (`read-glucose-ketone-index.ts`); a client draws the value it
* is given and never recomputes it. Ketone readings are a few a day at most,
* so the window is never bucketed.
*/
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import type { SeriesRead } from "./kinds";
export async function readGlucoseKetoneIndexSeries(
userId: string,
since: Date,
): Promise<SeriesRead> {
const points = await readGlucoseKetoneIndex({
userId,
from: since,
// Open-ended: a reading stamped a moment ahead of the server clock is
// still the latest one.
to: new Date(Date.now() + 86_400_000),
});
return {
points: points.map((p) => ({
id: p.ketoneId,
at: p.at,
value: p.value,
secondary: null,
gki: {
glucoseId: p.glucoseId,
glucoseAt: p.glucoseAt,
glucoseMmol: p.glucoseMmol,
ketonesMmol: p.ketonesMmol,
},
})),
};
}
@@ -28,6 +28,7 @@ import {
} from "./kinds";
import { readBloodPressureSeries } from "./read-blood-pressure";
import { readBucketedSeries, takesBucketedRead } from "./read-bucketed";
import { readGlucoseKetoneIndexSeries } from "./read-glucose-ketone-index";
import { readRawSeries } from "./read-raw";
import { readSleepSeries } from "./read-sleep";
@@ -40,6 +41,9 @@ async function readPoints(
): Promise<SeriesRead> {
if (kind === "sleep") return readSleepSeries(userId, since, days);
if (kind === "bloodPressure") return readBloodPressureSeries(userId, since);
if (kind === "glucoseKetoneIndex") {
return readGlucoseKetoneIndexSeries(userId, since);
}
if (await takesBucketedRead(userId, kind, days, since)) {
return readBucketedSeries(userId, kind, days, since);
}
+1
View File
@@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
SLEEP_DURATION: "measurements.typeSleep",
ACTIVITY_STEPS: "measurements.typeSteps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
BLOOD_KETONES: "measurements.typeBloodKetones",
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
BONE_MASS: "measurements.typeBoneMass",
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
+1
View File
@@ -21,6 +21,7 @@ const unitMap: Record<string, string> = {
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
BLOOD_KETONES: "mmol/L",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
+6
View File
@@ -20,6 +20,12 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
SLEEP_DURATION: { min: 0, max: 1440 },
ACTIVITY_STEPS: { min: 0, max: 200000 },
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
// Beta-hydroxybutyrate, mmol/L. The blood ketone meters people type these
// off (Abbott Precision Xtra / FreeStyle Optium, Keto-Mojo, Nova Max Plus)
// measure from 0.0 or 0.1 up to 8.0 mmol/L and print "HI" above it; 10
// leaves room for a venous value read off a lab sheet. A meter reads 0.0,
// so zero is a real reading, not an empty one.
BLOOD_KETONES: { min: 0, max: 10 },
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg)
// Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the
+4
View File
@@ -105,6 +105,10 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
STRESS_LEVEL: "recovery",
TRAINING_READINESS: "recovery",
CARDIO_RECOVERY: "recovery",
// Blood ketones are read off the same meter as fingerstick glucose and
// belong to the glucose module; they stay off the Coach snapshot, so the
// scope token cannot reach them.
BLOOD_KETONES: "glucose",
// The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD
// fallback for an account that only has ring/strap nightly RMSSD rows, so
// leaving this unowned would have let the fallback path serve exactly the
+3
View File
@@ -98,6 +98,7 @@ const STATIC_SURFACE_MODULE = {
// The per-night breathing-disturbance index is a sleep-page signal.
"insights-page:breathing-disturbances": "sleep",
"insights-page:blood-glucose": "glucose",
"insights-page:ketones": "glucose",
"insights-page:workouts": "workouts",
"insights-page:nutrients": "nutrients",
// Medications is surface-gated by design: its data routes stay open so an
@@ -160,6 +161,8 @@ const STATIC_SURFACE_MODULE = {
// ── Dashboard summary keys ──
"summary:SLEEP_DURATION": "sleep",
"summary:BLOOD_GLUCOSE": "glucose",
// Blood ketones sit beside glucose: same meter, same module.
"summary:BLOOD_KETONES": "glucose",
"summary:CARDIO_RECOVERY": "recovery",
// The recovery module owns the resting_hr domain (`measurement-scope.ts`),
// so the server gates the type on it; the form and the dashboard follow.
+21 -2
View File
@@ -696,6 +696,8 @@ const seriesKindEnum = z.enum([
"restingHeartRate",
"heartRateVariability",
"vo2Max",
"ketones",
"glucoseKetoneIndex",
]);
const seriesQuerySchema = z.object({
@@ -747,6 +749,23 @@ const seriesPointSchema = z.object({
.describe(
"v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.",
),
gki: z
.object({
glucoseId: z.string().describe("The paired glucose reading's id."),
glucoseAt: isoDateTime().describe(
"The paired glucose reading's timestamp.",
),
glucoseMmol: z
.number()
.describe("The paired glucose in mmol/L, one decimal."),
ketonesMmol: z
.number()
.describe("The ketone reading (beta-hydroxybutyrate), mmol/L."),
})
.optional()
.describe(
"#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are the ketone reading's. Absent for every other kind.",
),
});
const seriesResponse = z
@@ -755,7 +774,7 @@ const seriesResponse = z
unit: z
.string()
.describe(
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).",
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.",
),
points: z.array(seriesPointSchema),
stats: z
@@ -783,7 +802,7 @@ const seriesResponse = z
.meta({
id: "MeasurementsSeriesResponse",
description:
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it.",
});
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
+4 -4
View File
@@ -175,10 +175,11 @@ export function getPRDirection(
if (context.weightGoal === "higher") return PersonalRecordDirection.MAX;
return null;
// Explicitly no PersonalRecord — see comment block above.
// Explicitly no PersonalRecord (see above); ketones follow a clinician's plan.
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
case "BLOOD_GLUCOSE":
case "BLOOD_KETONES":
case "BODY_TEMPERATURE":
case "SKIN_TEMPERATURE":
case "PULSE":
@@ -267,9 +268,8 @@ export function getPRDirection(
case "TRAINING_READINESS":
case "ACUTE_TRAINING_LOAD":
// v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite
// (like the other sleep-quality indices), and the body-temperature
// deviation is a homeostatic signed offset centred on 0 — neither has a
// goal axis, so both stay null like the rest of the derived/homeostatic set.
// and the body-temperature deviation a homeostatic signed offset centred
// on 0: neither has a goal axis, so both stay null like the set above.
case "SLEEP_SCORE":
case "BODY_TEMPERATURE_DEVIATION":
// v1.19.0 — Oura resilience. An ordinal-encoded categorical band
@@ -0,0 +1,50 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/** One point of `kind=glucoseKetoneIndex`, as the server resolved it. */
export interface GlucoseKetoneIndexSeriesPoint {
/** The ketone reading's id. */
id: string;
/** The ketone reading's instant. */
at: string;
/** The index, one decimal. */
value: number;
gki: {
glucoseId: string;
glucoseAt: string;
glucoseMmol: number;
ketonesMmol: number;
};
}
interface GlucoseKetoneIndexSeries {
kind: "glucoseKetoneIndex";
unit: string;
points: GlucoseKetoneIndexSeriesPoint[];
}
/** The whole history the series route serves; the chart narrows it. */
export const GKI_SERIES_DAYS = 3650;
/**
* The glucose-ketone index over the last {@link GKI_SERIES_DAYS} days (#1367).
* The server pairs the readings and computes every index; the card draws them
* as given.
*/
export function useGlucoseKetoneIndexSeries() {
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: queryKeys.glucoseKetoneIndexSeries(GKI_SERIES_DAYS),
queryFn: () =>
apiGet<GlucoseKetoneIndexSeries>(
`/api/measurements/series?kind=glucoseKetoneIndex&days=${GKI_SERIES_DAYS}`,
),
staleTime: 60_000,
enabled: isAuthenticated,
});
}
+8
View File
@@ -127,6 +127,14 @@ export const measurementKeys = {
bpTimeOfDay: (days: number) =>
["measurements", "bp-time-of-day", days] as const,
/**
* #1367 — the glucose-ketone index series
* (`GET /api/measurements/series?kind=glucoseKetoneIndex&days=`). Under the
* `["measurements"]` prefix, so a new glucose or ketone reading refreshes it.
*/
glucoseKetoneIndexSeries: (days: number) =>
["measurements", "glucose-ketone-index", days] as const,
/**
* v1.8.5 — bounded recent-timestamp read powering the
* measurement-diversity nudge on an insights category page. Keyed by
@@ -63,9 +63,9 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(91);
expect(MEASUREMENT_LEAF_IDS).toHaveLength(92);
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
expect(ALL_LEAF_IDS).toHaveLength(111);
expect(ALL_LEAF_IDS).toHaveLength(112);
});
it("resolves a label for every leaf", () => {
+2
View File
@@ -197,6 +197,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
// ── glucose ──
BLOOD_GLUCOSE: "glucose",
BLOOD_KETONES: "glucose",
// ── fenced ──
// Mindful minutes and the State of Mind valence are mental-health
@@ -337,6 +338,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
INDOOR_HUMIDITY: "environment",
// Glucose: the raw series and the derived panel.
BLOOD_GLUCOSE: "glucose",
BLOOD_KETONES: "glucose",
GLUCOSE_PANEL: "glucose",
// The screening totals ride the mental-health module as well as being
// fenced in the panel. Item-level answers are never a Measurement, so only
@@ -328,5 +328,11 @@
"display": "Indoor relative humidity",
"unit": "%",
"category": "activity"
},
"BLOOD_KETONES": {
"loinc": "104816-4",
"display": "Beta hydroxybutyrate [Moles/volume] in Blood",
"unit": "mmol/L",
"category": "laboratory"
}
}
+29
View File
@@ -341,6 +341,35 @@ export const SIGNALS: Record<string, SignalDefinition> = {
mcp: true,
},
},
// Blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick meter,
// typed by hand. No band: what a ketone reading should be depends on the
// plan a clinician sets, so the value is shown as recorded. Off the Coach
// snapshot and the correlations, readable over MCP.
BLOOD_KETONES: {
key: "BLOOD_KETONES",
kind: "measurement",
source: { measurementType: "BLOOD_KETONES" },
displayName: "Blood ketones (BHB)",
unit: "mmol/L",
direction: "target-band",
archetype: "physiological-vital",
surfaces: {
detailPage: true,
correlationEligible: false,
coachSnapshot: false,
mcp: true,
},
// LOINC 104816-4 "Beta hydroxybutyrate [Moles/volume] in Blood": the
// quantitative, method-less blood term. A typed value does not say
// whether it came from a strip or a venous draw, and the strip term
// (66441-7) is semi-quantitative, which a numeric mmol/L reading is not.
fhir: {
loinc: "104816-4",
display: "Beta hydroxybutyrate [Moles/volume] in Blood",
unit: "mmol/L",
category: "laboratory",
},
},
WALKING_HEART_RATE_AVERAGE: {
key: "WALKING_HEART_RATE_AVERAGE",
kind: "measurement",
+2
View File
@@ -171,6 +171,8 @@ export const measurementTypeEnum = z.enum([
"ACUTE_TRAINING_LOAD",
// ── resting energy (#1340), HealthKit `basalEnergyBurned` ──
"BASAL_ENERGY_BURNED",
// ── blood ketones (#1367), beta-hydroxybutyrate in mmol/L ──
"BLOOD_KETONES",
]);
/**