mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge branch 'feat/v1472-ketones' into feat/cycle-feed-forecast-style
# Conflicts: # CHANGELOG.md
This commit is contained in:
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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "%",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ────────
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user