feat(measurements): record blood ketones and show the glucose-ketone index (#1367)

Ketones had to live in a custom metric, with the glucose-ketone index typed
in by hand beside it, where it drifted from the readings it came from.

- Type: BLOOD_KETONES (beta-hydroxybutyrate, mmol/L, migration 0496),
  plausible from 0 to 10: the blood ketone meters read 0.0 to 8.0 and print
  HI above it, and a venous value off a lab sheet fits under 10. Zero is a
  real reading. No band, no direction, no personal record, off the Coach
  snapshot and the correlations; readable over MCP and owned by the glucose
  module like glucose.
- Entry: choosing blood ketones in the measurement form also takes the
  glucose from the same fingerstick, in the account's glucose unit. Both
  rows go through the array arm of POST /api/measurements with one
  timestamp, so they land in one transaction or not at all.
- Index: glucose (mg/dL / 18.0182, the tree's one factor) over ketones,
  computed at read time and never stored. Each ketone reading above zero
  pairs with the glucose reading at the same instant, else the nearest one
  within 15 minutes either side; a tie goes to the same source, then to the
  earlier reading. Nothing pairs across a wider gap.
- Surfaces: GET /api/measurements/series takes kind=ketones and
  kind=glucoseKetoneIndex (each point names the paired readings), and the
  day carries glucoseKetoneIndex when both reading types are visible. The
  new Ketones page charts the readings with the index beneath; the day view
  shows the index beside the value tiles; the doctor report, its PDF and the
  clinician share print it in the glucose group when both leaves are chosen,
  since beside the ketones it gives the glucose back.
- FHIR: ketone readings export under LOINC 104816-4 (Beta hydroxybutyrate
  [Moles/volume] in Blood). The index is left out: it has no LOINC term,
  and both readings it is built from are in the bundle.
- The measurement form's action row moved into its own component to keep
  the form inside its size budget.

OpenAPI regenerated; all ten locales; the import unit table lists the type.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:28:57 +01:00
1 parent 157809c24f
commit f75337e6f2
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",
]);
/**