mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge remote-tracking branch 'origin/main' into fix/key-check-hardening
This commit is contained in:
commit
363070540b
27 files changed
+786
-310
No files matched your search
@@ -561,32 +561,6 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
});
|
||||
|
||||
it("names the owner's glucose unit on the glucose card", async () => {
|
||||
// Applied after the summary cache: the unit is the record's own choice
|
||||
// and the card's numbers are in it (the conversion is pinned in
|
||||
// `src/lib/dashboard/__tests__/glucose-card.test.ts`).
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, glucoseUnit: "mmol/L" },
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
_count: { _all: 4 },
|
||||
_max: { measuredAt: new Date(Date.now() - 10 * 86_400_000) },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ id: string; unit: string | null }> };
|
||||
};
|
||||
expect(body.data.metrics.find((m) => m.id === "glucose")?.unit).toBe(
|
||||
"mmol/L",
|
||||
);
|
||||
// Every other card keeps its own unit contract.
|
||||
expect(body.data.metrics.find((m) => m.id === "weight")?.unit).toBeNull();
|
||||
});
|
||||
|
||||
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
|
||||
// tile must SUM the asleep stages of the latest night and convert to
|
||||
|
||||
@@ -75,8 +75,6 @@ import {
|
||||
} from "@/lib/dashboard/summary-reads";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
|
||||
import { glucoseCardInDisplayUnit } from "@/lib/dashboard/glucose-card";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
@@ -146,9 +144,6 @@ interface MetricCard {
|
||||
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
|
||||
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
|
||||
* below for why the night total replaced the single-stage value.
|
||||
* v1.40.0 — the `glucose` tile sets it to the owner's glucose unit
|
||||
* (`"mg/dL"` or `"mmol/L"`), with its values converted to match
|
||||
* (`glucoseCardInDisplayUnit`, applied after the cache read).
|
||||
*/
|
||||
unit: string | null;
|
||||
/**
|
||||
@@ -330,10 +325,7 @@ export const GET = apiHandler(async () => {
|
||||
// it off. Filtering the cached body makes the toggle effective on the very
|
||||
// next request.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const glucoseUnit = resolveGlucoseUnit(user.glucoseUnit);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules).map((card) =>
|
||||
glucoseCardInDisplayUnit(card, glucoseUnit),
|
||||
);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules);
|
||||
annotate({
|
||||
meta: {
|
||||
metrics_emitted: metrics.length,
|
||||
|
||||
@@ -194,7 +194,8 @@ interface EntryResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.31.1 — emit one data arrival per workout this batch actually inserted.
|
||||
* v1.31.1 — emit one data arrival per workout this batch actually inserted,
|
||||
* and per hand-entered workout whose edit it actually wrote.
|
||||
*
|
||||
* `createManyAndReturn` gives the transaction the exact inserted ids. The
|
||||
* index-keyed map avoids a second lookup and keeps equal external ids from
|
||||
@@ -541,6 +542,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
let enrichedCount = 0;
|
||||
let updatedCount = 0;
|
||||
const insertedIdByIndex = new Map<number, string>();
|
||||
/** Workout ids of the manual edits this request actually wrote. */
|
||||
const overwrittenIdByIndex = new Map<number, string>();
|
||||
|
||||
if (survivors.length > 0) {
|
||||
// v1.4.42 W5 — only the write-time dedup survivors are probed
|
||||
@@ -709,6 +712,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: o.externalId,
|
||||
},
|
||||
});
|
||||
// The paragraph described the old values (a 12 km run corrected
|
||||
// to 1.2 km must not keep its 12 km story). It goes with them,
|
||||
// and the claim is let go of its workout the way a delete does
|
||||
// (`ON DELETE SET NULL`): the row stays as the day's ledger, so
|
||||
// the edit buys no slot back, and the arrival emitted below can
|
||||
// claim a fresh one through the ordinary path, under the same cap.
|
||||
await tx.workoutInsight.deleteMany({
|
||||
where: { userId: user.id, workoutId: o.workoutId },
|
||||
});
|
||||
await tx.workoutInsightGenerationClaim.updateMany({
|
||||
where: { userId: user.id, workoutId: o.workoutId },
|
||||
data: { workoutId: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,7 +857,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
index: o.index,
|
||||
status: landed ? "updated" : "duplicate",
|
||||
};
|
||||
if (landed) updatedCount += 1;
|
||||
if (landed) {
|
||||
updatedCount += 1;
|
||||
overwrittenIdByIndex.set(o.index, o.workoutId);
|
||||
}
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
@@ -914,13 +933,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
if (insertedCount > 0) {
|
||||
if (insertedCount > 0 || overwrittenIdByIndex.size > 0) {
|
||||
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
|
||||
// stop at the shared salience classifier before any queue work.
|
||||
// An enrichment raises none: the workout arrived when it was stored.
|
||||
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
|
||||
() => {},
|
||||
);
|
||||
// A hand-entered workout whose edit landed raises one again, so its
|
||||
// paragraph (dropped with the old values) is written from the new ones.
|
||||
void emitWorkoutArrivals(
|
||||
user.id,
|
||||
prepared,
|
||||
new Map([...insertedIdByIndex, ...overwrittenIdByIndex]),
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The cycle sheet's "add a custom symptom" popover offers the same icon
|
||||
* picker as the symptoms feature, and must look and behave the same: 44 px
|
||||
* targets on a phone, 36 px from `sm`, a translated name per icon.
|
||||
*
|
||||
* The popover portals and portals do not render server-side, so it is
|
||||
* replaced by pass-through wrappers; what is measured is the picker inside.
|
||||
*
|
||||
* Mutation check (run, seen red): put the cycle sheet's own inline picker
|
||||
* back (`size-7`, `aria-label={name}`) → this test goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../use-cycle", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../use-cycle")>()),
|
||||
useCreateCustomSymptom: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AddSymptomChip } from "../log-day-sheet";
|
||||
|
||||
describe("cycle custom-symptom icon picker", () => {
|
||||
it("has 44 px targets on a phone and translated icon names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<AddSymptomChip onCreated={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const buttons = html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
|
||||
expect(buttons).toHaveLength(CUSTOM_SYMPTOM_ICON_ALLOWLIST.length);
|
||||
for (const button of buttons) {
|
||||
expect(button).toContain("size-11");
|
||||
expect(button).toContain("sm:size-9");
|
||||
expect(button).not.toMatch(/\bsize-7\b/);
|
||||
}
|
||||
expect(html).toContain('aria-label="Herzschlag"');
|
||||
expect(html).not.toContain('aria-label="HeartPulse"');
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { SymptomIconPicker } from "@/components/symptoms/symptom-icon-picker";
|
||||
import { FieldInfo } from "./field-info";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
|
||||
@@ -1519,7 +1519,11 @@ export function SymptomRow({
|
||||
* label + icon popover to mint a new custom symptom. Same height and shape as
|
||||
* a symptom row so it reads as part of the list, not a foreign button.
|
||||
*/
|
||||
function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
|
||||
export function AddSymptomChip({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateCustomSymptom();
|
||||
const [openForm, setOpenForm] = useState(false);
|
||||
@@ -1594,34 +1598,11 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
|
||||
<p className="text-xs font-medium">
|
||||
{t("cycle.symptom.custom.icon")}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("cycle.symptom.custom.icon")}
|
||||
>
|
||||
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = customIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SymptomIconPicker
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
label={t("cycle.symptom.custom.icon")}
|
||||
/>
|
||||
</div>
|
||||
{limitReached ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
unitSkippedAnalytes,
|
||||
} from "../ocr-review-dialog";
|
||||
|
||||
const hookState = vi.hoisted(() => ({ extractPending: false }));
|
||||
const hookState = vi.hoisted(() => ({
|
||||
extractPending: false,
|
||||
runPhase: "idle" as "idle" | "running" | "waitingForWorker",
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
@@ -37,6 +40,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
vi.mock("../use-ocr-extract", () => ({
|
||||
useOcrExtract: () => ({
|
||||
isPending: hookState.extractPending,
|
||||
runPhase: hookState.runPhase,
|
||||
mutate: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
@@ -174,6 +178,35 @@ describe("<OcrReviewDialog> document-reading consent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A lab scan's proposed rows live only in its run: nothing is stored until
|
||||
* the person confirms them here. So the waiting line must not promise that
|
||||
* the page can be left (the result would be lost, and a second scan costs
|
||||
* another rate slot). Mutation check: swap `aiRuns.backgroundScan` for
|
||||
* `aiRuns.backgroundDocument` in the dialog → both cases go red.
|
||||
*/
|
||||
describe("<OcrReviewDialog> background read", () => {
|
||||
afterEach(() => {
|
||||
hookState.extractPending = false;
|
||||
hookState.runPhase = "idle";
|
||||
});
|
||||
|
||||
it.each([
|
||||
["en", "With a slow model", "leave this page"],
|
||||
["de", "Mit einem langsamen Modell", "Seite verlassen"],
|
||||
] as const)(
|
||||
"(%s) says it reads in the background, never that the page can be left",
|
||||
(locale, expected, forbidden) => {
|
||||
hookState.extractPending = true;
|
||||
hookState.runPhase = "running";
|
||||
const html = render(locale);
|
||||
expect(html).toContain('data-slot="ocr-run-phase"');
|
||||
expect(html).toContain(expected);
|
||||
expect(html).not.toContain(forbidden);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("extractErrorMessage", () => {
|
||||
const t = (key: string) => key;
|
||||
const refusal = (errorCode: string, status = 403) =>
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
* - drop the `resolvedAt === null` filter on the episodes → "offers no
|
||||
* episode selector when every episode is resolved" goes red;
|
||||
* - render the add chip unconditionally → "a delegate who may log but not
|
||||
* manage gets no add chip" goes red.
|
||||
* manage gets no add chip" goes red;
|
||||
* - drop the `definitions.isError` branch → "a failed read shows the error
|
||||
* row, not the empty hint" goes red (the empty hint renders instead).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
canManage: true,
|
||||
definitionsFailed: false,
|
||||
episodes: [] as Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -34,30 +37,42 @@ vi.mock("@/components/illness/use-illness", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../use-symptoms", () => ({
|
||||
useSymptomDefinitions: () => ({
|
||||
isLoading: false,
|
||||
data: {
|
||||
limit: 8,
|
||||
definitions: [
|
||||
{
|
||||
id: "d1",
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
|
||||
useSymptomDefinitions: () =>
|
||||
state.definitionsFailed
|
||||
? { isLoading: false, isError: true, data: undefined, refetch: vi.fn() }
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
limit: 8,
|
||||
definitions: [
|
||||
{
|
||||
id: "d1",
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
recent: {
|
||||
count30d: 0,
|
||||
maxIntensity30d: null,
|
||||
lastOccurredAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
label: "Headache",
|
||||
icon: "Brain",
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
recent: {
|
||||
count30d: 0,
|
||||
maxIntensity30d: null,
|
||||
lastOccurredAt: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
label: "Headache",
|
||||
icon: "Brain",
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
useLogSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useCreateSymptomDefinition: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
@@ -111,4 +126,19 @@ describe("<SymptomEntryForm>", () => {
|
||||
];
|
||||
expect(render()).not.toContain("-episode");
|
||||
});
|
||||
|
||||
it("a failed read shows the error row, not the empty hint", () => {
|
||||
state.canManage = true;
|
||||
state.episodes = [];
|
||||
state.definitionsFailed = true;
|
||||
try {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="query-error-row"');
|
||||
expect(html).toContain("load your symptoms");
|
||||
expect(html).not.toContain("first symptom");
|
||||
expect(html).not.toContain('data-testid="symptom-add-chip"');
|
||||
} finally {
|
||||
state.definitionsFailed = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Both symptom icon pickers (the add chip's popover and the edit sheet) share
|
||||
* one shape: 44 px targets on a phone, 36 px from `sm`, and a translated
|
||||
* accessible name per icon rather than the raw Lucide component name.
|
||||
*
|
||||
* The popover and the sheet portal, and portals do not render server-side,
|
||||
* so both are replaced by pass-through wrappers that render their children
|
||||
* inline; what is measured is the picker inside them.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - put `size-7` back on the add chip's icon buttons → "the add chip's
|
||||
* icon targets" goes red;
|
||||
* - put `aria-label={name}` back on either picker → the label assertions
|
||||
* go red ("HeartPulse" is a raw name, "Heartbeat" its English label).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../use-symptoms", () => {
|
||||
const mutation = {
|
||||
mutateAsync: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
};
|
||||
return {
|
||||
useCreateSymptomDefinition: () => mutation,
|
||||
useUpdateSymptomDefinition: () => mutation,
|
||||
useUpdateSymptomEvent: () => mutation,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
|
||||
import { AddSymptomChip } from "../add-symptom-chip";
|
||||
import { EditSymptomDefinitionSheet } from "../symptom-edit-sheets";
|
||||
|
||||
function iconButtons(html: string): string[] {
|
||||
return html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
|
||||
}
|
||||
|
||||
function assertPicker(html: string) {
|
||||
const buttons = iconButtons(html);
|
||||
expect(buttons).toHaveLength(SYMPTOM_ICON_ALLOWLIST.length);
|
||||
for (const button of buttons) {
|
||||
expect(button).toContain("size-11");
|
||||
expect(button).toContain("sm:size-9");
|
||||
expect(button).not.toMatch(/\bsize-7\b/);
|
||||
}
|
||||
expect(html).toContain('aria-label="Heartbeat"');
|
||||
expect(html).toContain('aria-label="Low battery"');
|
||||
expect(html).not.toContain('aria-label="HeartPulse"');
|
||||
expect(html).not.toContain('aria-label="BatteryLow"');
|
||||
}
|
||||
|
||||
describe("symptom icon pickers", () => {
|
||||
it("the add chip's icon targets are 44 px on a phone, with translated names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AddSymptomChip onCreated={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
assertPicker(html);
|
||||
});
|
||||
|
||||
it("the edit sheet's picker matches, with translated names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EditSymptomDefinitionSheet
|
||||
definition={{
|
||||
id: "d1",
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
recent: {
|
||||
count30d: 0,
|
||||
maxIntensity30d: null,
|
||||
lastOccurredAt: null,
|
||||
},
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
assertPicker(html);
|
||||
});
|
||||
|
||||
it("every allowlisted icon has a label in the English bundle", async () => {
|
||||
const en = (await import("../../../../messages/en.json")).default as {
|
||||
symptoms: { icons: Record<string, string> };
|
||||
};
|
||||
for (const name of SYMPTOM_ICON_ALLOWLIST) {
|
||||
expect(en.symptoms.icons[name], name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { symptomIcon } from "./symptom-icons";
|
||||
import { SymptomIconPicker } from "./symptom-icon-picker";
|
||||
import { useCreateSymptomDefinition } from "./use-symptoms";
|
||||
|
||||
export function AddSymptomChip({
|
||||
@@ -101,34 +99,11 @@ export function AddSymptomChip({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">{t("symptoms.iconLabel")}</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("symptoms.iconLabel")}
|
||||
>
|
||||
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = symptomIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SymptomIconPicker
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
label={t("symptoms.iconLabel")}
|
||||
/>
|
||||
</div>
|
||||
{create.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
|
||||
@@ -18,15 +18,13 @@ import { SliderField } from "@/components/ui/slider";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SYMPTOM_ICON_ALLOWLIST,
|
||||
SYMPTOM_INTENSITY_MAX,
|
||||
SYMPTOM_INTENSITY_MIN,
|
||||
type SymptomDefinitionDTO,
|
||||
type SymptomEventDTO,
|
||||
} from "@/lib/symptoms/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { symptomIcon } from "./symptom-icons";
|
||||
import { SymptomIconPicker } from "./symptom-icon-picker";
|
||||
import {
|
||||
useUpdateSymptomDefinition,
|
||||
useUpdateSymptomEvent,
|
||||
@@ -227,34 +225,11 @@ export function EditSymptomDefinitionSheet({
|
||||
</FieldGroup>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("symptoms.iconLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("symptoms.iconLabel")}
|
||||
>
|
||||
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = symptomIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SymptomIconPicker
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
label={t("symptoms.iconLabel")}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -210,59 +211,70 @@ export function SymptomEntryForm({
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label id={symptomGroupId}>{t("symptoms.entry.symptom")}</Label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby={symptomGroupId}
|
||||
aria-describedby={
|
||||
symptomError ? `${symptomGroupId}-error` : undefined
|
||||
}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{list.map((definition) => {
|
||||
const selected = definitionId === definition.id;
|
||||
return (
|
||||
<button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-testid="symptom-chip"
|
||||
onClick={() => {
|
||||
setDefinitionId(selected ? null : definition.id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<SymptomIcon name={definition.icon} className="size-4" />
|
||||
{definition.label ?? t("symptoms.unreadableLabel")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canDefine && !atLimit ? (
|
||||
<AddSymptomChip
|
||||
onCreated={(id) => {
|
||||
setDefinitionId(id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{list.length === 0 && !definitions.isLoading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{canDefine
|
||||
? t("symptoms.entry.emptyHint")
|
||||
: t("symptoms.entry.emptyHintNoManage")}
|
||||
</p>
|
||||
) : atLimit && canDefine ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("symptoms.entry.limitHint", { limit })}
|
||||
</p>
|
||||
) : null}
|
||||
{definitions.isError ? (
|
||||
// A failed read is not an empty list: the empty hint would invite
|
||||
// the person to define a symptom they may already have.
|
||||
<QueryErrorRow
|
||||
message={t("symptoms.section.loadError")}
|
||||
onRetry={() => void definitions.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby={symptomGroupId}
|
||||
aria-describedby={
|
||||
symptomError ? `${symptomGroupId}-error` : undefined
|
||||
}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{list.map((definition) => {
|
||||
const selected = definitionId === definition.id;
|
||||
return (
|
||||
<button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-testid="symptom-chip"
|
||||
onClick={() => {
|
||||
setDefinitionId(selected ? null : definition.id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<SymptomIcon name={definition.icon} className="size-4" />
|
||||
{definition.label ?? t("symptoms.unreadableLabel")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canDefine && !atLimit ? (
|
||||
<AddSymptomChip
|
||||
onCreated={(id) => {
|
||||
setDefinitionId(id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{list.length === 0 && !definitions.isLoading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{canDefine
|
||||
? t("symptoms.entry.emptyHint")
|
||||
: t("symptoms.entry.emptyHintNoManage")}
|
||||
</p>
|
||||
) : atLimit && canDefine ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("symptoms.entry.limitHint", { limit })}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{symptomError ? (
|
||||
<p
|
||||
id={`${symptomGroupId}-error`}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The icon picker a person-named symptom is given an icon with. One source for
|
||||
* the three places that offer it (the symptoms add chip, the symptom edit
|
||||
* sheet, and the cycle sheet's custom-symptom popover), which all draw from
|
||||
* the same allowlist: 44 px targets on a phone, 36 px from `sm`, and each icon
|
||||
* named in the reader's language rather than by its Lucide component name.
|
||||
*/
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { symptomIcon } from "./symptom-icons";
|
||||
|
||||
export function SymptomIconPicker({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (name: string) => void;
|
||||
/** The radiogroup's accessible name (the visible field label). */
|
||||
label: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
>
|
||||
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = symptomIcon(name);
|
||||
const selected = value === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={t(`symptoms.icons.${name}`)}
|
||||
onClick={() => onChange(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { glucoseCardInDisplayUnit } from "../glucose-card";
|
||||
|
||||
const card = (over: Record<string, unknown> = {}) => ({
|
||||
kind: "glucose",
|
||||
latestValue: 101 as number | null,
|
||||
secondaryValue: null as number | null,
|
||||
sparkline: [90, 101, 126],
|
||||
unit: null as string | null,
|
||||
unitKey: "dashboard.metric.unit.glucose",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("glucoseCardInDisplayUnit", () => {
|
||||
it("converts the card to mmol/L and names the unit", () => {
|
||||
expect(glucoseCardInDisplayUnit(card(), "mmol/L")).toMatchObject({
|
||||
latestValue: 5.6,
|
||||
secondaryValue: null,
|
||||
sparkline: [5, 5.6, 7],
|
||||
unit: "mmol/L",
|
||||
unitKey: "dashboard.metric.unit.glucose",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mg/dL values and names that unit too", () => {
|
||||
expect(glucoseCardInDisplayUnit(card(), "mg/dL")).toMatchObject({
|
||||
latestValue: 101,
|
||||
sparkline: [90, 101, 126],
|
||||
unit: "mg/dL",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty card empty", () => {
|
||||
expect(
|
||||
glucoseCardInDisplayUnit(
|
||||
card({ latestValue: null, sparkline: [] }),
|
||||
"mmol/L",
|
||||
),
|
||||
).toMatchObject({ latestValue: null, sparkline: [], unit: "mmol/L" });
|
||||
});
|
||||
|
||||
it("passes every other card through untouched", () => {
|
||||
const weight = card({ kind: "weight", latestValue: 80 });
|
||||
expect(glucoseCardInDisplayUnit(weight, "mmol/L")).toBe(weight);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/** The fields of a dashboard summary card the glucose unit touches. */
|
||||
interface GlucoseConvertibleCard {
|
||||
kind: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
sparkline: number[];
|
||||
unit: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the glucose card in the record owner's display unit.
|
||||
*
|
||||
* Glucose is stored in mg/dL. The summary card used to send that number with
|
||||
* `unit: null` and a `unitKey` that reads "mg/dL", so a reader who chose
|
||||
* mmol/L was shown 101 "mg/dL" on a client that honours the card. The card now
|
||||
* carries its value, its secondary value and its sparkline in the chosen unit
|
||||
* and names that unit in `unit`, the field that wins over `unitKey` (as the
|
||||
* sleep card's `"h"` already does). Every other card passes through untouched.
|
||||
*
|
||||
* Applied after the summary cache is read, so changing the unit takes effect
|
||||
* on the next request rather than after the cache window.
|
||||
*/
|
||||
export function glucoseCardInDisplayUnit<T extends GlucoseConvertibleCard>(
|
||||
card: T,
|
||||
unit: GlucoseUnit,
|
||||
): T {
|
||||
if (card.kind !== "glucose") return card;
|
||||
const show = (value: number | null) =>
|
||||
value === null ? null : convertGlucose(value, unit);
|
||||
return {
|
||||
...card,
|
||||
latestValue: show(card.latestValue),
|
||||
secondaryValue: show(card.secondaryValue),
|
||||
sparkline: card.sparkline.map((value) => convertGlucose(value, unit)),
|
||||
unit,
|
||||
};
|
||||
}
|
||||
@@ -71,7 +71,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
|
||||
it("commits when the stated unit is the marker's unit spelled another way", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 95,
|
||||
@@ -103,4 +103,45 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z");
|
||||
expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01");
|
||||
});
|
||||
|
||||
// The comparison is the shared spelling normaliser every other lab write
|
||||
// path uses, not a case fold. Mutation check: put the lower-cased equality
|
||||
// back → the first case commits (milli read as mega) and the second refuses
|
||||
// a µg/L reading the other paths accept.
|
||||
function observation(unit: string, markerUnit: string) {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "TSH",
|
||||
value: 2.1,
|
||||
unit,
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-2",
|
||||
name: "TSH",
|
||||
unit: markerUnit,
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-2",
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("refuses MIU/L against a marker in mIU/L: the capital is mega, not milli", async () => {
|
||||
observation("MIU/L", "mIU/L");
|
||||
await expect(
|
||||
commitApprovedFact("user-1", observationFact()),
|
||||
).rejects.toMatchObject({ code: "observation.unitMismatch" });
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts ug/L against a marker in µg/L, as the other lab write paths do", async () => {
|
||||
observation("ug/L", "µg/L");
|
||||
await expect(
|
||||
commitApprovedFact("user-1", observationFact()),
|
||||
).resolves.toEqual({ recordType: "labResult", recordId: "lr-2" });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { decryptFactData } from "@/lib/documents/store";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import {
|
||||
isUnreadableRange,
|
||||
parseReferenceRange,
|
||||
@@ -134,10 +135,10 @@ async function commitObservation(
|
||||
// the stated number under the catalog's unit would corrupt the reading AND
|
||||
// break the "transcribe verbatim" contract — so reject the fact and let the
|
||||
// user reconcile on the review screen, exactly like the `unitRequired` gate.
|
||||
// Spelling is compared through the shared normaliser, the same one the
|
||||
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
|
||||
if (!isQualitative) {
|
||||
const stated = (data.unit as string).trim().toLowerCase();
|
||||
const target = biomarker.unit.trim().toLowerCase();
|
||||
if (stated !== target) {
|
||||
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
|
||||
throw new FactCommitError(
|
||||
"observation.unitMismatch",
|
||||
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
|
||||
|
||||
@@ -186,7 +186,7 @@ const dashboardMetricCard = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind.",
|
||||
"Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.",
|
||||
),
|
||||
sleepStages: z
|
||||
.record(z.string(), z.number())
|
||||
|
||||
Reference in new issue
Block a user