mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
7c5f49c18e
36 files changed
+1613
-45
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The provider-health card and the HTTP status of the last failure.
|
||||
*
|
||||
* The ledger has recorded a status on every failure since v1.11.0 and no
|
||||
* surface read it, so the card could say a provider was failing without
|
||||
* saying whose problem it was. A 401 is a credential the operator has to
|
||||
* replace; a 503 is the provider's afternoon. These pin that the status
|
||||
* reaches the row, that it is left out when there was none to record,
|
||||
* and that a healthy provider does not carry a stale one.
|
||||
*/
|
||||
|
||||
const mockQueryState = vi.hoisted(() => ({
|
||||
data: null as null | object,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: mockQueryState.data,
|
||||
isPending: mockQueryState.isPending,
|
||||
isError: mockQueryState.isError,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ProviderHealthSection } from "../provider-health-section";
|
||||
|
||||
function row(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
providerType: "admin-openai",
|
||||
tracked: 5,
|
||||
failing: 2,
|
||||
maxConsecutiveFailures: 4,
|
||||
lastOkAt: null,
|
||||
lastFailureAt: "2026-08-27T06:00:00.000Z",
|
||||
lastFailureStatus: 401,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(providers: object[]) {
|
||||
mockQueryState.data = { providers };
|
||||
mockQueryState.isPending = false;
|
||||
mockQueryState.isError = false;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ProviderHealthSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ProviderHealthSection> — the last failure's HTTP status", () => {
|
||||
it("names the status beside the failure it belongs to", () => {
|
||||
expect(render([row()])).toContain("HTTP 401");
|
||||
});
|
||||
|
||||
it("says nothing where the failure never had a status to record", () => {
|
||||
expect(render([row({ lastFailureStatus: null })])).not.toContain("HTTP");
|
||||
});
|
||||
|
||||
it("carries no status for a provider that is not failing", () => {
|
||||
expect(
|
||||
render([
|
||||
row({ failing: 0, lastFailureAt: null, lastFailureStatus: 500 }),
|
||||
]),
|
||||
).not.toContain("HTTP");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,12 @@ import { formatDateTime } from "@/lib/format";
|
||||
* read the database. The card shows one row per provider type: how many
|
||||
* users' chains touch it, how many of them are currently failing, the worst
|
||||
* uninterrupted failure run, and the last success / failure instants.
|
||||
*
|
||||
* The failure line also carries the HTTP status the ledger recorded for that
|
||||
* failure, because "failing" on its own does not say whose problem it is. A
|
||||
* 401 is a dead credential and waiting will not fix it; a 429 or a 503 is the
|
||||
* provider's day and waiting is exactly right. Absent for a network-class
|
||||
* failure, which never had a status to record.
|
||||
*/
|
||||
|
||||
interface ProviderHealthRow {
|
||||
@@ -27,6 +33,7 @@ interface ProviderHealthRow {
|
||||
maxConsecutiveFailures: number;
|
||||
lastOkAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
lastFailureStatus: number | null;
|
||||
}
|
||||
|
||||
export function ProviderHealthSection() {
|
||||
@@ -106,6 +113,13 @@ export function ProviderHealthSection() {
|
||||
at: formatDateTime(p.lastFailureAt),
|
||||
})}`
|
||||
: ""}
|
||||
{p.failing > 0 &&
|
||||
p.lastFailureAt &&
|
||||
p.lastFailureStatus !== null
|
||||
? ` · ${t("admin.providerHealth.lastFailureStatus", {
|
||||
status: p.lastFailureStatus,
|
||||
})}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
{p.failing === 0 ? (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The manual entry form and the blood-glucose display unit.
|
||||
*
|
||||
* Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
|
||||
* account picked. Until the preference could be set at all, the form could
|
||||
* hardcode "mg/dL" and be right by accident. It no longer can: a reader on
|
||||
* mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
|
||||
* plausibility band (20–800) accepts without a word and every surface then
|
||||
* reads back as real. So the label and the stored number have to move
|
||||
* together — the field asks in the account's unit and the submit path
|
||||
* inverts to canonical mg/dL.
|
||||
*
|
||||
* The label half is rendered. The submit half is asserted against the source:
|
||||
* this project runs SSR-only component tests (`@testing-library/react` is not
|
||||
* a dependency), so nothing here can type into the field and press save. What
|
||||
* the source assertion pins is the wiring — that the glucose branch inverts
|
||||
* through `toCanonicalMgdl` rather than sending the typed number on — and
|
||||
* `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
|
||||
*/
|
||||
|
||||
let glucoseUnit: string | null = null;
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "testuser",
|
||||
role: "USER",
|
||||
unitPreference: "metric",
|
||||
glucoseUnit,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiPost: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementForm } from "../measurement-form";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const formSource = readFileSync(
|
||||
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("MeasurementForm — blood-glucose entry unit", () => {
|
||||
it("asks in mg/dL for an account that never chose", () => {
|
||||
glucoseUnit = null;
|
||||
const html = render();
|
||||
expect(html).toContain("mg/dL");
|
||||
expect(html).not.toContain("mmol/L");
|
||||
expect(html).toContain('placeholder="95"');
|
||||
});
|
||||
|
||||
it("asks in mmol/L once the account chose it", () => {
|
||||
glucoseUnit = "mmol/L";
|
||||
const html = render();
|
||||
expect(html).toContain("mmol/L");
|
||||
// A mmol/L reader must not be shown a three-digit mg/dL example.
|
||||
expect(html).toContain('placeholder="5.3"');
|
||||
expect(html).not.toContain('placeholder="95"');
|
||||
});
|
||||
|
||||
it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
|
||||
expect(formSource).toMatch(
|
||||
/if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the unit from the account rather than assuming one", () => {
|
||||
expect(formSource).toMatch(
|
||||
/const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The measurements list and the blood-glucose display unit.
|
||||
*
|
||||
* Glucose is stored in mg/dL and rendered in the unit the account chose.
|
||||
* The list is not part of the metric/imperial transform registry, so
|
||||
* without a branch of its own a reader on mmol/L would see the dashboard
|
||||
* tile say 5.3 and this list say 95 for the same reading.
|
||||
*
|
||||
* The edit sheet is the half that could do damage: its label follows the
|
||||
* same resolution, so the number in the input is in the displayed unit and
|
||||
* has to be inverted before the PATCH. Display and inversion are asserted
|
||||
* together on purpose — converting one without the other rewrites a
|
||||
* 95 mg/dL reading as 5.3 the first time somebody corrects a typo.
|
||||
*/
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: "m1",
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
measuredAt: "2026-05-15T10:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
];
|
||||
|
||||
let glucoseUnit: string | null = null;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { measurements: rows, meta: { total: 1 } },
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "testuser", role: "USER", glucoseUnit },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
|
||||
const listSource = readFileSync(
|
||||
join(process.cwd(), "src/components/measurements/measurement-list.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MeasurementList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MeasurementList — blood-glucose display unit", () => {
|
||||
it("shows the stored mg/dL reading as it stands for an mg/dL account", () => {
|
||||
glucoseUnit = null;
|
||||
const html = render();
|
||||
expect(html).toContain("95");
|
||||
expect(html).toContain("mg/dL");
|
||||
expect(html).not.toContain("mmol/L");
|
||||
});
|
||||
|
||||
it("converts the same reading for an mmol/L account", () => {
|
||||
glucoseUnit = "mmol/L";
|
||||
const html = render();
|
||||
// 95 mg/dL ÷ 18.0182 = 5.3 mmol/L.
|
||||
expect(html).toContain("5.3");
|
||||
expect(html).toContain("mmol/L");
|
||||
expect(html).not.toContain("95 mg/dL");
|
||||
});
|
||||
|
||||
it("inverts an edited glucose value back to mg/dL before the save", () => {
|
||||
expect(listSource).toMatch(
|
||||
/editsConvertedGlucose\(editing\)[\s\S]{0,400}?canonicalValue = toCanonicalMgdl\(parsedValue, "mmol\/L"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds the edit input in the unit its own label names", () => {
|
||||
expect(listSource).toMatch(
|
||||
/const seedValue = editsConvertedGlucose\(measurement\)\s*\?\s*rd\.value/,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an mg/dL edit on the identity path so a note fix cannot round the value", () => {
|
||||
// `toCanonicalMgdl(v, "mg/dL")` rounds, so routing the mg/dL branch
|
||||
// through it would quantise a stored 95.48 on an unrelated edit.
|
||||
expect(listSource).toMatch(
|
||||
/editsConvertedGlucose[\s\S]{0,300}?glucoseUnit === "mmol\/L"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ 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";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose";
|
||||
import {
|
||||
entryValueToCanonical,
|
||||
parseDecimalEntry,
|
||||
@@ -246,6 +248,14 @@ export function MeasurementForm({
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const { user } = useAuth();
|
||||
// Glucose is its own display axis, separate from metric/imperial: it is
|
||||
// stored in mg/dL and rendered in whichever of mg/dL and mmol/L the
|
||||
// account chose. The form has to ask in the same unit it labels, and
|
||||
// invert on the way out, or a reader on mmol/L would file "5.5" as five
|
||||
// and a half mg/dL — a number the plausibility band happily accepts and
|
||||
// every surface then reads back as a severe hypo.
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
|
||||
|
||||
// Normalize legacy BP types to combined mode
|
||||
const normalizedDefault =
|
||||
@@ -359,7 +369,9 @@ export function MeasurementForm({
|
||||
return;
|
||||
}
|
||||
let canonicalValue = typed;
|
||||
if (unitDisplay.isTransformed(type)) {
|
||||
if (isGlucoseMode) {
|
||||
canonicalValue = toCanonicalMgdl(typed, glucoseUnit);
|
||||
} else if (unitDisplay.isTransformed(type)) {
|
||||
const inverted = unitDisplay.fromDisplay(type, typed);
|
||||
canonicalValue =
|
||||
unitDisplay.preference === "imperial"
|
||||
@@ -537,13 +549,15 @@ export function MeasurementForm({
|
||||
// For a type with a metric/imperial transform the label follows the
|
||||
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
|
||||
// its static catalogue unit.
|
||||
unit: typeInfo
|
||||
? unitDisplay.isTransformed(type)
|
||||
? unitDisplay.unitFor(type)
|
||||
: "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
: "",
|
||||
unit: isGlucoseMode
|
||||
? glucoseUnit
|
||||
: typeInfo
|
||||
? unitDisplay.isTransformed(type)
|
||||
? unitDisplay.unitFor(type)
|
||||
: "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
@@ -558,14 +572,16 @@ export function MeasurementForm({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
typeInfo
|
||||
? unitDisplay.preference === "imperial" &&
|
||||
"placeholderImperial" in typeInfo
|
||||
? typeInfo.placeholderImperial
|
||||
: "placeholder" in typeInfo
|
||||
? typeInfo.placeholder
|
||||
: undefined
|
||||
: undefined
|
||||
isGlucoseMode && glucoseUnit === "mmol/L"
|
||||
? "5.3"
|
||||
: typeInfo
|
||||
? unitDisplay.preference === "imperial" &&
|
||||
"placeholderImperial" in typeInfo
|
||||
? typeInfo.placeholderImperial
|
||||
: "placeholder" in typeInfo
|
||||
? typeInfo.placeholder
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
aria-required="true"
|
||||
|
||||
@@ -72,6 +72,11 @@ import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
convertGlucose,
|
||||
resolveGlucoseUnit,
|
||||
toCanonicalMgdl,
|
||||
} from "@/lib/glucose";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -303,17 +308,40 @@ export function MeasurementList({
|
||||
// transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the
|
||||
// type's canonical unit) is shown verbatim so its stored unit is never
|
||||
// silently relabelled. Metric users take the identity path — unchanged.
|
||||
// Glucose sits on its own display axis: stored mg/dL, rendered in whichever
|
||||
// of mg/dL and mmol/L the account chose. It is not part of the
|
||||
// metric/imperial registry, so it needs its own branch or a reader on
|
||||
// mmol/L would see the dashboard tile and this list disagree about the
|
||||
// same reading.
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
|
||||
// An edit whose input is NOT in the stored unit. Only the mmol/L branch
|
||||
// qualifies: an mg/dL account edits in the unit the column holds, and
|
||||
// routing it through the converter anyway would quantise a stored 95.48 to
|
||||
// 95 the moment somebody opened the sheet to fix a note.
|
||||
const editsConvertedGlucose = useCallback(
|
||||
(m: { type: string; unit: string }) =>
|
||||
m.type === "BLOOD_GLUCOSE" &&
|
||||
glucoseUnit === "mmol/L" &&
|
||||
m.unit === getUnitForType(m.type),
|
||||
[glucoseUnit],
|
||||
);
|
||||
const rowDisplay = useCallback(
|
||||
(m: { type: string; value: number; unit: string }) => {
|
||||
if (m.unit !== getUnitForType(m.type)) {
|
||||
return { value: m.value, unit: m.unit };
|
||||
}
|
||||
if (m.type === "BLOOD_GLUCOSE") {
|
||||
return {
|
||||
value: convertGlucose(m.value, glucoseUnit),
|
||||
unit: glucoseUnit,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: unitDisplay.toDisplay(m.type, m.value),
|
||||
unit: unitDisplay.unitFor(m.type),
|
||||
};
|
||||
},
|
||||
[unitDisplay],
|
||||
[unitDisplay, glucoseUnit],
|
||||
);
|
||||
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
|
||||
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
|
||||
@@ -791,10 +819,11 @@ export function MeasurementList({
|
||||
// stored value, unchanged. Round the converted imperial seed so the input
|
||||
// shows "210" rather than a long float.
|
||||
const rd = rowDisplay(measurement);
|
||||
const seedValue =
|
||||
unitDisplay.preference === "imperial" &&
|
||||
unitDisplay.isTransformed(measurement.type) &&
|
||||
measurement.unit === getUnitForType(measurement.type)
|
||||
const seedValue = editsConvertedGlucose(measurement)
|
||||
? rd.value
|
||||
: unitDisplay.preference === "imperial" &&
|
||||
unitDisplay.isTransformed(measurement.type) &&
|
||||
measurement.unit === getUnitForType(measurement.type)
|
||||
? Math.round(rd.value * 100) / 100
|
||||
: measurement.value;
|
||||
const seed = {
|
||||
@@ -852,7 +881,11 @@ export function MeasurementList({
|
||||
// the PATCH so an imperial edit persists in the stored unit. Metric users
|
||||
// and legacy non-canonical rows are the identity path — value unchanged.
|
||||
let canonicalValue = parsedValue;
|
||||
if (
|
||||
if (editsConvertedGlucose(editing)) {
|
||||
// The label above the input said mmol/L, so the number in it is
|
||||
// mmol/L. Sending it on would rewrite a 95 mg/dL reading as 5.3.
|
||||
canonicalValue = toCanonicalMgdl(parsedValue, "mmol/L");
|
||||
} else if (
|
||||
unitDisplay.isTransformed(editing.type) &&
|
||||
editing.unit === getUnitForType(editing.type)
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* Settings → Profile blood-glucose unit dropdown.
|
||||
*
|
||||
* The control the preference column never had. It sits beside the
|
||||
* metric/imperial select and is deliberately not a branch of it: metric
|
||||
* countries are split on which unit they read glucose in, so one dropdown
|
||||
* would get one of them wrong.
|
||||
*
|
||||
* SSR-only, like the unit-system select next to it: the render pass pins the
|
||||
* options and the selected value, and the mutation contract — endpoint,
|
||||
* method, body — is asserted against the source, because nothing here can
|
||||
* dispatch a change event.
|
||||
*/
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: buildUser(null), isAuthenticated: true }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return { ...actual, useAuth: () => authSpy() };
|
||||
});
|
||||
|
||||
function buildUser(glucoseUnit: string | null): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "user",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit,
|
||||
unitPreference: "metric",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
disableCoach: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
} as AuthUser;
|
||||
}
|
||||
|
||||
import { GlucoseUnitSelect } from "../glucose-unit-select";
|
||||
|
||||
const componentSource = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/glucose-unit-select.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
authSpy.mockClear();
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(null),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
});
|
||||
|
||||
function render(isAuthenticated = true): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Settings — GlucoseUnitSelect", () => {
|
||||
it("offers both units the app renders", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-glucose-unit-select"');
|
||||
expect(html).toContain('value="mg/dL"');
|
||||
expect(html).toContain('value="mmol/L"');
|
||||
});
|
||||
|
||||
// React renders a controlled select's current value as `selected` on the
|
||||
// option under SSR, not as an attribute on the `<select>`.
|
||||
function selectedOption(html: string): string | null {
|
||||
return /<option[^>]*selected[^>]*>/.exec(html)?.[0] ?? null;
|
||||
}
|
||||
|
||||
it("shows mg/dL for an account that never chose", () => {
|
||||
expect(selectedOption(render())).toContain('value="mg/dL"');
|
||||
});
|
||||
|
||||
it("shows the unit an account already chose", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser("mmol/L"),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
expect(selectedOption(render())).toContain('value="mmol/L"');
|
||||
});
|
||||
|
||||
it("disables the select when unauthenticated", () => {
|
||||
const select = /<select[^>]*settings-glucose-unit-select[^>]*>/.exec(
|
||||
render(false),
|
||||
);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select![0]).toContain("disabled");
|
||||
});
|
||||
|
||||
it("PATCHes the glucose-unit endpoint field-by-field", () => {
|
||||
expect(componentSource).toMatch(
|
||||
/apiFetchRaw\(\s*"\/api\/auth\/me\/glucose-unit"[\s\S]{0,200}?method: "PATCH"[\s\S]{0,200}?JSON\.stringify\(\{ glucoseUnit: next \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the account payload every glucose surface reads from", () => {
|
||||
expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/);
|
||||
});
|
||||
|
||||
it("is actually mounted on the settings page", () => {
|
||||
// A control nobody can reach is the same defect as no control.
|
||||
const accountSection = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/account-section/index.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(accountSection).toMatch(/<GlucoseUnitSelect\s/);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -421,14 +422,17 @@ export function AccountSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone + unit system + hour format share one grid block —
|
||||
all personal display preferences (like language above). The
|
||||
unit and time-format dropdowns PATCH their own endpoints on
|
||||
change; the timezone saves through the form's submit
|
||||
handler. */}
|
||||
{/* Timezone, unit system, glucose unit and the date/hour formats
|
||||
share one grid block — all personal display preferences (like
|
||||
language above). The glucose unit is its own dropdown rather
|
||||
than a branch of the unit system because metric countries are
|
||||
split on which unit they read glucose in. Every dropdown here
|
||||
PATCHes its own endpoint on change; the timezone saves through
|
||||
the form's submit handler. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TimezonePicker value={timezone} onChange={setTimezone} />
|
||||
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
|
||||
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
|
||||
<TimeFormatSelect isAuthenticated={isAuthenticated} />
|
||||
<DateFormatSelect isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* Blood-glucose display unit as a Profile dropdown.
|
||||
*
|
||||
* The preference column and its thirty-odd readers shipped in v1.2 with no
|
||||
* control to set them, so every account stayed on the mg/dL default and the
|
||||
* mmol/L half of the app was unreachable. This is the control.
|
||||
*
|
||||
* It sits beside the metric/imperial dropdown because it is the same kind of
|
||||
* setting — how a number is shown, not what is stored — but it is a separate
|
||||
* choice: metric countries are split on which unit they read glucose in, so
|
||||
* folding it into metric/imperial would get one of them wrong. The option
|
||||
* labels are the unit symbols themselves, which read the same in every
|
||||
* locale.
|
||||
*
|
||||
* Persistence mirrors the unit-system select: a change PATCHes
|
||||
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`
|
||||
* plus `queryKeys.userGlucoseUnit()`, so the dashboard tiles, the glucose
|
||||
* insight page and the targets panel re-render on the next /me refetch.
|
||||
*/
|
||||
export function GlucoseUnitSelect({
|
||||
isAuthenticated,
|
||||
id = "glucose-unit",
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function scheduleClear() {
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
}
|
||||
clearTimerRef.current = setTimeout(() => {
|
||||
clearTimerRef.current = null;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const value: GlucoseUnit =
|
||||
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: GlucoseUnit) => {
|
||||
const res = await apiFetchRaw("/api/auth/me/glucose-unit", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ glucoseUnit: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(json.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(t("settings.dashboard.glucoseUnit.saved"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userGlucoseUnit(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.dashboard.glucoseUnit.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelect(next: GlucoseUnit) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("settings.dashboard.glucoseUnit.title")}</Label>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
data-testid="settings-glucose-unit-select"
|
||||
value={value}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
onChange={(e) => handleSelect(e.target.value as GlucoseUnit)}
|
||||
>
|
||||
{/* Unit symbols, not translated copy — they read the same
|
||||
everywhere, and a locale that "translated" one would be
|
||||
naming a different measurement. */}
|
||||
<option value="mg/dL">mg/dL</option>
|
||||
<option value="mmol/L">mmol/L</option>
|
||||
</NativeSelect>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg ?? t("settings.dashboard.glucoseUnit.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user