mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
commit
a486c3c8a3
148 files changed
+4668
-347
No files matched your search
@@ -11,7 +11,7 @@
|
||||
"components/insights/coach-panel/use-coach.ts": 1486,
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1086,
|
||||
"components/insights/insights-tab-strip.tsx": 1087,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1157,
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
@@ -39,7 +39,7 @@
|
||||
"lib/daily/digest.ts": 1317,
|
||||
"lib/daily/load-digest.ts": 1000,
|
||||
"lib/daily/today-overview.ts": 838,
|
||||
"lib/dashboard-layout.ts": 976,
|
||||
"lib/dashboard-layout.ts": 977,
|
||||
"lib/dashboard/snapshot.ts": 1550,
|
||||
"lib/day/records.ts": 1174,
|
||||
"lib/export/backup-plan.ts": 838,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 523,
|
||||
"total": 5476,
|
||||
"total": 5475,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -440,7 +440,7 @@
|
||||
"lib/jobs/__tests__/insight-status-generate.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2,
|
||||
"lib/jobs/__tests__/measurement-reminder.test.ts": 33,
|
||||
"lib/jobs/__tests__/measurement-reminder.test.ts": 32,
|
||||
"lib/jobs/__tests__/medication-intake-import.test.ts": 1,
|
||||
"lib/jobs/__tests__/medication-reminder-check.test.ts": 27,
|
||||
"lib/jobs/__tests__/mood-reminder.test.ts": 15,
|
||||
|
||||
@@ -118,13 +118,20 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("the calendar feed body", () => {
|
||||
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
|
||||
"carries no day-log content in %s mode",
|
||||
async (titleMode) => {
|
||||
it.each(
|
||||
(["DISCREET", "DESCRIPTIVE"] as const).flatMap((titleMode) =>
|
||||
(["EXACT", "MARGIN", "BAND"] as const).map(
|
||||
(forecastStyle) => [titleMode, forecastStyle] as const,
|
||||
),
|
||||
),
|
||||
)(
|
||||
"carries no day-log content in %s mode, %s forecasts",
|
||||
async (titleMode, forecastStyle) => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode,
|
||||
forecastStyle,
|
||||
historyCycles: 12,
|
||||
horizonCycles: 6,
|
||||
includeFertile: true,
|
||||
@@ -146,6 +153,7 @@ describe("the calendar feed body", () => {
|
||||
{
|
||||
...FEED,
|
||||
titleMode: "DESCRIPTIVE",
|
||||
forecastStyle: "MARGIN",
|
||||
historyCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
|
||||
@@ -149,6 +149,8 @@ const ALLOWED_SQL: Record<string, string> = {
|
||||
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
|
||||
"lib/dashboard/summary-reads.ts::1":
|
||||
"Selects from `unnest` of the type list; `measurements` is read only inside COUNT scalar subqueries, before the fold window for a type the DAY rollups cover.",
|
||||
"lib/measurements/read-glucose-ketone-index.ts::1":
|
||||
"Ketone readings in the window: typed off a fingerstick meter, a few a day at most. Each one is paired with a glucose reading, so the rows themselves are needed.",
|
||||
"lib/dashboard/summary-reads.ts::2":
|
||||
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
|
||||
};
|
||||
|
||||
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(111);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
|
||||
expect(ALL_LEAF_IDS.length).toBe(112);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(92);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
|
||||
});
|
||||
|
||||
|
||||
@@ -930,7 +930,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/labs/overview/route.ts": {
|
||||
domain: "labs",
|
||||
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
|
||||
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only. The category order it applies comes from the caller's own list layout, never the owner's, so it reveals nothing about the record.",
|
||||
},
|
||||
"app/api/biomarkers/route.ts": {
|
||||
domain: "labs",
|
||||
|
||||
@@ -294,7 +294,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
reason: "the onboarding unit picker: its options are the two units",
|
||||
},
|
||||
"components/measurements/measurement-form.tsx": {
|
||||
lines: 11,
|
||||
lines: 12,
|
||||
reason:
|
||||
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
|
||||
},
|
||||
@@ -362,9 +362,9 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
reason: "the legacy export endpoint's canonical storage-unit contract",
|
||||
},
|
||||
"lib/measurements/series/kinds.ts": {
|
||||
lines: 4,
|
||||
lines: 5,
|
||||
reason:
|
||||
"canonical wire units; glucose is overridden to the reader's unit per request",
|
||||
"canonical wire units; glucose is overridden to the reader's unit per request, ketones have no unit choice",
|
||||
},
|
||||
"lib/doctor-report/stat-display.ts": {
|
||||
lines: 3,
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
*
|
||||
* One transaction: the live feed is revoked and its successor minted with the
|
||||
* same options, so there is never a moment with two live links or with none.
|
||||
* The forecast style is copied too: a feed minted before that option keeps
|
||||
* BAND rather than taking a new feed's default.
|
||||
* Calendars holding the old link stop updating on their next poll. The new
|
||||
* URL is returned exactly once. Two rotations racing both revoke the same
|
||||
* feed, and the second successor then meets the one-live-feed index: that
|
||||
* one gets the mint path's 409 `cycle.feed.exists`, and the winner's link
|
||||
* stands.
|
||||
* URL is returned exactly once. Of two rotations racing, the second finds
|
||||
* the feed already revoked (or its successor meets the one-live-feed index)
|
||||
* and gets the mint path's 409 `cycle.feed.exists`; the winner's link
|
||||
* stands. A subscription turned off during a rotation stays off, whichever
|
||||
* commits first, and the rotation then answers 404 `cycle.feed.none`; an
|
||||
* option changed during one lands on the link that stays live.
|
||||
*
|
||||
* Same gates as turning the subscription on: cookie session only, the owner or
|
||||
* the guardian of a managed profile (`requireGuardianAuth`), a fresh proof,
|
||||
@@ -33,7 +37,11 @@ import {
|
||||
feedLinks,
|
||||
noLiveFeed,
|
||||
} from "@/lib/cycle/feed/manage";
|
||||
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
|
||||
import {
|
||||
FEED_SUMMARY_SELECT,
|
||||
findLiveFeed,
|
||||
toFeedSummary,
|
||||
} from "@/lib/cycle/feed/status";
|
||||
import { mintFeedToken } from "@/lib/cycle/feed/token";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrigin } from "@/lib/http/origin";
|
||||
@@ -70,21 +78,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const minted = mintFeedToken();
|
||||
const rotated = await prisma
|
||||
.$transaction(async (tx) => {
|
||||
const live = await tx.cycleCalendarFeed.findFirst({
|
||||
const found = await tx.cycleCalendarFeed.findFirst({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) return null;
|
||||
// Only while it is still live: a subscription turned off after the
|
||||
// read above stays off instead of coming back under a new link.
|
||||
const revoked = await tx.cycleCalendarFeed.updateMany({
|
||||
where: { id: found.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (revoked.count === 0) return "raced" as const;
|
||||
// The options are read after the revoke holds the row, so a change
|
||||
// that committed in between is the one the new link carries, and one
|
||||
// that comes later waits and then finds the new link.
|
||||
const live = await tx.cycleCalendarFeed.findUniqueOrThrow({
|
||||
where: { id: found.id },
|
||||
select: {
|
||||
id: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
await tx.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const created = await tx.cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -95,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
forecastStyle: live.forecastStyle,
|
||||
},
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
@@ -113,7 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (isP2002(err)) return "raced" as const;
|
||||
throw err;
|
||||
});
|
||||
if (rotated === "raced") return feedExists();
|
||||
// Lost to another rotation, the link that won stands; lost to the
|
||||
// subscription being turned off, there is none.
|
||||
if (rotated === "raced") {
|
||||
return (await findLiveFeed(user.id)) ? feedExists() : noLiveFeed();
|
||||
}
|
||||
if (!rotated) return noLiveFeed();
|
||||
|
||||
return apiSuccess(
|
||||
|
||||
@@ -59,7 +59,10 @@ import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
|
||||
import {
|
||||
NEW_FEED_FORECAST_STYLE,
|
||||
cycleFeedOptionsPatchSchema,
|
||||
} from "@/lib/validations/cycle-feed";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireGuardianAuth();
|
||||
@@ -110,6 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
userId: user.id,
|
||||
createdById: actor.id,
|
||||
forecastStyle: NEW_FEED_FORECAST_STYLE,
|
||||
tokenHash: minted.hash,
|
||||
tokenPrefix: minted.prefix,
|
||||
},
|
||||
@@ -166,7 +170,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const live = await findLiveFeed(user.id);
|
||||
let live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
|
||||
const data = {
|
||||
@@ -180,10 +184,29 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
? { includeFertile: input.includeFertile }
|
||||
: {}),
|
||||
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
|
||||
...(input.forecastStyle !== undefined
|
||||
? { forecastStyle: input.forecastStyle }
|
||||
: {}),
|
||||
};
|
||||
const updated = await prisma.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
// Only while the feed is still the live one. A rotation that committed
|
||||
// after the read above revoked it and copied its old options onto the new
|
||||
// link; the change then goes to that link, once. Updating the revoked row
|
||||
// would answer 200 with the old options left in force.
|
||||
let written = await prisma.cycleCalendarFeed.updateMany({
|
||||
where: { id: live.id, revokedAt: null },
|
||||
data,
|
||||
});
|
||||
if (written.count === 0) {
|
||||
live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
written = await prisma.cycleCalendarFeed.updateMany({
|
||||
where: { id: live.id, revokedAt: null },
|
||||
data,
|
||||
});
|
||||
if (written.count === 0) return feedExists();
|
||||
}
|
||||
const updated = await prisma.cycleCalendarFeed.findUniqueOrThrow({
|
||||
where: { id: live.id },
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
|
||||
@@ -197,12 +220,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
forecastStyle: live.forecastStyle,
|
||||
},
|
||||
after: {
|
||||
horizonCycles: updated.horizonCycles,
|
||||
historyCycles: updated.historyCycles,
|
||||
includeFertile: updated.includeFertile,
|
||||
titleMode: updated.titleMode,
|
||||
forecastStyle: updated.forecastStyle,
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -220,10 +245,17 @@ export const DELETE = apiHandler(async () => {
|
||||
const live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
|
||||
await prisma.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// Every live feed of the record, not only the one read above, and twice:
|
||||
// a rotation that held that row while this waited commits its successor
|
||||
// after the first statement looked, and the second finds it. Off stays
|
||||
// off even when the link was being renewed at the same moment.
|
||||
const revokedAt = new Date();
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
await prisma.cycleCalendarFeed.updateMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
data: { revokedAt },
|
||||
});
|
||||
}
|
||||
// The link stops working on the provider's next poll. What the provider
|
||||
// already copied is beyond reach, which the settings card says before the
|
||||
// link is ever made.
|
||||
|
||||
@@ -2,10 +2,12 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { categoriseOverview } from "@/lib/labs/marker-category";
|
||||
import { loadLabOverview } from "@/lib/labs/overview";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveModuleListLayout } from "@/lib/settings/module-list-layout";
|
||||
import { labOverviewQuerySchema } from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,11 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
|
||||
* `categories` and `grouped` are the list's categories, resolved here once
|
||||
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
|
||||
* combined status. Labels follow the viewer's locale, so on a managed record
|
||||
* the caller's own language wins, not the record owner's.
|
||||
* the caller's own language wins, not the record owner's. The order is the
|
||||
* default one with the caller's `categoryOrder` (the labs list layout,
|
||||
* `PUT /api/settings/module-list-layout`) applied, for the same reason: it is
|
||||
* the viewer's presentation, so a person acting for another record sees the
|
||||
* categories in their own order. A client renders the order as given.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("read", "labs");
|
||||
@@ -34,15 +40,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const markers = await loadLabOverview(user.id);
|
||||
const [markers, layoutRow] = await Promise.all([
|
||||
loadLabOverview(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: actor.id },
|
||||
select: { moduleListLayoutJson: true },
|
||||
}),
|
||||
]);
|
||||
const { categoryOrder } = resolveModuleListLayout(
|
||||
layoutRow?.moduleListLayoutJson,
|
||||
).lists.labs;
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: actor.locale ?? null,
|
||||
});
|
||||
const { categories, grouped } = categoriseOverview(markers, locale);
|
||||
const { categories, grouped } = categoriseOverview(
|
||||
markers,
|
||||
locale,
|
||||
categoryOrder,
|
||||
);
|
||||
annotate({
|
||||
action: { name: "labs.overview" },
|
||||
meta: { markers: markers.length, categories: categories.length, grouped },
|
||||
meta: {
|
||||
markers: markers.length,
|
||||
categories: categories.length,
|
||||
grouped,
|
||||
categoryOrderLength: categoryOrder.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ markers, categories, grouped });
|
||||
});
|
||||
@@ -272,8 +272,13 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
|
||||
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
|
||||
});
|
||||
|
||||
it("re-enabling a disabled short-cycle reminder schedules it from now", async () => {
|
||||
const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false };
|
||||
it("re-enabling a disabled weekly calendar rule schedules it from now", async () => {
|
||||
const weekly = {
|
||||
...BASE_ROW,
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=WEEKLY",
|
||||
enabled: false,
|
||||
};
|
||||
findFirstMock.mockResolvedValue(weekly);
|
||||
|
||||
const res = await PATCH(makeRequest({ enabled: true }), params);
|
||||
@@ -303,6 +308,26 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
|
||||
expect("nextDueAt" in persisted).toBe(false);
|
||||
});
|
||||
|
||||
it("re-enabling a missed short rolling reminder keeps its open slot", async () => {
|
||||
// Every two days, never measured: switching it off and on again is not
|
||||
// measuring it either.
|
||||
findFirstMock.mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
intervalDays: 2,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const res = await PATCH(makeRequest({ enabled: true }), params);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(persisted.enabled).toBe(true);
|
||||
expect("nextDueAt" in persisted).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a resent first due date on the same calendar day as unchanged", async () => {
|
||||
// A booster row stores the dose instant plus N days; the web form sends
|
||||
// the same day back as local midnight. Same day in the profile zone, so
|
||||
|
||||
@@ -219,7 +219,7 @@ export const PATCH = apiHandler(
|
||||
const reEnabled = updateData.enabled === true && !existing.enabled;
|
||||
// Re-enabling a reminder that stays due after it is sent keeps the slot it
|
||||
// was on, overdue or not: switching it off and on again is not doing it.
|
||||
// A short-cycle reminder is scheduled from now, as before.
|
||||
// A calendar rule that recurs within the week is scheduled from now.
|
||||
const keepsOpenSlot =
|
||||
reEnabled &&
|
||||
!cadenceChanged &&
|
||||
|
||||
@@ -58,14 +58,15 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
// 500 ids of up to 64 characters plus the envelope.
|
||||
// 500 ids plus 200 category keys of up to 64 characters, plus the envelope.
|
||||
const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, {
|
||||
maxBytes: 48 * 1024,
|
||||
maxBytes: 64 * 1024,
|
||||
action: "settings.module-list-layout.validation-failed",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen, categoryOrder } =
|
||||
parsed.data;
|
||||
// Preserve-when-absent: one stored read fills whichever field the body
|
||||
// left out, and every other list stays as stored. Only fields a person has
|
||||
// chosen are written, so a field never picked keeps following the default.
|
||||
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
...(sortDir ? { sortDir } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(groupsOpen ? { groupsOpen } : {}),
|
||||
...(categoryOrder ? { categoryOrder: [...new Set(categoryOrder)] } : {}),
|
||||
};
|
||||
const next = { ...stored, [list]: nextChoices };
|
||||
await tx.user.update({
|
||||
@@ -98,6 +100,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
groupBy: layout.lists[list].groupBy,
|
||||
groupsOpen: layout.lists[list].groupsOpen,
|
||||
orderLength: layout.lists[list].order.length,
|
||||
categoryOrderLength: layout.lists[list].categoryOrder.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(layout);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
|
||||
*
|
||||
* The answer is an empty page whose cursor sits a minute before `serverNow`
|
||||
* The answer is an empty page whose cursor sits two minutes before `serverNow`
|
||||
* in every domain (a write stamped before it and committed after it is not
|
||||
* skipped), in the feed's own opaque format, and nothing is read: the point is
|
||||
* to skip the history walk, so a read here would be the bug. The database is
|
||||
@@ -55,7 +55,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes?start=now", () => {
|
||||
it("answers an empty page with a cursor a minute before serverNow in every domain", async () => {
|
||||
it("answers an empty page with a cursor two minutes before serverNow in every domain", async () => {
|
||||
const before = Date.now();
|
||||
const res = await GET(req("?start=now"));
|
||||
const after = Date.now();
|
||||
@@ -82,7 +82,7 @@ describe("GET /api/sync/changes?start=now", () => {
|
||||
expect(cursor).not.toBeNull();
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
expect(cursor![domain], domain).toEqual({
|
||||
updatedAtMs: nowMs - 60_000,
|
||||
updatedAtMs: nowMs - 120_000,
|
||||
id: "0",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
* - `hasMore` is true when ANY served domain still has rows past its
|
||||
* page; the next `cursor` advances every domain that returned rows.
|
||||
* When `hasMore` is false the client is caught up as of `serverNow`.
|
||||
* A row domain that ran dry takes a watermark two minutes before
|
||||
* `serverNow` (`drainedWatermark`; on the last page every one does):
|
||||
* a row stamped in that stretch whose write commits after the pull
|
||||
* still arrives on the next one, at the cost of reading the stretch
|
||||
* twice, and a domain that stays quiet keeps a fresh watermark instead
|
||||
* of ageing into `cursorExpired`.
|
||||
* - `cursorExpired: true` when ANY domain watermark predates the
|
||||
* tombstone-retention horizon — the client drops its cursor and does
|
||||
* a clean initial sync (a deletion older than retention may have
|
||||
@@ -43,7 +49,7 @@
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits a minute
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits two minutes
|
||||
* before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same
|
||||
* opaque format, so a client that
|
||||
* only wants what changes from now on (the Apple Health write-back) pages
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Droplet } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { GkiCard } from "@/components/insights/ketones/gki-card";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
/** mmol/L, from the one canonical unit table; ketones have no unit choice. */
|
||||
const KETONE_UNIT = getUnitForType("BLOOD_KETONES");
|
||||
|
||||
/**
|
||||
* `/insights/ketones` (#1367).
|
||||
*
|
||||
* Blood ketones (beta-hydroxybutyrate, mmol/L) on the generic metric
|
||||
* scaffold, with the glucose-ketone index beneath the chart. Readings are
|
||||
* typed off a meter, alone or together with the glucose from the same
|
||||
* fingerstick, so the empty state offers manual entry. No band and no
|
||||
* assessment card: what a ketone reading should be follows the plan a
|
||||
* clinician sets, and the page shows the readings as recorded.
|
||||
*/
|
||||
export default function InsightsKetonesPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_KETONES"
|
||||
insightMetric="BLOOD_KETONES"
|
||||
chartKey="bloodKetones"
|
||||
i18nPrefix="insights.bloodKetones"
|
||||
color="var(--chart-3)"
|
||||
unit={KETONE_UNIT}
|
||||
yAxisUnit={KETONE_UNIT}
|
||||
statFractionDigits={1}
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType="BLOOD_KETONES"
|
||||
captureType="BLOOD_KETONES"
|
||||
afterChart={<GkiCard />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -261,6 +261,21 @@ export function MeasurementGroups({
|
||||
max: show(type, stat!.max, digits),
|
||||
})}
|
||||
/>
|
||||
{type === "BLOOD_KETONES" &&
|
||||
report.glucoseKetoneIndex &&
|
||||
scope.admits("BLOOD_GLUCOSE") ? (
|
||||
// #1367 — the index the server derived from the ketone
|
||||
// and glucose readings, beside the ketones; no unit.
|
||||
<StatRow
|
||||
label={t("measurements.glucoseKetoneIndex")}
|
||||
value={statSummary(t, "", {
|
||||
latest: fmtDigits(report.glucoseKetoneIndex.latest, 1),
|
||||
avg: fmtDigits(report.glucoseKetoneIndex.avg, 1),
|
||||
min: fmtDigits(report.glucoseKetoneIndex.min, 1),
|
||||
max: fmtDigits(report.glucoseKetoneIndex.max, 1),
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{unvalidated > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,9 +29,9 @@ import {
|
||||
type DayEvent,
|
||||
type DayEventKind,
|
||||
type DayNotable,
|
||||
type DayResponse,
|
||||
type DayRunningItem,
|
||||
type DayRunningKind,
|
||||
type DayValue,
|
||||
} from "@/lib/day/contract";
|
||||
import { visitKindWords } from "@/components/timeline/item-words";
|
||||
import {
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
WORKOUT_SPORT_KEY,
|
||||
} from "./label-keys";
|
||||
import { cycleRunningLine } from "./day-cycle-line";
|
||||
import { DayGkiTile } from "./day-gki-tile";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
|
||||
/**
|
||||
@@ -508,12 +509,14 @@ function ValueTile({
|
||||
}
|
||||
|
||||
export function DayValues({
|
||||
values,
|
||||
day,
|
||||
focusTypes,
|
||||
}: {
|
||||
values: readonly DayValue[];
|
||||
/** The day's values and its server-derived glucose-ketone index (#1367). */
|
||||
day: Pick<DayResponse, "values" | "glucoseKetoneIndex">;
|
||||
focusTypes: readonly string[];
|
||||
}) {
|
||||
const { values } = day;
|
||||
const { t, tCount } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (values.length === 0) return null;
|
||||
@@ -537,6 +540,7 @@ export function DayValues({
|
||||
selected={focusKeys.has(tile.key)}
|
||||
/>
|
||||
))}
|
||||
<DayGkiTile points={day.glucoseKetoneIndex ?? []} />
|
||||
</ul>
|
||||
{rest.length > 0 ? (
|
||||
<button
|
||||
|
||||
@@ -296,7 +296,7 @@ export function DayView({
|
||||
<DayRunning items={data.running} date={date} />
|
||||
{data.values.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayValues values={data.values} focusTypes={focusTypes} />
|
||||
<DayValues day={data} focusTypes={focusTypes} />
|
||||
<DayNotableLines
|
||||
notables={otherNotables}
|
||||
labelFor={labelFor}
|
||||
|
||||
@@ -380,6 +380,7 @@ export const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navBloodGlucose",
|
||||
metric: "BLOOD_GLUCOSE",
|
||||
},
|
||||
ketones: { labelKey: "insights.navBloodKetones", metric: "BLOOD_KETONES" },
|
||||
"skin-temperature": {
|
||||
labelKey: "insights.navSkinTemperature",
|
||||
metric: "SKIN_TEMPERATURE",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Divide } from "lucide-react";
|
||||
|
||||
import {
|
||||
ChartErrorBoundary,
|
||||
ChartErrorState,
|
||||
} from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { GKI_COMPONENT_UNIT } from "@/lib/measurements/glucose-ketone-index";
|
||||
import { useGlucoseKetoneIndexSeries } from "@/lib/queries/use-glucose-ketone-index";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
// The line chart the custom metrics draw: a value over time with range tabs
|
||||
// and the day doors. Loaded through the one recharts boundary.
|
||||
const IndexChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.CustomMetricChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
/**
|
||||
* The glucose-ketone index (#1367), beneath the ketone chart. Every point is
|
||||
* one ketone reading and the glucose reading the server paired with it; the
|
||||
* value is the server's and is drawn as given. The latest pair is spelled out
|
||||
* so the number can be traced to the two readings it came from. No band and
|
||||
* no verdict: what the index should be is the treating clinician's call.
|
||||
*/
|
||||
export function GkiCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const query = useGlucoseKetoneIndexSeries();
|
||||
const points = query.data?.points ?? [];
|
||||
const latest = points[points.length - 1];
|
||||
const title = t("measurements.glucoseKetoneIndex");
|
||||
|
||||
return (
|
||||
<Card data-slot="gki-card" aria-busy={query.isFetching || undefined}>
|
||||
<CardHeader>
|
||||
<TileHeader icon={Divide} title={title} titleAs="h2" />
|
||||
<CardDescription>
|
||||
{t("insights.bloodKetones.gki.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{query.isPending ? (
|
||||
<ChartSkeleton />
|
||||
) : query.isError ? (
|
||||
<ChartErrorState
|
||||
error={query.error}
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={title}
|
||||
onAction={() => void query.refetch()}
|
||||
/>
|
||||
) : !latest ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
title={t("insights.bloodKetones.gki.emptyTitle")}
|
||||
description={t("insights.bloodKetones.gki.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm" data-slot="gki-latest">
|
||||
{t("insights.bloodKetones.gki.latest", {
|
||||
value: fmt.number(latest.value, 1),
|
||||
glucose: fmt.number(latest.gki.glucoseMmol, 1),
|
||||
ketones: fmt.number(latest.gki.ketonesMmol, 1),
|
||||
unit: GKI_COMPONENT_UNIT,
|
||||
date: fmt.dateTime(latest.at),
|
||||
})}
|
||||
</p>
|
||||
<ChartErrorBoundary>
|
||||
<IndexChartLazy
|
||||
name={title}
|
||||
unit=""
|
||||
targetLow={null}
|
||||
targetHigh={null}
|
||||
decimals={1}
|
||||
entries={points.map((p) => ({
|
||||
id: p.id,
|
||||
customMetricId: "",
|
||||
value: p.value,
|
||||
unit: "",
|
||||
measuredAt: p.at,
|
||||
note: null,
|
||||
createdAt: p.at,
|
||||
}))}
|
||||
/>
|
||||
</ChartErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -131,10 +131,12 @@ const EXPECTED_TYPES = [
|
||||
"ACUTE_TRAINING_LOAD",
|
||||
// ── resting energy (#1340) ──
|
||||
"BASAL_ENERGY_BURNED",
|
||||
// ── blood ketones (#1367) ──
|
||||
"BLOOD_KETONES",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 91 canonical measurement types", () => {
|
||||
it("exposes the 92 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("parseModuleListPrefs", () => {
|
||||
order: ["a", "b"],
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,6 +205,85 @@ describe("runSetModuleListPrefs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSetModuleListPrefs category order", () => {
|
||||
const t = (key: string) => key;
|
||||
const overview = (keys: string[]) => ({
|
||||
markers: [],
|
||||
grouped: true,
|
||||
categories: keys.map((key) => ({ key, label: key })),
|
||||
});
|
||||
|
||||
it("moves the cached overview's categories at once and refetches it", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const cell = queryKeys.labResultsOverview("en");
|
||||
queryClient.setQueryData(cell, overview(["lipids", "thyroid", "other"]));
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
let cachedDuringWrite: unknown;
|
||||
vi.mocked(apiPut).mockImplementation(async () => {
|
||||
cachedDuringWrite = queryClient.getQueryData(cell);
|
||||
return resolveModuleListLayout({
|
||||
labs: { categoryOrder: ["thyroid", "other", "lipids"] },
|
||||
});
|
||||
});
|
||||
|
||||
await runSetModuleListPrefs({
|
||||
list: "labs",
|
||||
patch: { categoryOrder: ["thyroid", "other", "lipids"] },
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
|
||||
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
|
||||
version: 1,
|
||||
list: "labs",
|
||||
categoryOrder: ["thyroid", "other", "lipids"],
|
||||
});
|
||||
expect(
|
||||
(cachedDuringWrite as ReturnType<typeof overview>).categories.map(
|
||||
(c) => c.key,
|
||||
),
|
||||
).toEqual(["thyroid", "other", "lipids"]);
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
});
|
||||
// An order move is its own feedback, like the marker order.
|
||||
expect(toastWrittenOutcome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refetches the overview after a failed write too, so the server order returns", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsOverview("en"),
|
||||
overview(["lipids", "thyroid"]),
|
||||
);
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
|
||||
await runSetModuleListPrefs({
|
||||
list: "labs",
|
||||
patch: { categoryOrder: ["thyroid", "lipids"] },
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
expect(showErrorToast).toHaveBeenCalled();
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the overview alone for any other field", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
vi.mocked(apiPut).mockResolvedValue(resolveModuleListLayout(null));
|
||||
await runSetModuleListPrefs({
|
||||
list: "labs",
|
||||
patch: { order: ["m1"] },
|
||||
queryClient,
|
||||
t,
|
||||
});
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOrder", () => {
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
const idOf = (i: { id: string }) => i.id;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* #1350 — how a forecast period is drawn in each style. Dates only: the
|
||||
* projection is handed in, so every expected day below is the arithmetic of
|
||||
* one fixed cycle and nothing else.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectedCycle } from "@/lib/cycle/projection";
|
||||
import { eventUid } from "@/lib/cycle/ics";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
import {
|
||||
DISCREET_MARGIN_SUMMARY,
|
||||
DISCREET_SUMMARY,
|
||||
buildFeedCalendar,
|
||||
type FeedForecastStyle,
|
||||
type FeedTitleMode,
|
||||
} from "../events";
|
||||
|
||||
/** Most likely start 17 Nov, five bleeding days, ±2 days. */
|
||||
const CYCLE: ProjectedCycle = {
|
||||
k: 1,
|
||||
start: "2026-11-17",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-11-15",
|
||||
bandEnd: "2026-11-19",
|
||||
};
|
||||
|
||||
function events(
|
||||
forecastStyle: FeedForecastStyle,
|
||||
projected: ProjectedCycle[] = [CYCLE],
|
||||
titleMode: FeedTitleMode = "DISCREET",
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return buildFeedCalendar({
|
||||
feedId: "feed-1",
|
||||
titleMode,
|
||||
forecastStyle,
|
||||
projected,
|
||||
history: [],
|
||||
fertile: null,
|
||||
translator: getServerTranslator(locale),
|
||||
}).events;
|
||||
}
|
||||
|
||||
/** start .. endExclusive, the pair DTSTART / DTEND carry. */
|
||||
const span = (e: { start: string; endExclusive: string }) => [
|
||||
e.start,
|
||||
e.endExclusive,
|
||||
];
|
||||
|
||||
describe("forecast styles", () => {
|
||||
it("BAND draws one event from the earliest start to the latest period's end", () => {
|
||||
const out = events("BAND");
|
||||
expect(out.map(span)).toEqual([["2026-11-15", "2026-11-24"]]);
|
||||
expect(out[0].summary).toBe(DISCREET_SUMMARY);
|
||||
});
|
||||
|
||||
it("EXACT draws only the calculated period", () => {
|
||||
const out = events("EXACT");
|
||||
expect(out.map(span)).toEqual([["2026-11-17", "2026-11-22"]]);
|
||||
expect(out[0].summary).toBe(DISCREET_SUMMARY);
|
||||
});
|
||||
|
||||
it("MARGIN draws the calculated period with the possible days on each side", () => {
|
||||
const out = events("MARGIN");
|
||||
expect(out.map(span)).toEqual([
|
||||
["2026-11-15", "2026-11-17"],
|
||||
["2026-11-17", "2026-11-22"],
|
||||
["2026-11-22", "2026-11-24"],
|
||||
]);
|
||||
expect(out.map((e) => e.summary)).toEqual([
|
||||
DISCREET_MARGIN_SUMMARY,
|
||||
DISCREET_SUMMARY,
|
||||
DISCREET_MARGIN_SUMMARY,
|
||||
]);
|
||||
});
|
||||
|
||||
it("MARGIN covers exactly the days BAND covers, without overlap", () => {
|
||||
const [band] = events("BAND");
|
||||
const margin = events("MARGIN");
|
||||
expect(margin[0].start).toBe(band.start);
|
||||
expect(margin.at(-1)!.endExclusive).toBe(band.endExclusive);
|
||||
for (let i = 1; i < margin.length; i++) {
|
||||
expect(margin[i].start).toBe(margin[i - 1].endExclusive);
|
||||
}
|
||||
});
|
||||
|
||||
it("MARGIN draws no side event where the band is empty", () => {
|
||||
const exact = { ...CYCLE, bandStart: CYCLE.start, bandEnd: CYCLE.start };
|
||||
expect(events("MARGIN", [exact]).map(span)).toEqual([
|
||||
["2026-11-17", "2026-11-22"],
|
||||
]);
|
||||
const lateOnly = { ...CYCLE, bandStart: CYCLE.start };
|
||||
expect(events("MARGIN", [lateOnly]).map(span)).toEqual([
|
||||
["2026-11-17", "2026-11-22"],
|
||||
["2026-11-22", "2026-11-24"],
|
||||
]);
|
||||
const earlyOnly = { ...CYCLE, bandEnd: CYCLE.start };
|
||||
expect(events("MARGIN", [earlyOnly]).map(span)).toEqual([
|
||||
["2026-11-15", "2026-11-17"],
|
||||
["2026-11-17", "2026-11-22"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the period's UID across styles and gives each margin its own", () => {
|
||||
const main = eventUid("feed-1", "pred:1");
|
||||
expect(events("BAND")[0].uid).toBe(main);
|
||||
expect(events("EXACT")[0].uid).toBe(main);
|
||||
expect(events("MARGIN").map((e) => e.uid)).toEqual([
|
||||
eventUid("feed-1", "pred:1:before"),
|
||||
main,
|
||||
eventUid("feed-1", "pred:1:after"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("draws every forecast cycle it is given", () => {
|
||||
const next = {
|
||||
k: 2,
|
||||
start: "2026-12-15",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-12-12",
|
||||
bandEnd: "2026-12-18",
|
||||
};
|
||||
expect(events("EXACT", [CYCLE, next])).toHaveLength(2);
|
||||
expect(events("MARGIN", [CYCLE, next])).toHaveLength(6);
|
||||
expect(events("BAND", [CYCLE, next])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("names the possible days in the record's language when descriptive", () => {
|
||||
expect(
|
||||
events("MARGIN", [CYCLE], "DESCRIPTIVE").map((e) => e.summary),
|
||||
).toEqual(["Period possible", "Period (estimate)", "Period possible"]);
|
||||
expect(
|
||||
events("MARGIN", [CYCLE], "DESCRIPTIVE", "de").map((e) => e.summary),
|
||||
).toEqual(["Periode möglich", "Periode (Schätzung)", "Periode möglich"]);
|
||||
});
|
||||
|
||||
it("describes every forecast event with its band", () => {
|
||||
for (const e of events("MARGIN", [CYCLE], "DESCRIPTIVE")) {
|
||||
expect(e.description).toBe("Most likely start Nov 17, ±2 days");
|
||||
}
|
||||
for (const e of events("EXACT")) expect(e.description).toBe("±2 days");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export const FEED = {
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
forecastStyle: "BAND" as const,
|
||||
};
|
||||
|
||||
export const SUBJECT = {
|
||||
|
||||
@@ -54,6 +54,7 @@ const FEED_ROW = {
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET",
|
||||
forecastStyle: "BAND",
|
||||
};
|
||||
|
||||
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
|
||||
|
||||
@@ -30,7 +30,11 @@ import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { icsContentHash, type IcsCalendar } from "../ics";
|
||||
import { buildFeedCalendar, type FeedTitleMode } from "./events";
|
||||
import {
|
||||
buildFeedCalendar,
|
||||
type FeedForecastStyle,
|
||||
type FeedTitleMode,
|
||||
} from "./events";
|
||||
import { historyPeriods, historyWindowStart } from "./history";
|
||||
|
||||
/** The feed options the body depends on. */
|
||||
@@ -40,6 +44,7 @@ export interface FeedOptions {
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
}
|
||||
|
||||
/** The record the feed serves, as far as building it needs. */
|
||||
@@ -147,6 +152,7 @@ export async function buildCycleFeed(
|
||||
const calendar = buildFeedCalendar({
|
||||
feedId: feed.id,
|
||||
titleMode: feed.titleMode,
|
||||
forecastStyle: feed.forecastStyle,
|
||||
projected: projection.cycles,
|
||||
history,
|
||||
fertile,
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
* DISCREET (the default) titles every event with a dot and names the calendar
|
||||
* "HealthLog", so a glance at a shared screen or a lock-screen notification
|
||||
* says nothing. DESCRIPTIVE names the period, in the record's own language.
|
||||
*
|
||||
* A forecast period is drawn in one of three styles. EXACT is the period as
|
||||
* calculated: the most likely first day for the predicted number of bleeding
|
||||
* days. MARGIN is the same event, plus the days the period may start earlier
|
||||
* or run on later as their own "possible" events, so the calculated days stay
|
||||
* readable as such. BAND is one event over every day the period may fall on,
|
||||
* from the earliest likely start to the last bleeding day of the latest.
|
||||
*/
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import type { ProjectedCycle } from "@/lib/cycle/projection";
|
||||
@@ -21,9 +28,12 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
|
||||
|
||||
/** The discreet title: one dot, no word. */
|
||||
export const DISCREET_SUMMARY = "●";
|
||||
/** The discreet title of a MARGIN style's possible days: an open dot. */
|
||||
export const DISCREET_MARGIN_SUMMARY = "○";
|
||||
/** The discreet calendar name. */
|
||||
export const DISCREET_CALENDAR_NAME = "HealthLog";
|
||||
|
||||
@@ -39,6 +49,7 @@ export interface HistoryPeriod {
|
||||
export interface FeedEventInput {
|
||||
feedId: string;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
projected: readonly ProjectedCycle[];
|
||||
history: readonly HistoryPeriod[];
|
||||
/** The next fertile window, already gated; null when not shown. */
|
||||
@@ -63,26 +74,81 @@ function bandText(days: number, translator: ServerTranslator): string {
|
||||
);
|
||||
}
|
||||
|
||||
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
|
||||
/** The forecast's description: the likely start and the band, or the band. */
|
||||
function forecastDescription(p: ProjectedCycle, input: FeedEventInput): string {
|
||||
const { translator } = input;
|
||||
const band = bandText(dayDiff(p.bandEnd, p.start), translator);
|
||||
return input.titleMode === "DESCRIPTIVE"
|
||||
? translator.t("cycle.feed.ics.likelyStart", {
|
||||
date: shortDate(p.start, translator),
|
||||
band,
|
||||
})
|
||||
: band;
|
||||
}
|
||||
|
||||
/**
|
||||
* One forecast cycle's events. DTEND is exclusive throughout, so the period
|
||||
* itself ends the day after its last bleeding day.
|
||||
*/
|
||||
function forecastEvents(p: ProjectedCycle, input: FeedEventInput): IcsEvent[] {
|
||||
const { translator } = input;
|
||||
const band = dayDiff(p.bandEnd, p.start);
|
||||
const descriptive = input.titleMode === "DESCRIPTIVE";
|
||||
return {
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
// The whole window the period may fall in: earliest likely start through
|
||||
// the last bleeding day of the latest likely start. DTEND is exclusive.
|
||||
start: p.bandStart,
|
||||
endExclusive: addDays(p.bandEnd, p.periodLength),
|
||||
summary: descriptive
|
||||
? translator.t("cycle.feed.ics.periodEstimate")
|
||||
: DISCREET_SUMMARY,
|
||||
description: descriptive
|
||||
? translator.t("cycle.feed.ics.likelyStart", {
|
||||
date: shortDate(p.start, translator),
|
||||
band: bandText(band, translator),
|
||||
})
|
||||
: bandText(band, translator),
|
||||
};
|
||||
const description = forecastDescription(p, input);
|
||||
const periodSummary = descriptive
|
||||
? translator.t("cycle.feed.ics.periodEstimate")
|
||||
: DISCREET_SUMMARY;
|
||||
|
||||
if (input.forecastStyle === "BAND") {
|
||||
return [
|
||||
{
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
// The whole window the period may fall in: earliest likely start
|
||||
// through the last bleeding day of the latest likely start.
|
||||
start: p.bandStart,
|
||||
endExclusive: addDays(p.bandEnd, p.periodLength),
|
||||
summary: periodSummary,
|
||||
description,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const periodEnd = addDays(p.start, p.periodLength);
|
||||
const events: IcsEvent[] = [
|
||||
{
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
start: p.start,
|
||||
endExclusive: periodEnd,
|
||||
summary: periodSummary,
|
||||
description,
|
||||
},
|
||||
];
|
||||
if (input.forecastStyle === "EXACT") return events;
|
||||
|
||||
const marginSummary = descriptive
|
||||
? translator.t("cycle.feed.ics.periodPossible")
|
||||
: DISCREET_MARGIN_SUMMARY;
|
||||
// The days before the likely start it may begin on, and the days after the
|
||||
// likely period it may still run on. A side with no band has no event.
|
||||
if (dayDiff(p.start, p.bandStart) > 0) {
|
||||
events.push({
|
||||
uid: eventUid(input.feedId, `pred:${p.k}:before`),
|
||||
start: p.bandStart,
|
||||
endExclusive: p.start,
|
||||
summary: marginSummary,
|
||||
description,
|
||||
});
|
||||
}
|
||||
const bandEndExclusive = addDays(p.bandEnd, p.periodLength);
|
||||
if (dayDiff(bandEndExclusive, periodEnd) > 0) {
|
||||
events.push({
|
||||
uid: eventUid(input.feedId, `pred:${p.k}:after`),
|
||||
start: periodEnd,
|
||||
endExclusive: bandEndExclusive,
|
||||
summary: marginSummary,
|
||||
description,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
|
||||
@@ -116,7 +182,7 @@ function fertileEvent(
|
||||
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
|
||||
const events: IcsEvent[] = [
|
||||
...input.history.map((h) => historyEvent(h, input)),
|
||||
...input.projected.map((p) => forecastEvent(p, input)),
|
||||
...input.projected.flatMap((p) => forecastEvents(p, input)),
|
||||
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
|
||||
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
|
||||
return {
|
||||
|
||||
@@ -116,6 +116,7 @@ const FEED_SELECT = {
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ProjectionState } from "@/lib/cycle/projection";
|
||||
|
||||
import { buildCycleFeed, type FeedSubject } from "./build";
|
||||
import { fetcherFamily, type FetcherFamily } from "./fetcher";
|
||||
import type { FeedTitleMode } from "./events";
|
||||
import type { FeedForecastStyle, FeedTitleMode } from "./events";
|
||||
|
||||
export const FEED_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
@@ -23,6 +23,7 @@ export const FEED_SUMMARY_SELECT = {
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
forecastStyle: true,
|
||||
} as const;
|
||||
|
||||
export interface FeedSummaryRow {
|
||||
@@ -36,6 +37,7 @@ export interface FeedSummaryRow {
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
}
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
@@ -49,6 +51,7 @@ export interface CycleFeedSummary {
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
forecastStyle: FeedForecastStyle;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
@@ -74,6 +77,7 @@ export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
|
||||
historyCycles: row.historyCycles,
|
||||
includeFertile: row.includeFertile,
|
||||
titleMode: row.titleMode,
|
||||
forecastStyle: row.forecastStyle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1406,7 +1406,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
preventiveDue: [{ label: "Skin check", longCycle: true }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
@@ -1416,7 +1416,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
|
||||
it("does not pin a short-cycle reminder: it rolls on anyway", () => {
|
||||
it("does not pin a short-cycle reminder: it comes round within days", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }),
|
||||
t,
|
||||
@@ -1436,7 +1436,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
...crowd,
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
preventiveDue: [{ label: "Skin check", longCycle: true }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
@@ -1546,7 +1546,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
it("leaves the due check-ups item on the checkups list", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
preventiveDue: [{ label: "Skin check", longCycle: true }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
|
||||
@@ -166,11 +166,11 @@ export interface DailyDigestPreventiveDue {
|
||||
/** Display label, already resolved (a Coach cadence key is translated). */
|
||||
label: string;
|
||||
/**
|
||||
* Whether this reminder stays due after it is sent (a cycle longer than a
|
||||
* week, or none). Only such a reminder earns the check-up item a place
|
||||
* under the rail cap; a short-cycle rhythm rolls on by itself.
|
||||
* Whether this reminder is on a cycle longer than a week, or has none
|
||||
* (`hasLongCycle`). Only such a reminder earns the check-up item a place
|
||||
* under the rail cap; a short-cycle rhythm comes round again within days.
|
||||
*/
|
||||
staysDue?: boolean;
|
||||
longCycle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,8 +743,8 @@ function buildUpcomingVisitItems(
|
||||
/**
|
||||
* The bounded rail: at most `max` items, in priority order, with the pinned
|
||||
* items guaranteed a place. A pinned item is one a person can miss for good if
|
||||
* the cap drops it today: a reminder that stays due and is due or overdue
|
||||
* (its notification has already gone out, and it will not roll on by itself)
|
||||
* the cap drops it today: a reminder on a long cycle that is due or overdue
|
||||
* (its notification has already gone out, and the next one is a week away)
|
||||
* and a visit booked for today (the day ends). The rest fill
|
||||
* the remaining slots in order, and the result keeps the priority order, so an
|
||||
* overdue dose still leads.
|
||||
@@ -1207,7 +1207,7 @@ export function buildDailyDigest(
|
||||
// `capWithPinned`); they still sit below a dose in the displayed order.
|
||||
const pinned = new Set<PriorityItem>(
|
||||
[
|
||||
input.preventiveDue.some((due) => due.staysDue) ? preventive : null,
|
||||
input.preventiveDue.some((due) => due.longCycle) ? preventive : null,
|
||||
visits.today,
|
||||
].filter((item): item is PriorityItem => item !== null),
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
|
||||
import { hasLongCycle } from "@/lib/measurement-reminders/holds-open";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import { isArrivalKind } from "@/lib/arrivals/types";
|
||||
@@ -592,9 +592,9 @@ export async function loadDailyDigest(
|
||||
//
|
||||
// Read up to the end of the person's local day; the filter below keeps
|
||||
// what is due now, plus what is due later today when it is a reminder
|
||||
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
|
||||
// reminder keeps its due date after it is sent, so the overdue ones
|
||||
// stay here until they are satisfied, skipped or snoozed.
|
||||
// on a long cycle (see `hasLongCycle`). A reminder that stays due after
|
||||
// it is sent (`holdsOpenAfterReminder`) keeps its due date, so the
|
||||
// overdue ones stay here until they are satisfied, skipped or snoozed.
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
@@ -896,21 +896,21 @@ export async function loadDailyDigest(
|
||||
// the checkups page and the dashboard card translate it, and so does this
|
||||
// line. A free-text label (every other row) is the person's own words.
|
||||
//
|
||||
// A reminder that stays due after it is sent (a cycle longer than a week,
|
||||
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
|
||||
// whole due day and keeps its place under the cap. A short-cycle one (a
|
||||
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
|
||||
// time has come, is not pinned, and its next reading moves it on.
|
||||
// A reminder on a cycle longer than a week (a fortnightly questionnaire, a
|
||||
// yearly check-up) is on the rail for its whole due day and keeps its place
|
||||
// under the cap. A short-cycle one (a daily weigh-in, a twice-daily course)
|
||||
// is a rhythm: it shows only once its time has come and is not pinned; a
|
||||
// missed rolling one then stays here, overdue, until its reading lands.
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = [];
|
||||
for (const row of dueReminders) {
|
||||
if (row.nextDueAt === null) continue;
|
||||
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
|
||||
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
|
||||
const longCycle = hasLongCycle(row, user.timezone, row.nextDueAt);
|
||||
if (!longCycle && row.nextDueAt.getTime() > now.getTime()) continue;
|
||||
let label = row.label;
|
||||
if (row.origin === "COACH") {
|
||||
label = translateOrNull(t, row.label) ?? label;
|
||||
}
|
||||
preventiveDue.push({ label, staysDue });
|
||||
preventiveDue.push({ label, longCycle });
|
||||
}
|
||||
|
||||
// Group the two figures for the reader. `Intl.NumberFormat` is the only
|
||||
|
||||
@@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [
|
||||
"scoreSleep",
|
||||
"scoreStress",
|
||||
"scoreStrain",
|
||||
"bloodKetones", // #1367 — the chart on the ketones page.
|
||||
] as const;
|
||||
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores";
|
||||
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
|
||||
import { readDayBands, shapeDayValues } from "@/lib/day/values";
|
||||
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
|
||||
import {
|
||||
resolveDayReadings,
|
||||
type DayReadings,
|
||||
@@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
]);
|
||||
|
||||
const presentTypes = [...readings.rowsByType.keys()];
|
||||
// The index reveals the glucose it was built from, so it needs both
|
||||
// reading types visible, not only the ketones the day holds.
|
||||
const gkiReadable =
|
||||
readings.rowsByType.has("BLOOD_KETONES") &&
|
||||
typeVisible("BLOOD_KETONES") &&
|
||||
typeVisible("BLOOD_GLUCOSE");
|
||||
const glucoseKetoneIndex = gkiReadable
|
||||
? await readGlucoseKetoneIndex({
|
||||
userId: recordId,
|
||||
from: dayStart,
|
||||
to: dayEnd,
|
||||
})
|
||||
: [];
|
||||
const [bands, notable] =
|
||||
presentTypes.length === 0
|
||||
? [new Map(), []]
|
||||
@@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
counts: { values: values.length, entries: events.length },
|
||||
running,
|
||||
values,
|
||||
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
|
||||
events,
|
||||
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
|
||||
scores,
|
||||
|
||||
@@ -117,6 +117,24 @@ const dayValue = z
|
||||
})
|
||||
.meta({ id: "DayValue" });
|
||||
|
||||
/**
|
||||
* #1367 — one glucose-ketone index of the day, derived from a ketone reading
|
||||
* and the glucose reading paired with it; never stored.
|
||||
*/
|
||||
const dayGlucoseKetoneIndex = z
|
||||
.object({
|
||||
at: instant.describe("The ketone reading's instant."),
|
||||
value: z.number().describe("The index, to one decimal."),
|
||||
ketoneId: z.string().describe("The ketone reading's id."),
|
||||
glucoseId: z.string().describe("The paired glucose reading's id."),
|
||||
glucoseAt: instant.describe("The paired glucose reading's instant."),
|
||||
ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."),
|
||||
glucoseMmol: z
|
||||
.number()
|
||||
.describe("The paired glucose in mmol/L, one decimal."),
|
||||
})
|
||||
.meta({ id: "DayGlucoseKetoneIndex" });
|
||||
|
||||
const dayEvent = z
|
||||
.object({
|
||||
at: instant
|
||||
@@ -232,6 +250,12 @@ export const dayResponseSchema = z
|
||||
}),
|
||||
running: z.array(dayRunningItem),
|
||||
values: z.array(dayValue),
|
||||
glucoseKetoneIndex: z
|
||||
.array(dayGlucoseKetoneIndex)
|
||||
.optional()
|
||||
.describe(
|
||||
"#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.",
|
||||
),
|
||||
events: z.array(dayEvent),
|
||||
notable: z.array(dayNotable),
|
||||
scores: z
|
||||
|
||||
@@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-context glucose rows. Values are stored canonically in mg/dL and print
|
||||
* in the owner's unit, in the precision their readings carry.
|
||||
* The glucose group's own rows: one per meal context, stored canonically in
|
||||
* mg/dL and printed in the owner's unit in the precision their readings
|
||||
* carry, then the glucose-ketone index (#1367), a ratio without a unit.
|
||||
*/
|
||||
function glucoseContextTableRows(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
@@ -209,6 +210,17 @@ function glucoseContextTableRows(
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
const gki = data.glucoseKetoneIndex;
|
||||
if (gki) {
|
||||
rows.push([
|
||||
t("measurements.glucoseKetoneIndex"),
|
||||
num(gki.latest, 1),
|
||||
num(gki.avg, 1),
|
||||
num(gki.min, 1),
|
||||
num(gki.max, 1),
|
||||
String(gki.count),
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,13 @@ export interface DoctorReportData {
|
||||
* `stillLearning` when the period is too thin to assert clinically.
|
||||
*/
|
||||
glucoseClinical: GlucoseClinicalMetrics;
|
||||
/**
|
||||
* #1367 — the glucose-ketone index over the period (count, mean, range,
|
||||
* latest), derived at read time from each ketone reading and the glucose
|
||||
* reading paired with it. Null when there is none or when either reading
|
||||
* type is withheld. Optional so older fixtures still typecheck.
|
||||
*/
|
||||
glucoseKetoneIndex?: DoctorReportStats | null;
|
||||
bmi: number | null;
|
||||
/** Keyed by medication id; see `DoctorReportCompliance`. */
|
||||
compliance: Record<string, DoctorReportCompliance>;
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import { loadDenseMeasurementBuckets } from "./dense-buckets";
|
||||
import { summariseDenseBuckets } from "./measurement-series";
|
||||
import { emptyGlucoseClinical } from "./glucose-panel";
|
||||
import { readReportGki } from "./glucose-ketone-index";
|
||||
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
|
||||
import { loadReportWorkouts } from "./workouts";
|
||||
import { loadReportMedicationEffects } from "./medication-effects";
|
||||
@@ -249,9 +250,8 @@ export async function collectDoctorReportData(
|
||||
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Narrow select: the collector reads exactly these seven fields.
|
||||
// The full-width read pulled every column — including the encrypted
|
||||
// notes — across up to 730 days of rows.
|
||||
// Narrow select: the full-width read pulled every column, encrypted
|
||||
// notes included, across up to 730 days of rows.
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
@@ -739,6 +739,7 @@ export async function collectDoctorReportData(
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseClinical,
|
||||
glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end),
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
|
||||
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The glucose-ketone index in the doctor report (#1367): count, mean, range
|
||||
* and latest over the report window, read by the one pairing rule
|
||||
* (`read-glucose-ketone-index.ts`) so the report carries the numbers the
|
||||
* ketones page shows.
|
||||
*
|
||||
* Both leaves must admit it. The index is glucose over ketones, so beside the
|
||||
* ketone series it gives the glucose back exactly: a report that withholds
|
||||
* glucose withholds the index too.
|
||||
*/
|
||||
import type { DoctorReportStats } from "@/lib/doctor-report-types";
|
||||
import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index";
|
||||
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
|
||||
|
||||
import type { ReportGate } from "./selection-gate";
|
||||
|
||||
export async function readReportGki(
|
||||
gate: ReportGate,
|
||||
/** The report's own stats: no ketone row in the window, no read. */
|
||||
stats: Readonly<Record<string, DoctorReportStats>>,
|
||||
userId: string,
|
||||
/** The report window; `end` is inclusive, as the measurement read has it. */
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportStats | null> {
|
||||
if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) {
|
||||
return null;
|
||||
}
|
||||
if (!stats.BLOOD_KETONES?.count) return null;
|
||||
const points = await readGlucoseKetoneIndex({
|
||||
userId,
|
||||
from: start,
|
||||
to: new Date(end.getTime() + 1),
|
||||
});
|
||||
const summary = summariseGlucoseKetoneIndex(points);
|
||||
if (!summary) return null;
|
||||
return {
|
||||
avg: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
count: summary.count,
|
||||
latest: summary.latest,
|
||||
latestAt: points[points.length - 1]!.at,
|
||||
};
|
||||
}
|
||||
@@ -193,6 +193,39 @@ describe("runInboundExtraction — stated-status-only mapping", () => {
|
||||
expect(result.facts.map((f) => f.needsReview)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('drops a lab\'s "(calc)" note from the unit, so two reports land in one', async () => {
|
||||
const provider = fakeProvider(
|
||||
JSON.stringify({
|
||||
facts: [
|
||||
{
|
||||
type: "OBSERVATION",
|
||||
label: "Globulin",
|
||||
value: 2.6,
|
||||
unit: "g/dL (calc)",
|
||||
confidence: 0.95,
|
||||
sourceText: "Globulin 2.6 g/dL (calc)",
|
||||
},
|
||||
{
|
||||
type: "OBSERVATION",
|
||||
label: "A/G Ratio",
|
||||
value: 1.7,
|
||||
unit: "(calc)",
|
||||
confidence: 0.95,
|
||||
sourceText: "A/G Ratio 1.7 (calc)",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runInboundExtraction(oneRead(provider));
|
||||
expect(
|
||||
result.facts.map((f) => (f.data as ObservationFactData).unit),
|
||||
).toEqual(["g/dL", null]);
|
||||
// A unit that was only the note goes to review: no unit is a choice a
|
||||
// person makes, not one a read makes for them.
|
||||
expect(result.facts.map((f) => f.needsReview)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("stores the document's real span, not the model's echo of it", async () => {
|
||||
// The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied
|
||||
// rendering of it. The stored provenance must be the document's characters.
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
type OcrReadSurface,
|
||||
} from "@/lib/documents/ocr-read";
|
||||
import { isDimensionlessUnit } from "@/lib/labs/dimensionless";
|
||||
import { withoutLabUnitAnnotation } from "@/lib/labs/unit-normalise";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
INBOUND_CONFIDENCE_FLOOR,
|
||||
@@ -168,7 +169,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
|
||||
codeSystem: system === "LOINC" ? system : null,
|
||||
value: hasNumeric ? raw.value : null,
|
||||
valueText: hasNumeric ? null : raw.valueText,
|
||||
unit: raw.unit,
|
||||
unit: observationUnit(raw.unit),
|
||||
referenceLow: raw.referenceLow,
|
||||
referenceHigh: raw.referenceHigh,
|
||||
referenceText: raw.referenceText,
|
||||
@@ -189,6 +190,17 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The printed unit without a lab's "(calc)" note, so the same marker read
|
||||
* from two reports, one with the note and one without, lands in one unit.
|
||||
* A unit that was only the note is no unit.
|
||||
*/
|
||||
function observationUnit(unit: string | null): string | null {
|
||||
if (unit === null) return null;
|
||||
const bare = withoutLabUnitAnnotation(unit);
|
||||
return bare === "" ? null : bare;
|
||||
}
|
||||
|
||||
/** A staged fact ready to persist (before it gets an id). */
|
||||
export interface StagedFactInput {
|
||||
factType: InboundFactType;
|
||||
@@ -306,7 +318,7 @@ function factKey(raw: ExtractedFactRaw, readDate: string | null): string {
|
||||
raw.label.trim().toLowerCase(),
|
||||
raw.value ?? "",
|
||||
(raw.valueText ?? "").trim().toLowerCase(),
|
||||
(raw.unit ?? "").trim().toLowerCase(),
|
||||
(observationUnit(raw.unit) ?? "").toLowerCase(),
|
||||
(raw.referenceText ?? "").trim().toLowerCase(),
|
||||
(raw.dose ?? "").trim().toLowerCase(),
|
||||
normaliseDate(raw.effectiveDate) ?? readDate ?? "",
|
||||
@@ -387,9 +399,15 @@ export async function runInboundExtraction(
|
||||
// A unit of "1" is the user's explicit no-unit choice. A read that
|
||||
// states it itself goes to review rather than straight to the record,
|
||||
// so a dropped unit never passes as a dimensionless value.
|
||||
// So does a unit column that held only a "(calc)" note: it reads as no
|
||||
// unit, and a person decides whether the value really has none.
|
||||
needsReview:
|
||||
confidence < INBOUND_CONFIDENCE_FLOOR ||
|
||||
(raw.type === "OBSERVATION" && isDimensionlessUnit(raw.unit)),
|
||||
(raw.type === "OBSERVATION" &&
|
||||
(isDimensionlessUnit(raw.unit) ||
|
||||
(raw.unit !== null &&
|
||||
raw.unit.trim() !== "" &&
|
||||
observationUnit(raw.unit) === null))),
|
||||
data,
|
||||
provenance: {
|
||||
sourceText: anchor.sourceText,
|
||||
|
||||
@@ -196,6 +196,32 @@ describe("measurementSeriesObservation", () => {
|
||||
expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc);
|
||||
});
|
||||
|
||||
it("codes blood ketones as beta-hydroxybutyrate in blood (#1367)", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
id: "k1",
|
||||
type: "BLOOD_KETONES",
|
||||
value: 1.4,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
measuredAt: AT,
|
||||
glucoseContext: null,
|
||||
},
|
||||
"mg/dL",
|
||||
null,
|
||||
)!;
|
||||
assertR4Observation(o);
|
||||
expect(o.category?.[0]?.coding?.[0]?.code).toBe("laboratory");
|
||||
expect(o.code.coding).toEqual([
|
||||
{
|
||||
system: "http://loinc.org",
|
||||
code: "104816-4",
|
||||
display: "Beta hydroxybutyrate [Moles/volume] in Blood",
|
||||
},
|
||||
]);
|
||||
expect(o.valueQuantity).toMatchObject({ value: 1.4, unit: "mmol/L" });
|
||||
});
|
||||
|
||||
it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => {
|
||||
const o = measurementSeriesObservation(
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:SLEEP_DURATION",
|
||||
"metric:ACTIVITY_STEPS",
|
||||
"metric:BLOOD_GLUCOSE",
|
||||
"metric:BLOOD_KETONES",
|
||||
"metric:TOTAL_BODY_WATER",
|
||||
"metric:BONE_MASS",
|
||||
"metric:OXYGEN_SATURATION",
|
||||
|
||||
@@ -80,6 +80,8 @@ export type InsightMetric =
|
||||
// `summaries[metric].count > 0` with no new handler branch (the
|
||||
// metric auto-lights-up the moment the first reading lands).
|
||||
| "BLOOD_GLUCOSE"
|
||||
// #1367 — blood ketones, keyed on its MeasurementType like glucose.
|
||||
| "BLOOD_KETONES"
|
||||
| "TOTAL_BODY_WATER"
|
||||
| "BONE_MASS"
|
||||
// Body fat percentage, keyed on its MeasurementType like the rest of the
|
||||
|
||||
@@ -139,6 +139,8 @@ const SUB_PAGE_METRIC = {
|
||||
daylight: ["TIME_IN_DAYLIGHT"],
|
||||
// ── metabolic (v1.7.0) ──
|
||||
"blood-glucose": ["BLOOD_GLUCOSE"],
|
||||
// #1367 — blood ketones, with the glucose-ketone index beneath the chart.
|
||||
ketones: ["BLOOD_KETONES"],
|
||||
"skin-temperature": ["SKIN_TEMPERATURE"],
|
||||
// v1.10.0 — overnight wrist temperature joins the metabolic cluster.
|
||||
"wrist-temperature": ["WRIST_TEMPERATURE"],
|
||||
@@ -243,6 +245,7 @@ export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
|
||||
daylight: "environment",
|
||||
// metabolic (v1.7.0)
|
||||
"blood-glucose": "metabolic",
|
||||
ketones: "metabolic",
|
||||
"skin-temperature": "metabolic",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// activity / mobility cluster
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
* logged since the last satisfy advances lastSatisfiedAt + recomputes
|
||||
* nextDueAt and suppresses the nudge. Free-text reminders never
|
||||
* auto-resolve.
|
||||
* - A reminder on a weekly or shorter cycle rolls on: a successful
|
||||
* dispatch advances nextDueAt past now. One on a longer cycle, or a
|
||||
* one-shot, does NOT roll on (measurement reminder or free text): it
|
||||
* stays due until it is satisfied, and a second nudge for the same open
|
||||
* slot waits a week. Appointments stay one-shot.
|
||||
* - A calendar rule recurring within the week rolls on: a successful
|
||||
* dispatch advances nextDueAt past now. A rolling interval of any
|
||||
* length, a rule on a longer cycle, or a one-shot does NOT roll on
|
||||
* (measurement reminder or free text): it stays due until it is
|
||||
* satisfied, and a second nudge for the same open slot waits its
|
||||
* interval, at most a week. Appointments stay one-shot.
|
||||
* - clientManaged suppresses the APNs leg only: the tick still
|
||||
* dispatches, and a reminder nothing delivered stays overdue.
|
||||
*
|
||||
@@ -58,6 +59,7 @@ beforeEach(() => {
|
||||
|
||||
// 09:00 Berlin in June = 07:00Z.
|
||||
const NINE_LOCAL = new Date("2026-06-15T07:00:00Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("evaluateMeasurementReminderDue — window boundary", () => {
|
||||
const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) };
|
||||
@@ -216,6 +218,16 @@ function makePrisma(opts: {
|
||||
return { prisma, updates };
|
||||
}
|
||||
|
||||
/** One tick against the stubbed Prisma surface: the one cast in this file's
|
||||
* newer cases, rather than one per call. */
|
||||
function tick(
|
||||
prisma: ReturnType<typeof makePrisma>["prisma"],
|
||||
now: Date,
|
||||
options: Parameters<typeof runMeasurementReminderTick>[2] = {},
|
||||
) {
|
||||
return runMeasurementReminderTick(prisma as never, now, options);
|
||||
}
|
||||
|
||||
function reminder(overrides: Partial<FakeReminder>): FakeReminder {
|
||||
return {
|
||||
id: "r1",
|
||||
@@ -312,7 +324,7 @@ describe("runMeasurementReminderTick", () => {
|
||||
|
||||
it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({})],
|
||||
reminders: [reminder({ intervalDays: null, rrule: "FREQ=WEEKLY" })],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
@@ -662,9 +674,15 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect("nextDueAt" in updates[0].data).toBe(false);
|
||||
});
|
||||
|
||||
it("still rolls a weekly measurement reminder on after its reminder", async () => {
|
||||
it("keeps a Coach-suggested daily measurement reminder due after its reminder", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({ origin: "COACH" })],
|
||||
reminders: [
|
||||
reminder({
|
||||
origin: "COACH",
|
||||
measurementType: "WEIGHT",
|
||||
intervalDays: 1,
|
||||
}),
|
||||
],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
@@ -672,9 +690,7 @@ describe("runMeasurementReminderTick", () => {
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
const advanced = updates[0].data.nextDueAt as Date;
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
expect(updates[0].data.lastNotifiedAt).toEqual(NINE_LOCAL);
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
});
|
||||
|
||||
it("still closes an appointment reminder after its one nudge", async () => {
|
||||
@@ -728,11 +744,9 @@ describe("runMeasurementReminderTick", () => {
|
||||
|
||||
const sixDays = makePrisma({ reminders: [open], labMatch: null });
|
||||
const heldDispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
await runMeasurementReminderTick(
|
||||
sixDays.prisma as never,
|
||||
new Date("2026-06-14T07:00:00Z"),
|
||||
{ dispatch: heldDispatch },
|
||||
);
|
||||
await tick(sixDays.prisma, new Date("2026-06-14T07:00:00Z"), {
|
||||
dispatch: heldDispatch,
|
||||
});
|
||||
expect(heldDispatch).not.toHaveBeenCalled();
|
||||
|
||||
// Seven calendar days on, at the same notify hour — a few seconds EARLIER
|
||||
@@ -740,11 +754,7 @@ describe("runMeasurementReminderTick", () => {
|
||||
// as "under a week".
|
||||
const sevenDays = makePrisma({ reminders: [open], labMatch: null });
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const summary = await runMeasurementReminderTick(
|
||||
sevenDays.prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
const summary = await tick(sevenDays.prisma, NINE_LOCAL, { dispatch });
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(sevenDays.updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
@@ -783,7 +793,7 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
});
|
||||
|
||||
it("rolls a weekly check-up on, since its next slot is the next nudge", async () => {
|
||||
it("keeps a weekly rolling check-up due: its next slot needs it done first", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [checkup({ rrule: null, intervalDays: 7 })],
|
||||
labMatch: null,
|
||||
@@ -792,8 +802,7 @@ describe("runMeasurementReminderTick", () => {
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
const advanced = updates[0].data.nextDueAt as Date;
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
|
||||
});
|
||||
|
||||
it("keeps a fortnightly check-up due", async () => {
|
||||
@@ -952,3 +961,160 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
|
||||
});
|
||||
});
|
||||
|
||||
// ── A rolling reminder stays due until it is measured ────────────────────
|
||||
//
|
||||
// The reported shape: blood pressure every two days at 18:00, first due a
|
||||
// month back, never measured since. The old tick rolled it two days on the
|
||||
// moment its reminder was sent, so the card read "in 2 days" every evening
|
||||
// and never "today" or "overdue".
|
||||
describe("runMeasurementReminderTick — a missed rolling reminder", () => {
|
||||
// 18:00 in Berlin in November (UTC+1).
|
||||
const SIX_PM = new Date("2026-11-08T17:00:00.000Z");
|
||||
const twoDay = (overrides: Partial<FakeReminder> = {}) =>
|
||||
reminder({
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 2,
|
||||
notifyHour: 18,
|
||||
// First due 4 October, local midnight; never satisfied since.
|
||||
anchorDate: new Date("2026-10-03T22:00:00.000Z"),
|
||||
nextDueAt: new Date("2026-10-04T16:00:00.000Z"),
|
||||
createdAt: new Date("2026-11-08T09:00:00.000Z"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("keeps a two-day reminder due after its reminder instead of rolling it on", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [twoDay()],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await tick(prisma, SIX_PM, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data).toEqual({ lastNotifiedAt: SIX_PM });
|
||||
});
|
||||
|
||||
it("keeps a daily and a weekly rolling reminder due the same way", async () => {
|
||||
for (const intervalDays of [1, 7]) {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [twoDay({ intervalDays })],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await tick(prisma, SIX_PM, { dispatch });
|
||||
|
||||
expect(updates[0].data, `every ${intervalDays} days`).toEqual({
|
||||
lastNotifiedAt: SIX_PM,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("repeats an open two-day reminder every second day, not every seventh", async () => {
|
||||
const open = twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) });
|
||||
|
||||
const nextDay = makePrisma({ reminders: [open], measurementMatch: null });
|
||||
const heldDispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const held = await tick(
|
||||
nextDay.prisma,
|
||||
new Date(SIX_PM.getTime() + 1 * DAY),
|
||||
{ dispatch: heldDispatch },
|
||||
);
|
||||
expect(held.skippedRepeatHeld).toBe(1);
|
||||
expect(heldDispatch).not.toHaveBeenCalled();
|
||||
|
||||
const twoDaysOn = makePrisma({ reminders: [open], measurementMatch: null });
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const at = new Date(SIX_PM.getTime() + 2 * DAY);
|
||||
const sent = await tick(twoDaysOn.prisma, at, { dispatch });
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(sent.dispatched).toBe(1);
|
||||
// Still open: the due date stays where it was.
|
||||
expect(twoDaysOn.updates[0].data).toEqual({ lastNotifiedAt: at });
|
||||
});
|
||||
|
||||
it("repeats an open daily reminder the next day", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
twoDay({
|
||||
intervalDays: 1,
|
||||
lastNotifiedAt: new Date(SIX_PM.getTime() - 1 * DAY + 4_000),
|
||||
}),
|
||||
],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await tick(prisma, SIX_PM, { dispatch });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still holds a long rolling cycle's repeat to a week", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
twoDay({
|
||||
intervalDays: 30,
|
||||
lastNotifiedAt: new Date(SIX_PM.getTime() - 6 * DAY + 4_000),
|
||||
}),
|
||||
],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await tick(prisma, SIX_PM, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.skippedRepeatHeld).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves on from the reading that satisfies it, two days from the reading", async () => {
|
||||
const measuredAt = new Date("2026-11-09T07:30:00.000Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) }),
|
||||
],
|
||||
measurementMatch: { measuredAt },
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await tick(prisma, new Date(SIX_PM.getTime() + 1 * DAY), {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.autoResolved).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
// 11 November, 18:00 in Berlin.
|
||||
expect(updates[0].data.nextDueAt).toEqual(
|
||||
new Date("2026-11-11T17:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("still rolls a weekly calendar rule on after its reminder", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
twoDay({
|
||||
intervalDays: null,
|
||||
rrule: "FREQ=WEEKLY",
|
||||
nextDueAt: SIX_PM,
|
||||
}),
|
||||
],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await tick(prisma, SIX_PM, { dispatch });
|
||||
|
||||
const advanced = updates[0].data.nextDueAt as Date;
|
||||
expect(advanced.getTime()).toBeGreaterThan(SIX_PM.getTime());
|
||||
expect(updates[0].data.lastNotifiedAt).toEqual(SIX_PM);
|
||||
});
|
||||
});
|
||||
@@ -9,19 +9,20 @@
|
||||
*
|
||||
* Differences from the mood reminder:
|
||||
*
|
||||
* 1. What a delivered reminder does to the row depends on its cycle.
|
||||
* A reminder whose next slot is more than a week away (a fortnightly
|
||||
* questionnaire, a yearly check-up) or that has no next slot at all (a
|
||||
* one-shot) stays due until it is satisfied, skipped or snoozed, so the
|
||||
* start page and the check-up card keep showing it as due and then
|
||||
* overdue; rolling it on silently filed an open task under a later
|
||||
* slot. While it stays open it is reminded again at most once a week,
|
||||
* gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on
|
||||
* a weekly or shorter cycle rolls on to its next slot, which is the
|
||||
* next nudge anyway, and an appointment (`origin: ENCOUNTER`) is
|
||||
* one-shot and closes (see `holdsOpenAfterReminder`). The per-day claim
|
||||
* in `notification_events` stays the guard against a second send on
|
||||
* the same local day.
|
||||
* 1. What a delivered reminder does to the row depends on its cadence.
|
||||
* A rolling reminder ("every N days", anchored on its last reading),
|
||||
* a calendar rule whose next slot is more than a week away (a yearly
|
||||
* check-up), and a reminder with no next slot at all (a one-shot) stay
|
||||
* due until they are satisfied, skipped or snoozed, so the start page
|
||||
* and the check-up card keep showing them as due and then overdue;
|
||||
* rolling them on silently filed an open task under a later slot. While
|
||||
* one stays open it is reminded again every N days for a rolling
|
||||
* interval, at most once a week, gated on `lastNotifiedAt` (see
|
||||
* `isCheckupRepeatHeld`). A calendar rule that recurs within the week
|
||||
* rolls on to its next slot, which is the next nudge anyway, and an
|
||||
* appointment (`origin: ENCOUNTER`) is one-shot and closes (see
|
||||
* `holdsOpenAfterReminder`). The per-day claim in `notification_events`
|
||||
* stays the guard against a second send on the same local day.
|
||||
* 2. Auto-resolve from an incoming measurement. Before deciding to
|
||||
* fire, the runner checks whether a matching reading of the
|
||||
* reminder's `measurementType` has landed since the last satisfy
|
||||
@@ -105,31 +106,49 @@ function moduleForMeasurementType(
|
||||
*/
|
||||
const DUE_QUERY_SLACK_MS = 15 * 60_000;
|
||||
|
||||
/** Calendar days an open reminder waits before it is sent again. */
|
||||
/** Calendar days an open reminder waits at most before it is sent again. */
|
||||
export const CHECKUP_REPEAT_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Calendar days an open reminder waits before it is sent again: its own
|
||||
* interval for a rolling reminder, capped at {@link CHECKUP_REPEAT_DAYS}, so
|
||||
* a missed every-two-days reading is asked for again every second day rather
|
||||
* than once a week; a week for everything else that stays open.
|
||||
*/
|
||||
function checkupRepeatDays(reminder: { intervalDays?: number | null }): number {
|
||||
const interval = reminder.intervalDays ?? null;
|
||||
if (interval === null) return CHECKUP_REPEAT_DAYS;
|
||||
return Math.max(1, Math.min(interval, CHECKUP_REPEAT_DAYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.2 — whether an open check-up's repeat nudge is still being held.
|
||||
*
|
||||
* Held when the last delivered nudge was for THIS slot (`lastNotifiedAt` at
|
||||
* or after `nextDueAt`) and fewer than {@link CHECKUP_REPEAT_DAYS} calendar
|
||||
* or after `nextDueAt`) and fewer than {@link checkupRepeatDays} calendar
|
||||
* days have passed in the person's timezone. Calendar days rather than a
|
||||
* 7 × 24 h gap, because the tick fires within the notify hour and the next
|
||||
* week's tick can land a few seconds earlier on the clock than the first
|
||||
* send did.
|
||||
* N × 24 h gap, because the tick fires within the notify hour and the next
|
||||
* tick can land a few seconds earlier on the clock than the first send did.
|
||||
*
|
||||
* A slot that moved after the last nudge — a snooze to a named day, a
|
||||
* Telegram "later", a skip, a satisfy — is a new slot and is never held.
|
||||
*/
|
||||
export function isCheckupRepeatHeld(
|
||||
reminder: { nextDueAt: Date | null; lastNotifiedAt?: Date | null },
|
||||
reminder: {
|
||||
nextDueAt: Date | null;
|
||||
lastNotifiedAt?: Date | null;
|
||||
intervalDays?: number | null;
|
||||
},
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): boolean {
|
||||
const { nextDueAt, lastNotifiedAt } = reminder;
|
||||
if (!nextDueAt || !lastNotifiedAt) return false;
|
||||
if (lastNotifiedAt.getTime() < nextDueAt.getTime()) return false;
|
||||
return calendarDaysUntil(now, lastNotifiedAt, timezone) < CHECKUP_REPEAT_DAYS;
|
||||
return (
|
||||
calendarDaysUntil(now, lastNotifiedAt, timezone) <
|
||||
checkupRepeatDays(reminder)
|
||||
);
|
||||
}
|
||||
|
||||
export interface MeasurementReminderSummary {
|
||||
@@ -341,9 +360,9 @@ export async function runMeasurementReminderTick(
|
||||
// there is nothing it could do this tick, so skip it before the
|
||||
// auto-resolve read: the eventful `reminder-satisfy` worker resolves it
|
||||
// the moment a reading or lab result lands, and the safety-net poll
|
||||
// below still runs for it once a day, in the notify hour. The weekly
|
||||
// below still runs for it once a day, in the notify hour. The repeat
|
||||
// hold stays after that poll on purpose, so the safety net is daily
|
||||
// rather than weekly.
|
||||
// rather than once per repeat.
|
||||
if (
|
||||
reminder.lastNotifiedAt != null &&
|
||||
reminder.nextDueAt !== null &&
|
||||
@@ -405,8 +424,8 @@ export async function runMeasurementReminderTick(
|
||||
}
|
||||
|
||||
// v1.39.2 — an open check-up stays due after its nudge, so without
|
||||
// this it would qualify again at every notify hour. Once a week is the
|
||||
// repeat; the per-day claim below is not enough on its own.
|
||||
// this it would qualify again at every notify hour. Its interval, at
|
||||
// most a week, is the repeat; the per-day claim is not enough alone.
|
||||
if (
|
||||
reminder.origin !== "ENCOUNTER" &&
|
||||
isCheckupRepeatHeld(reminder, timezone, now)
|
||||
@@ -529,9 +548,9 @@ export async function runMeasurementReminderTick(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dispatch succeeded. Record the delivery; a check-up keeps its due
|
||||
// date (it is still open, and `lastNotifiedAt` holds the repeat to a
|
||||
// week), everything else rolls on past this slot.
|
||||
// Dispatch succeeded. Record the delivery; a reminder that stays due
|
||||
// keeps its due date (it is still open, and `lastNotifiedAt` holds the
|
||||
// repeat), a short calendar rule rolls on past this slot.
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: holdsOpenAfterReminder(reminder, timezone, now)
|
||||
@@ -703,8 +722,9 @@ function nextSlotAfterReminder(
|
||||
// as-is is correct. For a ROLLING reminder the engine anchors the
|
||||
// first-due slot AT `anchorDate ?? createdAt` when never satisfied, which
|
||||
// stays ≤ now and would re-fire every tick — so re-anchor the rolling
|
||||
// cadence on `now` (a fire is the rhythm restarting from this dispatch) to
|
||||
// roll it forward by exactly one interval.
|
||||
// cadence on `now` to roll it forward by exactly one interval. A delivered
|
||||
// rolling reminder never comes here (it stays due); the rolling arm serves
|
||||
// the disabled-module skip, where nothing is left to deliver.
|
||||
const rolling = reminder.intervalDays !== null;
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ReferenceRangeStatus } from "@/lib/labs/reference-range";
|
||||
|
||||
import { ALIAS_PANEL, BIOMARKER_PANELS } from "../biomarker-catalog";
|
||||
import {
|
||||
applyCategoryOrder,
|
||||
categoriseOverview,
|
||||
customCategoryLabel,
|
||||
customPanelSuggestions,
|
||||
@@ -222,3 +223,110 @@ describe("categoriseOverview", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCategoryOrder", () => {
|
||||
const cats = (...keys: string[]) => keys.map((key) => ({ key }));
|
||||
const keysOf = (list: { key: string }[]) => list.map((c) => c.key);
|
||||
|
||||
it("keeps the default order without a saved order", () => {
|
||||
expect(
|
||||
keysOf(applyCategoryOrder(cats("lipids", "thyroid", "other"), [])),
|
||||
).toEqual(["lipids", "thyroid", "other"]);
|
||||
});
|
||||
|
||||
it("puts the listed categories first, in the listed order", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "thyroid", "liver", "other"), [
|
||||
"liver",
|
||||
"lipids",
|
||||
"thyroid",
|
||||
"other",
|
||||
]),
|
||||
),
|
||||
).toEqual(["liver", "lipids", "thyroid", "other"]);
|
||||
});
|
||||
|
||||
it("ignores keys the data does not have", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "thyroid"), [
|
||||
"custom:gone",
|
||||
"thyroid",
|
||||
"iron",
|
||||
"lipids",
|
||||
]),
|
||||
),
|
||||
).toEqual(["thyroid", "lipids"]);
|
||||
});
|
||||
|
||||
it("adds a category the order does not name after the listed ones, in the default order", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "glucose", "thyroid", "liver"), [
|
||||
"thyroid",
|
||||
"lipids",
|
||||
]),
|
||||
),
|
||||
).toEqual(["thyroid", "lipids", "glucose", "liver"]);
|
||||
});
|
||||
|
||||
it("keeps other last when the person left it at the end", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "iron", "custom:abc", "other"), [
|
||||
"lipids",
|
||||
"other",
|
||||
]),
|
||||
),
|
||||
).toEqual(["lipids", "iron", "custom:abc", "other"]);
|
||||
});
|
||||
|
||||
it("keeps other where the person moved it", () => {
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "iron", "thyroid", "other"), [
|
||||
"other",
|
||||
"lipids",
|
||||
"thyroid",
|
||||
]),
|
||||
),
|
||||
).toEqual(["other", "lipids", "thyroid", "iron"]);
|
||||
});
|
||||
|
||||
it("judges other's place among the present categories only", () => {
|
||||
// "thyroid" is gone from the data, so "other" ends the listed block.
|
||||
expect(
|
||||
keysOf(
|
||||
applyCategoryOrder(cats("lipids", "iron", "other"), [
|
||||
"lipids",
|
||||
"other",
|
||||
"thyroid",
|
||||
]),
|
||||
),
|
||||
).toEqual(["lipids", "iron", "other"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoriseOverview with a saved category order", () => {
|
||||
it("answers the categories in the person's order", () => {
|
||||
const { categories, grouped } = categoriseOverview(
|
||||
[
|
||||
marker("bm:1", "Mystery", null),
|
||||
marker("bm:2", "TSH", null),
|
||||
marker("bm:3", "LDL", null),
|
||||
marker("bm:4", "Cortisol", "Hormones"),
|
||||
],
|
||||
"en",
|
||||
["thyroid", "other", "lipids"],
|
||||
);
|
||||
expect(grouped).toBe(true);
|
||||
// Hormones is not in the order: it follows the listed categories.
|
||||
expect(categories.map((c) => c.label)).toEqual([
|
||||
"Thyroid",
|
||||
"Other",
|
||||
"Lipids",
|
||||
"Hormones",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normaliseLabUnit, sameLabUnit } from "../unit-normalise";
|
||||
import {
|
||||
normaliseLabUnit,
|
||||
sameLabUnit,
|
||||
withoutLabUnitAnnotation,
|
||||
} from "../unit-normalise";
|
||||
|
||||
describe("sameLabUnit — spellings of one unit", () => {
|
||||
it.each([
|
||||
@@ -88,3 +92,19 @@ describe("normaliseLabUnit", () => {
|
||||
expect(normaliseLabUnit("MG/DL")).toBe("mg/dL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a calculated-value note in the unit", () => {
|
||||
it("is not part of the unit", () => {
|
||||
expect(sameLabUnit("g/dL (calc)", "g/dL")).toBe(true);
|
||||
expect(sameLabUnit("mmol/l (berechnet)", "mmol/L")).toBe(true);
|
||||
expect(sameLabUnit("mg/dL (Calc.)", "MG/DL")).toBe(true);
|
||||
expect(withoutLabUnitAnnotation("(calc)")).toBe("");
|
||||
expect(withoutLabUnitAnnotation(" g/dL (calculated) ")).toBe("g/dL");
|
||||
});
|
||||
|
||||
it("keeps any other bracket, so it still surfaces as a mismatch", () => {
|
||||
expect(sameLabUnit("mg/dL (fasting)", "mg/dL")).toBe(false);
|
||||
expect(withoutLabUnitAnnotation("U/L (37 °C)")).toBe("U/L (37 °C)");
|
||||
expect(withoutLabUnitAnnotation("(calc) g/dL")).toBe("(calc) g/dL");
|
||||
});
|
||||
});
|
||||
@@ -191,14 +191,47 @@ export interface CategorisableMarker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category of an overview, in display order (catalog panels in catalog
|
||||
* order, then the person's own by label, then "other"), and whether the list
|
||||
* The person's category order (`categoryOrder` in the labs list layout)
|
||||
* over categories already in the default order.
|
||||
*
|
||||
* Listed keys come first, in the listed order; a key the data does not have
|
||||
* is skipped. A category the list does not name (a panel added since the
|
||||
* person arranged the list) follows the listed ones in the default order.
|
||||
* "other" is the one exception: left at the end of the listed categories it
|
||||
* stays last, after any category added later, because a catch-all belongs
|
||||
* at the bottom unless the person moved it up. An empty order is the
|
||||
* default order.
|
||||
*/
|
||||
export function applyCategoryOrder<T extends { key: string }>(
|
||||
categories: readonly T[],
|
||||
order: readonly string[],
|
||||
): T[] {
|
||||
if (order.length === 0) return [...categories];
|
||||
const present = new Set(categories.map((category) => category.key));
|
||||
const listed = order.filter((key) => present.has(key));
|
||||
const rank = new Map(listed.map((key, index) => [key, index]));
|
||||
const ordered = categories
|
||||
.filter((category) => rank.has(category.key))
|
||||
.sort((a, b) => rank.get(a.key)! - rank.get(b.key)!);
|
||||
const rest = categories.filter((category) => !rank.has(category.key));
|
||||
const otherLast = ordered.at(-1)?.key === OTHER_CATEGORY;
|
||||
if (!otherLast || rest.length === 0) return [...ordered, ...rest];
|
||||
return [...ordered.slice(0, -1), ...rest, ordered.at(-1)!];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category of an overview, in display order, and whether the list
|
||||
* groups at all: it does when there are two categories or more and at least
|
||||
* one is not "other". One category, or only "other", stays a flat list.
|
||||
*
|
||||
* The default order is catalog panels in catalog order (`BIOMARKER_PANELS`),
|
||||
* then the person's own by label, then "other"; `categoryOrder`, the
|
||||
* person's own arrangement, is applied over it (`applyCategoryOrder`).
|
||||
*/
|
||||
export function categoriseOverview(
|
||||
markers: readonly CategorisableMarker[],
|
||||
locale: Locale,
|
||||
categoryOrder: readonly string[] = [],
|
||||
): { categories: LabOverviewCategory[]; grouped: boolean } {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const buckets = new Map<
|
||||
@@ -264,5 +297,5 @@ export function categoriseOverview(
|
||||
const grouped =
|
||||
categories.length >= 2 &&
|
||||
categories.some((category) => category.key !== OTHER_CATEGORY);
|
||||
return { categories, grouped };
|
||||
return { categories: applyCategoryOrder(categories, categoryOrder), grouped };
|
||||
}
|
||||
@@ -21,9 +21,31 @@
|
||||
* does not recognise is compared exactly, so an unfamiliar spelling surfaces as
|
||||
* a mismatch rather than being guessed at.
|
||||
*
|
||||
* A lab's note that a value was calculated rather than measured (`g/dL
|
||||
* (calc)`, `(berechnet)`) is not part of the unit and is dropped first; see
|
||||
* `withoutLabUnitAnnotation`.
|
||||
*
|
||||
* Pure and dependency-free.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A trailing note in brackets that says the lab calculated the value. Some
|
||||
* reports print it in the unit column, and a read that copies the column
|
||||
* verbatim then minted a marker in "g/dL (calc)", which refused the next
|
||||
* report's plain "g/dL". Only these words are dropped, so a bracket that
|
||||
* carries meaning stays and still surfaces as a mismatch.
|
||||
*/
|
||||
const CALCULATED_NOTE =
|
||||
/\s*\((?:calc\.?|calculated|computed|berechnet|ber\.?)\)\s*$/i;
|
||||
|
||||
/**
|
||||
* The unit without a trailing calculated-value note, trimmed: `g/dL (calc)`
|
||||
* reads `g/dL`, and a unit that was only the note reads `""`.
|
||||
*/
|
||||
export function withoutLabUnitAnnotation(raw: string): string {
|
||||
return raw.replace(CALCULATED_NOTE, "").trim();
|
||||
}
|
||||
|
||||
/** Units that may be recognised when printed entirely in capitals. */
|
||||
const SHOUTED_UNITS: ReadonlyMap<string, string> = new Map(
|
||||
[
|
||||
@@ -79,7 +101,7 @@ function foldLitre(text: string): string {
|
||||
* means the same thing, and the (trimmed) string itself otherwise.
|
||||
*/
|
||||
export function normaliseLabUnit(raw: string): string {
|
||||
const spaced = foldSpacing(raw);
|
||||
const spaced = foldSpacing(withoutLabUnitAnnotation(raw));
|
||||
|
||||
const hasLowercase = /[a-zµμ]/.test(spaced);
|
||||
const hasUppercase = /[A-Z]/.test(spaced);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
HOLD_OPEN_MIN_CYCLE_DAYS,
|
||||
hasLongCycle,
|
||||
holdsOpenAfterReminder,
|
||||
} from "../holds-open";
|
||||
|
||||
@@ -35,13 +36,17 @@ describe("holdsOpenAfterReminder", () => {
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a weekly or shorter interval roll on", () => {
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 7 }), TZ, SLOT)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(holdsOpenAfterReminder(r({ intervalDays: 1 }), TZ, SLOT)).toBe(
|
||||
false,
|
||||
);
|
||||
// A rolling interval is anchored on its last satisfaction, so its next
|
||||
// slot only exists once it is measured: rolling it on after its reminder
|
||||
// re-anchored it on the send instead, and a missed every-two-days blood
|
||||
// pressure reading read "in 2 days" each evening and never "overdue".
|
||||
it("holds every rolling interval open, however short", () => {
|
||||
for (const intervalDays of [1, 2, 3, 7]) {
|
||||
expect(
|
||||
holdsOpenAfterReminder(r({ intervalDays }), TZ, SLOT),
|
||||
`every ${intervalDays} days`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a rule by the gap to its next occurrence", () => {
|
||||
@@ -109,3 +114,32 @@ describe("holdsOpenAfterReminder", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The digest's narrower question: whether a reminder is infrequent enough to
|
||||
// sit on the start page for its whole due day. A daily weigh-in at 20:00 is
|
||||
// not, even though it now stays due once its reminder is sent.
|
||||
describe("hasLongCycle", () => {
|
||||
it("reads a rolling interval by its length", () => {
|
||||
expect(hasLongCycle(r({ intervalDays: 1 }), TZ, SLOT)).toBe(false);
|
||||
expect(hasLongCycle(r({ intervalDays: 7 }), TZ, SLOT)).toBe(false);
|
||||
expect(hasLongCycle(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true);
|
||||
});
|
||||
|
||||
it("agrees with holdsOpenAfterReminder on every rule and on no cadence", () => {
|
||||
for (const rrule of [
|
||||
"FREQ=YEARLY;INTERVAL=1",
|
||||
"FREQ=WEEKLY",
|
||||
"FREQ=DAILY;BYHOUR=9,19",
|
||||
"FREQ=DAILY;UNTIL=20260926T235959Z",
|
||||
null,
|
||||
]) {
|
||||
expect(hasLongCycle(r({ rrule }), TZ, SLOT), String(rrule)).toBe(
|
||||
holdsOpenAfterReminder(r({ rrule }), TZ, SLOT),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("never counts an appointment", () => {
|
||||
expect(hasLongCycle(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,10 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
byHourTimesOfDay,
|
||||
computeReminderNextDueAt,
|
||||
parseReminderAnchor,
|
||||
type ReminderScheduleInput,
|
||||
} from "../scheduling";
|
||||
import { relativeDueKey } from "../due-day";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
@@ -52,6 +54,26 @@ describe("computeReminderNextDueAt", () => {
|
||||
expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z");
|
||||
});
|
||||
|
||||
// The create and edit routes compute `nextDueAt` with exactly this call.
|
||||
// A first due date weeks back, never measured since, stays on that day, so
|
||||
// the card reads it as overdue: it is not walked forward past now.
|
||||
it("rolling, never satisfied, first due weeks back: stays on the anchor day", () => {
|
||||
const now = new Date("2026-11-08T19:40:00Z"); // 20:40 in Berlin
|
||||
const reminder = base({
|
||||
intervalDays: 2,
|
||||
notifyHour: 18,
|
||||
anchorDate: parseReminderAnchor("2026-10-04", TZ),
|
||||
createdAt: now,
|
||||
});
|
||||
const next = computeReminderNextDueAt(reminder, TZ, now);
|
||||
// 4 October, 18:00 in Berlin (UTC+2).
|
||||
expect(next).toEqual(new Date("2026-10-04T16:00:00Z"));
|
||||
expect(relativeDueKey(next!.toISOString(), now.getTime(), TZ)).toEqual({
|
||||
key: "measurementReminders.overdueByDays",
|
||||
days: 35,
|
||||
});
|
||||
});
|
||||
|
||||
it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => {
|
||||
const reminder = base({
|
||||
intervalDays: 7,
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
/**
|
||||
* v1.39.2 — whether a reminder stays due after it is sent.
|
||||
*
|
||||
* Decided by the cycle length, not by what the reminder asks for. A reminder
|
||||
* whose next slot is more than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days after the
|
||||
* current one stays due until it is satisfied (a matching reading or lab
|
||||
* result, or "done"), skipped or snoozed: rolling it on would file an open
|
||||
* task under a slot weeks or a year away. That holds for free-text check-ups
|
||||
* and measurement reminders alike; the report that led here was a fortnightly
|
||||
* PHQ-9 and GAD-7 pair. A reminder with no cadence, or a rule with no
|
||||
* occurrence left, has no next slot and also stays due: its last slot is the
|
||||
* task. A weekly or shorter cycle rolls on, because its next slot is the next
|
||||
* reminder anyway, and holding a daily course on a missed slot would stall it.
|
||||
* An appointment (`ENCOUNTER`) is always one-shot.
|
||||
* A reminder that stays due is not moved by its reminder: it keeps its due
|
||||
* date until it is satisfied (a matching reading or lab result, or "done"),
|
||||
* skipped or snoozed, so every surface reads it as due and then overdue.
|
||||
*
|
||||
* - A rolling interval (`intervalDays`) always stays due. It is anchored on
|
||||
* its last satisfaction, so its next slot does not exist until it is
|
||||
* satisfied; rolling it on after a reminder re-anchored it on the send,
|
||||
* and a missed every-two-days blood pressure reading read "in 2 days"
|
||||
* each evening and was never shown as due or overdue.
|
||||
* - A calendar rule (`rrule`) stays due when its next occurrence is more
|
||||
* than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days away: rolling it on would
|
||||
* file an open task under a slot weeks or a year away. That holds for
|
||||
* free-text check-ups and measurement reminders alike; the report that
|
||||
* led here was a fortnightly PHQ-9 and GAD-7 pair. A rule that recurs
|
||||
* within the week rolls on, because its next occurrence is the next
|
||||
* reminder anyway and is fixed by the calendar, not by the last reading;
|
||||
* holding a twice-daily Coach course on a missed slot would stall it.
|
||||
* - A reminder with no cadence, or a rule with no occurrence left, has no
|
||||
* next slot and stays due: its last slot is the task.
|
||||
* - An appointment (`ENCOUNTER`) is always one-shot.
|
||||
*
|
||||
* The one definition. The reminder tick decides with it whether a delivered
|
||||
* reminder rolls on; the daily digest decides with it whether a reminder due
|
||||
* later today belongs on the start page already and keeps its place there;
|
||||
* the edit route decides with it whether re-enabling keeps the open slot.
|
||||
* reminder rolls on; the edit route decides with it whether re-enabling keeps
|
||||
* the open slot. The daily digest asks the narrower question underneath it,
|
||||
* {@link hasLongCycle}: whether a reminder is infrequent enough to sit on the
|
||||
* start page for its whole due day.
|
||||
*
|
||||
* A course window (`endsOn`) is deliberately not consulted: it ends a
|
||||
* Coach-suggested measurement course, which is a short-cycle rhythm and rolls
|
||||
@@ -25,7 +35,7 @@
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
|
||||
|
||||
/** A cycle of at most this many calendar days rolls on after its reminder. */
|
||||
/** A calendar rule recurring within this many calendar days rolls on after its reminder. */
|
||||
export const HOLD_OPEN_MIN_CYCLE_DAYS = 7;
|
||||
|
||||
export interface HoldOpenInput {
|
||||
@@ -41,15 +51,34 @@ export interface HoldOpenInput {
|
||||
/**
|
||||
* `slot` is the instant of the current slot: the send instant in the tick
|
||||
* (a few seconds past the occurrence it sends), the stored `nextDueAt`
|
||||
* elsewhere. For a rule the cycle is the calendar-day gap between its next
|
||||
* two occurrences from `slot` on, so the answer does not depend on where
|
||||
* between two occurrences `slot` sits. With one occurrence left the gap is
|
||||
* measured from `slot` to it; with none left the reminder holds open.
|
||||
* elsewhere.
|
||||
*/
|
||||
export function holdsOpenAfterReminder(
|
||||
reminder: HoldOpenInput,
|
||||
timezone: string,
|
||||
slot: Date,
|
||||
): boolean {
|
||||
if (reminder.origin === "ENCOUNTER") return false;
|
||||
if (reminder.intervalDays !== null) return true;
|
||||
return hasLongCycle(reminder, timezone, slot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the reminder's cycle is longer than {@link HOLD_OPEN_MIN_CYCLE_DAYS}
|
||||
* days, or it has no next slot at all. The daily digest puts such a reminder
|
||||
* on the start page from the morning of its due day and keeps it there under
|
||||
* the rail cap; a short rhythm (a daily weigh-in at 20:00) waits until its
|
||||
* time has come.
|
||||
*
|
||||
* For a rule the cycle is the calendar-day gap between its next two
|
||||
* occurrences from `slot` on, so the answer does not depend on where between
|
||||
* two occurrences `slot` sits. With one occurrence left the gap is measured
|
||||
* from `slot` to it; with none left the cycle counts as long.
|
||||
*/
|
||||
export function hasLongCycle(
|
||||
reminder: HoldOpenInput,
|
||||
timezone: string,
|
||||
slot: Date,
|
||||
): boolean {
|
||||
if (reminder.origin === "ENCOUNTER") return false;
|
||||
if (reminder.intervalDays !== null) {
|
||||
|
||||
@@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
// Blood ketones (#1367): HealthKit has no beta-hydroxybutyrate quantity
|
||||
// type, so a ketone reading is typed by hand. No HK mapping by design.
|
||||
"BLOOD_KETONES",
|
||||
]);
|
||||
|
||||
describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MGDL_PER_MMOL } from "@/lib/glucose";
|
||||
import {
|
||||
GKI_PAIRING_WINDOW_MINUTES,
|
||||
glucoseKetoneIndex,
|
||||
pairGlucoseKetoneIndex,
|
||||
summariseGlucoseKetoneIndex,
|
||||
type GkiReading,
|
||||
} from "@/lib/measurements/glucose-ketone-index";
|
||||
|
||||
const T0 = Date.parse("2026-11-01T08:00:00.000Z");
|
||||
const MIN = 60_000;
|
||||
|
||||
function reading(
|
||||
id: string,
|
||||
offsetMin: number,
|
||||
value: number,
|
||||
source = "MANUAL",
|
||||
): GkiReading {
|
||||
return { id, measuredAt: new Date(T0 + offsetMin * MIN), value, source };
|
||||
}
|
||||
|
||||
describe("glucoseKetoneIndex", () => {
|
||||
it("divides glucose in mmol/L by ketones, converting with the one factor", () => {
|
||||
// 90 mg/dL = 4.995 mmol/L; over 1.5 mmol/L ketones.
|
||||
expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(90 / MGDL_PER_MMOL / 1.5);
|
||||
expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(3.33, 2);
|
||||
});
|
||||
|
||||
it("has no index without ketones or with a value that is not positive", () => {
|
||||
expect(glucoseKetoneIndex(90, 0)).toBeNull();
|
||||
expect(glucoseKetoneIndex(90, -1)).toBeNull();
|
||||
expect(glucoseKetoneIndex(0, 1)).toBeNull();
|
||||
expect(glucoseKetoneIndex(Number.NaN, 1)).toBeNull();
|
||||
expect(glucoseKetoneIndex(90, Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairGlucoseKetoneIndex", () => {
|
||||
it("pairs a fingerstick written with one timestamp", () => {
|
||||
const [point, ...rest] = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1.2)],
|
||||
[reading("g1", 0, 81)],
|
||||
);
|
||||
expect(rest).toEqual([]);
|
||||
expect(point).toEqual({
|
||||
ketoneId: "k1",
|
||||
glucoseId: "g1",
|
||||
at: new Date(T0).toISOString(),
|
||||
glucoseAt: new Date(T0).toISOString(),
|
||||
// 81 / 18.0182 = 4.4954 mmol/L; / 1.2 = 3.746 -> 3.7
|
||||
value: 3.7,
|
||||
ketonesMmol: 1.2,
|
||||
glucoseMmol: 4.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the same instant over a nearer-looking neighbour", () => {
|
||||
const [point] = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1)],
|
||||
[reading("g-early", -1, 200), reading("g-same", 0, 90)],
|
||||
);
|
||||
expect(point?.glucoseId).toBe("g-same");
|
||||
});
|
||||
|
||||
it("takes the nearest glucose inside the window, before or after", () => {
|
||||
const [before] = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1)],
|
||||
[reading("g-far", -14, 100), reading("g-near", -3, 90)],
|
||||
);
|
||||
expect(before?.glucoseId).toBe("g-near");
|
||||
const [after] = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1)],
|
||||
[reading("g-after", 4, 90), reading("g-before", -9, 100)],
|
||||
);
|
||||
expect(after?.glucoseId).toBe("g-after");
|
||||
});
|
||||
|
||||
it("pairs at the window's edge and never beyond it", () => {
|
||||
const edge = GKI_PAIRING_WINDOW_MINUTES;
|
||||
expect(
|
||||
pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", edge, 90)]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", -edge, 90)]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1)],
|
||||
[reading("g1", edge + 0.5, 90), reading("g2", -(edge + 1), 90)],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves out a ketone reading of zero or without a partner", () => {
|
||||
expect(
|
||||
pairGlucoseKetoneIndex(
|
||||
[reading("k-zero", 0, 0), reading("k-alone", 120, 1.4)],
|
||||
[reading("g1", 0, 90)],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("breaks a tie by source, then by the earlier reading", () => {
|
||||
const [bySource] = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1, "MANUAL")],
|
||||
[reading("g-cgm", 0, 140, "APPLE_HEALTH"), reading("g-own", 0, 90)],
|
||||
);
|
||||
expect(bySource?.glucoseId).toBe("g-own");
|
||||
const [byTime] = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1)],
|
||||
[reading("g-later", 5, 100), reading("g-earlier", -5, 90)],
|
||||
);
|
||||
expect(byTime?.glucoseId).toBe("g-earlier");
|
||||
});
|
||||
|
||||
it("gives the same pair for a full tie whatever order the rows came in", () => {
|
||||
const a = reading("g-a", 0, 90, "APPLE_HEALTH");
|
||||
const b = reading("g-b", 0, 110, "NIGHTSCOUT");
|
||||
for (const glucose of [
|
||||
[a, b],
|
||||
[b, a],
|
||||
]) {
|
||||
const [point] = pairGlucoseKetoneIndex([reading("k1", 0, 1)], glucose);
|
||||
expect(point?.glucoseId).toBe("g-a");
|
||||
}
|
||||
});
|
||||
|
||||
it("answers in ketone order whatever order the readings came in", () => {
|
||||
const points = pairGlucoseKetoneIndex(
|
||||
[reading("k2", 60, 2), reading("k1", 0, 1)],
|
||||
[reading("g2", 61, 90), reading("g1", 1, 90)],
|
||||
);
|
||||
expect(points.map((p) => [p.ketoneId, p.glucoseId])).toEqual([
|
||||
["k1", "g1"],
|
||||
["k2", "g2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets one glucose reading serve two ketone readings and names it", () => {
|
||||
const points = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1), reading("k2", 6, 2)],
|
||||
[reading("g1", 3, 90)],
|
||||
);
|
||||
expect(points.map((p) => p.glucoseId)).toEqual(["g1", "g1"]);
|
||||
expect(points[1]!.value).toBeLessThan(points[0]!.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseGlucoseKetoneIndex", () => {
|
||||
it("summarises the indices and is null for none", () => {
|
||||
expect(summariseGlucoseKetoneIndex([])).toBeNull();
|
||||
const points = pairGlucoseKetoneIndex(
|
||||
[reading("k1", 0, 1), reading("k2", 60, 2), reading("k3", 120, 4)],
|
||||
[reading("g1", 0, 90), reading("g2", 60, 90), reading("g3", 120, 90)],
|
||||
);
|
||||
// 90 mg/dL = 4.995 mmol/L: indices 5.0, 2.5, 1.2.
|
||||
expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.2]);
|
||||
expect(summariseGlucoseKetoneIndex(points)).toEqual({
|
||||
count: 3,
|
||||
mean: 2.9,
|
||||
min: 1.2,
|
||||
max: 5,
|
||||
latest: 1.2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,7 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
|
||||
// ── Metabolic ──
|
||||
["BLOOD_GLUCOSE", "metabolic"],
|
||||
["BLOOD_KETONES", "metabolic"],
|
||||
["SKIN_TEMPERATURE", "metabolic"],
|
||||
// v1.10.0 — wrist temperature is an overnight skin-side reading;
|
||||
// it sits with skin temperature in the metabolic cluster.
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* The glucose-ketone index (GKI), derived from a ketone reading and the
|
||||
* glucose reading taken with it (#1367).
|
||||
*
|
||||
* GKI = glucose (mmol/L) / beta-hydroxybutyrate (mmol/L)
|
||||
*
|
||||
* Glucose is stored in mg/dL and converted with {@link MGDL_PER_MMOL}, the one
|
||||
* factor the tree uses for that conversion, so the glucose a GKI is built from
|
||||
* is the same number every glucose surface shows. The index is computed when
|
||||
* it is read and never stored: a stored ratio drifts the moment either reading
|
||||
* it came from is edited or deleted, which is the failure a hand-typed GKI has.
|
||||
*
|
||||
* Pairing, per ketone reading:
|
||||
*
|
||||
* 1. A glucose reading at the same instant wins. A fingerstick entry writes
|
||||
* both readings with one timestamp, so this is the usual case.
|
||||
* 2. Otherwise the nearest glucose reading no more than
|
||||
* {@link GKI_PAIRING_WINDOW_MINUTES} minutes before or after it.
|
||||
* 3. Nothing further away. A ketone reading with no glucose inside the
|
||||
* window, or with a value of zero, has no index.
|
||||
*
|
||||
* Ties at the same distance go to the reading from the same source as the
|
||||
* ketone reading (a manual fingerstick pairs with the manual glucose rather
|
||||
* than with a sensor reading at the same minute), then to the earlier one. One
|
||||
* glucose reading may serve two ketone readings taken close together; each
|
||||
* index names the glucose reading it was built from.
|
||||
*
|
||||
* The figure is descriptive. Nothing here grades it: what a GKI should be is a
|
||||
* matter for the plan a clinician sets.
|
||||
*/
|
||||
import { MGDL_PER_MMOL } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* The widest gap between a ketone and a glucose reading that still pairs.
|
||||
*
|
||||
* Fifteen minutes covers two strips taken one after the other, a meter that
|
||||
* reads ketones from a second drop, a reading typed in a few minutes late, and
|
||||
* a sensor that samples every five minutes, while staying short enough that
|
||||
* glucose has not moved on: after a meal it can rise by a few mg/dL a minute,
|
||||
* so an hour-old reading describes a different moment and the ratio built from
|
||||
* it would describe neither.
|
||||
*/
|
||||
export const GKI_PAIRING_WINDOW_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* The unit both components of the index are in. Fixed, not the reader's
|
||||
* glucose unit: the index is defined on mmol/L, and a ratio taken over mg/dL
|
||||
* would be eighteen times larger and mean something else.
|
||||
*/
|
||||
export const GKI_COMPONENT_UNIT = "mmol/L";
|
||||
export const GKI_PAIRING_WINDOW_MS = GKI_PAIRING_WINDOW_MINUTES * 60_000;
|
||||
|
||||
/** One stored reading, as the pairing needs it. */
|
||||
export interface GkiReading {
|
||||
id: string;
|
||||
measuredAt: Date;
|
||||
/** Ketones in mmol/L; glucose in canonical mg/dL. */
|
||||
value: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** One derived index, with the two readings it was built from. */
|
||||
export interface GlucoseKetoneIndexPoint {
|
||||
/** The ketone reading's id. The index belongs to the ketone reading. */
|
||||
ketoneId: string;
|
||||
/** The glucose reading it was paired with. */
|
||||
glucoseId: string;
|
||||
/** The ketone reading's instant, ISO 8601. */
|
||||
at: string;
|
||||
/** The glucose reading's instant, ISO 8601. */
|
||||
glucoseAt: string;
|
||||
/** The index, to one decimal. */
|
||||
value: number;
|
||||
/** Beta-hydroxybutyrate as recorded, mmol/L. */
|
||||
ketonesMmol: number;
|
||||
/** The paired glucose in mmol/L, to one decimal. */
|
||||
glucoseMmol: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index of one glucose (mg/dL) and one ketone (mmol/L) value, unrounded,
|
||||
* or null when it is undefined: no ketones, or a value that is not a positive
|
||||
* finite number on either side.
|
||||
*/
|
||||
export function glucoseKetoneIndex(
|
||||
glucoseMgdl: number,
|
||||
ketonesMmol: number,
|
||||
): number | null {
|
||||
if (!Number.isFinite(glucoseMgdl) || !Number.isFinite(ketonesMmol)) {
|
||||
return null;
|
||||
}
|
||||
if (ketonesMmol <= 0 || glucoseMgdl <= 0) return null;
|
||||
return glucoseMgdl / MGDL_PER_MMOL / ketonesMmol;
|
||||
}
|
||||
|
||||
function oneDecimal(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair every ketone reading with its glucose reading and compute the index.
|
||||
* Pure. Both lists may come in any order; the answer is in ketone-reading
|
||||
* order (oldest first). Ketone readings without a partner are left out.
|
||||
*/
|
||||
export function pairGlucoseKetoneIndex(
|
||||
ketones: readonly GkiReading[],
|
||||
glucose: readonly GkiReading[],
|
||||
): GlucoseKetoneIndexPoint[] {
|
||||
// The id breaks the last tie, so two glucose readings alike in time and
|
||||
// source always give the same pair, whatever order the rows came in.
|
||||
const sortedGlucose = [...glucose].sort(
|
||||
(a, b) =>
|
||||
a.measuredAt.getTime() - b.measuredAt.getTime() ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const sortedKetones = [...ketones].sort(
|
||||
(a, b) =>
|
||||
a.measuredAt.getTime() - b.measuredAt.getTime() ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const out: GlucoseKetoneIndexPoint[] = [];
|
||||
let lo = 0;
|
||||
for (const ketone of sortedKetones) {
|
||||
if (!(ketone.value > 0)) continue;
|
||||
const at = ketone.measuredAt.getTime();
|
||||
// Ketone readings are walked in time order, so the window's lower edge
|
||||
// only ever moves forward.
|
||||
while (
|
||||
lo < sortedGlucose.length &&
|
||||
sortedGlucose[lo]!.measuredAt.getTime() < at - GKI_PAIRING_WINDOW_MS
|
||||
) {
|
||||
lo++;
|
||||
}
|
||||
let best: GkiReading | null = null;
|
||||
let bestRank: [number, number, number] | null = null;
|
||||
for (let i = lo; i < sortedGlucose.length; i++) {
|
||||
const candidate = sortedGlucose[i]!;
|
||||
const t = candidate.measuredAt.getTime();
|
||||
if (t > at + GKI_PAIRING_WINDOW_MS) break;
|
||||
const rank: [number, number, number] = [
|
||||
Math.abs(t - at),
|
||||
candidate.source === ketone.source ? 0 : 1,
|
||||
t,
|
||||
];
|
||||
if (
|
||||
bestRank === null ||
|
||||
rank[0] < bestRank[0] ||
|
||||
(rank[0] === bestRank[0] &&
|
||||
(rank[1] < bestRank[1] ||
|
||||
(rank[1] === bestRank[1] && rank[2] < bestRank[2])))
|
||||
) {
|
||||
best = candidate;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
if (best === null) continue;
|
||||
const index = glucoseKetoneIndex(best.value, ketone.value);
|
||||
if (index === null) continue;
|
||||
out.push({
|
||||
ketoneId: ketone.id,
|
||||
glucoseId: best.id,
|
||||
at: ketone.measuredAt.toISOString(),
|
||||
glucoseAt: best.measuredAt.toISOString(),
|
||||
value: oneDecimal(index),
|
||||
ketonesMmol: ketone.value,
|
||||
glucoseMmol: oneDecimal(best.value / MGDL_PER_MMOL),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Count, mean, min, max and latest of a run of indices, or null when empty. */
|
||||
export interface GlucoseKetoneIndexSummary {
|
||||
count: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
latest: number;
|
||||
}
|
||||
|
||||
export function summariseGlucoseKetoneIndex(
|
||||
points: readonly GlucoseKetoneIndexPoint[],
|
||||
): GlucoseKetoneIndexSummary | null {
|
||||
if (points.length === 0) return null;
|
||||
const values = points.map((p) => p.value);
|
||||
return {
|
||||
count: values.length,
|
||||
mean: oneDecimal(values.reduce((a, b) => a + b, 0) / values.length),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
latest: values[values.length - 1]!,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Read the glucose-ketone index of a window (#1367): the ketone readings in
|
||||
* it and the glucose readings within pairing reach of one, paired by
|
||||
* `pairGlucoseKetoneIndex`. Every surface that shows the index (the series
|
||||
* route, the day, the doctor report) reads it here, so the pairing rule has
|
||||
* one implementation.
|
||||
*
|
||||
* Glucose and ketones carry no source ladder (neither is in `RANKED_TYPES`),
|
||||
* so every live row of every source is a candidate, as on the glucose chart.
|
||||
* The glucose read is bounded by the ketone readings: only rows within the
|
||||
* pairing window of one are fetched, so a sensor writing every five minutes
|
||||
* contributes a handful of rows per fingerstick, not the window's thousands.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
import {
|
||||
GKI_PAIRING_WINDOW_MINUTES,
|
||||
pairGlucoseKetoneIndex,
|
||||
type GkiReading,
|
||||
type GlucoseKetoneIndexPoint,
|
||||
} from "./glucose-ketone-index";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function toReading(row: Row): GkiReading {
|
||||
return {
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
measuredAt: row.measured_at,
|
||||
source: row.source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The indices of the ketone readings taken in `[from, to)`, oldest first.
|
||||
* A glucose reading just outside the window still pairs with a ketone reading
|
||||
* just inside it.
|
||||
*/
|
||||
export async function readGlucoseKetoneIndex(args: {
|
||||
userId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<GlucoseKetoneIndexPoint[]> {
|
||||
const { userId, from, to } = args;
|
||||
const ketones = await prisma.$queryRaw<Row[]>`
|
||||
SELECT k."id", k."value", k."measured_at", k."source"::text AS "source"
|
||||
FROM measurements k
|
||||
WHERE k."user_id" = ${userId}
|
||||
AND k."type" = 'BLOOD_KETONES'::"measurement_type"
|
||||
AND k."deleted_at" IS NULL
|
||||
AND k."measured_at" >= ${from}
|
||||
AND k."measured_at" < ${to}
|
||||
AND k."value" > 0
|
||||
ORDER BY k."measured_at" ASC, k."id" ASC
|
||||
`;
|
||||
if (ketones.length === 0) return [];
|
||||
// An interval between two stored instants: no zone enters the arithmetic.
|
||||
const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`;
|
||||
// Driven from the ketone side: one index range per ketone reading on
|
||||
// (user_id, type, measured_at), so a person with years of CGM glucose
|
||||
// reads only the glucose around their fingersticks, never the series. A
|
||||
// correlated EXISTS over a range had no equality to drive it the other
|
||||
// way and walked every glucose row in the window.
|
||||
const at = ketones.map((k) => k.measured_at);
|
||||
const glucose = await prisma.$queryRaw<Row[]>`
|
||||
SELECT DISTINCT g."id", g."value", g."measured_at", g."source"
|
||||
FROM unnest(${at}::timestamp(3)[]) AS k("at")
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT m."id", m."value", m."measured_at", m."source"::text AS "source"
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" BETWEEN k."at" - ${reach}::interval
|
||||
AND k."at" + ${reach}::interval
|
||||
) g
|
||||
`;
|
||||
return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading));
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export type SeriesKind =
|
||||
| "oxygenSaturation"
|
||||
| "restingHeartRate"
|
||||
| "heartRateVariability"
|
||||
| "vo2Max";
|
||||
| "vo2Max"
|
||||
| "ketones"
|
||||
| "glucoseKetoneIndex";
|
||||
|
||||
export interface SeriesReferenceLine {
|
||||
value: number;
|
||||
|
||||
@@ -72,6 +72,10 @@ export const kindEnum = z.enum([
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
// #1367 — blood ketones, and the glucose-ketone index derived from them and
|
||||
// the glucose reading taken with each (`glucose-ketone-index.ts`).
|
||||
"ketones",
|
||||
"glucoseKetoneIndex",
|
||||
]);
|
||||
|
||||
export type SeriesKind = z.infer<typeof kindEnum>;
|
||||
@@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record<SeriesKind, MeasurementType> = {
|
||||
restingHeartRate: "RESTING_HEART_RATE",
|
||||
heartRateVariability: "HEART_RATE_VARIABILITY",
|
||||
vo2Max: "VO2_MAX",
|
||||
ketones: "BLOOD_KETONES",
|
||||
// The index has no stored type. It belongs to its ketone reading, so the
|
||||
// change direction and day values read as the ketone series' would; neither
|
||||
// has a band, so nothing is judged.
|
||||
glucoseKetoneIndex: "BLOOD_KETONES",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -119,6 +128,9 @@ export const SERIES_UNIT: Record<SeriesKind, string> = {
|
||||
restingHeartRate: "bpm",
|
||||
heartRateVariability: "ms",
|
||||
vo2Max: "mL/(kg·min)",
|
||||
ketones: "mmol/L",
|
||||
// A ratio of two concentrations in the same unit: dimensionless.
|
||||
glucoseKetoneIndex: "ratio",
|
||||
};
|
||||
|
||||
export interface SeriesPoint {
|
||||
@@ -142,6 +154,17 @@ export interface SeriesPoint {
|
||||
*/
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
/**
|
||||
* #1367 — the two readings a `kind=glucoseKetoneIndex` point was built
|
||||
* from. Absent for every other kind. `id` and `at` are the ketone
|
||||
* reading's; this names the glucose reading paired with it.
|
||||
*/
|
||||
gki?: {
|
||||
glucoseId: string;
|
||||
glucoseAt: string;
|
||||
glucoseMmol: number;
|
||||
ketonesMmol: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function stdDev(values: number[]): number {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `kind=glucoseKetoneIndex`: one point per ketone reading that pairs with a
|
||||
* glucose reading (#1367). The index is derived at read time by the one
|
||||
* pairing rule (`read-glucose-ketone-index.ts`); a client draws the value it
|
||||
* is given and never recomputes it. Ketone readings are a few a day at most,
|
||||
* so the window is never bucketed.
|
||||
*/
|
||||
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
|
||||
|
||||
import type { SeriesRead } from "./kinds";
|
||||
|
||||
export async function readGlucoseKetoneIndexSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<SeriesRead> {
|
||||
const points = await readGlucoseKetoneIndex({
|
||||
userId,
|
||||
from: since,
|
||||
// Open-ended: a reading stamped a moment ahead of the server clock is
|
||||
// still the latest one.
|
||||
to: new Date(Date.now() + 86_400_000),
|
||||
});
|
||||
return {
|
||||
points: points.map((p) => ({
|
||||
id: p.ketoneId,
|
||||
at: p.at,
|
||||
value: p.value,
|
||||
secondary: null,
|
||||
gki: {
|
||||
glucoseId: p.glucoseId,
|
||||
glucoseAt: p.glucoseAt,
|
||||
glucoseMmol: p.glucoseMmol,
|
||||
ketonesMmol: p.ketonesMmol,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./kinds";
|
||||
import { readBloodPressureSeries } from "./read-blood-pressure";
|
||||
import { readBucketedSeries, takesBucketedRead } from "./read-bucketed";
|
||||
import { readGlucoseKetoneIndexSeries } from "./read-glucose-ketone-index";
|
||||
import { readRawSeries } from "./read-raw";
|
||||
import { readSleepSeries } from "./read-sleep";
|
||||
|
||||
@@ -40,6 +41,9 @@ async function readPoints(
|
||||
): Promise<SeriesRead> {
|
||||
if (kind === "sleep") return readSleepSeries(userId, since, days);
|
||||
if (kind === "bloodPressure") return readBloodPressureSeries(userId, since);
|
||||
if (kind === "glucoseKetoneIndex") {
|
||||
return readGlucoseKetoneIndexSeries(userId, since);
|
||||
}
|
||||
if (await takesBucketedRead(userId, kind, days, since)) {
|
||||
return readBucketedSeries(userId, kind, days, since);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
|
||||
SLEEP_DURATION: "measurements.typeSleep",
|
||||
ACTIVITY_STEPS: "measurements.typeSteps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
BLOOD_KETONES: "measurements.typeBloodKetones",
|
||||
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
|
||||
BONE_MASS: "measurements.typeBoneMass",
|
||||
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
|
||||
|
||||
@@ -21,6 +21,7 @@ const unitMap: Record<string, string> = {
|
||||
SLEEP_DURATION: "minutes",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
BLOOD_KETONES: "mmol/L",
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
|
||||
@@ -20,6 +20,12 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
SLEEP_DURATION: { min: 0, max: 1440 },
|
||||
ACTIVITY_STEPS: { min: 0, max: 200000 },
|
||||
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
|
||||
// Beta-hydroxybutyrate, mmol/L. The blood ketone meters people type these
|
||||
// off (Abbott Precision Xtra / FreeStyle Optium, Keto-Mojo, Nova Max Plus)
|
||||
// measure from 0.0 or 0.1 up to 8.0 mmol/L and print "HI" above it; 10
|
||||
// leaves room for a venous value read off a lab sheet. A meter reads 0.0,
|
||||
// so zero is a real reading, not an empty one.
|
||||
BLOOD_KETONES: { min: 0, max: 10 },
|
||||
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
|
||||
BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg)
|
||||
// Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the
|
||||
|
||||
@@ -86,6 +86,21 @@ describe("crisisResourcesForLocale", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a Korean reader 119 and the 109 suicide prevention line", () => {
|
||||
for (const locale of ["ko", "ko-KR", "ko_KR", "kr", "en-KR"]) {
|
||||
const set = crisisResourcesForLocale(locale);
|
||||
expect(set.emergencyNumber, locale).toBe("119");
|
||||
expect(set.resources, locale).toEqual([
|
||||
{ id: "suicidePrevention109", contacts: ["109"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
]);
|
||||
}
|
||||
const { t } = getServerTranslator("ko");
|
||||
expect(
|
||||
t("mentalHealth.crisisResource.suicidePrevention109.name"),
|
||||
).toContain("109");
|
||||
});
|
||||
|
||||
it("resolves the country from the region subtag whatever the language", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["en-BE", "zelfmoordlijn1813"],
|
||||
@@ -143,6 +158,7 @@ describe("crisisResourcesForLocale", () => {
|
||||
"en-US",
|
||||
"fr",
|
||||
"nl",
|
||||
"ko",
|
||||
].flatMap((l) => crisisResourcesForLocale(l).resources.map((r) => r.id)),
|
||||
);
|
||||
for (const locale of locales) {
|
||||
|
||||
@@ -178,6 +178,20 @@ const BE: CrisisResourceSet = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* South Korea (the region "kr", and Korean with no region, which is read
|
||||
* almost only there). 119 is the emergency number for an ambulance. Since
|
||||
* January 2024 the national suicide prevention line 109 has taken over the
|
||||
* earlier counselling numbers and answers around the clock.
|
||||
*/
|
||||
const KR: CrisisResourceSet = {
|
||||
emergencyNumber: "119",
|
||||
resources: [
|
||||
{ id: "suicidePrevention109", contacts: ["109"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch with no region signal. The app ships one Dutch bundle, `nl`, read in
|
||||
* the Netherlands and in Flanders alike, and the account keeps no country to
|
||||
@@ -205,6 +219,7 @@ const BY_REGION: Readonly<Record<string, CrisisResourceSet>> = {
|
||||
br: BR,
|
||||
ch: CH,
|
||||
de: DE,
|
||||
kr: KR,
|
||||
nl: NL,
|
||||
pt: PT,
|
||||
us: US,
|
||||
@@ -235,7 +250,7 @@ function regionSubtag(tag: string): string | null {
|
||||
* bundle (see PT_BR_AMBIGUOUS), so it keeps the ambiguous set. The web stores
|
||||
* the app locale, which is otherwise a bare "de", "nl", "en", so it lands on
|
||||
* the combined sets: never assume Germany, the Netherlands or Brazil when the
|
||||
* region is unknown. A bare country code ("at", "ch", "us", "br", "be")
|
||||
* region is unknown. A bare country code ("at", "ch", "us", "br", "be", "kr")
|
||||
* still resolves to that country.
|
||||
*/
|
||||
export function crisisResourcesForLocale(
|
||||
@@ -248,7 +263,14 @@ export function crisisResourcesForLocale(
|
||||
const byRegion = BY_REGION[region];
|
||||
if (byRegion) return byRegion;
|
||||
}
|
||||
if (lc === "at" || lc === "ch" || lc === "us" || lc === "br" || lc === "be") {
|
||||
if (
|
||||
lc === "at" ||
|
||||
lc === "ch" ||
|
||||
lc === "us" ||
|
||||
lc === "br" ||
|
||||
lc === "be" ||
|
||||
lc === "kr"
|
||||
) {
|
||||
return BY_REGION[lc];
|
||||
}
|
||||
const language = lc.split("-")[0];
|
||||
@@ -258,5 +280,6 @@ export function crisisResourcesForLocale(
|
||||
if (language === "pt") return PT_BR_AMBIGUOUS;
|
||||
// The bare "nl" the web stores, and any other Dutch tag: both countries.
|
||||
if (language === "nl") return NL_BE;
|
||||
if (language === "ko") return KR;
|
||||
return INTERNATIONAL;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
/**
|
||||
* Per-list presentation for the labs, illness and checkups lists: card or
|
||||
* list view, a manual order, and (labs only) the sort and the categories.
|
||||
* list view, a manual order, and (labs only) the sort, the categories and
|
||||
* their order.
|
||||
*
|
||||
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
|
||||
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
|
||||
@@ -198,6 +199,11 @@ type Translator = (
|
||||
* Apply one field optimistically (the control has to feel instant), then
|
||||
* persist it. On failure the cache rolls back and a toast says so; a view or
|
||||
* sort change confirms with a toast, an order change is its own feedback.
|
||||
*
|
||||
* The category order is applied by the server (`GET /api/labs/overview`
|
||||
* answers `categories` already arranged), so a change to it moves the
|
||||
* cached overview's categories to the full order the editor sent, at once,
|
||||
* and then refetches the overview, whose answer is the one that stays.
|
||||
*/
|
||||
export async function runSetModuleListPrefs(deps: {
|
||||
list: ModuleListKey;
|
||||
@@ -210,6 +216,25 @@ export async function runSetModuleListPrefs(deps: {
|
||||
if (queryClient.isFetching({ queryKey: key }) > 0) {
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
}
|
||||
const overviewKey = queryKeys.labResultsOverview();
|
||||
const categoryOrder = patch.categoryOrder;
|
||||
if (categoryOrder !== undefined) {
|
||||
await queryClient.cancelQueries({ queryKey: overviewKey });
|
||||
queryClient.setQueriesData<{ categories?: { key: string }[] }>(
|
||||
{ queryKey: overviewKey },
|
||||
(cached) =>
|
||||
cached?.categories
|
||||
? {
|
||||
...cached,
|
||||
categories: applyOrder(
|
||||
cached.categories,
|
||||
categoryOrder,
|
||||
(category) => category.key,
|
||||
),
|
||||
}
|
||||
: cached,
|
||||
);
|
||||
}
|
||||
const previous = queryClient.getQueryData<ModuleListLayout>(key);
|
||||
const base = previous ?? resolveModuleListLayout(null);
|
||||
queryClient.setQueryData<ModuleListLayout>(key, {
|
||||
@@ -237,6 +262,10 @@ export async function runSetModuleListPrefs(deps: {
|
||||
} catch (failure) {
|
||||
queryClient.setQueryData(key, previous);
|
||||
showErrorToast(t("settings.savingError"), { cause: failure });
|
||||
} finally {
|
||||
if (categoryOrder !== undefined) {
|
||||
void queryClient.invalidateQueries({ queryKey: overviewKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +281,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
setSortDir: (sortDir: ModuleSortDir) => void;
|
||||
setGroupBy: (groupBy: ModuleGroupBy) => void;
|
||||
setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void;
|
||||
setCategoryOrder: (categoryOrder: string[]) => void;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
@@ -283,6 +313,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
sortDir: entry.sortDir,
|
||||
groupBy: entry.groupBy,
|
||||
groupsOpen: entry.groupsOpen,
|
||||
categoryOrder: entry.categoryOrder,
|
||||
};
|
||||
}
|
||||
const defaults = moduleListDefaults(list);
|
||||
@@ -293,6 +324,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
sortDir: entry.sortDir,
|
||||
groupBy: entry.groupBy,
|
||||
groupsOpen: entry.groupsOpen,
|
||||
categoryOrder: entry.categoryOrder,
|
||||
}
|
||||
: defaults;
|
||||
if (!mounted) return fallback;
|
||||
@@ -328,6 +360,11 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
[write],
|
||||
);
|
||||
|
||||
const setCategoryOrder = useCallback(
|
||||
(categoryOrder: string[]) => write({ categoryOrder }),
|
||||
[write],
|
||||
);
|
||||
|
||||
return {
|
||||
prefs,
|
||||
isLoaded,
|
||||
@@ -336,5 +373,6 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
setSortDir,
|
||||
setGroupBy,
|
||||
setGroupsOpen,
|
||||
setCategoryOrder,
|
||||
};
|
||||
}
|
||||
@@ -105,6 +105,10 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
|
||||
STRESS_LEVEL: "recovery",
|
||||
TRAINING_READINESS: "recovery",
|
||||
CARDIO_RECOVERY: "recovery",
|
||||
// Blood ketones are read off the same meter as fingerstick glucose and
|
||||
// belong to the glucose module; they stay off the Coach snapshot, so the
|
||||
// scope token cannot reach them.
|
||||
BLOOD_KETONES: "glucose",
|
||||
// The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD
|
||||
// fallback for an account that only has ring/strap nightly RMSSD rows, so
|
||||
// leaving this unowned would have let the fallback path serve exactly the
|
||||
|
||||
@@ -98,6 +98,7 @@ const STATIC_SURFACE_MODULE = {
|
||||
// The per-night breathing-disturbance index is a sleep-page signal.
|
||||
"insights-page:breathing-disturbances": "sleep",
|
||||
"insights-page:blood-glucose": "glucose",
|
||||
"insights-page:ketones": "glucose",
|
||||
"insights-page:workouts": "workouts",
|
||||
"insights-page:nutrients": "nutrients",
|
||||
// Medications is surface-gated by design: its data routes stay open so an
|
||||
@@ -160,6 +161,8 @@ const STATIC_SURFACE_MODULE = {
|
||||
// ── Dashboard summary keys ──
|
||||
"summary:SLEEP_DURATION": "sleep",
|
||||
"summary:BLOOD_GLUCOSE": "glucose",
|
||||
// Blood ketones sit beside glucose: same meter, same module.
|
||||
"summary:BLOOD_KETONES": "glucose",
|
||||
"summary:CARDIO_RECOVERY": "recovery",
|
||||
// The recovery module owns the resting_hr domain (`measurement-scope.ts`),
|
||||
// so the server gates the type on it; the form and the dashboard follow.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
cycleFeedForecastStyleEnum,
|
||||
cycleFeedOptionsPatchSchema,
|
||||
cycleFeedTitleModeEnum,
|
||||
} from "@/lib/validations/cycle-feed";
|
||||
@@ -76,6 +77,9 @@ const feedSummary = z
|
||||
historyCycles: z.number().int().min(0).max(12),
|
||||
includeFertile: z.boolean(),
|
||||
titleMode: cycleFeedTitleModeEnum,
|
||||
forecastStyle: cycleFeedForecastStyleEnum.describe(
|
||||
"How a forecast period is drawn. `EXACT`: one event over the predicted bleeding days from the most likely start. `MARGIN`: that event, plus a separate event for the days before it the period may start on and one for the days after it may still run on (each only when the band reaches that side). `BAND`: one event from the earliest likely start through the last bleeding day of the latest likely start. A subscription turned on before the option existed is `BAND`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CycleFeedSummary",
|
||||
@@ -202,7 +206,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Turn the calendar subscription on (#1350)",
|
||||
description:
|
||||
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
|
||||
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles, `MARGIN` forecasts. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"409": {
|
||||
@@ -215,7 +219,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Change the calendar subscription's options (#1350)",
|
||||
description:
|
||||
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.",
|
||||
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. A change saved while the link is being replaced applies to the link that stays live.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: feedOptionsRequest } },
|
||||
@@ -233,6 +237,11 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The link was replaced twice while the change was being saved (`cycle.feed.exists`); nothing changed, and sending it again applies it to the link that is live now.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...malformedJsonResponse,
|
||||
...stdResponses,
|
||||
@@ -271,7 +280,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Replace the calendar subscription's link (#1350)",
|
||||
description:
|
||||
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.",
|
||||
"One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands; a rotation racing the subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off.",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"404": {
|
||||
|
||||
@@ -171,7 +171,7 @@ export const labOverviewResponse = z
|
||||
markers: z.array(labOverviewMarker),
|
||||
categories: z.array(labOverviewCategory).meta({
|
||||
description:
|
||||
"The list's categories in display order: catalog panels in catalog order, then the person's own panels by label, then `other`.",
|
||||
"The list's categories in display order, already arranged for the caller: the default order is catalog panels in catalog order, then the person's own panels by label, then `other`, and the caller's `categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this order.",
|
||||
}),
|
||||
grouped: z.boolean().meta({
|
||||
description:
|
||||
|
||||
@@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "List Vorsorge reminders (v1.17.1)",
|
||||
description:
|
||||
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.",
|
||||
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. A rolling reminder (intervalDays, any length), an rrule reminder whose next occurrence is more than seven days out, and a reminder with no cadence keep their nextDueAt after their reminder is sent, with or without a measurementType: they stay due, then overdue, until satisfied (a matching reading or lab result, or completed), skipped or snoozed. While one stays open the server repeats the reminder every intervalDays local days for a rolling reminder, capped at seven, and every seven local days otherwise. An rrule reminder that recurs within seven days rolls on to its next slot after a delivered reminder.",
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
|
||||
@@ -696,6 +696,8 @@ const seriesKindEnum = z.enum([
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
"ketones",
|
||||
"glucoseKetoneIndex",
|
||||
]);
|
||||
|
||||
const seriesQuerySchema = z.object({
|
||||
@@ -747,6 +749,23 @@ const seriesPointSchema = z.object({
|
||||
.describe(
|
||||
"v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.",
|
||||
),
|
||||
gki: z
|
||||
.object({
|
||||
glucoseId: z.string().describe("The paired glucose reading's id."),
|
||||
glucoseAt: isoDateTime().describe(
|
||||
"The paired glucose reading's timestamp.",
|
||||
),
|
||||
glucoseMmol: z
|
||||
.number()
|
||||
.describe("The paired glucose in mmol/L, one decimal."),
|
||||
ketonesMmol: z
|
||||
.number()
|
||||
.describe("The ketone reading (beta-hydroxybutyrate), mmol/L."),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are the ketone reading's. Absent for every other kind.",
|
||||
),
|
||||
});
|
||||
|
||||
const seriesResponse = z
|
||||
@@ -755,7 +774,7 @@ const seriesResponse = z
|
||||
unit: z
|
||||
.string()
|
||||
.describe(
|
||||
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).",
|
||||
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.",
|
||||
),
|
||||
points: z.array(seriesPointSchema),
|
||||
stats: z
|
||||
@@ -783,7 +802,7 @@ const seriesResponse = z
|
||||
.meta({
|
||||
id: "MeasurementsSeriesResponse",
|
||||
description:
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it.",
|
||||
});
|
||||
|
||||
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
|
||||
|
||||
@@ -59,6 +59,11 @@ const moduleListPrefsEntry = z
|
||||
.describe(
|
||||
'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.',
|
||||
),
|
||||
categoryOrder: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'The person\'s order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing from the live data are ignored; a category missing from the list follows the listed ones in the default order, and "other" left at the end of the list stays last. Default empty (the default order). The other lists ignore it.',
|
||||
),
|
||||
saved: z
|
||||
.boolean()
|
||||
.describe(
|
||||
|
||||
@@ -256,7 +256,7 @@ const syncChangesQuery = z
|
||||
.literal("now")
|
||||
.optional()
|
||||
.describe(
|
||||
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
|
||||
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits two minutes before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in those two minutes (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
|
||||
),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
@@ -285,7 +285,9 @@ export const syncChangesResponse = z
|
||||
.describe("Opaque cursor to echo into the next request."),
|
||||
hasMore: z
|
||||
.boolean()
|
||||
.describe("False once the client is caught up as of `serverNow`."),
|
||||
.describe(
|
||||
"False once the client is caught up as of `serverNow`. Every row domain that ran dry on a page takes a watermark two minutes before `serverNow` (on the last page of a walk, all of them), so the next pull reads rows changed in that stretch again (a write still in flight may commit a row stamped there) and a domain that stays quiet never ages its cursor past the tombstone horizon; upserts are idempotent.",
|
||||
),
|
||||
cursorExpired: z
|
||||
.boolean()
|
||||
.describe(
|
||||
|
||||
@@ -175,10 +175,11 @@ export function getPRDirection(
|
||||
if (context.weightGoal === "higher") return PersonalRecordDirection.MAX;
|
||||
return null;
|
||||
|
||||
// Explicitly no PersonalRecord — see comment block above.
|
||||
// Explicitly no PersonalRecord (see above); ketones follow a clinician's plan.
|
||||
case "BLOOD_PRESSURE_SYS":
|
||||
case "BLOOD_PRESSURE_DIA":
|
||||
case "BLOOD_GLUCOSE":
|
||||
case "BLOOD_KETONES":
|
||||
case "BODY_TEMPERATURE":
|
||||
case "SKIN_TEMPERATURE":
|
||||
case "PULSE":
|
||||
@@ -267,9 +268,8 @@ export function getPRDirection(
|
||||
case "TRAINING_READINESS":
|
||||
case "ACUTE_TRAINING_LOAD":
|
||||
// v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite
|
||||
// (like the other sleep-quality indices), and the body-temperature
|
||||
// deviation is a homeostatic signed offset centred on 0 — neither has a
|
||||
// goal axis, so both stay null like the rest of the derived/homeostatic set.
|
||||
// and the body-temperature deviation a homeostatic signed offset centred
|
||||
// on 0: neither has a goal axis, so both stay null like the set above.
|
||||
case "SLEEP_SCORE":
|
||||
case "BODY_TEMPERATURE_DEVIATION":
|
||||
// v1.19.0 — Oura resilience. An ordinal-encoded categorical band
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** One point of `kind=glucoseKetoneIndex`, as the server resolved it. */
|
||||
export interface GlucoseKetoneIndexSeriesPoint {
|
||||
/** The ketone reading's id. */
|
||||
id: string;
|
||||
/** The ketone reading's instant. */
|
||||
at: string;
|
||||
/** The index, one decimal. */
|
||||
value: number;
|
||||
gki: {
|
||||
glucoseId: string;
|
||||
glucoseAt: string;
|
||||
glucoseMmol: number;
|
||||
ketonesMmol: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GlucoseKetoneIndexSeries {
|
||||
kind: "glucoseKetoneIndex";
|
||||
unit: string;
|
||||
points: GlucoseKetoneIndexSeriesPoint[];
|
||||
}
|
||||
|
||||
/** The whole history the series route serves; the chart narrows it. */
|
||||
export const GKI_SERIES_DAYS = 3650;
|
||||
|
||||
/**
|
||||
* The glucose-ketone index over the last {@link GKI_SERIES_DAYS} days (#1367).
|
||||
* The server pairs the readings and computes every index; the card draws them
|
||||
* as given.
|
||||
*/
|
||||
export function useGlucoseKetoneIndexSeries() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return useQuery({
|
||||
queryKey: queryKeys.glucoseKetoneIndexSeries(GKI_SERIES_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<GlucoseKetoneIndexSeries>(
|
||||
`/api/measurements/series?kind=glucoseKetoneIndex&days=${GKI_SERIES_DAYS}`,
|
||||
),
|
||||
staleTime: 60_000,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
}
|
||||
@@ -127,6 +127,14 @@ export const measurementKeys = {
|
||||
bpTimeOfDay: (days: number) =>
|
||||
["measurements", "bp-time-of-day", days] as const,
|
||||
|
||||
/**
|
||||
* #1367 — the glucose-ketone index series
|
||||
* (`GET /api/measurements/series?kind=glucoseKetoneIndex&days=`). Under the
|
||||
* `["measurements"]` prefix, so a new glucose or ketone reading refreshes it.
|
||||
*/
|
||||
glucoseKetoneIndexSeries: (days: number) =>
|
||||
["measurements", "glucose-ketone-index", days] as const,
|
||||
|
||||
/**
|
||||
* v1.8.5 — bounded recent-timestamp read powering the
|
||||
* measurement-diversity nudge on an insights category page. Keyed by
|
||||
|
||||
@@ -63,9 +63,9 @@ function resolves(key: string): boolean {
|
||||
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(91);
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(92);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(111);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(112);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
|
||||
@@ -197,6 +197,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
|
||||
|
||||
// ── glucose ──
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
BLOOD_KETONES: "glucose",
|
||||
|
||||
// ── fenced ──
|
||||
// Mindful minutes and the State of Mind valence are mental-health
|
||||
@@ -337,6 +338,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
INDOOR_HUMIDITY: "environment",
|
||||
// Glucose: the raw series and the derived panel.
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
BLOOD_KETONES: "glucose",
|
||||
GLUCOSE_PANEL: "glucose",
|
||||
// The screening totals ride the mental-health module as well as being
|
||||
// fenced in the panel. Item-level answers are never a Measurement, so only
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
MODULE_LIST_ORDER_MAX,
|
||||
normaliseModuleListOrder,
|
||||
resolveModuleListLayout,
|
||||
storedModuleListLayout,
|
||||
mergeCategoryOrder,
|
||||
} from "../module-list-layout";
|
||||
|
||||
describe("resolveModuleListLayout", () => {
|
||||
@@ -16,6 +18,7 @@ describe("resolveModuleListLayout", () => {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
});
|
||||
expect(layout.lists.illness.view).toBe("cards");
|
||||
@@ -34,6 +37,7 @@ describe("resolveModuleListLayout", () => {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: true,
|
||||
});
|
||||
expect(layout.lists.labs.saved).toBe(false);
|
||||
@@ -50,6 +54,7 @@ describe("resolveModuleListLayout", () => {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
});
|
||||
expect(layout.lists.vorsorge.saved).toBe(false);
|
||||
@@ -70,11 +75,49 @@ describe("category choices", () => {
|
||||
expect(layout.lists.illness).toMatchObject({
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
saved: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("category order", () => {
|
||||
it("keeps a stored category order, deduplicated, and marks the list saved", () => {
|
||||
const layout = resolveModuleListLayout({
|
||||
labs: { categoryOrder: ["thyroid", "lipids", "thyroid", 7, "other"] },
|
||||
});
|
||||
expect(layout.lists.labs).toMatchObject({
|
||||
categoryOrder: ["thyroid", "lipids", "other"],
|
||||
saved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a malformed category order and leaves the default", () => {
|
||||
const layout = resolveModuleListLayout({
|
||||
labs: { categoryOrder: "lipids" },
|
||||
});
|
||||
expect(layout.lists.labs.categoryOrder).toEqual([]);
|
||||
expect(layout.lists.labs.saved).toBe(false);
|
||||
});
|
||||
|
||||
it("caps the category order on its own limit", () => {
|
||||
const keys = Array.from(
|
||||
{ length: MODULE_LIST_CATEGORY_ORDER_MAX + 5 },
|
||||
(_, i) => `custom:${i}`,
|
||||
);
|
||||
const layout = resolveModuleListLayout({ labs: { categoryOrder: keys } });
|
||||
expect(layout.lists.labs.categoryOrder).toHaveLength(
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
);
|
||||
});
|
||||
|
||||
it("hands every list its own copy of the default", () => {
|
||||
const layout = resolveModuleListLayout(null);
|
||||
layout.lists.labs.categoryOrder.push("lipids");
|
||||
expect(resolveModuleListLayout(null).lists.labs.categoryOrder).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseModuleListOrder", () => {
|
||||
it("keeps strings, drops duplicates, empties and over-long ids", () => {
|
||||
expect(
|
||||
@@ -101,3 +144,15 @@ describe("storedModuleListLayout", () => {
|
||||
).toEqual({ labs: { sortDir: "manual" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCategoryOrder", () => {
|
||||
it("keeps the place of a saved category that is not on screen", () => {
|
||||
expect(
|
||||
mergeCategoryOrder(["lipids", "liver"], ["liver", "thyroid", "lipids"]),
|
||||
).toEqual(["lipids", "liver", "thyroid"]);
|
||||
});
|
||||
|
||||
it("is the shown order when nothing else was saved", () => {
|
||||
expect(mergeCategoryOrder(["b", "a"], [])).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@
|
||||
* the list under its server-resolved categories (`"category"`) or flat
|
||||
* (`"none"`, the default); `groupsOpen` says which categories start open:
|
||||
* all of them, only those with a value outside its range, or none.
|
||||
* - `categoryOrder`: only the labs list reads it, and only on the server: a
|
||||
* category-key allow-list `GET /api/labs/overview` applies before it
|
||||
* answers `categories` (`applyCategoryOrder` in
|
||||
* `src/lib/labs/marker-category.ts`), so every client receives the
|
||||
* categories already in the person's order. Same tolerance as `order`.
|
||||
*
|
||||
* Null / missing column = the defaults below. The GET never lazy-writes, and
|
||||
* the PUT stores only the fields a person has actually chosen, so a default
|
||||
@@ -51,6 +56,29 @@ export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number];
|
||||
export const MODULE_LIST_ORDER_MAX = 500;
|
||||
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
|
||||
export const MODULE_LIST_ID_MAX = 64;
|
||||
/**
|
||||
* Longest category order a list keeps. Ten catalog panels plus "other" plus
|
||||
* the person's own panels; a longer one is cut, never refused.
|
||||
*/
|
||||
export const MODULE_LIST_CATEGORY_ORDER_MAX = 200;
|
||||
|
||||
/**
|
||||
* The category order to save after a move: the categories on screen in
|
||||
* their new order, then every saved key that is not on screen now, in its
|
||||
* saved order. A category without readings at the moment (or one of another
|
||||
* record a guardian arranged) keeps its place in the list instead of being
|
||||
* dropped by a move that never showed it.
|
||||
*/
|
||||
export function mergeCategoryOrder(
|
||||
shown: readonly string[],
|
||||
saved: readonly string[],
|
||||
): string[] {
|
||||
const onScreen = new Set(shown);
|
||||
return [...shown, ...saved.filter((key) => !onScreen.has(key))].slice(
|
||||
0,
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ModuleListPrefs {
|
||||
view: ModuleListView;
|
||||
@@ -58,6 +86,7 @@ export interface ModuleListPrefs {
|
||||
sortDir: ModuleSortDir;
|
||||
groupBy: ModuleGroupBy;
|
||||
groupsOpen: ModuleGroupsOpen;
|
||||
categoryOrder: string[];
|
||||
}
|
||||
|
||||
export interface ModuleListPrefsEntry extends ModuleListPrefs {
|
||||
@@ -76,6 +105,7 @@ const BASE_DEFAULTS: ModuleListPrefs = {
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
categoryOrder: [],
|
||||
};
|
||||
|
||||
/** Labs opens on the compact list, the dense reading table (#40). */
|
||||
@@ -87,7 +117,11 @@ const LIST_DEFAULTS: Record<ModuleListKey, ModuleListPrefs> = {
|
||||
|
||||
export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
|
||||
const defaults = LIST_DEFAULTS[list];
|
||||
return { ...defaults, order: [...defaults.order] };
|
||||
return {
|
||||
...defaults,
|
||||
order: [...defaults.order],
|
||||
categoryOrder: [...defaults.categoryOrder],
|
||||
};
|
||||
}
|
||||
|
||||
export function isModuleListView(value: unknown): value is ModuleListView {
|
||||
@@ -119,7 +153,10 @@ export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen {
|
||||
}
|
||||
|
||||
/** Keep string ids of a sane length, first occurrence wins, capped. */
|
||||
export function normaliseModuleListOrder(raw: unknown): string[] | null {
|
||||
export function normaliseModuleListOrder(
|
||||
raw: unknown,
|
||||
max: number = MODULE_LIST_ORDER_MAX,
|
||||
): string[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -129,7 +166,7 @@ export function normaliseModuleListOrder(raw: unknown): string[] | null {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
if (out.length >= MODULE_LIST_ORDER_MAX) break;
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -148,8 +185,13 @@ export function storedModuleListChoices(
|
||||
sortDir?: unknown;
|
||||
groupBy?: unknown;
|
||||
groupsOpen?: unknown;
|
||||
categoryOrder?: unknown;
|
||||
};
|
||||
const order = normaliseModuleListOrder(blob.order);
|
||||
const categoryOrder = normaliseModuleListOrder(
|
||||
blob.categoryOrder,
|
||||
MODULE_LIST_CATEGORY_ORDER_MAX,
|
||||
);
|
||||
return {
|
||||
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
|
||||
...(order !== null ? { order } : {}),
|
||||
@@ -158,6 +200,7 @@ export function storedModuleListChoices(
|
||||
...(isModuleGroupsOpen(blob.groupsOpen)
|
||||
? { groupsOpen: blob.groupsOpen }
|
||||
: {}),
|
||||
...(categoryOrder !== null ? { categoryOrder } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user