mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
157809c24f
commit
f75337e6f2
75 files changed
+2133
-94
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user