Merge remote-tracking branch 'origin/release/v1.47.2' into takeover/1370

# Conflicts:
#	CHANGELOG.md
#	src/lib/module-list-prefs.ts
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:04:51 +01:00
commit a486c3c8a3
148 files changed
+4668 -347

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"
@@ -30,6 +30,7 @@ const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "MARGIN" as const,
};
describe("<CycleCalendarFeedCard>", () => {
@@ -58,6 +59,30 @@ describe("<CycleCalendarFeedCard>", () => {
expect(html).not.toContain("Fertile window");
});
it("offers the three forecast styles and explains the chosen one", () => {
const html = render({
feed: FEED,
projectionState: "OK",
fertileAvailable: false,
});
expect(html).toContain("How forecasts look");
for (const value of ["EXACT", "MARGIN", "BAND"]) {
expect(html).toContain(`value="${value}"`);
}
expect(html).toContain('aria-describedby="cycle-feed-forecast-hint"');
expect(html).toMatch(
/data-slot="cycle-feed-forecast-hint"[^>]*>Expected days, with the days it may start earlier or later as separate entries\.</,
);
const band = render({
feed: { ...FEED, forecastStyle: "BAND" },
projectionState: "OK",
fertileAvailable: false,
});
expect(band).toContain("One entry covering every day it may fall on.");
expect(band).not.toContain("as separate entries");
});
it("explains a shorter feed in the muted tone", () => {
const html = render({
feed: FEED,
@@ -28,6 +28,7 @@ import {
type CycleFeedMinted,
type CycleFeedOptionsPatch,
type CycleFeedStatus,
type FeedForecastStyle,
type FeedTitleMode,
} from "./use-cycle-feed";
@@ -44,6 +45,7 @@ import {
const HORIZONS = [1, 3, 6] as const;
const HISTORIES = [0, 3, 6, 12] as const;
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
const FORECAST_STYLES: FeedForecastStyle[] = ["EXACT", "MARGIN", "BAND"];
/** A long URL with its middle elided, so both ends stay recognisable. */
function middleTruncate(url: string, keep = 28): string {
@@ -352,6 +354,31 @@ function FeedOptions({ status }: { status: CycleFeedStatus }) {
</NativeSelect>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-forecast">{t("cycle.feed.forecasts")}</Label>
<NativeSelect
id="cycle-feed-forecast"
value={feed.forecastStyle}
disabled={update.isPending}
aria-describedby="cycle-feed-forecast-hint"
onChange={(e) =>
save({ forecastStyle: e.target.value as FeedForecastStyle })
}
>
{FORECAST_STYLES.map((s) => (
<option key={s} value={s}>
{t(`cycle.feed.forecastStyle.${s}`)}
</option>
))}
</NativeSelect>
<p
id="cycle-feed-forecast-hint"
className="text-muted-foreground text-sm"
data-slot="cycle-feed-forecast-hint"
>
{t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)}
</p>
</div>
{status.fertileAvailable ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
+7 -1
View File
@@ -17,6 +17,7 @@ import { queryKeys } from "@/lib/query-keys";
import type { ProjectionState } from "./types";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
export interface CycleFeedSummary {
@@ -29,6 +30,7 @@ export interface CycleFeedSummary {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedStatus {
@@ -46,7 +48,11 @@ export interface CycleFeedMinted {
export type CycleFeedOptionsPatch = Partial<
Pick<
CycleFeedSummary,
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
| "horizonCycles"
| "historyCycles"
| "includeFertile"
| "titleMode"
| "forecastStyle"
>
>;
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import type { DayResponse } from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
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),
unit: GKI_COMPONENT_UNIT,
})}
</span>
</Link>
</li>
);
}
+7 -3
View File
@@ -29,9 +29,9 @@ import {
type DayEvent,
type DayEventKind,
type DayNotable,
type DayResponse,
type DayRunningItem,
type DayRunningKind,
type DayValue,
} from "@/lib/day/contract";
import { visitKindWords } from "@/components/timeline/item-words";
import {
@@ -62,6 +62,7 @@ import {
WORKOUT_SPORT_KEY,
} from "./label-keys";
import { cycleRunningLine } from "./day-cycle-line";
import { DayGkiTile } from "./day-gki-tile";
import { useDayValueFormat } from "./use-day-value-format";
/**
@@ -508,12 +509,14 @@ function ValueTile({
}
export function DayValues({
values,
day,
focusTypes,
}: {
values: readonly DayValue[];
/** The day's values and its server-derived glucose-ketone index (#1367). */
day: Pick<DayResponse, "values" | "glucoseKetoneIndex">;
focusTypes: readonly string[];
}) {
const { values } = day;
const { t, tCount } = useTranslations();
const [expanded, setExpanded] = useState(false);
if (values.length === 0) return null;
@@ -537,6 +540,7 @@ export function DayValues({
selected={focusKeys.has(tile.key)}
/>
))}
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
</ul>
{rest.length > 0 ? (
<button
+1 -1
View File
@@ -296,7 +296,7 @@ export function DayView({
<DayRunning items={data.running} date={date} />
{data.values.length > 0 ? (
<div className="space-y-2.5">
<DayValues values={data.values} focusTypes={focusTypes} />
<DayValues day={data} focusTypes={focusTypes} />
<DayNotableLines
notables={otherNotables}
labelFor={labelFor}
@@ -380,6 +380,7 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
ketones: { labelKey: "insights.navBloodKetones", metric: "BLOOD_KETONES" },
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
@@ -0,0 +1,109 @@
"use client";
import dynamic from "next/dynamic";
import { Divide } from "lucide-react";
import {
ChartErrorBoundary,
ChartErrorState,
} from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
import { useGlucoseKetoneIndexSeries } from "@/lib/queries/use-glucose-ketone-index";
import { importWithRetry } from "@/lib/retry-import";
// The line chart the custom metrics draw: a value over time with range tabs
// and the day doors. Loaded through the one recharts boundary.
const IndexChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.CustomMetricChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The glucose-ketone index (#1367), beneath the ketone chart. Every point is
* one ketone reading and the glucose reading the server paired with it; the
* value is the server's and is drawn as given. The latest pair is spelled out
* so the number can be traced to the two readings it came from. No band and
* no verdict: what the index should be is the treating clinician's call.
*/
export function GkiCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const query = useGlucoseKetoneIndexSeries();
const points = query.data?.points ?? [];
const latest = points[points.length - 1];
const title = t("measurements.glucoseKetoneIndex");
return (
<Card data-slot="gki-card" aria-busy={query.isFetching || undefined}>
<CardHeader>
<TileHeader icon={Divide} title={title} titleAs="h2" />
<CardDescription>
{t("insights.bloodKetones.gki.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isPending ? (
<ChartSkeleton />
) : query.isError ? (
<ChartErrorState
error={query.error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={title}
onAction={() => void query.refetch()}
/>
) : !latest ? (
<EmptyState
variant="plain"
size="compact"
title={t("insights.bloodKetones.gki.emptyTitle")}
description={t("insights.bloodKetones.gki.emptyDescription")}
/>
) : (
<>
<p className="text-sm" data-slot="gki-latest">
{t("insights.bloodKetones.gki.latest", {
value: fmt.number(latest.value, 1),
glucose: fmt.number(latest.gki.glucoseMmol, 1),
ketones: fmt.number(latest.gki.ketonesMmol, 1),
unit: GKI_COMPONENT_UNIT,
date: fmt.dateTime(latest.at),
})}
</p>
<ChartErrorBoundary>
<IndexChartLazy
name={title}
unit=""
targetLow={null}
targetHigh={null}
decimals={1}
entries={points.map((p) => ({
id: p.id,
customMetricId: "",
value: p.value,
unit: "",
measuredAt: p.at,
note: null,
createdAt: p.at,
}))}
/>
</ChartErrorBoundary>
</>
)}
</CardContent>
</Card>
);
}
@@ -179,6 +179,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
},
},
});
@@ -226,6 +227,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
},
},
},
@@ -322,6 +324,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
},
},
},
@@ -378,6 +381,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
illness: {
@@ -386,6 +390,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
vorsorge: {
@@ -394,6 +399,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
},
@@ -424,6 +430,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "manual",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: true,
},
illness: {
@@ -432,6 +439,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
vorsorge: {
@@ -440,6 +448,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
},
},
@@ -496,6 +505,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "expanded",
categoryOrder: [],
},
},
});
@@ -533,6 +543,7 @@ describe("LabList stale category and group header handling", () => {
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "flagged",
categoryOrder: [],
},
},
});
@@ -48,6 +48,7 @@ const mockModulePrefs: ModuleListPrefs = {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
vi.mock("@/lib/module-list-prefs", async (importOriginal) => {
@@ -21,6 +21,7 @@ let mockModulePrefs: ModuleListPrefs = {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
const displayZone = vi.hoisted(() => ({ current: "UTC" }));
@@ -85,6 +86,7 @@ describe("<LabList>", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
});
@@ -184,6 +186,7 @@ describe("<LabList>", () => {
sortDir: "recentDesc",
groupBy: "category",
groupsOpen: "expanded",
categoryOrder: [],
};
const queryClient = new QueryClient({
@@ -257,6 +260,7 @@ describe("<LabList>", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
const queryClient = new QueryClient({
@@ -28,6 +28,21 @@ function render(prefs: Partial<ModuleListPrefs>) {
biomarkers: [],
customPanels: [],
});
// The server's answer, already in the person's order.
queryClient.setQueryData(queryKeys.labResultsOverview("en"), {
markers: [],
grouped: true,
categories: [
{ key: "thyroid", label: "Thyroid" },
{ key: "other", label: "Other" },
{ key: "lipids", label: "Lipids" },
].map((category) => ({
...category,
markerKeys: [],
counts: { inRange: 0, below: 0, above: 0, unknown: 0 },
status: "unknown",
})),
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -60,4 +75,26 @@ describe("<LabsSettings> categories", () => {
"The order applies within each category.",
);
});
it("offers the category order only while categories are on", () => {
expect(render({})).not.toContain('id="labs-category-order"');
expect(render({ groupBy: "category" })).toContain(
'id="labs-category-order"',
);
});
it("lists the categories as the server ordered them, under their labels", () => {
const html = render({ groupBy: "category" });
const card = html.slice(html.indexOf('id="labs-category-order"'));
const at = (label: string) => card.indexOf(`title="${label}"`);
expect(at("Thyroid")).toBeGreaterThan(-1);
expect(at("Thyroid")).toBeLessThan(at("Other"));
expect(at("Other")).toBeLessThan(at("Lipids"));
});
it("says how the categories are ordered until the person moves one", () => {
expect(render({ groupBy: "category" })).toContain(
"Until you move one, built-in categories keep a fixed order, your own follow alphabetically and Other comes last.",
);
});
});
+77 -5
View File
@@ -5,8 +5,8 @@
*
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers, and whether the list shows its categories and
* which of them start open. Persisted per account through
* order of the biomarkers, and whether the list shows its categories, which
* of them start open and their order. Persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
@@ -19,6 +19,7 @@ import {
ArrowDownUp,
FlaskConical,
FolderTree,
Layers,
LayoutGrid,
ListOrdered,
ScanText,
@@ -27,6 +28,7 @@ import {
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Segmented } from "@/components/ui/segmented";
@@ -47,16 +49,28 @@ import {
type ModuleGroupsOpen,
type ModuleSortDir,
} from "@/lib/module-list-prefs";
import { mergeCategoryOrder } from "@/lib/settings/module-list-layout";
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabOverviewResponse,
} from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } =
useModuleListPrefs("labs");
const {
prefs,
setView,
setOrder,
setSortDir,
setGroupBy,
setGroupsOpen,
setCategoryOrder,
} = useModuleListPrefs("labs");
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -120,6 +134,14 @@ export function LabsSettings() {
onGroupsOpen={setGroupsOpen}
/>
{prefs.groupBy === "category" ? (
<LabsCategoryOrderCard
onChange={(shown) =>
setCategoryOrder(mergeCategoryOrder(shown, prefs.categoryOrder))
}
/>
) : null}
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
@@ -204,6 +226,56 @@ function LabsGroupingCard({
);
}
/**
* The order of the categories. The server answers them already in the
* person's order (`GET /api/labs/overview` applies `categoryOrder`), so the
* editor lists them as given, under the labels the overview carries, and
* sends the whole order after every move.
*/
function LabsCategoryOrderCard({
onChange,
}: {
onChange: (categoryOrder: string[]) => void;
}) {
const { t, locale } = useTranslations();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.labResultsOverview(locale),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const items: ReorderItem[] = (data?.categories ?? []).map((category) => ({
id: category.key,
name: category.label,
}));
return (
<SettingsCard id="labs-category-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Layers}
title={t("labs.groups.orderHeading")}
description={t("labs.groups.orderDefault", {
other: t("labs.groups.other"),
})}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
<QueryErrorRow
error={error}
inset
slot="labs-category-order-error"
onRetry={() => void refetch()}
/>
) : (
<ModuleOrderEditor items={items} onChange={onChange} />
)}
</SettingsCard>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
@@ -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",