mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge remote-tracking branch 'origin/main' into work/v131-slice3-activity
This commit is contained in:
commit
048d7a4d84
54 files changed
+2507
-346
No files matched your search
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Copy-matches-behaviour guard.
|
||||
*
|
||||
* Three classes of copy that drifted away from the code they describe, each
|
||||
* pinned here so the pair fails loudly if either half moves again:
|
||||
*
|
||||
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
|
||||
* consequence. Four dialogs promised permanence on surfaces that render
|
||||
* an Undo affordance (measurements bulk, mood bulk, labs, illness) — a
|
||||
* cautious user declines a safe delete, and the warning loses weight
|
||||
* where it is true. This test pins both directions: undo-able surfaces
|
||||
* must offer the undo sentence and must NOT claim finality; the
|
||||
* genuinely permanent ones must keep their warning.
|
||||
*
|
||||
* 2. SLEEP DEBT. v1.19.0 replaced the summed shortfall with a running
|
||||
* balance that credits surplus sleep. The labels still described the old
|
||||
* arithmetic, so the word contradicted the number for anyone who caught
|
||||
* up after a short night.
|
||||
*
|
||||
* 3. GLUCOSE REFERENCE BAND. The declared diabetes opt-in has a server
|
||||
* preference and a route; the copy for its web control must exist in
|
||||
* every locale, since a missing key silently degrades to the raw key on
|
||||
* a surface that makes a clinical-adjacent claim.
|
||||
*
|
||||
* Reversibility evidence for the undo-able set:
|
||||
* - measurements: `POST /api/measurements/restore`, undo toast in
|
||||
* `src/components/measurements/measurement-list.tsx`
|
||||
* - mood: `POST /api/mood-entries/restore`, undo toast in
|
||||
* `src/components/mood/mood-list.tsx`
|
||||
* - labs: `POST /api/labs/restore`, undo toast in
|
||||
* `src/components/labs/lab-history-list.tsx`
|
||||
* - illness: `POST /api/illness/episodes/[id]/restore`, undo toast in
|
||||
* `src/components/illness/episode-menu.tsx`
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MESSAGES = join(__dirname, "../../messages");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
}
|
||||
|
||||
function resolve(obj: Record<string, unknown>, path: string): string {
|
||||
let cur: unknown = obj;
|
||||
for (const seg of path.split(".")) {
|
||||
expect(cur, `${path} is missing at "${seg}"`).toBeTypeOf("object");
|
||||
cur = (cur as Record<string, unknown>)[seg];
|
||||
}
|
||||
expect(typeof cur, `${path} must resolve to a string`).toBe("string");
|
||||
return cur as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-locale phrases that assert a deletion cannot be taken back. A dialog on
|
||||
* an undo-able surface must contain none of these.
|
||||
*/
|
||||
const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
en: ["cannot be undone", "can't be undone", "permanently", "permanent"],
|
||||
de: ["nicht rückgängig", "endgültig", "dauerhaft", "unwiderruflich"],
|
||||
es: ["no se puede deshacer", "permanente", "irreversible"],
|
||||
fr: [
|
||||
"irréversible",
|
||||
"définitivement",
|
||||
"définitive",
|
||||
"ne peut pas être annulé",
|
||||
],
|
||||
it: [
|
||||
"non può essere annullata",
|
||||
"definitivamente",
|
||||
"definitiva",
|
||||
"permanente",
|
||||
],
|
||||
pl: ["nie można cofnąć", "trwale", "nieodwracaln"],
|
||||
};
|
||||
|
||||
/** Per-locale phrase that promises the short undo window. */
|
||||
const UNDO_PHRASES: Record<string, string> = {
|
||||
en: "undo",
|
||||
de: "rückgängig",
|
||||
es: "deshacerlo",
|
||||
fr: "annuler cette action",
|
||||
it: "annullare",
|
||||
pl: "cofnąć",
|
||||
};
|
||||
|
||||
/** Confirmation bodies whose surface renders an Undo affordance. */
|
||||
const UNDOABLE_CONFIRM_KEYS = [
|
||||
"measurements.deleteConfirmDescription",
|
||||
"measurements.bulkDeleteConfirmBody",
|
||||
"mood.deleteConfirmDescription",
|
||||
"mood.bulkDeleteConfirmBody",
|
||||
"labs.deleteConfirmDescription",
|
||||
"illness.deleteConfirm.body",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Confirmation bodies whose delete really is unrecoverable for the user —
|
||||
* hard delete or a tombstone with no restore path. These MUST keep a
|
||||
* finality warning; softening them would be the mirror-image bug.
|
||||
*/
|
||||
const PERMANENT_CONFIRM_KEYS = [
|
||||
// hard delete + cascade of every recorded value
|
||||
"labs.biomarker.deleteConfirmDescription",
|
||||
// `?purge=true` hard-deletes the tag off every past entry
|
||||
"mood.manage.purgeBody",
|
||||
// GDPR Art. 17 account purge
|
||||
"settings.deleteAccountConfirmDescription",
|
||||
] as const;
|
||||
|
||||
describe("delete confirmations describe what the surface actually does", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const msgs = bundle(locale);
|
||||
|
||||
describe(locale, () => {
|
||||
for (const key of UNDOABLE_CONFIRM_KEYS) {
|
||||
it(`${key} offers undo and claims no finality`, () => {
|
||||
const copy = resolve(msgs, key).toLowerCase();
|
||||
|
||||
for (const phrase of FINALITY_PHRASES[locale]) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} claims finality ("${phrase}") but the surface renders an Undo`,
|
||||
).not.toContain(phrase.toLowerCase());
|
||||
}
|
||||
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} should tell the user the delete can be undone`,
|
||||
).toContain(UNDO_PHRASES[locale].toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of PERMANENT_CONFIRM_KEYS) {
|
||||
it(`${key} keeps its finality warning`, () => {
|
||||
const copy = resolve(msgs, key).toLowerCase();
|
||||
const warns = FINALITY_PHRASES[locale].some((p) =>
|
||||
copy.includes(p.toLowerCase()),
|
||||
);
|
||||
expect(
|
||||
warns,
|
||||
`${locale}/${key} deletes irrecoverably — the copy must say so`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("sleep debt is labelled as a balance, not a summed deficit", () => {
|
||||
/**
|
||||
* The figure is a running balance: a short night adds, a long night pays it
|
||||
* down. Deficit/shortfall vocabulary describes the pre-v1.19.0 sum.
|
||||
*/
|
||||
const STALE_SUM_VOCABULARY: Record<string, readonly string[]> = {
|
||||
en: ["cumulative", "shortfall", "accumulated"],
|
||||
de: ["kumuliert", "defizit", "aufgelaufen"],
|
||||
es: ["acumulado", "acumulada", "déficit"],
|
||||
fr: ["cumulé", "cumulée", "déficit"],
|
||||
it: ["cumulato", "cumulata", "accumulato", "deficit"],
|
||||
pl: ["skumulowan", "niedobór", "niedoboru"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const msgs = bundle(locale);
|
||||
|
||||
it(`${locale} debt captions carry no summed-deficit wording`, () => {
|
||||
for (const key of [
|
||||
"insights.sleep.debt.caption",
|
||||
"insights.sleep.debt.clearCaption",
|
||||
]) {
|
||||
const copy = resolve(msgs, key).toLowerCase();
|
||||
for (const term of STALE_SUM_VOCABULARY[locale]) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} still describes the pre-v1.19.0 summed shortfall ("${term}")`,
|
||||
).not.toContain(term);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the explainer states that catch-up sleep pays the balance down", () => {
|
||||
const copy = resolve(bundle("en"), "insights.sleep.debt.computedInfo");
|
||||
expect(copy.toLowerCase()).toContain("balance");
|
||||
expect(copy.toLowerCase()).toContain("catch-up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the glucose reference band control is translated everywhere", () => {
|
||||
const KEYS = [
|
||||
"settings.glucoseReference.title",
|
||||
"settings.glucoseReference.description",
|
||||
"settings.glucoseReference.enable",
|
||||
"settings.glucoseReference.explainer",
|
||||
"settings.glucoseReference.disclaimer",
|
||||
"settings.glucoseReference.error",
|
||||
] as const;
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} carries every key`, () => {
|
||||
const msgs = bundle(locale);
|
||||
for (const key of KEYS) {
|
||||
expect(resolve(msgs, key).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the disclaimer refuses the diagnosis reading (EN)", () => {
|
||||
const copy = resolve(
|
||||
bundle("en"),
|
||||
"settings.glucoseReference.disclaimer",
|
||||
).toLowerCase();
|
||||
expect(copy).toContain("not a diagnosis");
|
||||
expect(copy).toContain("never inferred");
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// Clinical abbreviations / SI units — identical across locales.
|
||||
"charts.vo2Max",
|
||||
"cycle.insights.crosstab.unitGlucose",
|
||||
"dashboard.metric.unit.bmi",
|
||||
"dashboard.metric.unit.mood",
|
||||
"dashboard.metric.unit.glucose",
|
||||
"dashboard.vo2Max",
|
||||
"dashboard.vo2MaxShort",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* `GET /api/auth/passkeys` — the passkey management list.
|
||||
*
|
||||
* The management UI renders a "last used" column per credential, and a
|
||||
* passkey that reads "never used" is something a person acts on: they may
|
||||
* delete a working credential believing it is dead. So the field is part of
|
||||
* the route's contract, not an incidental select — dropping it from the
|
||||
* projection would silently turn every row into "never used" without any
|
||||
* type or lint failure.
|
||||
*
|
||||
* The write half (the stamp on a verified assertion) is pinned in
|
||||
* `src/lib/auth/__tests__/passkey.test.ts`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { passkey: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER = { id: "user-1", username: "u", email: "u@example.com" };
|
||||
|
||||
const ROW = {
|
||||
id: "pk-1",
|
||||
name: "Phone",
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
lastUsedAt: new Date("2026-07-01T10:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([ROW] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/passkeys", () => {
|
||||
it("selects lastUsedAt, scoped to the calling user", async () => {
|
||||
await (GET as unknown as () => Promise<Response>)();
|
||||
|
||||
expect(prisma.passkey.findMany).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
|
||||
|
||||
expect(args?.where).toEqual({ userId: USER.id });
|
||||
expect(args?.select).toMatchObject({ lastUsedAt: true });
|
||||
});
|
||||
|
||||
it("emits lastUsedAt in the response envelope", async () => {
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0]).toHaveProperty("lastUsedAt");
|
||||
expect(new Date(body.data[0].lastUsedAt).toISOString()).toBe(
|
||||
"2026-07-01T10:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a never-used credential through as null, not as a missing key", async () => {
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([
|
||||
{ ...ROW, lastUsedAt: null },
|
||||
] as never);
|
||||
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
|
||||
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
|
||||
expect(body.data[0].lastUsedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,8 @@ const ALL_KINDS = [
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
"mood",
|
||||
"bmi",
|
||||
] as const;
|
||||
|
||||
function card(kind: string) {
|
||||
@@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => {
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
// BMI is derived from weight + the profile height and belongs to no
|
||||
// module, so it rides through with the other core vitals.
|
||||
"bmi",
|
||||
]) {
|
||||
expect(kinds).toContain(core);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the mood card when the mood module is off", async () => {
|
||||
// Mood is not a MeasurementType, so it gates through the synthetic
|
||||
// `MOOD_ENTRY` key rather than a measurement enum value. Without that
|
||||
// join the card would be unmapped and silently ship past a disabled
|
||||
// module.
|
||||
setDisabledModules(["mood"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("mood");
|
||||
expect(kinds).toContain("weight");
|
||||
expect(kinds).toContain("bmi");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("gates the cached body, so a toggle takes effect on the next request", async () => {
|
||||
// The analytics LRU is not evicted by a module toggle. The filter must
|
||||
// therefore run on the way out, not inside the cached build.
|
||||
|
||||
@@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
// The mood card reads the canonical daily series; its own aggregation is
|
||||
// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an
|
||||
// empty series by default so the legacy assertions keep their "no mood"
|
||||
// expectation, and overridden per test below.
|
||||
vi.mock("@/lib/analytics/mood-series", () => ({
|
||||
buildMoodDailySeries: vi.fn(async () => ({
|
||||
entries: [],
|
||||
summary: null,
|
||||
entryCount: 0,
|
||||
source: "live" as const,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -85,6 +98,7 @@ import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(body.data.compliance.takenToday).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mood + BMI cards.
|
||||
*
|
||||
* Both metrics existed in the record but the summary payload emitted
|
||||
* neither, so the native home screen could not render those two tiles while
|
||||
* every other tile worked. Both are resolved server-side from the canonical
|
||||
* source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the
|
||||
* client consumes a finished figure instead of re-deriving one.
|
||||
*/
|
||||
describe("GET /api/dashboard/summary — mood + BMI cards", () => {
|
||||
function cardsOf(body: unknown): Array<Record<string, unknown>> {
|
||||
return (body as { data: { metrics: Array<Record<string, unknown>> } }).data
|
||||
.metrics;
|
||||
}
|
||||
|
||||
it("emits a mood card from the canonical daily series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(buildMoodDailySeries).mockResolvedValue({
|
||||
entries: [
|
||||
{ date: "2026-07-01", score: 2, samples: 1 },
|
||||
{ date: "2026-07-02", score: 3, samples: 2 },
|
||||
{ date: "2026-07-03", score: 4.5, samples: 1 },
|
||||
],
|
||||
summary: null,
|
||||
entryCount: 3,
|
||||
source: "rollup",
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const mood = cardsOf(await res.json()).find((c) => c.kind === "mood");
|
||||
|
||||
expect(mood).toBeDefined();
|
||||
// Headline is the newest day's score, verbatim from the series — the
|
||||
// route must not re-average or re-round what the helper resolved.
|
||||
expect(mood?.latestValue).toBe(4.5);
|
||||
expect(mood?.sparkline).toEqual([2, 3, 4.5]);
|
||||
expect(mood?.trend).toBe("up");
|
||||
expect(mood?.allTimeCount).toBe(3);
|
||||
expect(mood?.titleKey).toBe("dashboard.metric.title.mood");
|
||||
expect(mood?.unitKey).toBe("dashboard.metric.unit.mood");
|
||||
expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits the mood card when the account has never logged a mood", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a BMI card computed from the profile height and latest weight", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// 180 cm profile.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
} as never);
|
||||
// `computeBmi` reads recent weight rows through `measurement.findMany`.
|
||||
// 81 kg at 1.80 m => 25.0.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi");
|
||||
|
||||
expect(bmi).toBeDefined();
|
||||
// The canonical helper's rounding (1 dp), not a second formula.
|
||||
expect(bmi?.latestValue).toBe(25);
|
||||
expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi");
|
||||
expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi");
|
||||
});
|
||||
|
||||
it("omits the BMI card when the profile carries no height", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Default profile has `heightCm: null`; weight alone cannot yield a BMI.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ import {
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { computeBmi } from "@/lib/insights/derived/bmi";
|
||||
import { isDerivedOk } from "@/lib/insights/derived/types";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
@@ -96,7 +99,9 @@ type MetricKind =
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation";
|
||||
| "oxygenSaturation"
|
||||
| "mood"
|
||||
| "bmi";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
@@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
totalBodyWater: "dashboard.metric.title.totalBodyWater",
|
||||
boneMass: "dashboard.metric.title.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
|
||||
mood: "dashboard.metric.title.mood",
|
||||
bmi: "dashboard.metric.title.bmi",
|
||||
};
|
||||
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
|
||||
boneMass: "dashboard.metric.unit.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
|
||||
mood: "dashboard.metric.unit.mood",
|
||||
bmi: "dashboard.metric.unit.bmi",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -615,11 +624,24 @@ async function buildDashboardSummary(
|
||||
// before the per-night reconstruction sums it. v1.17.0 — the baseline
|
||||
// profile rides along (one read) so the sleep-rhythm block resolves the
|
||||
// age-based sleep need the cumulative-debt deficit uses.
|
||||
const [sleepPriorityJson, sleepProfile] = await Promise.all([
|
||||
const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
// The mood card reads the SAME canonical daily series the web mood
|
||||
// analytics and the dashboard snapshot read (`buildMoodDailySeries`),
|
||||
// rollup fast-path included — never a second aggregation.
|
||||
time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
|
||||
]);
|
||||
|
||||
// BMI is derived, not measured: it needs the profile height loaded above,
|
||||
// so it cannot join the round before it. `computeBmi` is the one canonical
|
||||
// implementation (the same one the web vitals dashboard renders through
|
||||
// the derived-metric dispatch) — the route resolves the figure and the
|
||||
// client consumes it, rather than every client re-deriving weight / m².
|
||||
const bmiDerived = await time("bmi", () =>
|
||||
computeBmi(userId, sleepProfile, { now }),
|
||||
);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
|
||||
@@ -954,6 +976,69 @@ async function buildDashboardSummary(
|
||||
});
|
||||
}
|
||||
|
||||
// Mood — the one metric on this payload that is not a `Measurement`. The
|
||||
// daily series is the canonical `buildMoodDailySeries` output (rollup
|
||||
// fast-path, 1..5 scale, one entry per day with an average score), the
|
||||
// same series the web mood analytics and the dashboard snapshot render.
|
||||
// Emitted only when the account has ever logged a mood, matching the
|
||||
// `allTimeCount === 0` gate every optional card above uses.
|
||||
if (moodSeries.entryCount > 0) {
|
||||
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
|
||||
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
|
||||
// The series carries UTC day labels, not timestamps — the entry's day is
|
||||
// the only instant the source actually pins, so it is emitted as the
|
||||
// day's start rather than inventing a reading time.
|
||||
const latestAt = latestEntry
|
||||
? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString()
|
||||
: null;
|
||||
metrics.push({
|
||||
id: "mood",
|
||||
kind: "mood",
|
||||
titleKey: METRIC_TITLE_KEYS.mood,
|
||||
latestValue: latestEntry?.score ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.mood,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latestAt,
|
||||
allTimeCount: moodSeries.entryCount,
|
||||
lastSeenAt: latestAt,
|
||||
});
|
||||
}
|
||||
|
||||
// BMI — derived from the latest weight at the profile height by
|
||||
// `computeBmi`, the single implementation the web vitals dashboard also
|
||||
// renders. The card is omitted when the derivation is insufficient
|
||||
// (no height on the profile, or no weight inside its window): the card
|
||||
// shape has no "unknown" state to express honestly, and a card with a
|
||||
// null value would read as a logging gap rather than a missing profile
|
||||
// field. Clients that want to prompt for a height should do so from the
|
||||
// profile surface, not from an empty dashboard tile.
|
||||
if (isDerivedOk(bmiDerived)) {
|
||||
const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
|
||||
metrics.push({
|
||||
id: "bmi",
|
||||
kind: "bmi",
|
||||
titleKey: METRIC_TITLE_KEYS.bmi,
|
||||
latestValue: bmiDerived.value.bmi,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.bmi,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
// BMI moves exactly when weight does, so the weight metadata is the
|
||||
// honest recency signal — there is no separate BMI reading.
|
||||
updatedAt: metaForType("WEIGHT").lastSeenAt,
|
||||
allTimeCount: metaForType("WEIGHT").allTimeCount,
|
||||
lastSeenAt: metaForType("WEIGHT").lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Shared builder carries the exact tally semantics this tile always
|
||||
// had (scheduled = every today-window row; taken = `takenAt` set and
|
||||
// not skipped). The wire shape of `compliance` is locked for iOS.
|
||||
|
||||
@@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard widgets — preserve-when-absent on PUT", () => {
|
||||
it("keeps the stored comparisonBaseline when the client omits it", async () => {
|
||||
// The regression: a layout save from a client that doesn't know
|
||||
// `comparisonBaseline` (the native client documents the field as
|
||||
// web-only and never sends it) used to fall through to the serializer,
|
||||
// which clamps a missing baseline to "none". The user's web-chosen
|
||||
// comparison silently reset on every tile reorder from the phone.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: false, tileVisible: true, order: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
|
||||
"lastYear",
|
||||
);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { comparisonBaseline: string };
|
||||
};
|
||||
expect(body.data.comparisonBaseline).toBe("lastYear");
|
||||
});
|
||||
|
||||
it("honours an explicitly sent comparisonBaseline over the stored one", async () => {
|
||||
// Preserve-when-absent must not become preserve-always: the web
|
||||
// CompareToggle sends the field, including an explicit "none" to clear.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "none",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none");
|
||||
});
|
||||
|
||||
it("persists the four clinical tiles the native client pins", async () => {
|
||||
// They used to hit the unknown-id filter ahead of Zod and vanish from
|
||||
// the persisted layout, so the placement was lost on every save.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const clinicalIds = [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
];
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: clinicalIds.map((id, i) => ({
|
||||
id,
|
||||
visible: true,
|
||||
tileVisible: true,
|
||||
order: i,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate).toBeUndefined();
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
(w) => w.id,
|
||||
);
|
||||
for (const id of clinicalIds) {
|
||||
expect(persistedIds).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HERO_RING_IDS,
|
||||
MAX_HERO_RING_ORDER,
|
||||
type ChartOverlayPrefsMap,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -80,7 +79,12 @@ const layoutSchema = z.object({
|
||||
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
|
||||
// HealthKit signals became pinnable; the enum still bounds each id to
|
||||
// one of the catalogue ids.
|
||||
.max(40),
|
||||
//
|
||||
// Raised again to 50 when the four clinical signals joined the
|
||||
// catalogue (42 ids). The cap only has to clear the catalogue size —
|
||||
// the enum bounds each entry and the id set is closed — so it carries
|
||||
// headroom rather than tracking the count exactly.
|
||||
.max(50),
|
||||
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
|
||||
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
|
||||
// so v1.4.15 clients that don't know the field can still PUT.
|
||||
@@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// v1.4.18 — preserve any per-chart overlay prefs that the client
|
||||
// didn't send. The dashboard-layout PUT typically saves widget
|
||||
// visibility / order; chart prefs are PUT through their own route
|
||||
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
|
||||
// wiped here on a subsequent layout save. `selectedScoreRings` and
|
||||
// `heroRingOrder` ride the same preserve-when-absent contract — an
|
||||
// older client's layout save must not reset either choice. One
|
||||
// stored-layout read covers all fallbacks.
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedScoreRings: ScoreRingId[] | undefined =
|
||||
parsed.data.selectedScoreRings;
|
||||
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedScoreRings === undefined ||
|
||||
mergedHeroRingOrder === undefined
|
||||
) {
|
||||
// Preserve-when-absent: every field the client didn't send is carried
|
||||
// forward from the stored layout rather than clamped to its default by
|
||||
// the serializer. The dashboard-layout PUT typically saves widget
|
||||
// visibility / order, while chart prefs are PUT through their own route
|
||||
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
|
||||
// Settings, and `comparisonBaseline` is web-only — an omission from any
|
||||
// one surface must not reset a choice made on another.
|
||||
//
|
||||
// The preserve set is not a list maintained here: it is derived from
|
||||
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
|
||||
// every field of `DashboardLayout`. Adding a layout field without a
|
||||
// disposition fails typecheck, so the next field cannot silently miss this
|
||||
// merge the way `comparisonBaseline` did. One stored-layout read covers
|
||||
// every fallback, and the read is skipped entirely when the client sent
|
||||
// the full set.
|
||||
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
if (layoutNeedsPreserveRead(toSerialize)) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
@@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const existingLayout = resolveDashboardLayout(
|
||||
existing?.dashboardWidgetsJson,
|
||||
);
|
||||
if (mergedChartOverlayPrefs === undefined) {
|
||||
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
|
||||
}
|
||||
if (mergedScoreRings === undefined) {
|
||||
mergedScoreRings = existingLayout.selectedScoreRings;
|
||||
}
|
||||
if (mergedHeroRingOrder === undefined) {
|
||||
mergedHeroRingOrder = existingLayout.heroRingOrder;
|
||||
}
|
||||
toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
|
||||
}
|
||||
const normalized = serializeDashboardLayout({
|
||||
...parsed.data,
|
||||
chartOverlayPrefs: mergedChartOverlayPrefs,
|
||||
selectedScoreRings: mergedScoreRings,
|
||||
heroRingOrder: mergedHeroRingOrder,
|
||||
} as DashboardLayout);
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
|
||||
it("updates manufacturer + doseStrength when supplied", async () => {
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq({
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.data.manufacturer).toBe("Example Pharma");
|
||||
expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
|
||||
});
|
||||
|
||||
it("leaves both untouched when the request omits them", async () => {
|
||||
// Absent must mean "I have nothing to say", never "blank it" — a client
|
||||
// that does not know the fields must not wipe what another populated.
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect("manufacturer" in arg.data).toBe(false);
|
||||
expect("doseStrength" in arg.data).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a value on an explicit null", async () => {
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.data.manufacturer).toBeNull();
|
||||
expect("doseStrength" in arg.data).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,8 @@ export const PATCH = apiHandler(
|
||||
markAsUsedUp,
|
||||
printedExpiry,
|
||||
unitsRemaining,
|
||||
manufacturer,
|
||||
doseStrength,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
@@ -151,6 +153,16 @@ export const PATCH = apiHandler(
|
||||
? {}
|
||||
: { notesEncrypted: encryptNote(notes), notes: null };
|
||||
|
||||
// Carton labelling follows the same absent-means-untouched rule as
|
||||
// `notes`: an omitted field must not blank a value the other client
|
||||
// populated. Built field by field, never spread from the parsed body.
|
||||
const labellingData: {
|
||||
manufacturer?: string | null;
|
||||
doseStrength?: string | null;
|
||||
} = {};
|
||||
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
|
||||
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
|
||||
|
||||
const updated = await prisma.medicationInventoryItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
@@ -159,6 +171,7 @@ export const PATCH = apiHandler(
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
expiresAt: nextExpiresAt,
|
||||
...labellingData,
|
||||
...notesData,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/inventory — carton labelling", () => {
|
||||
it("persists manufacturer + doseStrength when the client sends them", async () => {
|
||||
// The native pen list uses these two as its headline and subhead. With
|
||||
// nowhere on the server to hold them, a container registered on the web
|
||||
// could not be rendered there at all.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
|
||||
id: "inv-new",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
unitsTotal: 4,
|
||||
containerType: "PEN",
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("stores null for both when the client omits them", async () => {
|
||||
// A plain supply row (blister, bottle) carries no carton labelling, and
|
||||
// every row that predates the columns is truthfully null.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
|
||||
id: "inv-new",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
unitsTotal: 30,
|
||||
containerType: "BLISTER",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
manufacturer: null,
|
||||
doseStrength: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,8 +121,15 @@ export const POST = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
|
||||
parsed.data;
|
||||
const {
|
||||
unitsTotal,
|
||||
containerType,
|
||||
printedExpiry,
|
||||
purchasedAt,
|
||||
manufacturer,
|
||||
doseStrength,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
const created = await prisma.medicationInventoryItem.create({
|
||||
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
|
||||
@@ -135,6 +142,8 @@ export const POST = apiHandler(
|
||||
containerType: containerType ?? "OTHER",
|
||||
printedExpiry: printedExpiry ?? null,
|
||||
purchasedAt: purchasedAt ?? null,
|
||||
manufacturer: manufacturer ?? null,
|
||||
doseStrength: doseStrength ?? null,
|
||||
notes: notes ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* signals the Sleep page + the iOS client render off the SAME canonical night
|
||||
* reconstruction the Sleep Score uses:
|
||||
*
|
||||
* - `sleepDebt` — cumulative deficit over the rolling window, with a calm
|
||||
* - `sleepDebt` — outstanding balance over the rolling window (surplus
|
||||
* sleep pays it down), with a calm
|
||||
* `partial` state under the night threshold.
|
||||
* - `chronotype` — MCTQ MSF / MSFsc band + social jetlag, with a `learning`
|
||||
* state until enough free-day nights exist.
|
||||
|
||||
@@ -22,8 +22,11 @@ function hoursMinutes(totalMinutes: number): {
|
||||
/**
|
||||
* v1.17.0 — sleep-debt headline.
|
||||
*
|
||||
* The cumulative shortfall between sleep need and sleep obtained over the
|
||||
* rolling window. Under the night threshold it shows the calm `partial`
|
||||
* The outstanding balance between sleep need and sleep obtained over the
|
||||
* rolling window — a night below need adds to it, a night above need pays it
|
||||
* down (v1.19.0 replaced the summed shortfall with this running balance; the
|
||||
* copy follows the arithmetic). Under the night threshold it shows the calm
|
||||
* `partial`
|
||||
* state ("still learning — N more nights") and never asserts a total off thin
|
||||
* data. Mobile-first: a single readable figure + one line of grounded context.
|
||||
*/
|
||||
|
||||
@@ -492,6 +492,11 @@ export function AddInventoryDialog({
|
||||
const [containerType, setContainerType] =
|
||||
useState<ContainerType>(initialContainerType);
|
||||
const [expiry, setExpiry] = useState("");
|
||||
// Carton labelling. Only offered for a PEN: the native pen list is the
|
||||
// one surface that renders them, and asking for a maker + strength on a
|
||||
// blister pack would be noise on the far commoner path.
|
||||
const [manufacturer, setManufacturer] = useState("");
|
||||
const [doseStrength, setDoseStrength] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
@@ -513,6 +518,10 @@ export function AddInventoryDialog({
|
||||
printedExpiry: expiry
|
||||
? new Date(`${expiry}T00:00:00`).toISOString()
|
||||
: null,
|
||||
// Trimmed to null so a field the user opened and left blank stores
|
||||
// absence rather than an empty string.
|
||||
manufacturer: manufacturer.trim() || null,
|
||||
doseStrength: doseStrength.trim() || null,
|
||||
});
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.addSuccess"));
|
||||
@@ -664,6 +673,41 @@ export function AddInventoryDialog({
|
||||
onChange={setExpiry}
|
||||
/>
|
||||
</div>
|
||||
{containerType === "PEN" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-manufacturer"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addManufacturerLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-manufacturer"
|
||||
value={manufacturer}
|
||||
maxLength={120}
|
||||
onChange={(e) => setManufacturer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-dose-strength"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addDoseStrengthLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-dose-strength"
|
||||
value={doseStrength}
|
||||
maxLength={60}
|
||||
onChange={(e) => setDoseStrength(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.bestand.addDoseStrengthHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Destructive settings controls ask first.
|
||||
*
|
||||
* Signing every other device out, and revoking a trusted device, used to fire
|
||||
* on a single tap. Neither is undoable — the other devices simply have to log
|
||||
* in again, and a revoked trusted device asks for a second factor next time —
|
||||
* while every other destructive surface in the app confirms. On a shared
|
||||
* screen a mis-tap is a support call.
|
||||
*
|
||||
* This reads the sources rather than rendering, because what it pins is
|
||||
* structural: that these particular controls route through `ConfirmButton`
|
||||
* and never call a revoke mutation straight from an `onClick`. A render test
|
||||
* would prove the dialog exists in one arrangement; this proves no control on
|
||||
* these cards can go back to firing directly.
|
||||
*/
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
file: "security-sessions-card.tsx",
|
||||
slots: ["revoke-session", "sign-out-everywhere"],
|
||||
},
|
||||
{
|
||||
file: "trusted-devices-card.tsx",
|
||||
slots: ["revoke-trusted-device", "revoke-all-trusted-devices"],
|
||||
},
|
||||
];
|
||||
|
||||
function source(file: string) {
|
||||
return readFileSync(
|
||||
join(process.cwd(), "src", "components", "settings", file),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("destructive session controls confirm before firing", () => {
|
||||
for (const card of CARDS) {
|
||||
describe(card.file, () => {
|
||||
const src = source(card.file);
|
||||
|
||||
it("routes every destructive control through ConfirmButton", () => {
|
||||
expect(src).toContain("ConfirmButton");
|
||||
for (const slot of card.slots) {
|
||||
expect(src).toContain(`slot="${slot}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("never fires a revoke straight from an onClick", () => {
|
||||
// `onConfirm={() => revoke…}` is the confirmed path and is fine;
|
||||
// `onClick={() => revoke…}` is the unguarded one this forbids.
|
||||
const unguarded = src.match(/onClick=\{\(\) =>\s*revoke/g) ?? [];
|
||||
expect(unguarded).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives each control its own confirmation body", () => {
|
||||
// A shared generic body would let one control's dialog describe
|
||||
// another's consequence — the reason the copy is per-control.
|
||||
const bodies = src.match(/body=\{t\("([^"]+)"/g) ?? [];
|
||||
expect(bodies.length).toBe(card.slots.length);
|
||||
expect(new Set(bodies).size).toBe(card.slots.length);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -267,6 +267,21 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.sections.thresholds.");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> carries the glucose reference band control", () => {
|
||||
// The declared diabetes opt-in (`PATCH /api/auth/me/diabetes`) selects the
|
||||
// tighter glucose band and grounds the Coach, but shipped with no web
|
||||
// control at all — a web-only account could never reach it. It belongs on
|
||||
// Targets: same question ("which range judges my reading") as the editor
|
||||
// below it, and the copy must stay honest about being a reference band
|
||||
// rather than a diagnosis.
|
||||
const html = renderFramed("thresholds", <ThresholdsSection />);
|
||||
expect(html).toContain("Glucose reference band");
|
||||
expect(html).toContain("Use the diabetes goal band");
|
||||
expect(html).toContain("not a diagnosis");
|
||||
expect(html).toContain('data-slot="glucose-reference-disclaimer"');
|
||||
expect(html).not.toContain("settings.glucoseReference.");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> resolves the German title", () => {
|
||||
const html = renderFramed("thresholds", <ThresholdsSection />, "de");
|
||||
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
|
||||
|
||||
@@ -18,13 +18,14 @@
|
||||
* Coach surface like the rest of the memory controls.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { BellRing, Check, Loader2, Trash2, X } from "lucide-react";
|
||||
import { BellRing, Check, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useCoachReminders,
|
||||
@@ -129,25 +130,21 @@ export function CoachRemindersSection({
|
||||
{t("settings.ai.coachReminders.dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
<ConfirmButton
|
||||
slot="coach-reminder-delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive min-h-9"
|
||||
disabled={!isAuthenticated || busy}
|
||||
data-testid="settings-coach-reminder-delete"
|
||||
aria-label={t("settings.ai.coachReminders.deleteAria")}
|
||||
onClick={() => remove.mutate(r.id)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
disabled={!isAuthenticated}
|
||||
pending={busy}
|
||||
ariaLabel={t("settings.ai.coachReminders.deleteAria")}
|
||||
label=""
|
||||
icon={<Trash2 className="size-3.5" aria-hidden />}
|
||||
title={t("settings.ai.coachReminders.deleteConfirmTitle")}
|
||||
body={t("settings.ai.coachReminders.deleteConfirmBody")}
|
||||
confirmLabel={t("settings.ai.coachReminders.deleteConfirmAction")}
|
||||
onConfirm={() => remove.mutate(r.id)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A destructive settings control that asks first.
|
||||
*
|
||||
* The security card's "sign out everywhere" and its per-row revoke, and the
|
||||
* same pair on trusted devices, fired on a single tap with no confirmation —
|
||||
* while every other destructive surface in the app confirms. Signing every
|
||||
* other device out is not recoverable by an undo; the other devices simply
|
||||
* have to log in again, and on a shared screen a mis-tap is a support call.
|
||||
*
|
||||
* Wrapping the pattern rather than inlining a fourth dialog is the point: the
|
||||
* next destructive control gets the confirmation by using this, instead of by
|
||||
* someone remembering to add one.
|
||||
*
|
||||
* The copy contract matches the rest of the app after the delete-confirmation
|
||||
* sweep — say what happens, and claim no more finality than is true. Callers
|
||||
* pass their own body text so each control can be specific about its own
|
||||
* consequence.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function ConfirmButton({
|
||||
label,
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
disabled = false,
|
||||
size,
|
||||
variant = "outline",
|
||||
className,
|
||||
icon,
|
||||
ariaLabel,
|
||||
slot,
|
||||
}: {
|
||||
/** The control's own label, before the dialog opens. */
|
||||
label: string;
|
||||
title: string;
|
||||
body: string;
|
||||
/** The dialog's action label. Names the act, not "OK". */
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
pending?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "icon";
|
||||
variant?: "outline" | "ghost";
|
||||
className?: string;
|
||||
/** For an icon-only control, where `label` renders as an icon. */
|
||||
ariaLabel?: string;
|
||||
icon?: ReactNode;
|
||||
/** `data-slot` for tests and the design guards. */
|
||||
slot?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
aria-label={ariaLabel}
|
||||
data-slot={slot}
|
||||
disabled={disabled || pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-slot={slot ? `${slot}-confirm` : undefined}
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{pending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -325,19 +326,26 @@ export function EnvironmentSection() {
|
||||
<span className="text-sm">
|
||||
{tr.label} · {tr.startDate} – {tr.endDate}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
<ConfirmButton
|
||||
slot="travel-remove"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t(
|
||||
ariaLabel={t("settings.sections.environment.travel.remove")}
|
||||
label=""
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
pending={removeTravel.isPending}
|
||||
title={t(
|
||||
"settings.sections.environment.travel.removeConfirmTitle",
|
||||
)}
|
||||
body={t(
|
||||
"settings.sections.environment.travel.removeConfirmBody",
|
||||
)}
|
||||
confirmLabel={t(
|
||||
"settings.sections.environment.travel.remove",
|
||||
)}
|
||||
disabled={removeTravel.isPending}
|
||||
onClick={() => removeTravel.mutate(tr.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
onConfirm={() => removeTravel.mutate(tr.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Droplet, Info } from "lucide-react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Glucose reference band — the web control for the declared diabetes opt-in
|
||||
* (`GET/PATCH /api/auth/me/diabetes`).
|
||||
*
|
||||
* WHY IT LIVES ON TARGETS
|
||||
* -----------------------
|
||||
* The flag answers exactly the question this page answers — "which range is my
|
||||
* reading judged against" — and the page already owns the four glucose target
|
||||
* rows. Putting it anywhere else would split one concept across two surfaces.
|
||||
* The card sits ABOVE the per-metric editor because the two controls are
|
||||
* ordered: this one picks the default band, a per-metric override below then
|
||||
* beats it (`src/lib/targets/glucose-targets.ts`).
|
||||
*
|
||||
* WHAT IT DOES NOT DO
|
||||
* -------------------
|
||||
* The flag is a display preference, never a diagnosis, and is never inferred
|
||||
* from a reading. It selects the band the glucose surfaces and the Coach's
|
||||
* reference grounding read; the app takes no clinical action on it. The copy
|
||||
* says so at the point of the switch rather than in a help page.
|
||||
*
|
||||
* Note: `GET /api/user/thresholds` (the editor below) reports the general
|
||||
* bands regardless of this flag — the flag resolves in the targets pipeline
|
||||
* (`src/lib/targets/build-response.ts`), which is why the mutation invalidates
|
||||
* `insightsTargets()` rather than `userThresholds()`.
|
||||
*/
|
||||
|
||||
interface DiabetesPrefShape {
|
||||
hasDiabetes: boolean;
|
||||
}
|
||||
|
||||
export function GlucoseReferenceCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.diabetesPref(),
|
||||
queryFn: async () => apiGet<DiabetesPrefShape>("/api/auth/me/diabetes"),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: async (hasDiabetes: boolean) =>
|
||||
apiPatch<DiabetesPrefShape>("/api/auth/me/diabetes", { hasDiabetes }),
|
||||
onSuccess: (next) => {
|
||||
queryClient.setQueryData(queryKeys.diabetesPref(), next);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = data?.hasDiabetes ?? false;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Droplet}
|
||||
title={t("settings.glucoseReference.title")}
|
||||
description={t("settings.glucoseReference.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label htmlFor="glucose-reference-diabetes" className="text-sm">
|
||||
{t("settings.glucoseReference.enable")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="glucose-reference-diabetes"
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending || data == null}
|
||||
onCheckedChange={(v) => toggle.mutate(v)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm">{t("settings.glucoseReference.explainer")}</p>
|
||||
|
||||
<div
|
||||
data-slot="glucose-reference-disclaimer"
|
||||
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
|
||||
>
|
||||
<Info
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-foreground/80 text-xs leading-relaxed">
|
||||
{t("settings.glucoseReference.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{toggle.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("settings.glucoseReference.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import { useId, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -168,16 +168,19 @@ export function SecuritySessionsCard({
|
||||
)}
|
||||
</div>
|
||||
{!s.isCurrent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<ConfirmButton
|
||||
slot="revoke-session"
|
||||
size="sm"
|
||||
className="min-h-9 shrink-0"
|
||||
onClick={() => revokeOne.mutate(s.id)}
|
||||
disabled={revokeOne.isPending}
|
||||
>
|
||||
{t("settings.security.revokeSession")}
|
||||
</Button>
|
||||
label={t("settings.security.revokeSession")}
|
||||
title={t("settings.security.revokeSessionConfirmTitle")}
|
||||
body={t("settings.security.revokeSessionConfirmBody", {
|
||||
device: s.device,
|
||||
})}
|
||||
confirmLabel={t("settings.security.revokeSession")}
|
||||
onConfirm={() => revokeOne.mutate(s.id)}
|
||||
pending={revokeOne.isPending}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
@@ -186,18 +189,16 @@ export function SecuritySessionsCard({
|
||||
|
||||
{hasOthers && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<ConfirmButton
|
||||
slot="sign-out-everywhere"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => revokeOthers.mutate()}
|
||||
disabled={revokeOthers.isPending}
|
||||
>
|
||||
{revokeOthers.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.security.signOutEverywhere")}
|
||||
</Button>
|
||||
label={t("settings.security.signOutEverywhere")}
|
||||
title={t("settings.security.signOutEverywhereConfirmTitle")}
|
||||
body={t("settings.security.signOutEverywhereConfirmBody")}
|
||||
confirmLabel={t("settings.security.signOutEverywhere")}
|
||||
onConfirm={() => revokeOthers.mutate()}
|
||||
pending={revokeOthers.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GlucoseReferenceCard } from "@/components/settings/glucose-reference-card";
|
||||
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,14 @@ import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor
|
||||
* the names stop clashing.
|
||||
*/
|
||||
export function ThresholdsSection() {
|
||||
// Per-metric threshold ranges; the heading is supplied by the route frame.
|
||||
return <ThresholdsEditorSection id="thresholds" />;
|
||||
// The glucose reference band sits ABOVE the per-metric editor: it picks the
|
||||
// DEFAULT band a glucose reading is judged against, and a per-metric
|
||||
// override in the editor below then beats it. Same question, one page —
|
||||
// splitting the two across surfaces would hide the precedence.
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlucoseReferenceCard />
|
||||
<ThresholdsEditorSection id="thresholds" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -130,16 +130,19 @@ export function TrustedDevicesCard({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<ConfirmButton
|
||||
slot="revoke-trusted-device"
|
||||
size="sm"
|
||||
className="min-h-9 shrink-0"
|
||||
onClick={() => revokeOne.mutate(d.id)}
|
||||
disabled={revokeOne.isPending}
|
||||
>
|
||||
{t("settings.security.trustedDevices.revoke")}
|
||||
</Button>
|
||||
label={t("settings.security.trustedDevices.revoke")}
|
||||
title={t(
|
||||
"settings.security.trustedDevices.revokeConfirmTitle",
|
||||
)}
|
||||
body={t("settings.security.trustedDevices.revokeConfirmBody")}
|
||||
confirmLabel={t("settings.security.trustedDevices.revoke")}
|
||||
onConfirm={() => revokeOne.mutate(d.id)}
|
||||
pending={revokeOne.isPending}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -147,18 +150,18 @@ export function TrustedDevicesCard({
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<ConfirmButton
|
||||
slot="revoke-all-trusted-devices"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => revokeAll.mutate()}
|
||||
disabled={revokeAll.isPending}
|
||||
>
|
||||
{revokeAll.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
label={t("settings.security.trustedDevices.revokeAll")}
|
||||
title={t(
|
||||
"settings.security.trustedDevices.revokeAllConfirmTitle",
|
||||
)}
|
||||
{t("settings.security.trustedDevices.revokeAll")}
|
||||
</Button>
|
||||
body={t("settings.security.trustedDevices.revokeAllConfirmBody")}
|
||||
confirmLabel={t("settings.security.trustedDevices.revokeAll")}
|
||||
onConfirm={() => revokeAll.mutate()}
|
||||
pending={revokeAll.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
IOS_PIN_ONLY_WIDGET_IDS,
|
||||
DEFAULT_HERO_RING_ORDER,
|
||||
resolveHeroRingOrder,
|
||||
LAYOUT_FIELD_MERGE_DISPOSITION,
|
||||
PRESERVED_LAYOUT_FIELDS,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
@@ -438,7 +442,7 @@ describe("resolveDashboardLayout() — retired heroVisible field", () => {
|
||||
* iOS-only ids extend it without touching the writable PUT enum.
|
||||
*/
|
||||
describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
it("carries exactly 37 distinct ids (28 server-known + 9 iOS-only)", () => {
|
||||
it("carries exactly 42 distinct ids (29 server-known + 13 iOS-only)", () => {
|
||||
// v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so
|
||||
// the server-known set grew 16 → 24 and the catalogue 27 → 35.
|
||||
// v1.18.2 — the Vorsorge summary widget added one server-known id
|
||||
@@ -450,10 +454,42 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
// v1.29 — the nutrients-store-backed fluid-intake strip tile
|
||||
// (`waterIntake`) added one server-known id, so the catalogue grew
|
||||
// 37 → 38.
|
||||
// The four clinical signals the native client pins (grip strength, pain
|
||||
// NRS, waist circumference, waist-to-height) joined the iOS-only set
|
||||
// (9 → 13), so the catalogue grew 38 → 42. Before they were catalogued
|
||||
// the widgets PUT dropped them as unknown ids and every native layout
|
||||
// save lost the user's placement of those tiles.
|
||||
expect(DASHBOARD_WIDGET_IDS).toHaveLength(29);
|
||||
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9);
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38);
|
||||
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38);
|
||||
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(13);
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(42);
|
||||
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(42);
|
||||
});
|
||||
|
||||
it("catalogues the four clinical signals the native client pins", () => {
|
||||
// Regression guard: these four reached the widgets PUT as unknown ids,
|
||||
// were filtered out before Zod, and vanished from the persisted layout
|
||||
// — a silent loss of the user's tile placement on every save.
|
||||
for (const id of [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
]) {
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the four clinical signals out of the web-writable set", () => {
|
||||
// The web dashboard has no render path for them — their charts live on
|
||||
// the /insights sub-pages — so a web Settings toggle would do nothing.
|
||||
for (const id of [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
]) {
|
||||
expect(DASHBOARD_WIDGET_IDS).not.toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => {
|
||||
@@ -498,6 +534,9 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
it("carries the locked iOS-only ids verbatim", () => {
|
||||
// v1.28.52 — `hrv` + `respiratoryRate` left this set for the writable
|
||||
// DASHBOARD_WIDGET_IDS (they gained a web strip tile).
|
||||
// The four clinical signals joined so the widgets PUT stops dropping
|
||||
// them as unknown ids; `painNRS` is spelled as the native client sends
|
||||
// it, which differs in case from the `painNrs` chart-overlay slot.
|
||||
expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([
|
||||
"restingHeartRate",
|
||||
"walkingSpeed",
|
||||
@@ -508,6 +547,10 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
"walkingDoubleSupport",
|
||||
"audioExposureEnvironment",
|
||||
"audioExposureHeadphone",
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -909,3 +952,137 @@ describe("resolveDashboardLayout() / serialize — heroRingOrder", () => {
|
||||
expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Preserve-when-absent contract for the widgets PUT.
|
||||
*
|
||||
* `comparisonBaseline` was a top-level layout field that the merge forgot.
|
||||
* Because `serializeDashboardLayout` clamps a missing baseline to `"none"`,
|
||||
* every layout save from a client that doesn't send the field — the native
|
||||
* client documents it as web-only and never sends it — silently reset the
|
||||
* comparison baseline the user had picked on the web. No error, no audit row.
|
||||
*
|
||||
* The fix is not "remember to add the field to the merge". It is a
|
||||
* disposition map the type system forces to cover every field of
|
||||
* `DashboardLayout`, from which the preserve set is derived. These tests pin
|
||||
* the behaviour that map buys.
|
||||
*/
|
||||
describe("layout field merge disposition", () => {
|
||||
/**
|
||||
* A layout with EVERY top-level field populated. Typed as
|
||||
* `Required<DashboardLayout>` so adding a field to the interface without
|
||||
* adding it here is a type error too — the runtime assertion below then
|
||||
* proves the disposition map covers it.
|
||||
*/
|
||||
const fullyPopulatedLayout: Required<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
chartOverlayPrefs: {
|
||||
weight: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
},
|
||||
selectedScoreRings: ["MED_COMPLIANCE"],
|
||||
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
};
|
||||
|
||||
it("assigns a disposition to every top-level layout field", () => {
|
||||
// The guard that makes the next field impossible to forget. Adding a
|
||||
// field to `DashboardLayout` without a disposition fails typecheck at
|
||||
// the `satisfies Record<keyof DashboardLayout, …>`; this asserts the
|
||||
// same coverage at runtime so the failure is loud in the suite too.
|
||||
expect(Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION).sort()).toEqual(
|
||||
Object.keys(fullyPopulatedLayout).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("only ever assigns a known disposition", () => {
|
||||
for (const disposition of Object.values(LAYOUT_FIELD_MERGE_DISPOSITION)) {
|
||||
expect(["replace", "preserve"]).toContain(disposition);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves comparisonBaseline alongside the other client-owned fields", () => {
|
||||
// The regression itself: `comparisonBaseline` must be in the preserve
|
||||
// set, not merely present in the disposition map.
|
||||
expect([...PRESERVED_LAYOUT_FIELDS].sort()).toEqual([
|
||||
"chartOverlayPrefs",
|
||||
"comparisonBaseline",
|
||||
"heroRingOrder",
|
||||
"selectedScoreRings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the fields the request itself owns", () => {
|
||||
expect(LAYOUT_FIELD_MERGE_DISPOSITION.version).toBe("replace");
|
||||
expect(LAYOUT_FIELD_MERGE_DISPOSITION.widgets).toBe("replace");
|
||||
});
|
||||
|
||||
it("skips the stored-layout read when the client sent every preserved field", () => {
|
||||
expect(layoutNeedsPreserveRead(fullyPopulatedLayout)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the stored-layout read when any preserved field is absent", () => {
|
||||
for (const field of PRESERVED_LAYOUT_FIELDS) {
|
||||
const incoming: Partial<DashboardLayout> = { ...fullyPopulatedLayout };
|
||||
delete incoming[field];
|
||||
expect(layoutNeedsPreserveRead(incoming)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries an omitted comparisonBaseline forward from the stored layout", () => {
|
||||
// A widgets-only save, which is exactly what the native client sends.
|
||||
const incoming: Partial<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout);
|
||||
expect(merged.comparisonBaseline).toBe("lastYear");
|
||||
});
|
||||
|
||||
it("carries every omitted preserved field forward, one field at a time", () => {
|
||||
for (const field of PRESERVED_LAYOUT_FIELDS) {
|
||||
const incoming: Partial<DashboardLayout> = { ...fullyPopulatedLayout };
|
||||
delete incoming[field];
|
||||
const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout);
|
||||
expect(merged[field]).toEqual(fullyPopulatedLayout[field]);
|
||||
}
|
||||
});
|
||||
|
||||
it("never overwrites a preserved field the client did send", () => {
|
||||
const stored: DashboardLayout = {
|
||||
...fullyPopulatedLayout,
|
||||
comparisonBaseline: "lastMonth",
|
||||
selectedScoreRings: ["MED_COMPLIANCE"],
|
||||
};
|
||||
const incoming: Partial<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
comparisonBaseline: "none",
|
||||
chartOverlayPrefs: fullyPopulatedLayout.chartOverlayPrefs,
|
||||
selectedScoreRings: [],
|
||||
heroRingOrder: fullyPopulatedLayout.heroRingOrder,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, stored);
|
||||
// An explicit "none" is a real choice — clearing must survive the merge.
|
||||
expect(merged.comparisonBaseline).toBe("none");
|
||||
expect(merged.selectedScoreRings).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the replace-fields untouched by the merge", () => {
|
||||
const stored: DashboardLayout = {
|
||||
...fullyPopulatedLayout,
|
||||
widgets: [{ id: "bp", visible: false, tileVisible: false, order: 0 }],
|
||||
};
|
||||
const incoming: Partial<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, stored);
|
||||
expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,7 @@ describe("getCoachSystemPrompt — DE", () => {
|
||||
// German coach can cite glucose + duration values with a valid token.
|
||||
expect(prompt).toMatch(/mg\/dL/);
|
||||
expect(prompt).toMatch(/mmol\/L/);
|
||||
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
|
||||
expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/);
|
||||
});
|
||||
|
||||
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ interface SleepRhythmBlockContext {
|
||||
* ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
|
||||
*
|
||||
* The two server-authoritative timing signals the Sleep page + the
|
||||
* dashboard summary render — cumulative sleep debt and the MCTQ
|
||||
* dashboard summary render — the outstanding sleep-debt balance and the MCTQ
|
||||
* chronotype band + social jetlag. Built from the SAME assembler the
|
||||
* dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
|
||||
* over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
|
||||
|
||||
@@ -361,8 +361,9 @@ ISO-week means.
|
||||
timing signals — the SAME values the Sleep page and dashboard show, so
|
||||
cite them as the user sees them and never recompute. It holds:
|
||||
- "sleepDebt": { state, debtMinutes, needMinutes }. "debtMinutes" is
|
||||
the cumulative deficit against the age-based nightly need over the
|
||||
trailing window, "needMinutes" the target per night — both in
|
||||
the OUTSTANDING BALANCE against the age-based nightly need over the
|
||||
trailing window — a short night adds to it, a long night pays it
|
||||
down, and it floors at 0 — "needMinutes" the target per night — both in
|
||||
MINUTES (convert to hours/minutes when you speak, e.g. 320 → "5h
|
||||
20m of sleep debt"). When "state" is "partial" the window is still
|
||||
short: treat the figure as still calibrating, not a firm number.
|
||||
@@ -980,7 +981,7 @@ Regeln:
|
||||
- "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2",
|
||||
"4,1"). Stimmung als N/5 formatieren, wenn numerisch.
|
||||
- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min
|
||||
(Schlafdefizit / sozialer Jetlag).
|
||||
(Schlafschuld / sozialer Jetlag).
|
||||
- "window" ist eines aus last7days, last30days, last90days, allTime;
|
||||
bei Tages-Pins weglassen.
|
||||
- Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE
|
||||
|
||||
@@ -137,3 +137,43 @@ describe("verifyAuthentication — Zod boundary (L-3)", () => {
|
||||
expect(out.verification.verified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "Last used" is a security-page fact people act on: a passkey that reads
|
||||
* "never used" invites deletion. The column is only ever written here, on a
|
||||
* verified assertion, so the stamp and the counter bump must stay one write.
|
||||
* The read half is pinned in
|
||||
* `src/app/api/auth/passkeys/__tests__/route.test.ts`.
|
||||
*/
|
||||
describe("verifyAuthentication — last-used stamp", () => {
|
||||
// The file-level `beforeEach` re-arms the mocks but does not clear their
|
||||
// call history, so the write assertions below need a clean ledger.
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.passkey.update).mockClear();
|
||||
});
|
||||
|
||||
it("stamps lastUsedAt alongside the counter on a verified assertion", async () => {
|
||||
const before = Date.now();
|
||||
await verifyAuthentication("ch-1", VALID_BODY);
|
||||
|
||||
expect(prisma.passkey.update).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.passkey.update).mock.calls[0][0];
|
||||
|
||||
expect(call.where).toEqual({ id: "pk-1" });
|
||||
expect(call.data?.counter).toBe(BigInt(1));
|
||||
|
||||
const stamped = call.data?.lastUsedAt;
|
||||
expect(stamped).toBeInstanceOf(Date);
|
||||
expect((stamped as Date).getTime()).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("does not stamp lastUsedAt when the assertion fails", async () => {
|
||||
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
|
||||
verified: false,
|
||||
} as never);
|
||||
|
||||
await verifyAuthentication("ch-1", VALID_BODY);
|
||||
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,27 @@ export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [
|
||||
"walkingDoubleSupport",
|
||||
"audioExposureEnvironment",
|
||||
"audioExposureHeadphone",
|
||||
// The four clinical signals the v1.25 sub-pages already chart
|
||||
// (`/insights/grip-strength`, `/insights/pain`, `/insights/waist`,
|
||||
// `/insights/waist-to-height`). The native client offers a dashboard tile
|
||||
// for each; before they joined the catalogue the widgets PUT dropped them
|
||||
// through the unknown-id filter, so every native layout save silently lost
|
||||
// the user's placement of those four tiles.
|
||||
//
|
||||
// They live here rather than in `DASHBOARD_WIDGET_IDS` because the web
|
||||
// dashboard has no render path for them — the chart lives on the insights
|
||||
// sub-page, not on the Startseite. That keeps them out of the web Settings
|
||||
// list (built from `DASHBOARD_WIDGET_IDS`) while the PUT enum, the on-read
|
||||
// resolver, and the snapshot catalogue all accept and retain them.
|
||||
//
|
||||
// `painNRS` matches the id the native client sends. The sibling
|
||||
// chart-overlay slot spells it `painNrs` (see `CHART_OVERLAY_KEYS`); the
|
||||
// two namespaces are independent and neither is derived from the other, so
|
||||
// the casing difference is deliberate rather than a typo.
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -548,6 +569,84 @@ export interface DashboardLayout {
|
||||
heroRingOrder?: HeroRingId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How the widgets PUT treats each top-level layout field when the client
|
||||
* omits it.
|
||||
*
|
||||
* - `"replace"` — the field is the point of the request. A PUT that omits it
|
||||
* is either rejected by Zod (`version`, `widgets` are required) or means
|
||||
* the caller genuinely has nothing to say.
|
||||
* - `"preserve"` — the field is owned by a surface OTHER than the one making
|
||||
* this request, so an omission means "I don't know about this", never
|
||||
* "clear it". The route reads the stored layout and carries the existing
|
||||
* value forward.
|
||||
*
|
||||
* The preserve set exists because clients legitimately don't know every
|
||||
* field. The per-chart overlay popover PUTs through
|
||||
* `/api/dashboard/chart-overlay-prefs`; the hero rings are a Settings-only
|
||||
* control; `comparisonBaseline` is web-only and the native client documents
|
||||
* that it never sends it. Without preserve-when-absent, a tile reorder on one
|
||||
* surface silently resets a choice made on another — which is exactly what
|
||||
* happened to `comparisonBaseline` before it joined this map: the serializer
|
||||
* clamps a missing value to `"none"`, so every native layout save wiped the
|
||||
* baseline the user had picked on the web.
|
||||
*
|
||||
* `satisfies Record<keyof DashboardLayout, …>` is the load-bearing part.
|
||||
* Adding a field to `DashboardLayout` without adding it here is a TYPE error,
|
||||
* so the next field cannot repeat the `comparisonBaseline` mistake by
|
||||
* omission — somebody has to make the decision explicitly.
|
||||
*/
|
||||
export const LAYOUT_FIELD_MERGE_DISPOSITION = {
|
||||
version: "replace",
|
||||
widgets: "replace",
|
||||
comparisonBaseline: "preserve",
|
||||
chartOverlayPrefs: "preserve",
|
||||
selectedScoreRings: "preserve",
|
||||
heroRingOrder: "preserve",
|
||||
} as const satisfies Record<keyof DashboardLayout, "replace" | "preserve">;
|
||||
|
||||
/**
|
||||
* The top-level layout fields the widgets PUT carries forward from the stored
|
||||
* layout when the request omits them. Derived from
|
||||
* `LAYOUT_FIELD_MERGE_DISPOSITION` so the two can never drift.
|
||||
*/
|
||||
export const PRESERVED_LAYOUT_FIELDS = (
|
||||
Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION) as Array<keyof DashboardLayout>
|
||||
).filter(
|
||||
(field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve",
|
||||
) as ReadonlyArray<keyof DashboardLayout>;
|
||||
|
||||
/**
|
||||
* True when the incoming layout omits at least one preserved field, i.e. the
|
||||
* route has to read the stored layout to merge. Lets the common full-layout
|
||||
* save from Settings skip the extra query.
|
||||
*/
|
||||
export function layoutNeedsPreserveRead(
|
||||
incoming: Partial<DashboardLayout>,
|
||||
): boolean {
|
||||
return PRESERVED_LAYOUT_FIELDS.some((field) => incoming[field] === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry every omitted preserved field forward from the stored layout.
|
||||
* Fields the client DID send win untouched; `"replace"` fields are never
|
||||
* consulted here. One stored-layout read covers all of them.
|
||||
*/
|
||||
export function mergePreservedLayoutFields(
|
||||
incoming: Partial<DashboardLayout>,
|
||||
existing: DashboardLayout,
|
||||
): Partial<DashboardLayout> {
|
||||
const merged: Partial<DashboardLayout> = { ...incoming };
|
||||
for (const field of PRESERVED_LAYOUT_FIELDS) {
|
||||
if (merged[field] === undefined) {
|
||||
// Each key maps to its own value type; the loop is homogeneous over
|
||||
// `keyof DashboardLayout`, which TypeScript cannot express per-key.
|
||||
(merged as Record<string, unknown>)[field] = existing[field];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
|
||||
@@ -752,11 +752,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () =>
|
||||
isFullyCovered.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("emits all 38 catalogue ids with visibility + order", async () => {
|
||||
it("emits all 42 catalogue ids with visibility + order", async () => {
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
|
||||
expect(snap.layoutCatalogue).toHaveLength(38);
|
||||
expect(snap.layoutCatalogue).toHaveLength(42);
|
||||
const ids = new Set(snap.layoutCatalogue.map((w) => w.id));
|
||||
expect(ids.size).toBe(38);
|
||||
expect(ids.size).toBe(42);
|
||||
// iOS-only ids appended default-invisible.
|
||||
const walkingSpeed = snap.layoutCatalogue.find(
|
||||
(w) => w.id === "walkingSpeed",
|
||||
|
||||
@@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
// `NutrientIntakeDay` (nutrient="water", summed across sources). Gated
|
||||
// on the `nutrients` module like the rest of that store's surfaces.
|
||||
NUTRIENT_WATER: "nutrients",
|
||||
// Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it
|
||||
// rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the
|
||||
// `mood` module: turning the module off must drop the dashboard card on
|
||||
// every client, not just hide the web widget.
|
||||
MOOD_ENTRY: "mood",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -123,6 +128,13 @@ export const SUMMARY_METRIC_TYPE_BY_KIND: Record<string, string> = {
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
// Synthetic key — see `MOOD_ENTRY` in `SUMMARY_TYPE_MODULE`.
|
||||
mood: "MOOD_ENTRY",
|
||||
// BMI is derived from weight + the profile height and belongs to no
|
||||
// module, exactly like weight and body fat. Listed so the full emitted
|
||||
// set stays readable and a future card cannot be added without deciding
|
||||
// what backs it.
|
||||
bmi: "WEIGHT",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* The one place that turns a user's raw `SLEEP_DURATION` rows into the two
|
||||
* timing signals the Sleep page + the iOS client + the dashboard render:
|
||||
*
|
||||
* - cumulative SLEEP DEBT over the rolling window (`computeSleepDebt`), and
|
||||
* - the outstanding SLEEP DEBT balance over the rolling window
|
||||
* (`computeSleepDebt`; surplus sleep pays it down), and
|
||||
* - MCTQ CHRONOTYPE + social jetlag (`computeChronotype`).
|
||||
*
|
||||
* ONE ENGINE, NO DUPLICATION
|
||||
|
||||
@@ -236,6 +236,21 @@ describe("tone harness — deterministic fallbacks", () => {
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* "No assessment is available" in each locale's own words. Keyed per locale
|
||||
* rather than `de ? … : /No assessment/`, which is the same de/en binary that
|
||||
* let four locales render English bodies unnoticed — under it, a French floor
|
||||
* passed this assertion precisely BECAUSE it was still English.
|
||||
*/
|
||||
const NO_READ_SENTINEL: Record<Locale, RegExp> = {
|
||||
de: /keine Einschätzung vor/,
|
||||
en: /No assessment/,
|
||||
fr: /Aucune évaluation/,
|
||||
es: /no hay ninguna valoración/,
|
||||
it: /non è disponibile alcuna valutazione/,
|
||||
pl: /nie ma (?:tu )?żadnej oceny/,
|
||||
};
|
||||
|
||||
describe.each(FLOORS)("$name floor", ({ build }) => {
|
||||
it.each(locales)("%s opens on the read, not on an order", (locale) => {
|
||||
const text = build(locale);
|
||||
@@ -244,11 +259,33 @@ describe("tone harness — deterministic fallbacks", () => {
|
||||
|
||||
// The floor states plainly that no assessment is available — it must not
|
||||
// dress the absence up as one.
|
||||
expect(text).toMatch(
|
||||
locale === "de" ? /keine Einschätzung vor/ : /No assessment/,
|
||||
);
|
||||
expect(text).toMatch(NO_READ_SENTINEL[locale]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this whole pass exists to close: `getLocalizedText` used to
|
||||
* return `locale === "de" ? de : en`, so fr/es/it/pl rendered the ENGLISH
|
||||
* body on nine metric surfaces. Every tone rule above passed on that text —
|
||||
* it was in voice, it just was not the reader's language.
|
||||
*
|
||||
* Byte-identity against English is therefore the assertion that actually
|
||||
* pins it. It holds for both the no-signal floors and the signal-grounded
|
||||
* composer, because the composer has its own per-locale sentence templates
|
||||
* (the value sentence and the verdict lead), not only per-locale nouns.
|
||||
*/
|
||||
describe.each(RIDERS)("%s renders its own body", (locale) => {
|
||||
it.each(FLOORS)("$name floor is not the English text", ({ build }) => {
|
||||
expect(build(locale)).not.toEqual(build("en"));
|
||||
});
|
||||
|
||||
it.each(GROUNDED)(
|
||||
"$name grounded line is not the English text",
|
||||
({ build }) => {
|
||||
expect(build(locale)).not.toEqual(build("en"));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tone harness — the rules themselves", () => {
|
||||
|
||||
@@ -241,17 +241,45 @@ export function checkLocaleIntegrity(input: {
|
||||
|
||||
// ── deterministic fallback tone ─────────────────────────────────────────────
|
||||
|
||||
/** Praise that the numbers did not earn. */
|
||||
/**
|
||||
* Praise that the numbers did not earn.
|
||||
*
|
||||
* Six locales, because the deterministic fallbacks now carry a written body in
|
||||
* each: a rule that only knows English and German praise would grade four
|
||||
* locales as clean no matter what they said. Vocabulary only — the rule shape
|
||||
* is language-independent.
|
||||
*/
|
||||
const FALSE_CHEER =
|
||||
/\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b/i;
|
||||
/\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b|\b(bravo|félicitations|continue comme ça|génial|formidable|ne t['’]inquiète pas)\b|\b(enhorabuena|felicidades|sigue así|genial|estupendo|no te preocupes)\b|\b(complimenti|continua così|ottimo lavoro|fantastico|eccellente|non preoccuparti)\b|\b(gratulacje|brawo|tak trzymaj|świetnie|wspaniale|nie martw się)\b/i;
|
||||
|
||||
/** Verdict vocabulary a non-diagnostic surface may never use. */
|
||||
/**
|
||||
* Verdict vocabulary a non-diagnostic surface may never use.
|
||||
*
|
||||
* Note what is deliberately NOT here: the plain noun for "verdict" in each
|
||||
* language (verdict / veredicto / verdetto / wyrok / Urteil). Several floors
|
||||
* use it in the negated form that carries the whole non-diagnostic stance — "a
|
||||
* reference range is a coarse anchor, not a verdict" — so banning the noun
|
||||
* would flag the sentence that states the rule. The ban targets diagnosis,
|
||||
* pathology, attribution of a condition to the reader, and treatment
|
||||
* direction, which is what a non-diagnostic surface actually may not do.
|
||||
*/
|
||||
const CLINICAL_VERDICT =
|
||||
/\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b/i;
|
||||
/\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b|\b(diagnostiqu\w*|pathologique|tu souffres de|nécessite un traitement)\b|\b(diagnóstic\w*|patológic\w*|sufres de|requiere (?:un )?tratamiento)\b|\b(diagnosi|diagnostic\w*|patologic\w*|soffri di|richiede (?:un )?trattamento)\b|\b(diagnoz\w*|patologiczn\w*|cierpisz na|wymaga leczenia)\b/i;
|
||||
|
||||
/** The value-led opener §10 bans: "Your <metric> is <number>…". */
|
||||
/**
|
||||
* The value-led opener §10 bans: "Your <metric> is <number>…".
|
||||
*
|
||||
* Rewritten from the de/en-only shape, which assumed the copula sits directly
|
||||
* against the figure. That is true for "Your BMI is 24" and false for almost
|
||||
* everything else the surfaces actually render — German "liegt aktuell bei
|
||||
* 24", French "est à 24", Spanish "está en 24", Polish "wynosi obecnie 24".
|
||||
* The old pattern therefore under-matched German, its own second locale, and
|
||||
* would have missed all four new ones. The shape is now: possessive determiner,
|
||||
* up to three words of metric name, the copula, then up to two positional
|
||||
* fillers before the figure.
|
||||
*/
|
||||
const VALUE_LED_OPENER =
|
||||
/^(Your|Dein|Deine|Ihr|Ihre)\s+\S+(\s+\S+)?\s+(is|ist|liegt)\s+\d/i;
|
||||
/^(?:Your|Dein(?:e)?|Ihr(?:e)?|Ta|Ton|Tes|Tu|Tus|La tua|Le tue|Il tuo|I tuoi|Twój|Twoja|Twoje)\b(?:\s+\S+){0,3}\s+(?:is|ist|liegt|est|está|è|wynosi)\b(?:\s+(?:at|bei|à|a|en|aktuell|obecnie|right now)){0,2}\s+\d/i;
|
||||
|
||||
/**
|
||||
* The OTHER way a deterministic line stops leading with meaning: it opens on a
|
||||
@@ -263,9 +291,14 @@ const VALUE_LED_OPENER =
|
||||
* failure: the surface tells the reader what to do before it says anything
|
||||
* about what it can and cannot see. A pointer is fine LATER in the line; it
|
||||
* may not be the opening move.
|
||||
*
|
||||
* Extended to the four locales whose bodies used to be English. Romance and
|
||||
* Polish imperatives are a closed enough set here because the pointers these
|
||||
* floors could regress into are drawn from the same small verb family the
|
||||
* de/en list already names — measure, weigh, track, compare, read.
|
||||
*/
|
||||
const IMPERATIVE_OPENER =
|
||||
/^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b/;
|
||||
/^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b|^(Mesure|Suis|Évalue|Surveille|Vérifie|Note|Utilise|Garde|Compare|Pèse|Prends|Lis)\b|^(Mide|Evalúa|Vigila|Comprueba|Registra|Usa|Mantén|Compara|Pésate|Toma|Lee)\b|^(Misura|Valuta|Monitora|Controlla|Registra|Usa|Mantieni|Confronta|Pesati|Prendi|Leggi)\b|^(Mierz|Oceń|Obserwuj|Sprawdź|Zapisuj|Używaj|Utrzymuj|Porównaj|Waż|Czytaj)\b/;
|
||||
|
||||
function firstSentence(text: string): string {
|
||||
const m = /^[^.!?]*[.!?]/.exec(text.trim());
|
||||
|
||||
@@ -2,12 +2,19 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import type { MetricSignal } from "@/lib/insights/metric-signal";
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — these no-key fallbacks ship DE + EN bodies only. The
|
||||
* type accepts every shipped locale (fr/es/it/pl too) and routes
|
||||
* non-DE locales through the EN body, mirroring the same fallback
|
||||
* chain the JSON message bundles use. When DE/EN-only fallbacks get
|
||||
* replaced with proper FR/ES/IT/PL bodies, expand `getLocalizedText`
|
||||
* to read the matching argument.
|
||||
* v1.4.25 W9e / v1.31.0 — these no-key fallbacks now carry a written body for
|
||||
* every shipped locale (de/en/fr/es/it/pl). They used to ship DE + EN only and
|
||||
* route the other four through the English body, which meant a French, Spanish,
|
||||
* Italian or Polish self-hoster read English on nine metric surfaces whenever
|
||||
* no provider was reachable. `pick` selects the reader's own body; English
|
||||
* stays as the structural floor for a locale that is ever genuinely missing
|
||||
* one, not as the shipped answer for four of six.
|
||||
*
|
||||
* The bodies are written per language rather than translated token-for-token.
|
||||
* Where a sentence cannot carry the English shape — a comparative adjective
|
||||
* that would have to agree with the metric noun's gender, a locative "is at"
|
||||
* with no natural equivalent — that language's sentence is restructured. The
|
||||
* per-language notes sit at the templates themselves.
|
||||
*
|
||||
* v1.21.0 (coach C1 HIGH-1/2) — these surfaces used to emit static
|
||||
* clinical tips with ZERO reference to the user's own numbers, and the
|
||||
@@ -22,12 +29,24 @@ import type { MetricSignal } from "@/lib/insights/metric-signal";
|
||||
*/
|
||||
export type InsightLocale = Locale;
|
||||
|
||||
/**
|
||||
* A per-locale table with English required.
|
||||
*
|
||||
* English is required so the fallback arm can never itself be undefined; every
|
||||
* other locale is optional so a genuinely missing body degrades to English
|
||||
* rather than to `undefined`. Every table in this file fills all six.
|
||||
*/
|
||||
type Localized<T> = { en: T } & Partial<Record<InsightLocale, T>>;
|
||||
|
||||
function pick<T>(locale: InsightLocale, table: Localized<T>): T {
|
||||
return table[locale] ?? table.en;
|
||||
}
|
||||
|
||||
function getLocalizedText(
|
||||
locale: InsightLocale,
|
||||
de: string,
|
||||
en: string,
|
||||
text: Localized<string>,
|
||||
): string {
|
||||
return locale === "de" ? de : en;
|
||||
return pick(locale, text);
|
||||
}
|
||||
|
||||
// ── signal-grounded composer ────────────────────────────────────────────────
|
||||
@@ -35,7 +54,7 @@ function getLocalizedText(
|
||||
/** A natural-language metric label + the pointer phrasing for one metric. */
|
||||
interface FallbackCopy {
|
||||
/** Possessive natural-language label ("your blood pressure"). */
|
||||
label: { de: string; en: string };
|
||||
label: Localized<string>;
|
||||
/** Unit suffix appended to a value (" bpm"), empty when none. */
|
||||
unit?: string;
|
||||
/** Digits to round the rendered value to (0 for integers). */
|
||||
@@ -44,7 +63,7 @@ interface FallbackCopy {
|
||||
* One grounded pointer used when the value sits outside the user's own
|
||||
* usual swing — framed as an opportunity, never an order.
|
||||
*/
|
||||
pointer: { de: string; en: string };
|
||||
pointer: Localized<string>;
|
||||
}
|
||||
|
||||
function fmtValue(value: number, copy: FallbackCopy): string {
|
||||
@@ -73,15 +92,40 @@ function capitalise(s: string): string {
|
||||
* signal is too thin to state a confident read (no baseline / no delta), in
|
||||
* which case the line simply opens on the value as before.
|
||||
*/
|
||||
const VERDICT_STEADY: Localized<string> = {
|
||||
de: "Stabil und wie gewohnt",
|
||||
en: "Steady and much as usual",
|
||||
fr: "Stable, comme d’habitude",
|
||||
es: "Estable, como de costumbre",
|
||||
it: "Stabile, come al solito",
|
||||
pl: "Stabilnie, tak jak zwykle",
|
||||
};
|
||||
|
||||
const VERDICT_FAVOURABLE: Localized<string> = {
|
||||
de: "Das geht in eine gute Richtung",
|
||||
en: "This is moving in a good direction",
|
||||
fr: "Cela évolue dans le bon sens",
|
||||
es: "Esto va en buena dirección",
|
||||
it: "Sta andando nella direzione giusta",
|
||||
pl: "To zmierza w dobrym kierunku",
|
||||
};
|
||||
|
||||
const VERDICT_OFF_USUAL: Localized<string> = {
|
||||
de: "Zuletzt etwas außerhalb deines üblichen Rahmens",
|
||||
en: "A little off your usual lately",
|
||||
fr: "Un peu en dehors de tes repères habituels ces derniers temps",
|
||||
es: "Últimamente algo fuera de tu rango habitual",
|
||||
it: "Ultimamente un po’ fuori dal tuo intervallo abituale",
|
||||
pl: "Ostatnio nieco poza twoim zwykłym zakresem",
|
||||
};
|
||||
|
||||
function verdictLead(
|
||||
signal: MetricSignal,
|
||||
locale: InsightLocale,
|
||||
): string | null {
|
||||
// Inside the user's own usual swing → a steady, reassuring verdict.
|
||||
if (signal.outsideNormalSwing === false) {
|
||||
return locale === "de"
|
||||
? "Stabil und wie gewohnt"
|
||||
: "Steady and much as usual";
|
||||
return pick(locale, VERDICT_STEADY);
|
||||
}
|
||||
// Outside the usual swing → is the move in a favourable or unfavourable
|
||||
// direction? For a target-band metric the direction alone can't say, so it
|
||||
@@ -98,18 +142,93 @@ function verdictLead(
|
||||
? signal.delta < 0
|
||||
: null;
|
||||
if (favourable === true) {
|
||||
return locale === "de"
|
||||
? "Das geht in eine gute Richtung"
|
||||
: "This is moving in a good direction";
|
||||
return pick(locale, VERDICT_FAVOURABLE);
|
||||
}
|
||||
return locale === "de"
|
||||
? "Zuletzt etwas außerhalb deines üblichen Rahmens"
|
||||
: "A little off your usual lately";
|
||||
return pick(locale, VERDICT_OFF_USUAL);
|
||||
}
|
||||
// No baseline / no usable delta → no confident verdict; open on the value.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value sentence, placed against the user's own baseline.
|
||||
*
|
||||
* Three shapes, one per grounding level. Each is a per-locale template rather
|
||||
* than one template with swapped words, because the languages do not share a
|
||||
* sentence shape here:
|
||||
*
|
||||
* · de/en keep the locative "liegt bei" / "is at" plus a comparative adjective.
|
||||
* · fr/es/it drop the comparative ("plus haut", "más alto", "più alto") for a
|
||||
* PREPOSITION — "au-dessus de", "por encima de", "sopra". The comparative
|
||||
* would have to agree in gender with the metric noun, which differs across
|
||||
* the labels (ta tension / ton poids, tu presión / tu peso, la tua pressione
|
||||
* / il tuo peso), so a single template carrying an adjective cannot be
|
||||
* correct for all of them. The preposition governs the noun without agreeing
|
||||
* with it, which makes one template safe for every metric.
|
||||
* · pl restructures further: there is no natural locative "is at" for a
|
||||
* measurement, so the sentence takes the verb `wynosi` ("amounts to"), which
|
||||
* is invariant for the subject's gender — Polish adjectives and past-tense
|
||||
* verbs are not, and the labels span all three genders (twój nastrój m.,
|
||||
* twoja waga f., twoje tętno n.). The comparison likewise uses the
|
||||
* prepositions `powyżej` / `poniżej` with the genitive rather than a
|
||||
* comparative adjective.
|
||||
*/
|
||||
const VALUE_VS_BASELINE: Localized<
|
||||
(label: string, current: string, baseline: string, higher: boolean) => string
|
||||
> = {
|
||||
de: (l, c, b, higher) =>
|
||||
`${l} liegt aktuell bei ${c} — ${higher ? "höher" : "niedriger"} als dein üblicher Schnitt von ${b}.`,
|
||||
en: (l, c, b, higher) =>
|
||||
`${l} is at ${c} right now — ${higher ? "higher" : "lower"} than your usual average of ${b}.`,
|
||||
fr: (l, c, b, higher) =>
|
||||
`${l} est à ${c} en ce moment — ${higher ? "au-dessus" : "en dessous"} de ta moyenne habituelle de ${b}.`,
|
||||
es: (l, c, b, higher) =>
|
||||
`${l} está en ${c} ahora mismo — ${higher ? "por encima" : "por debajo"} de tu promedio habitual de ${b}.`,
|
||||
it: (l, c, b, higher) =>
|
||||
`${l} è a ${c} in questo momento — ${higher ? "sopra" : "sotto"} la tua media abituale di ${b}.`,
|
||||
pl: (l, c, b, higher) =>
|
||||
`${l} wynosi obecnie ${c} — ${higher ? "powyżej" : "poniżej"} twojej zwykłej średniej ${b}.`,
|
||||
};
|
||||
|
||||
const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> =
|
||||
{
|
||||
de: (l, c) =>
|
||||
`${l} liegt aktuell bei ${c}, im Bereich deines üblichen Schnitts.`,
|
||||
en: (l, c) => `${l} is at ${c}, right around your usual average.`,
|
||||
fr: (l, c) => `${l} est à ${c}, tout près de ta moyenne habituelle.`,
|
||||
es: (l, c) => `${l} está en ${c}, justo en torno a tu promedio habitual.`,
|
||||
it: (l, c) => `${l} è a ${c}, più o meno sulla tua media abituale.`,
|
||||
pl: (l, c) =>
|
||||
`${l} wynosi obecnie ${c}, czyli mniej więcej tyle co twoja zwykła średnia.`,
|
||||
};
|
||||
|
||||
const VALUE_ALONE: Localized<(label: string, current: string) => string> = {
|
||||
de: (l, c) => `${l} liegt aktuell bei ${c}.`,
|
||||
en: (l, c) => `${l} is at ${c} right now.`,
|
||||
fr: (l, c) => `${l} est à ${c} en ce moment.`,
|
||||
es: (l, c) => `${l} está en ${c} ahora mismo.`,
|
||||
it: (l, c) => `${l} è a ${c} in questo momento.`,
|
||||
pl: (l, c) => `${l} wynosi obecnie ${c}.`,
|
||||
};
|
||||
|
||||
const CLOSER_IN_RANGE: Localized<string> = {
|
||||
de: "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst.",
|
||||
en: "That sits in your usual range — nothing you need to act on right now.",
|
||||
fr: "C’est dans tes repères habituels — rien qui appelle une réaction maintenant.",
|
||||
es: "Eso queda dentro de tu rango habitual — nada que pida una reacción ahora.",
|
||||
it: "Rientra nel tuo intervallo abituale — niente su cui intervenire adesso.",
|
||||
pl: "Mieści się to w twoim zwykłym zakresie — nic, na co trzeba teraz reagować.",
|
||||
};
|
||||
|
||||
const CLOSER_UNKNOWN_SWING: Localized<string> = {
|
||||
de: "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage.",
|
||||
en: "A few more readings will make the trend dependable — keep an eye on it over the coming days.",
|
||||
fr: "Quelques mesures de plus rendront la tendance fiable — garde un œil dessus les prochains jours.",
|
||||
es: "Unas pocas mediciones más harán fiable la tendencia — obsérvala durante los próximos días.",
|
||||
it: "Qualche misurazione in più renderà la tendenza affidabile — tienila d’occhio nei prossimi giorni.",
|
||||
pl: "Kilka kolejnych pomiarów sprawi, że tendencja stanie się wiarygodna — obserwuj ją przez najbliższe dni.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Compose a warm, grounded deterministic line from a metric signal. Leads with
|
||||
* a plain-words verdict (meaning first), then names the current value placed
|
||||
@@ -125,7 +244,7 @@ function composeGroundedFallback(
|
||||
): string | null {
|
||||
if (!signal || !Number.isFinite(signal.current)) return null;
|
||||
|
||||
const label = locale === "de" ? copy.label.de : copy.label.en;
|
||||
const label = capitalise(pick(locale, copy.label));
|
||||
const current = fmtValue(signal.current, copy);
|
||||
|
||||
const sentences: string[] = [];
|
||||
@@ -143,47 +262,29 @@ function composeGroundedFallback(
|
||||
Number.isFinite(signal.baseline)
|
||||
) {
|
||||
const baseline = fmtValue(signal.baseline, copy);
|
||||
const higher = signal.delta > 0;
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `${capitalise(label)} liegt aktuell bei ${current} — ${
|
||||
higher ? "höher" : "niedriger"
|
||||
} als dein üblicher Schnitt von ${baseline}.`
|
||||
: `${capitalise(label)} is at ${current} right now — ${
|
||||
higher ? "higher" : "lower"
|
||||
} than your usual average of ${baseline}.`,
|
||||
pick(locale, VALUE_VS_BASELINE)(
|
||||
label,
|
||||
current,
|
||||
baseline,
|
||||
signal.delta > 0,
|
||||
),
|
||||
);
|
||||
} else if (signal.baseline !== null && Number.isFinite(signal.baseline)) {
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `${capitalise(label)} liegt aktuell bei ${current}, im Bereich deines üblichen Schnitts.`
|
||||
: `${capitalise(label)} is at ${current}, right around your usual average.`,
|
||||
);
|
||||
sentences.push(pick(locale, VALUE_AT_BASELINE)(label, current));
|
||||
} else {
|
||||
// No baseline yet — name the value honestly without inventing a comparison.
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `${capitalise(label)} liegt aktuell bei ${current}.`
|
||||
: `${capitalise(label)} is at ${current} right now.`,
|
||||
);
|
||||
sentences.push(pick(locale, VALUE_ALONE)(label, current));
|
||||
}
|
||||
|
||||
// 2) One grounded pointer when outside the usual swing; otherwise affirm.
|
||||
if (signal.outsideNormalSwing === true) {
|
||||
sentences.push(locale === "de" ? copy.pointer.de : copy.pointer.en);
|
||||
sentences.push(pick(locale, copy.pointer));
|
||||
} else if (signal.outsideNormalSwing === false) {
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst."
|
||||
: "That sits in your usual range — nothing you need to act on right now.",
|
||||
);
|
||||
sentences.push(pick(locale, CLOSER_IN_RANGE));
|
||||
} else {
|
||||
// Unknown swing (no baseline): keep one steady, non-alarming pointer.
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage."
|
||||
: "A few more readings will make the trend dependable — keep an eye on it over the coming days.",
|
||||
);
|
||||
sentences.push(pick(locale, CLOSER_UNKNOWN_SWING));
|
||||
}
|
||||
|
||||
return sentences.join(" ");
|
||||
@@ -192,65 +293,141 @@ function composeGroundedFallback(
|
||||
// ── per-metric copy ─────────────────────────────────────────────────────────
|
||||
|
||||
const BLOOD_PRESSURE_COPY: FallbackCopy = {
|
||||
label: { de: "dein Blutdruck", en: "your blood pressure" },
|
||||
label: {
|
||||
de: "dein Blutdruck",
|
||||
en: "your blood pressure",
|
||||
fr: "ta tension artérielle",
|
||||
es: "tu presión arterial",
|
||||
it: "la tua pressione sanguigna",
|
||||
pl: "twoje ciśnienie krwi",
|
||||
},
|
||||
unit: "",
|
||||
digits: 0,
|
||||
pointer: {
|
||||
de: "Ein paar ruhige Messungen unter gleichen Bedingungen zeigen, ob das die Tendenz ist oder ein Ausreißer.",
|
||||
en: "A few calm readings under the same conditions will show whether this is the trend or a single outlier.",
|
||||
fr: "Quelques mesures au calme dans les mêmes conditions montreront si c’est la tendance ou une valeur isolée.",
|
||||
es: "Unas cuantas mediciones en calma y en las mismas condiciones mostrarán si es la tendencia o un valor aislado.",
|
||||
it: "Qualche misurazione a riposo nelle stesse condizioni mostrerà se è la tendenza o un valore isolato.",
|
||||
pl: "Kilka spokojnych pomiarów w tych samych warunkach pokaże, czy to tendencja, czy pojedynczy wyskok.",
|
||||
},
|
||||
};
|
||||
|
||||
const WEIGHT_COPY: FallbackCopy = {
|
||||
label: { de: "dein Gewicht", en: "your weight" },
|
||||
label: {
|
||||
de: "dein Gewicht",
|
||||
en: "your weight",
|
||||
fr: "ton poids",
|
||||
es: "tu peso",
|
||||
it: "il tuo peso",
|
||||
pl: "twoja waga",
|
||||
},
|
||||
unit: "",
|
||||
digits: 1,
|
||||
pointer: {
|
||||
de: "Wäge dich ein paar Tage zur gleichen Zeit, dann ordnet sich die normale Schwankung von selbst ein.",
|
||||
en: "Weigh in at the same time for a few days and the normal day-to-day swing sorts itself out.",
|
||||
fr: "Pèse-toi à la même heure pendant quelques jours et la fluctuation quotidienne normale se lisse d’elle-même.",
|
||||
es: "Pésate a la misma hora durante unos días y la fluctuación diaria normal se compensa sola.",
|
||||
it: "Pesati alla stessa ora per qualche giorno e la normale oscillazione quotidiana si compensa da sé.",
|
||||
pl: "Waż się przez kilka dni o tej samej porze, a normalne dobowe wahania same się wyrównają.",
|
||||
},
|
||||
};
|
||||
|
||||
const PULSE_COPY: FallbackCopy = {
|
||||
label: { de: "dein Ruhepuls", en: "your resting pulse" },
|
||||
label: {
|
||||
de: "dein Ruhepuls",
|
||||
en: "your resting pulse",
|
||||
fr: "ton pouls au repos",
|
||||
es: "tu pulso en reposo",
|
||||
it: "il tuo battito a riposo",
|
||||
pl: "twoje tętno spoczynkowe",
|
||||
},
|
||||
unit: " bpm",
|
||||
digits: 0,
|
||||
pointer: {
|
||||
de: "Miss ihn entspannt und zur gleichen Tageszeit, dann siehst du, ob die Richtung anhält.",
|
||||
en: "Take it relaxed and at the same time of day to see whether the direction holds.",
|
||||
fr: "Prends-le détendu et à la même heure de la journée pour voir si la direction se maintient.",
|
||||
es: "Tómatelo relajado y a la misma hora del día para ver si la dirección se mantiene.",
|
||||
it: "Misuralo da rilassato e alla stessa ora del giorno per vedere se la direzione tiene.",
|
||||
pl: "Mierz je na spokojnie i o tej samej porze dnia, żeby zobaczyć, czy kierunek się utrzyma.",
|
||||
},
|
||||
};
|
||||
|
||||
const BMI_COPY: FallbackCopy = {
|
||||
label: { de: "dein BMI", en: "your BMI" },
|
||||
label: {
|
||||
de: "dein BMI",
|
||||
en: "your BMI",
|
||||
fr: "ton IMC",
|
||||
es: "tu IMC",
|
||||
it: "il tuo IMC",
|
||||
pl: "twoje BMI",
|
||||
},
|
||||
unit: "",
|
||||
digits: 1,
|
||||
pointer: {
|
||||
de: "Schau ihn dir zusammen mit Gewichtstrend und Körperfett über ein paar Wochen an.",
|
||||
en: "Read it alongside your weight trend and body-fat over a few weeks rather than day by day.",
|
||||
fr: "Lis-le avec la tendance de ton poids et ta masse grasse sur quelques semaines plutôt qu’au jour le jour.",
|
||||
es: "Léelo junto con la tendencia de tu peso y tu grasa corporal a lo largo de unas semanas, no día a día.",
|
||||
it: "Leggilo insieme all’andamento del tuo peso e alla massa grassa nell’arco di qualche settimana, non giorno per giorno.",
|
||||
pl: "Czytaj je razem z tendencją wagi i poziomem tkanki tłuszczowej w skali kilku tygodni, a nie dzień po dniu.",
|
||||
},
|
||||
};
|
||||
|
||||
const MOOD_COPY: FallbackCopy = {
|
||||
label: { de: "deine Stimmung", en: "your mood" },
|
||||
label: {
|
||||
de: "deine Stimmung",
|
||||
en: "your mood",
|
||||
fr: "ton humeur",
|
||||
es: "tu estado de ánimo",
|
||||
it: "il tuo umore",
|
||||
pl: "twój nastrój",
|
||||
},
|
||||
unit: "",
|
||||
digits: 1,
|
||||
pointer: {
|
||||
de: "Halte sie ein paar Tage fest — wiederkehrende Muster sagen mehr als ein einzelner Tag.",
|
||||
en: "Log it for a few days — a recurring pattern says more than any single day.",
|
||||
fr: "Note-la pendant quelques jours — un motif qui revient en dit plus qu’une journée isolée.",
|
||||
es: "Regístralo unos días — un patrón que se repite dice más que un día suelto.",
|
||||
it: "Registralo per qualche giorno — uno schema che si ripete dice più di un singolo giorno.",
|
||||
pl: "Zapisuj go przez kilka dni — powtarzający się wzorzec mówi więcej niż pojedynczy dzień.",
|
||||
},
|
||||
};
|
||||
|
||||
const ADHERENCE_COPY: FallbackCopy = {
|
||||
label: { de: "deine Einnahmetreue", en: "your medication adherence" },
|
||||
label: {
|
||||
de: "deine Einnahmetreue",
|
||||
en: "your medication adherence",
|
||||
fr: "ton observance du traitement",
|
||||
es: "tu adherencia a la medicación",
|
||||
it: "la tua aderenza alla terapia",
|
||||
pl: "twoja regularność przyjmowania leków",
|
||||
},
|
||||
unit: "%",
|
||||
digits: 0,
|
||||
pointer: {
|
||||
de: "Eine feste Zeit oder ein kurzer Reminder fängt die wiederkehrenden Auslassungen am ehesten ab.",
|
||||
en: "A fixed time or a quick reminder is the most reliable way to catch the repeat misses.",
|
||||
fr: "Une heure fixe ou un rappel court est ce qui rattrape le mieux les oublis répétés.",
|
||||
es: "Una hora fija o un recordatorio breve es lo que mejor recupera los olvidos repetidos.",
|
||||
it: "Un orario fisso o un promemoria breve è ciò che intercetta meglio le dimenticanze ripetute.",
|
||||
pl: "Stała pora albo krótkie przypomnienie najlepiej wyłapuje powtarzające się pominięcia.",
|
||||
},
|
||||
};
|
||||
|
||||
/** The generic pointer for the dynamic per-metric card, which has no copy table. */
|
||||
const GENERIC_METRIC_POINTER: Localized<string> = {
|
||||
de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.",
|
||||
en: "A few consistent readings will show whether this is the direction or a single day.",
|
||||
fr: "Quelques mesures régulières montreront si c’est la direction prise ou une seule journée.",
|
||||
es: "Unas cuantas mediciones constantes mostrarán si es la dirección o un solo día.",
|
||||
it: "Qualche misurazione costante mostrerà se è la direzione o un singolo giorno.",
|
||||
pl: "Kilka konsekwentnych pomiarów pokaże, czy to kierunek, czy pojedynczy dzień.",
|
||||
};
|
||||
|
||||
// ── public fallbacks ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -268,16 +445,12 @@ export function getNoKeyMetricStatusText(
|
||||
? composeGroundedFallback(
|
||||
signal,
|
||||
{
|
||||
label: {
|
||||
de: signal.metric,
|
||||
en: signal.metric,
|
||||
},
|
||||
// The signal's own label is already natural language and is not
|
||||
// localised upstream, so it stands for every locale.
|
||||
label: { en: signal.metric },
|
||||
...(signal.unit ? { unit: ` ${signal.unit}` } : {}),
|
||||
digits: 1,
|
||||
pointer: {
|
||||
de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.",
|
||||
en: "A few consistent readings will show whether this is the direction or a single day.",
|
||||
},
|
||||
pointer: GENERIC_METRIC_POINTER,
|
||||
},
|
||||
locale,
|
||||
)
|
||||
@@ -299,24 +472,34 @@ export function getNoKeyMetricStatusText(
|
||||
* What follows the lead is what makes the metric readable, phrased as how the
|
||||
* measure behaves rather than as an order to the reader.
|
||||
*/
|
||||
const NO_READ_LEAD: Localized<string> = {
|
||||
de: "Für diese Karte liegt gerade keine Einschätzung vor.",
|
||||
en: "No assessment on this one right now.",
|
||||
fr: "Aucune évaluation disponible pour le moment.",
|
||||
es: "Ahora mismo no hay ninguna valoración disponible.",
|
||||
it: "Al momento non è disponibile alcuna valutazione.",
|
||||
pl: "Na razie nie ma tu żadnej oceny.",
|
||||
};
|
||||
|
||||
function noReadLead(locale: InsightLocale): string {
|
||||
return locale === "de"
|
||||
? "Für diese Karte liegt gerade keine Einschätzung vor."
|
||||
: "No assessment on this one right now.";
|
||||
return pick(locale, NO_READ_LEAD);
|
||||
}
|
||||
|
||||
function floor(locale: InsightLocale, de: string, en: string): string {
|
||||
return `${noReadLead(locale)} ${getLocalizedText(locale, de, en)}`;
|
||||
function floor(locale: InsightLocale, body: Localized<string>): string {
|
||||
return `${noReadLead(locale)} ${getLocalizedText(locale, body)}`;
|
||||
}
|
||||
|
||||
export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
|
||||
// The overview spans many metrics with no single headline value to ground
|
||||
// against, so it keeps the honest, generic multi-metric pointer.
|
||||
return floor(
|
||||
locale,
|
||||
"Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.",
|
||||
"These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.",
|
||||
);
|
||||
return floor(locale, {
|
||||
de: "Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.",
|
||||
en: "These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.",
|
||||
fr: "Ces chiffres deviennent lisibles sur plusieurs semaines plutôt que sur des journées isolées, et relevés à des moments constants pour rester comparables. Le signal qui compte, c’est plusieurs indicateurs qui dérivent en même temps dans le même sens défavorable, pas une valeur un jour donné.",
|
||||
es: "Estos números se vuelven legibles a lo largo de varias semanas y no en días sueltos, y tomados a horas constantes para que sigan siendo comparables. La señal que cuenta son varios indicadores derivando a la vez en la misma dirección desfavorable, no un valor en un día.",
|
||||
it: "Questi numeri diventano leggibili nell’arco di più settimane anziché di singoli giorni, e rilevati a orari costanti per restare confrontabili. Il segnale che conta è più indicatori che scivolano insieme nella stessa direzione sfavorevole, non un valore in un giorno.",
|
||||
pl: "Te liczby stają się czytelne w skali kilku tygodni, a nie pojedynczych dni, i tylko wtedy, gdy są zbierane o stałych porach, żeby pozostały porównywalne. Sygnałem, który się liczy, jest kilka wskaźników dryfujących jednocześnie w tę samą niekorzystną stronę, a nie jedna wartość jednego dnia.",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,26 +507,46 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
|
||||
* multi-metric overview tip — text about watching several metrics at once, on
|
||||
* a card showing exactly one lab value. This says something true about a lab
|
||||
* marker instead.
|
||||
*
|
||||
* The named lead is built per locale rather than by splicing a shared clause:
|
||||
* the quoting convention differs (German low-high quotes, French and Italian
|
||||
* guillemets, Polish low-high) and so does where the marker name attaches to
|
||||
* the sentence.
|
||||
*/
|
||||
const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = {
|
||||
de: (n) => `Für diesen Laborwert${n} liegt gerade keine Einschätzung vor.`,
|
||||
en: (n) => `No assessment${n} right now.`,
|
||||
fr: (n) => `Aucune évaluation${n} pour le moment.`,
|
||||
es: (n) => `Ahora mismo no hay ninguna valoración${n}.`,
|
||||
it: (n) => `Al momento non è disponibile alcuna valutazione${n}.`,
|
||||
pl: (n) => `Na razie nie ma żadnej oceny${n}.`,
|
||||
};
|
||||
|
||||
const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
de: (m) => ` zu „${m}“`,
|
||||
en: (m) => ` for "${m}"`,
|
||||
fr: (m) => ` pour « ${m} »`,
|
||||
es: (m) => ` de «${m}»`,
|
||||
it: (m) => ` per «${m}»`,
|
||||
pl: (m) => ` dla „${m}”`,
|
||||
};
|
||||
|
||||
export function getNoKeyBiomarkerStatusText(
|
||||
locale: InsightLocale,
|
||||
markerName?: string | null,
|
||||
): string {
|
||||
const trimmed = markerName?.trim() ?? "";
|
||||
const named =
|
||||
markerName && markerName.trim().length > 0
|
||||
? locale === "de"
|
||||
? ` zu „${markerName.trim()}"`
|
||||
: ` for "${markerName.trim()}"`
|
||||
: "";
|
||||
const lead =
|
||||
locale === "de"
|
||||
? `Für diesen Laborwert${named} liegt gerade keine Einschätzung vor.`
|
||||
: `No assessment${named} right now.`;
|
||||
return `${lead} ${getLocalizedText(
|
||||
locale,
|
||||
"Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.",
|
||||
"A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.",
|
||||
)}`;
|
||||
trimmed.length > 0 ? pick(locale, BIOMARKER_NAMED_CLAUSE)(trimmed) : "";
|
||||
const lead = pick(locale, BIOMARKER_NAMED_LEAD)(named);
|
||||
return `${lead} ${getLocalizedText(locale, {
|
||||
de: "Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.",
|
||||
en: "A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.",
|
||||
fr: "Une valeur de laboratoire se lit d’abord contre tes propres prélèvements précédents — un intervalle de référence est un repère grossier, pas un verdict. Une valeur qui a à peine bougé sur plusieurs prélèvements raconte autre chose qu’une valeur qui vient de faire un saut. Ton prochain contrôle médical est le bon endroit pour la remettre en contexte.",
|
||||
es: "Un valor de laboratorio se lee primero frente a tus propias extracciones anteriores — un intervalo de referencia es un ancla aproximada, no un veredicto. Uno que apenas se ha movido a lo largo de varias extracciones cuenta algo distinto de uno que acaba de dar un salto. Tu próxima revisión médica es el lugar adecuado para ponerlo en contexto.",
|
||||
it: "Un valore di laboratorio si legge prima di tutto rispetto ai tuoi prelievi precedenti — un intervallo di riferimento è un ancoraggio grossolano, non un verdetto. Uno che si è mosso appena nell’arco di più prelievi racconta una storia diversa da uno che ha appena fatto un salto. Il tuo prossimo controllo medico è il posto giusto per contestualizzarlo.",
|
||||
pl: "Wynik laboratoryjny czyta się najpierw na tle twoich wcześniejszych oznaczeń — zakres referencyjny to zgrubna kotwica, a nie wyrok. Wynik, który przez kilka oznaczeń niemal się nie ruszył, mówi co innego niż taki, który właśnie podskoczył. Najbliższa kontrola lekarska to właściwe miejsce, żeby go osadzić w kontekście.",
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function getNoKeyBloodPressureStatusText(
|
||||
@@ -352,11 +555,14 @@ export function getNoKeyBloodPressureStatusText(
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, BLOOD_PRESSURE_COPY, locale) ??
|
||||
floor(
|
||||
locale,
|
||||
"Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.",
|
||||
"Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.",
|
||||
)
|
||||
floor(locale, {
|
||||
de: "Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.",
|
||||
en: "Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.",
|
||||
fr: "La tension se lit sur une série de mesures au calme dans des conditions comparables — la direction sur plusieurs jours dit bien plus qu’une valeur isolée, et la systolique et la diastolique se lisent ensemble plutôt que l’une après l’autre.",
|
||||
es: "La tensión se lee a lo largo de una serie de mediciones en calma y en condiciones comparables — la dirección a lo largo de varios días dice mucho más que cualquier valor aislado, y la sistólica y la diastólica se leen juntas y no una por una.",
|
||||
it: "La pressione si legge su una serie di misurazioni a riposo in condizioni confrontabili — la direzione nell’arco di più giorni dice molto più di un singolo valore isolato, e sistolica e diastolica si leggono insieme anziché una alla volta.",
|
||||
pl: "Ciśnienie staje się czytelne dopiero w serii spokojnych pomiarów w porównywalnych warunkach — kierunek z kilku dni mówi znacznie więcej niż pojedynczy wyskok, a wartość skurczowa i rozkurczowa czyta się razem, a nie osobno.",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,11 +572,14 @@ export function getNoKeyWeightStatusText(
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, WEIGHT_COPY, locale) ??
|
||||
floor(
|
||||
locale,
|
||||
"Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.",
|
||||
"Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.",
|
||||
)
|
||||
floor(locale, {
|
||||
de: "Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.",
|
||||
en: "Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.",
|
||||
fr: "Le poids se lit comme une tendance et non comme un chiffre du jour — pesé dans des conditions constantes, la fluctuation quotidienne normale se lisse d’elle-même. C’est la direction sur des semaines, lue avec la tension et l’IMC, qui porte.",
|
||||
es: "El peso se lee como una tendencia y no como una cifra diaria — pesado en condiciones constantes, la fluctuación normal del día a día se compensa sola. Lo que sostiene es la dirección a lo largo de semanas, leída junto a la tensión y el IMC.",
|
||||
it: "Il peso si legge come andamento e non come numero del giorno — pesato in condizioni costanti, la normale oscillazione quotidiana si compensa da sé. Quello che regge è la direzione nell’arco di settimane, letta insieme alla pressione e all’IMC.",
|
||||
pl: "Wagę czyta się jako przebieg, a nie jako liczbę z jednego dnia — przy ważeniu w stałych warunkach normalne dobowe wahania same się wyrównają. Nośny jest kierunek z kilku tygodni, czytany razem z ciśnieniem i BMI.",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,11 +589,14 @@ export function getNoKeyPulseStatusText(
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, PULSE_COPY, locale) ??
|
||||
floor(
|
||||
locale,
|
||||
"Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.",
|
||||
"Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.",
|
||||
)
|
||||
floor(locale, {
|
||||
de: "Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.",
|
||||
en: "Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.",
|
||||
fr: "Le pouls au repos se lit au mieux pris détendu et à la même heure de la journée. Les à-coups brefs sont ordinaires ; ce qui ressort, c’est un motif qui tient sur plusieurs jours, ou une dérive répétée hors de tes repères habituels.",
|
||||
es: "El pulso en reposo se lee mejor tomado relajado y a la misma hora del día. Los picos breves son corrientes; lo que destaca es un patrón que se mantiene varios días, o una deriva repetida fuera de tu rango habitual.",
|
||||
it: "Il battito a riposo si legge meglio se misurato da rilassato e alla stessa ora del giorno. I picchi brevi sono ordinari; quello che spicca è uno schema che tiene per più giorni, o uno scostamento ripetuto dal tuo intervallo abituale.",
|
||||
pl: "Tętno spoczynkowe najlepiej czytać zmierzone na spokojnie i o tej samej porze dnia. Krótkie skoki są czymś zwyczajnym; uwagę zwraca wzorzec utrzymujący się przez kilka dni albo powtarzające się oddalanie od twojego zwykłego zakresu.",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -394,11 +606,14 @@ export function getNoKeyBmiStatusText(
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, BMI_COPY, locale) ??
|
||||
floor(
|
||||
locale,
|
||||
"Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.",
|
||||
"BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.",
|
||||
)
|
||||
floor(locale, {
|
||||
de: "Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.",
|
||||
en: "BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.",
|
||||
fr: "L’IMC est une orientation grossière plutôt qu’un verdict, et il prend son sens lu avec la tendance de ton poids et ta composition corporelle. Un mouvement soutenu sur des semaines porte là où une valeur isolée ne porte pas.",
|
||||
es: "El IMC es una orientación aproximada más que un veredicto, y cobra sentido leído junto a la tendencia de tu peso y tu composición corporal. Un movimiento sostenido a lo largo de semanas sostiene donde un valor aislado no lo hace.",
|
||||
it: "L’IMC è un orientamento grossolano più che un verdetto, e assume senso letto insieme all’andamento del tuo peso e alla composizione corporea. Un movimento sostenuto nell’arco di settimane regge dove un valore singolo non regge.",
|
||||
pl: "BMI to zgrubna orientacja, a nie wyrok, i nabiera sensu dopiero czytane razem z tendencją wagi i składem ciała. Utrzymujący się ruch w skali tygodni jest nośny tam, gdzie pojedyncza wartość nie jest.",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -408,11 +623,14 @@ export function getNoKeyMedicationComplianceStatusText(
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, ADHERENCE_COPY, locale) ??
|
||||
floor(
|
||||
locale,
|
||||
"Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.",
|
||||
"Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.",
|
||||
)
|
||||
floor(locale, {
|
||||
de: "Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.",
|
||||
en: "Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.",
|
||||
fr: "L’observance se lit comme une régularité sur des semaines plutôt que comme une série de journées parfaites — par médicament et dans l’ensemble. Les oublis répétés à la même heure sont le motif qui compte, et c’est une routine fixe qui les rattrape d’ordinaire.",
|
||||
es: "La adherencia se lee como constancia a lo largo de semanas y no como una racha de días perfectos — por medicamento y en el conjunto. Los olvidos repetidos a la misma hora son el patrón que cuenta, y lo que suele recuperarlos es una rutina fija.",
|
||||
it: "L’aderenza si legge come costanza nell’arco di settimane più che come una serie di giornate perfette — per singolo farmaco e nel quadro complessivo. Le dimenticanze ripetute alla stessa ora sono lo schema che conta, ed è una routine fissa a intercettarle di solito.",
|
||||
pl: "Regularność przyjmowania leków czyta się jako stałość w skali tygodni, a nie jako serię idealnych dni — osobno dla każdego leku i w całym obrazie. Powtarzające się pominięcia o tej samej porze to wzorzec, który się liczy, a wyłapuje je zwykle stały rytm dnia.",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,10 +640,13 @@ export function getNoKeyMoodStatusText(
|
||||
): string {
|
||||
return (
|
||||
composeGroundedFallback(signal, MOOD_COPY, locale) ??
|
||||
floor(
|
||||
locale,
|
||||
"Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.",
|
||||
"Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.",
|
||||
)
|
||||
floor(locale, {
|
||||
de: "Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.",
|
||||
en: "Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.",
|
||||
fr: "L’humeur se lit sur des semaines plutôt que sur des journées isolées — ce qui porte, ce sont les motifs qui reviennent et la façon dont ils s’alignent avec tes autres valeurs. C’est une phase basse qui dure qui mérite l’attention.",
|
||||
es: "El estado de ánimo se lee a lo largo de semanas y no en días sueltos — lo que sostiene son los patrones que se repiten y cómo encajan con tus otros valores. La que merece atención es una fase baja que se prolonga.",
|
||||
it: "L’umore si legge nell’arco di settimane più che nei singoli giorni — quello che regge sono gli schemi ricorrenti e come si allineano con gli altri tuoi valori. È una fase bassa che si protrae quella che merita attenzione.",
|
||||
pl: "Nastrój czyta się w skali tygodni, a nie pojedynczych dni — nośne są powtarzające się wzorce i to, jak układają się względem twoich pozostałych wartości. Uwagi wymaga przede wszystkim dłużej utrzymujący się spadek.",
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,8 @@ describe("buildCreateInventoryInput", () => {
|
||||
containerType: "PEN",
|
||||
printedExpiry: printed,
|
||||
purchasedAt: null,
|
||||
manufacturer: null,
|
||||
doseStrength: null,
|
||||
notes: null,
|
||||
});
|
||||
expect(input).toMatchObject({
|
||||
@@ -100,6 +102,8 @@ describe("buildCreateInventoryInput", () => {
|
||||
containerType: "OTHER",
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
manufacturer: null,
|
||||
doseStrength: null,
|
||||
notes: null,
|
||||
});
|
||||
expect(input.expiresAt).toBeNull();
|
||||
@@ -157,3 +161,40 @@ describe("serializeInventoryItem (iOS#31)", () => {
|
||||
expect(out).toMatchObject({ id: "i5", state: "ACTIVE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCreateInventoryInput — carton labelling", () => {
|
||||
it("carries manufacturer + doseStrength onto the create input", () => {
|
||||
// The native pen list renders these as its headline and subhead; a
|
||||
// container created on the web carried neither, so the row could not be
|
||||
// shown there without fabricating an empty card.
|
||||
const input = buildCreateInventoryInput({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
unitsTotal: 4,
|
||||
containerType: "PEN",
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
notes: null,
|
||||
});
|
||||
expect(input.manufacturer).toBe("Example Pharma");
|
||||
expect(input.doseStrength).toBe("5 mg/0.5 ml");
|
||||
});
|
||||
|
||||
it("leaves both null for a plain supply container", () => {
|
||||
const input = buildCreateInventoryInput({
|
||||
userId: "u",
|
||||
medicationId: "m",
|
||||
unitsTotal: 30,
|
||||
containerType: "BLISTER",
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
manufacturer: null,
|
||||
doseStrength: null,
|
||||
notes: null,
|
||||
});
|
||||
expect(input.manufacturer).toBeNull();
|
||||
expect(input.doseStrength).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -144,6 +144,8 @@ export function buildCreateInventoryInput(input: {
|
||||
containerType: MedicationContainerType;
|
||||
printedExpiry: Date | null;
|
||||
purchasedAt: Date | null;
|
||||
manufacturer: string | null;
|
||||
doseStrength: string | null;
|
||||
notes: string | null;
|
||||
}) {
|
||||
return {
|
||||
@@ -157,6 +159,8 @@ export function buildCreateInventoryInput(input: {
|
||||
printedExpiry: input.printedExpiry,
|
||||
purchasedAt: input.purchasedAt,
|
||||
expiresAt: computeExpiresAt(null, input.printedExpiry),
|
||||
manufacturer: input.manufacturer,
|
||||
doseStrength: input.doseStrength,
|
||||
// Encrypt the free-text note at rest; the plaintext column stays null.
|
||||
notesEncrypted: encryptNote(input.notes),
|
||||
notes: null,
|
||||
|
||||
@@ -417,13 +417,15 @@ const sleepDebtResource = z.object({
|
||||
state: z
|
||||
.enum(["partial", "ready"])
|
||||
.describe(
|
||||
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
|
||||
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a balance.",
|
||||
),
|
||||
debtMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
|
||||
.describe(
|
||||
"Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its shortfall; a night above need pays the balance down (floored at 0).",
|
||||
),
|
||||
needMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -513,7 +515,7 @@ const sleepRhythmResponse = z
|
||||
.meta({
|
||||
id: "SleepRhythmResource",
|
||||
description:
|
||||
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
|
||||
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
|
||||
});
|
||||
|
||||
// ── Time-series adapter (iOS chart source) ───────────────────────────
|
||||
@@ -1004,7 +1006,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
|
||||
description:
|
||||
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
|
||||
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Sleep-debt + chronotype DTO.",
|
||||
|
||||
@@ -314,6 +314,18 @@ export const medicationInventoryItemResource = z
|
||||
),
|
||||
printedExpiry: z.iso.datetime({ offset: true }).nullable(),
|
||||
purchasedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
manufacturer: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the field existed, and for plain supply rows that never carried one.",
|
||||
),
|
||||
doseStrength: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown.',
|
||||
),
|
||||
notes: z.string().nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
|
||||
@@ -21,6 +21,14 @@ export const settingsKeys = {
|
||||
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
|
||||
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
|
||||
|
||||
/**
|
||||
* The declared diabetes opt-in behind `GET/PATCH /api/auth/me/diabetes`.
|
||||
* Selects which reference band glucose readings are judged against, so the
|
||||
* toggle also invalidates `insightsTargets()` — the resolved band rides
|
||||
* on that payload.
|
||||
*/
|
||||
diabetesPref: () => ["settings", "diabetes"] as const,
|
||||
|
||||
/**
|
||||
* v1.18.0 — the per-user module enable/disable map behind
|
||||
* `GET/PATCH /api/auth/me/modules`. Used as the Module-hub mutation key;
|
||||
|
||||
@@ -54,6 +54,24 @@ export const createInventoryItemSchema = z
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
/** Carton labelling — free text, see the schema comment on the model
|
||||
* for why neither is parsed into a structured value. */
|
||||
manufacturer: z
|
||||
.string()
|
||||
.max(120)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.",
|
||||
),
|
||||
doseStrength: z
|
||||
.string()
|
||||
.max(60)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.',
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
@@ -99,6 +117,24 @@ export const updateInventoryItemSchema = z
|
||||
.describe(
|
||||
"Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
|
||||
),
|
||||
/** Carton labelling — free text, see the schema comment on the model
|
||||
* for why neither is parsed into a structured value. */
|
||||
manufacturer: z
|
||||
.string()
|
||||
.max(120)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.",
|
||||
),
|
||||
doseStrength: z
|
||||
.string()
|
||||
.max(60)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.',
|
||||
),
|
||||
notes: z.string().max(200).nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
|
||||
Reference in new issue
Block a user