mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both touched the same three places. Resolved as one set: the link facade carries the reverse document-to-visit direction and the new dose pair side by side, the sharing literals hold both sets of admissions with the count re-added on top, and the language bundles take the shorter module description the trunk settled on.
This commit is contained in:
commit
e1a4b7631c
200 files changed
+13599
-2858
No files matched your search
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* 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:
|
||||
* Four classes of copy that must not drift away from the code they describe,
|
||||
* each pinned here so the pair fails loudly if either half moves. The first
|
||||
* three are repairs — copy that had already drifted. The fourth arrives with
|
||||
* the feature it governs, because a wording rule that depends on review is a
|
||||
* wording rule that survives until the fifth locale.
|
||||
*
|
||||
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
|
||||
* consequence. Four dialogs promised permanence on surfaces that render
|
||||
@@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => {
|
||||
expect(copy).toContain("never inferred");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past
|
||||
* days and it may never be phrased as a cause. "A value around 5.4 would
|
||||
* have been expected" is what the arithmetic supports; "overtime lowered
|
||||
* your mood" is not, and the difference is not a matter of taste — the model
|
||||
* is a correlation over a dozen columns and a causal sentence would be a
|
||||
* claim it cannot make about somebody's health.
|
||||
*
|
||||
* The second half is the count. A forecast built from thirty days and one
|
||||
* built from three hundred render identically, so every statement that
|
||||
* quotes a value has to quote what it rests on. Both halves are a test here
|
||||
* rather than a review habit, because a review habit does not survive the
|
||||
* fifth locale.
|
||||
*/
|
||||
describe("the mood prognosis never states a cause", () => {
|
||||
/**
|
||||
* Causal constructions, per locale. Multi-word where a single word would
|
||||
* over-match — Italian `causa` is a noun in ordinary use and only `a causa
|
||||
* di` is the construction being banned.
|
||||
*/
|
||||
const CAUSAL_PHRASES: Record<string, readonly string[]> = {
|
||||
de: [
|
||||
"weil",
|
||||
"verursacht",
|
||||
"führt zu",
|
||||
"sorgt für",
|
||||
"wegen",
|
||||
"verbessert deine",
|
||||
"verschlechtert deine",
|
||||
"senkt deine",
|
||||
"hebt deine",
|
||||
],
|
||||
en: [
|
||||
"because",
|
||||
"causes",
|
||||
"caused",
|
||||
"leads to",
|
||||
"due to",
|
||||
"makes you",
|
||||
"lowers your",
|
||||
"raises your",
|
||||
"improves your",
|
||||
"worsens your",
|
||||
],
|
||||
es: [
|
||||
"porque",
|
||||
"provoca",
|
||||
"debido a",
|
||||
"a causa de",
|
||||
"mejora tu",
|
||||
"empeora tu",
|
||||
],
|
||||
fr: [
|
||||
"parce que",
|
||||
"provoque",
|
||||
"en raison de",
|
||||
"à cause de",
|
||||
"améliore ton",
|
||||
"dégrade ton",
|
||||
],
|
||||
it: [
|
||||
"perché",
|
||||
"provoca",
|
||||
"a causa di",
|
||||
"migliora il tuo",
|
||||
"peggiora il tuo",
|
||||
],
|
||||
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
|
||||
};
|
||||
|
||||
/** Every leaf string under `insights.mood.prognosis`, flattened. */
|
||||
function prognosisStrings(locale: string): Array<[string, string]> {
|
||||
const root = (
|
||||
bundle(locale).insights as Record<string, Record<string, unknown>>
|
||||
).mood.prognosis as Record<string, unknown>;
|
||||
expect(
|
||||
root,
|
||||
`${locale} carries no insights.mood.prognosis block`,
|
||||
).toBeTypeOf("object");
|
||||
return Object.entries(root).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} carries the block, and every string in it`, () => {
|
||||
const strings = prognosisStrings(locale);
|
||||
// The positive control. Every assertion below passes on an empty list,
|
||||
// so the count is the evidence and the empty offender set is only the
|
||||
// verdict.
|
||||
expect(strings.length).toBeGreaterThanOrEqual(17);
|
||||
for (const [key, value] of strings) {
|
||||
expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${locale} names no cause`, () => {
|
||||
const offenders = prognosisStrings(locale)
|
||||
.filter(([, value]) =>
|
||||
CAUSAL_PHRASES[locale].some((phrase) =>
|
||||
value.toLowerCase().includes(phrase),
|
||||
),
|
||||
)
|
||||
.map(([key]) => key);
|
||||
expect(
|
||||
offenders,
|
||||
`${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Statements that quote a value must quote what it rests on. The list is
|
||||
* explicit rather than derived, so adding a statement key forces a decision
|
||||
* here instead of inheriting silence.
|
||||
*/
|
||||
const COUNTED_STATEMENTS: Record<string, readonly string[]> = {
|
||||
statement: ["{value}", "{n}"],
|
||||
learning: ["{entries}", "{threshold}"],
|
||||
noPattern: ["{entries}"],
|
||||
band: ["{low}", "{high}"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} keeps the count beside the claim`, () => {
|
||||
const strings = new Map(prognosisStrings(locale));
|
||||
for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) {
|
||||
const copy = strings.get(key);
|
||||
expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string");
|
||||
for (const placeholder of placeholders) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`,
|
||||
).toContain(placeholder);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => {
|
||||
const en = resolve(bundle("en"), "insights.mood.prognosis.statement");
|
||||
expect(en.toLowerCase()).toContain("would have been expected");
|
||||
const de = resolve(bundle("de"), "insights.mood.prognosis.statement");
|
||||
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
|
||||
});
|
||||
});
|
||||
@@ -111,13 +111,6 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
"every health record on the account, or the whole account including passkeys and audit history",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "research-mode switch, off position",
|
||||
reason:
|
||||
"Turning research mode off drops the stored acknowledgement receipt, which is regenerated by turning it back on and re-acknowledging. Nothing a person authored or measured is involved, so a dialog on a labelled switch would gate a preference rather than a loss.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/settings/security-sessions-card.tsx",
|
||||
@@ -388,6 +381,26 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Visits and the address book ─────────────────────────────────────────
|
||||
{
|
||||
file: "hooks/use-encounters.ts",
|
||||
destroys:
|
||||
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
|
||||
triggers: ["components/encounters/encounter-sheet.tsx"],
|
||||
// The route soft-deletes and a restore route exists, but nothing in the
|
||||
// product reaches it yet, so from a person's side the visit is gone. This
|
||||
// becomes "restorable" the day a surface offers the undo, not before.
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-practitioners.ts",
|
||||
destroys: "one address-book entry",
|
||||
triggers: ["components/practitioners/practitioner-list.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Cycle ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/cycle/use-cycle.ts",
|
||||
|
||||
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(91);
|
||||
expect(ALL_LEAF_IDS.length).toBe(92);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `MoodPrediction` has exactly one writer, and it is not a request.
|
||||
*
|
||||
* The product rule underneath this guard: a day carries two readings — the
|
||||
* one the person gave it and the one their own past days imply — and they are
|
||||
* never merged. The self-assessment leads and is stored exactly as it was
|
||||
* given; the prognosis is computed, carries a band and a case count, and the
|
||||
* user cannot overwrite it. A route that wrote `moodPrediction` would make the
|
||||
* second value editable, and an editable prognosis is not a prognosis: the
|
||||
* deviation between the two, which is the only thing either number is for,
|
||||
* would quietly become a number somebody could tune to zero.
|
||||
*
|
||||
* The second failure the single writer prevents is the one the cycle module
|
||||
* already paid for: a forecast written as a side effect of a page view makes
|
||||
* the output a function of browsing rather than of data
|
||||
* (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
|
||||
*
|
||||
* **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
|
||||
* expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
|
||||
* identical from the outside — which is precisely how the bearer-scope guard
|
||||
* stayed green for months while matching nothing at all. Each leg therefore
|
||||
* pins a separate positive: the number of files SCANNED, and the write calls
|
||||
* the legitimate writer really does make. `describe("the matcher sees what it
|
||||
* looks for")` proves the regex against synthetic sources, including the
|
||||
* broken-across-lines form that defeated an earlier guard in this repository.
|
||||
*
|
||||
* **What this guard structurally cannot see, stated like the session-surface
|
||||
* guard states its own limit.** The matcher keys on a literal `moodPrediction`
|
||||
* delegate access. The "Delete All Data" wipe DOES delete this table — the
|
||||
* model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so
|
||||
* through a delegate resolved dynamically from the model name (`client[key]
|
||||
* .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the
|
||||
* matcher to catch, and that deletion is correctly NOT counted as a writer.
|
||||
* The wipe path's own completeness is proven elsewhere, by
|
||||
* `data-wipe-completeness.test.ts` enumerating the schema; this guard is only
|
||||
* the one about request-time writers, and it cannot be the gate on a dynamic
|
||||
* delegate. A future writer that reached the table through a computed property
|
||||
* would slip this matcher for the same reason.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* A write against the `moodPrediction` delegate, on any receiver.
|
||||
*
|
||||
* Whitespace-tolerant in every joint: Prettier breaks a long call across
|
||||
* lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
|
||||
* is a matcher that stops matching the day the line gets long enough. The
|
||||
* receiver is left open (`prisma`, `tx`, `db`, the worker client) because
|
||||
* pinning one name would let a rename walk straight past.
|
||||
*/
|
||||
const WRITE_CALL =
|
||||
/\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
|
||||
|
||||
/** The one file allowed to write, relative to `src/`. */
|
||||
const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "generated" || entry.name === "node_modules") continue;
|
||||
out.push(...sourceFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeCallsIn(source: string): string[] {
|
||||
return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
const ALL_FILES = sourceFiles(SRC);
|
||||
const PRODUCTION_FILES = ALL_FILES.filter(
|
||||
(f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
|
||||
);
|
||||
|
||||
describe("MoodPrediction is written by one file", () => {
|
||||
it("scans a plausible number of source files", () => {
|
||||
// The positive control for legs (a) and (b) below, both of which pass on
|
||||
// an empty result. If this collapses, they are asserting nothing.
|
||||
expect(ALL_FILES.length).toBeGreaterThan(1000);
|
||||
expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("(a) no route, page or component writes it", () => {
|
||||
const offenders = PRODUCTION_FILES.filter(
|
||||
(f) =>
|
||||
f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
|
||||
).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
|
||||
|
||||
expect(
|
||||
offenders.map((f) => f.slice(SRC.length + 1)),
|
||||
"a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("(b) the writer set is exactly the refresh job", () => {
|
||||
const writers = PRODUCTION_FILES.filter(
|
||||
(f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
|
||||
).map((f) => f.slice(SRC.length + 1));
|
||||
|
||||
expect(writers.sort()).toEqual([SOLE_WRITER]);
|
||||
});
|
||||
|
||||
it("(c) that writer really does write — the matcher is not matching nothing", () => {
|
||||
expect(
|
||||
existsSync(join(SRC, SOLE_WRITER)),
|
||||
`${SOLE_WRITER} does not exist — the table has a guard and no writer`,
|
||||
).toBe(true);
|
||||
const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
|
||||
const calls = writeCallsIn(source);
|
||||
expect(
|
||||
calls.length,
|
||||
"the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
|
||||
).toBeGreaterThan(0);
|
||||
// It replaces rather than accumulates: a pass writes the current window
|
||||
// and clears whatever fell out of it.
|
||||
expect(calls).toContain("deleteMany");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
it("matches a call Prettier broke across lines", () => {
|
||||
const wrapped = [
|
||||
"await prisma.moodPrediction",
|
||||
" .upsert(",
|
||||
" { where: { userId_date: { userId, date } } },",
|
||||
" );",
|
||||
].join("\n");
|
||||
expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
|
||||
});
|
||||
|
||||
it("matches through an aliased client", () => {
|
||||
expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
|
||||
"deleteMany",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not match a read", () => {
|
||||
expect(
|
||||
writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match a different model", () => {
|
||||
expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,44 @@ describe("shared-record navigation", () => {
|
||||
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["profile"]);
|
||||
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile"]);
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
|
||||
"/profile",
|
||||
"/checkups",
|
||||
]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
|
||||
expect(navigation.allowsPath("/profile")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
|
||||
});
|
||||
|
||||
it("presents the checkups page under both domains whose content it shows", () => {
|
||||
// The page holds two things: the preventive-care list, which is a
|
||||
// `measurements` read, and the visits section, which is `profile`. A grant
|
||||
// naming either one must be able to reach it; a grant naming neither must
|
||||
// not. Before the visits section the `measurements` omission was invisible,
|
||||
// because a whole-record grant admits every path regardless.
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"),
|
||||
).toBe(true);
|
||||
expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"),
|
||||
).toBe(false);
|
||||
|
||||
// Listed twice, offered once: a grant holding both domains must not put
|
||||
// two identical entries in the navigation.
|
||||
expect(
|
||||
resolveSharedRecordNavigation([
|
||||
"measurements",
|
||||
"profile",
|
||||
]).destinationHrefs.filter((href) => href === "/checkups"),
|
||||
).toEqual(["/checkups"]);
|
||||
});
|
||||
|
||||
it("uses the active record scope instead of the actor's module flags", () => {
|
||||
const actorModules = {
|
||||
medications: false,
|
||||
|
||||
@@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "record",
|
||||
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
|
||||
},
|
||||
"app/api/mood/prognosis/route.ts": {
|
||||
domain: "record",
|
||||
why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".",
|
||||
},
|
||||
"app/api/mood-entries/[id]/route.ts": {
|
||||
domain: "mind",
|
||||
why: "One mood entry of the record, fetch-then-guard against the resolved user.",
|
||||
@@ -935,6 +939,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
|
||||
},
|
||||
"app/api/encounters/suggest/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
|
||||
},
|
||||
"app/api/vaccinations/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's immunization history. Health background in the same sense as the allergies and the visits already under this domain: it names no credential, no integration and no notification channel. It carries the same honest cost as those two, which is that a delegate granted `profile` to read an allergy list also sees every dose the person has had; the consent copy names the immunization history for exactly this reason. The create arm is a delegable write.",
|
||||
@@ -1884,13 +1892,17 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* managed-record configuration adds one more: 196 → 197. The bounded
|
||||
* read-only profile summary makes the next explicit admission: 197 → 198.
|
||||
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
|
||||
* The mood day's linked figures, read-only, add one: 200 → 201.
|
||||
* The mood day's linked figures, read-only, add one: 200 -> 201. The visit
|
||||
* record and its address book bring twelve: 201 -> 213. The mood prognosis,
|
||||
* read-only and admitted on the whole record for the same reason as the linked
|
||||
* figures, adds one: 213 -> 214. The visit-suggestion read, which serves the
|
||||
* three incidental-linking moments from the same rule, adds one: 214 -> 215.
|
||||
*
|
||||
* v1.38.0 — the immunization log adds six: two reads and one restore on the
|
||||
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
|
||||
* record list, one create on the write literal, and the edit/delete pair plus
|
||||
* the restore on the manage literal. 213 → 219.
|
||||
* the restore on the manage literal. 215 -> 221.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 219;
|
||||
const FROZEN_ENTRY_COUNT = 221;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -230,9 +230,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/medications/intake-history-list-v2.tsx": {
|
||||
"text-success": 2,
|
||||
},
|
||||
"src/components/medications/research-mode-acknowledgment-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/scheduling/schedule-history-timeline.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
@@ -286,7 +283,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/account-section/avatar-section.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 2 },
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 1 },
|
||||
"src/components/settings/advanced-section.tsx": { "text-success": 2 },
|
||||
"src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/anthropic-provider-form.tsx": {
|
||||
@@ -341,6 +338,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
|
||||
// The one that used to sit inside `account-section/index.tsx` — the card
|
||||
// moved to Settings → Security, the line moved with it.
|
||||
"src/components/settings/password-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/security-sessions-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
|
||||
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
|
||||
it("is idempotent when the column already holds the requested value", async () => {
|
||||
// Toggling a column from true→true (or false→false) still writes
|
||||
// through so the audit row exists. Matches the research-mode
|
||||
// through so the audit row exists. Matches the doctor-report-prefs
|
||||
// route's "always write" posture.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE, GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPost(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mkDelete(): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the disabled default for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
expect(env.data.currentDisclaimerVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the enabled state with the persisted version", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const ackAt = new Date("2026-05-14T10:00:00Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: ackAt,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string;
|
||||
acknowledgedVersion: string;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables Research Mode and writes the audit log on a valid ack", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale disclaimer version with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a body without acknowledged=true with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Research Mode and clears the acknowledgment", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when Research Mode is already off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
// The route writes the canonical off-state even when the
|
||||
// column is already off — guarantees audit trail symmetry.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
|
||||
*
|
||||
* GET /api/auth/me/research-mode — returns the current flag,
|
||||
* the timestamp + version of
|
||||
* the last acknowledgment, and
|
||||
* the live disclaimer version
|
||||
* so the Settings UI can
|
||||
* compare and re-prompt.
|
||||
* POST /api/auth/me/research-mode — body { acknowledged: true,
|
||||
* version }. Flips the flag
|
||||
* on. Rejects 400 if `version`
|
||||
* is not the live version.
|
||||
* DELETE /api/auth/me/research-mode — disables Research Mode and
|
||||
* clears the acknowledgment.
|
||||
* Idempotent.
|
||||
*
|
||||
* Per-user POST rate-limit is intentionally low (5/min) — this is a
|
||||
* one-tap acknowledgment dialog, not a hot path. Anything above that
|
||||
* traffic looks like a malformed client retry-loop or an exploit.
|
||||
*
|
||||
* Prompt-injection surface — the API accepts the disclaimer
|
||||
* `version` string and nothing else from the client. The version
|
||||
* value never reaches the LLM; it is matched against the
|
||||
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
|
||||
* persisted as the stamp on the user row. No free-text body.
|
||||
*
|
||||
* Audit-log entries fire on every state-changing call (POST, DELETE)
|
||||
* with the previous + next state, mirroring the doctor-report-prefs
|
||||
* route's reconcile pattern (W10 security M-3).
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const POST_RATE_LIMIT = 5;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
type ResearchModeResponse = {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
|
||||
function shape(row: {
|
||||
researchModeEnabled: boolean;
|
||||
researchModeAcknowledgedAt: Date | null;
|
||||
researchModeAcknowledgedVersion: string | null;
|
||||
}): ResearchModeResponse {
|
||||
return {
|
||||
enabled: row.researchModeEnabled,
|
||||
acknowledgedAt: row.researchModeAcknowledgedAt
|
||||
? row.researchModeAcknowledgedAt.toISOString()
|
||||
: null,
|
||||
acknowledgedVersion: row.researchModeAcknowledgedVersion,
|
||||
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.research-mode.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
// `requireAuth` already proved the user exists; defensive default
|
||||
// here only matters for the (impossible-in-practice) race where
|
||||
// the user is deleted mid-request.
|
||||
return apiSuccess(
|
||||
shape(
|
||||
row ?? {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user rate-limit. 5/min is generous for a once-per-update
|
||||
// acknowledgment dialog; anything more looks like a retry loop.
|
||||
const rl = await checkRateLimit(
|
||||
`research-mode:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "research-mode.body.invalid_json");
|
||||
}
|
||||
|
||||
// Hand-rolled validation — the body is tiny (two fields), and
|
||||
// staying off Zod keeps the prompt-injection surface visibly
|
||||
// simple in a code review.
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
(body as { acknowledged?: unknown }).acknowledged !== true ||
|
||||
typeof (body as { version?: unknown }).version !== "string"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
|
||||
throw new HttpError(422, "research-mode.body.invalid_shape");
|
||||
}
|
||||
|
||||
const submittedVersion = (body as { version: string }).version;
|
||||
|
||||
// Stale-version guard — the dialog must show the *live* copy at
|
||||
// the moment of acknowledgment. A stale version means the user
|
||||
// is acknowledging older wording; we refuse and force the
|
||||
// Settings UI to re-render with the new disclaimer.
|
||||
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post.stale_version" },
|
||||
meta: {
|
||||
submitted: submittedVersion,
|
||||
current: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
},
|
||||
});
|
||||
throw new HttpError(400, "research-mode.version.stale");
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.enable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt:
|
||||
next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post" },
|
||||
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
|
||||
});
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent — if Research Mode is already off, we still write
|
||||
// the canonical "off" state so the audit row exists.
|
||||
const next = {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.disable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { previous: previous ?? null, next },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.me.research-mode.delete" } });
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
@@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
encounterDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessEpisode: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounter: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -129,6 +137,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadConditionLinks } from "@/lib/documents/links";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -83,9 +86,20 @@ export const POST = apiHandler(
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op, not an error (the undo
|
||||
// toast may fire after a concurrent restore).
|
||||
const links = await loadConditionLinks(user.id, [row.id]);
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [row.id]),
|
||||
loadDocumentEncounterLinks(user.id, [row.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []),
|
||||
serialiseDocumentDetail(
|
||||
row,
|
||||
row.facts,
|
||||
links.get(row.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(row.id) ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +145,10 @@ export const POST = apiHandler(
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const links = await loadConditionLinks(user.id, [restored.id]);
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [restored.id]),
|
||||
loadDocumentEncounterLinks(user.id, [restored.id]),
|
||||
]);
|
||||
|
||||
await auditLog("documents.inbound.restore", {
|
||||
userId: user.id,
|
||||
@@ -149,6 +166,10 @@ export const POST = apiHandler(
|
||||
restored,
|
||||
restored.facts,
|
||||
links.get(restored.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(restored.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
narrowOwnedEncounterIds,
|
||||
narrowOwnedEpisodeIds,
|
||||
replaceConditionLinks,
|
||||
replaceEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -92,8 +95,9 @@ export const GET = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const [links, contentIndex, thumbnail] = await Promise.all([
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
@@ -117,6 +121,7 @@ export const GET = apiHandler(
|
||||
contentIndex !== null,
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -169,6 +174,21 @@ export const PATCH = apiHandler(
|
||||
nextEpisodeIds = narrowed;
|
||||
}
|
||||
|
||||
// Replace-set visit links, same semantics and same refusal.
|
||||
let nextEncounterIds: string[] | undefined;
|
||||
if (parsed.data.encounterIds !== undefined) {
|
||||
const narrowed = await narrowOwnedEncounterIds(
|
||||
user.id,
|
||||
parsed.data.encounterIds,
|
||||
);
|
||||
if (narrowed === null) {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
}
|
||||
nextEncounterIds = narrowed;
|
||||
}
|
||||
|
||||
// No mass assignment — each editable column is set explicitly only when
|
||||
// the client sent it. `userId` is never a body field.
|
||||
const data: Prisma.InboundDocumentUpdateInput = {};
|
||||
@@ -189,14 +209,23 @@ export const PATCH = apiHandler(
|
||||
if (nextEpisodeIds !== undefined) {
|
||||
await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds);
|
||||
}
|
||||
if (nextEncounterIds !== undefined) {
|
||||
await replaceEncounterLinks(
|
||||
prisma,
|
||||
user.id,
|
||||
existing.id,
|
||||
nextEncounterIds,
|
||||
);
|
||||
}
|
||||
|
||||
const document = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: existing.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const [links, contentIndex, thumbnail] = await Promise.all([
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
@@ -210,6 +239,7 @@ export const PATCH = apiHandler(
|
||||
const touched = [
|
||||
...Object.keys(data),
|
||||
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
|
||||
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
|
||||
];
|
||||
|
||||
await auditLog("documents.inbound.update", {
|
||||
@@ -234,6 +264,7 @@ export const PATCH = apiHandler(
|
||||
contentIndex !== null,
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
txCreateMany,
|
||||
txDocumentFindMany,
|
||||
txEpisodeFindMany,
|
||||
txEncounterFindMany,
|
||||
txEncounterLinkCreateMany,
|
||||
} = vi.hoisted(() => ({
|
||||
txQueryRaw: vi.fn(),
|
||||
txCreate: vi.fn(),
|
||||
@@ -28,6 +30,8 @@ const {
|
||||
// writes, so the transaction double has to answer for them.
|
||||
txDocumentFindMany: vi.fn(),
|
||||
txEpisodeFindMany: vi.fn(),
|
||||
txEncounterFindMany: vi.fn(),
|
||||
txEncounterLinkCreateMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
@@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => {
|
||||
$queryRaw: txQueryRaw,
|
||||
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
|
||||
illnessEpisode: { findMany: txEpisodeFindMany },
|
||||
encounter: { findMany: txEncounterFindMany },
|
||||
documentConditionLink: { createMany: txCreateMany },
|
||||
encounterDocumentLink: { createMany: txEncounterLinkCreateMany },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
@@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => {
|
||||
documentConditionLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounterDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => {
|
||||
illnessEpisode: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounter: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -183,6 +195,12 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
txEncounterFindMany.mockResolvedValue([]);
|
||||
txEncounterLinkCreateMany.mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Document vault: bulk actions over up to 100 owner-scoped documents.
|
||||
*
|
||||
* `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions:
|
||||
* `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition
|
||||
* links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A
|
||||
* `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result
|
||||
* array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode`
|
||||
* (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the
|
||||
* same table the visit's own sheet writes from the other end),
|
||||
* `delete` (tombstone, undo-able), `restore` (clear tombstone). A
|
||||
* partial failure never aborts the batch — each id reports `ok` or a short
|
||||
* machine reason. Every id is narrowed to the caller; a foreign id reads as
|
||||
* "notFound", exactly like the single-document routes.
|
||||
@@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { ids, action, kind, episodeId } = parsed.data;
|
||||
const { ids, action, kind, episodeId, encounterId } = parsed.data;
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
|
||||
// Episode actions: the target episode must be a LIVE episode of the caller.
|
||||
@@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Visit actions: the target visit must be a LIVE visit of the caller. Same
|
||||
// shape as the episode gate above — the endpoint is checked once, before the
|
||||
// batch, so a foreign id costs one refusal rather than a hundred.
|
||||
if (action === "linkEncounter" || action === "unlinkEncounter") {
|
||||
const encounter = await prisma.encounter.findFirst({
|
||||
where: { id: encounterId!, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!encounter) {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// One owner-scoped sweep resolves every id's current state; per-id work
|
||||
// then never touches a row the caller does not own.
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
@@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "linkEncounter": {
|
||||
if (row.deletedAt !== null) {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
await linkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "document",
|
||||
sourceId: id,
|
||||
targetKind: "encounter",
|
||||
targetIds: [encounterId!],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "unlinkEncounter": {
|
||||
// No tombstone check, matching `unlinkEpisode`: clearing a filing on
|
||||
// a document the account has since deleted must stay possible, or
|
||||
// the filing is unreachable.
|
||||
await unlinkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "document",
|
||||
sourceId: id,
|
||||
targetKind: "encounter",
|
||||
targetIds: [encounterId!],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
if (row.deletedAt !== null) {
|
||||
// Already tombstoned — deleting twice is a no-op success.
|
||||
|
||||
@@ -39,6 +39,8 @@ import { prisma } from "@/lib/db";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
narrowOwnedEncounterIds,
|
||||
narrowOwnedEpisodeIds,
|
||||
} from "@/lib/documents/links";
|
||||
import {
|
||||
@@ -116,8 +118,9 @@ async function duplicateResponse(
|
||||
userId: string,
|
||||
existing: SerialisableDocument,
|
||||
): Promise<NextResponse> {
|
||||
const [links, groups] = await Promise.all([
|
||||
const [links, visitLinks, groups] = await Promise.all([
|
||||
loadConditionLinks(userId, [existing.id]),
|
||||
loadDocumentEncounterLinks(userId, [existing.id]),
|
||||
prisma.extractedFact.groupBy({
|
||||
by: ["status"],
|
||||
where: { userId, documentId: existing.id },
|
||||
@@ -140,6 +143,10 @@ async function duplicateResponse(
|
||||
existing,
|
||||
{ factCount, pendingCount },
|
||||
links.get(existing.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(existing.id) ?? [],
|
||||
),
|
||||
error: null,
|
||||
meta: { duplicate: true },
|
||||
@@ -212,17 +219,21 @@ async function processUpload(
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional metadata (title / kind / documentDate / episodeIds). The file is
|
||||
// read separately; these are the form fields beside it. `episodeIds` may be
|
||||
// repeated.
|
||||
// Optional metadata (title / kind / documentDate / episodeIds /
|
||||
// encounterIds). The file is read separately; these are the form fields
|
||||
// beside it. Both id lists may be repeated.
|
||||
const rawEpisodeIds = formData
|
||||
.getAll("episodeIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const rawEncounterIds = formData
|
||||
.getAll("encounterIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const parsed = documentCreateSchema.safeParse({
|
||||
title: formData.get("title") ?? undefined,
|
||||
kind: formData.get("kind") ?? undefined,
|
||||
documentDate: formData.get("documentDate") ?? undefined,
|
||||
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
|
||||
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document metadata", 422, {
|
||||
@@ -268,6 +279,19 @@ async function processUpload(
|
||||
});
|
||||
}
|
||||
|
||||
// Same for the visit ids the review step offered. A refusal rather than a
|
||||
// silent drop: the person saw a visit named and would otherwise watch the
|
||||
// upload succeed without the link they asked for.
|
||||
const encounterIds = await narrowOwnedEncounterIds(
|
||||
user.id,
|
||||
parsed.data.encounterIds ?? [],
|
||||
);
|
||||
if (encounterIds === null) {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
// sha256 of the PLAINTEXT for same-user duplicate detection.
|
||||
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
@@ -346,6 +370,15 @@ async function processUpload(
|
||||
targetIds: episodeIds,
|
||||
});
|
||||
}
|
||||
if (encounterIds.length > 0) {
|
||||
await linkTargets(tx, {
|
||||
userId: user.id,
|
||||
sourceKind: "document",
|
||||
sourceId: created.id,
|
||||
targetKind: "encounter",
|
||||
targetIds: encounterIds,
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -382,6 +415,7 @@ async function processUpload(
|
||||
byteSize: document.byteSize,
|
||||
servingClass: detected.servingClass,
|
||||
linked: episodeIds.length,
|
||||
linkedVisits: encounterIds.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -406,12 +440,19 @@ async function processUpload(
|
||||
// because of it. Only fresh inserts reach here (a duplicate returns early).
|
||||
void enqueueDocumentSummary(user.id, document.id);
|
||||
|
||||
const links = await loadConditionLinks(user.id, [document.id]);
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocument(
|
||||
document,
|
||||
{ factCount: 0, pendingCount: 0 },
|
||||
links.get(document.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
201,
|
||||
);
|
||||
@@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
errorCode: "documents.inbound.invalidQuery",
|
||||
});
|
||||
}
|
||||
const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } =
|
||||
parsed.data;
|
||||
const {
|
||||
q,
|
||||
kind,
|
||||
episodeId,
|
||||
encounterId,
|
||||
year,
|
||||
from,
|
||||
to,
|
||||
sort,
|
||||
order,
|
||||
cursor,
|
||||
limit,
|
||||
} = parsed.data;
|
||||
|
||||
const where: Prisma.InboundDocumentWhereInput = {
|
||||
userId: user.id,
|
||||
@@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
};
|
||||
if (kind && kind.length > 0) where.kind = { in: kind };
|
||||
if (episodeId) where.conditionLinks = { some: { episodeId } };
|
||||
// The visit filter reads the SAME table the visit's own sheet writes, from
|
||||
// the document side. Filtering rather than post-filtering: a page of fifty
|
||||
// documents narrowed in Node would page wrongly.
|
||||
if (encounterId) where.encounterLinks = { some: { encounterId } };
|
||||
if (year !== undefined) {
|
||||
where.documentDate = {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
@@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
}
|
||||
|
||||
// Condition links for the page in ONE grouped query (no N+1).
|
||||
const linkMap = await loadConditionLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
);
|
||||
const [linkMap, visitLinkMap] = await Promise.all([
|
||||
loadConditionLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
),
|
||||
loadDocumentEncounterLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
),
|
||||
]);
|
||||
|
||||
// Which of the page's documents have a content index (drives the searchable
|
||||
// status + the provenance the UI reads to tell an AI-read document from a
|
||||
@@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
count: page.length,
|
||||
sort,
|
||||
order,
|
||||
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
|
||||
filtered: Boolean(
|
||||
q || (kind && kind.length > 0) || episodeId || encounterId || year,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
indexSources.has(doc.id),
|
||||
toContentIndexSource(indexSources.get(doc.id)),
|
||||
thumbnailIds.has(doc.id),
|
||||
visitLinkMap.get(doc.id) ?? [],
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
loadEncounterLinksForMany,
|
||||
closeCheckupForVisit,
|
||||
resolveClosableReminder,
|
||||
resolveOwnedPractitioner,
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
} from "@/lib/export/restore-skips";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = encounterListQuerySchema.safeParse({
|
||||
@@ -58,6 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
to: params.get("to") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
practitionerId: params.get("practitionerId") ?? undefined,
|
||||
episodeId: params.get("episodeId") ?? undefined,
|
||||
limit: params.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
@@ -75,6 +77,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
deletedAt: null,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.practitionerId ? { practitionerId: query.practitionerId } : {}),
|
||||
// Filtered in SQL rather than in Node: the two arms are separately
|
||||
// `take`-bounded, so narrowing after the read would page wrongly.
|
||||
...(query.episodeId
|
||||
? { conditionLinks: { some: { episodeId: query.episodeId } } }
|
||||
: {}),
|
||||
...(query.from || query.to
|
||||
? {
|
||||
occurredAt: {
|
||||
@@ -118,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
// The links ride the list. The card counts what a visit produced, and the
|
||||
// edit sheet seeds its pickers from the row it was handed — a list without
|
||||
// them makes the first read zero and the second unfile everything on save.
|
||||
// Three grouped queries for the page, not three per row.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const links = await loadEncounterLinksForMany(
|
||||
prisma,
|
||||
user.id,
|
||||
[...upcoming, ...past].map((row) => row.id),
|
||||
visible,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "encounter.visit.list", entity_type: "encounter" },
|
||||
meta: { upcoming: upcoming.length, past: past.length },
|
||||
});
|
||||
|
||||
const body: EncounterListDTO = {
|
||||
upcoming: upcoming.map((row) => toEncounterDTO(row)),
|
||||
past: past.map((row) => toEncounterDTO(row)),
|
||||
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
|
||||
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
|
||||
};
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* `GET /api/encounters/suggest?anchor=<iso>` — which visit does a record dated
|
||||
* `anchor` most plausibly belong to?
|
||||
*
|
||||
* The three capture moments that ask this question live in the browser: the
|
||||
* document upload review, the lab extraction review and the manual lab form.
|
||||
* The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and
|
||||
* the one-pre-selects / two-offer-a-picker verdict — lives in
|
||||
* `src/lib/encounters/suggest-window.ts` and is answered here so all three read
|
||||
* the same answer. Handing the client a windowed list and letting it decide
|
||||
* would put the rule in two places, and the copy in the browser is the one
|
||||
* nobody re-reads when the rule changes.
|
||||
*
|
||||
* A read of the record's own visits, so it declares the same domain the visit
|
||||
* list does. `userId` is narrowed from auth; the anchor is the only input and
|
||||
* it is a date, not an id.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { encounterSuggestQuerySchema } from "@/lib/validations/encounters";
|
||||
import { suggestEncounterForDate } from "@/lib/encounters/suggest-window";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = encounterSuggestQuerySchema.safeParse({
|
||||
anchor: params.get("anchor") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "encounter.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await suggestEncounterForDate(prisma, {
|
||||
userId: user.id,
|
||||
anchor: new Date(parsed.data.anchor),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "encounter.visit.suggest", entity_type: "encounter" },
|
||||
meta: {
|
||||
verdict: result.kind,
|
||||
candidates:
|
||||
result.kind === "many"
|
||||
? result.encounters.length
|
||||
: result.kind === "one"
|
||||
? 1
|
||||
: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
encounter: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
@@ -165,6 +166,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
linkOcrLabsToVaultDocument,
|
||||
type InsertedLabForLink,
|
||||
} from "@/lib/labs/vault-link";
|
||||
import { linkTargets } from "@/lib/links";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
import {
|
||||
@@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) {
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// The visit the review step offered, if it offered one. ONE LINK PER RESULT
|
||||
// ROW rather than one for the panel: a marker re-run on a different day
|
||||
// belongs to its own visit, which is exactly why the join is m:n and not a
|
||||
// scalar column on the result. Best-effort like the vault cross-link — the
|
||||
// readings are the authoritative write and must not be lost to a link that
|
||||
// could not be made. The link service narrows both ends to this record, so
|
||||
// an id naming nothing writes nothing.
|
||||
if (parsed.data.encounterId && linkable.length > 0) {
|
||||
await linkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "encounter",
|
||||
sourceId: parsed.data.encounterId,
|
||||
targetKind: "labResult",
|
||||
targetIds: linkable.map((row) => row.labResultId),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
await auditLog("labs.ocr.commit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) {
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.ocr.committed" },
|
||||
meta: { inserted: inserted.length, skipped: skipped.length },
|
||||
meta: {
|
||||
inserted: inserted.length,
|
||||
skipped: skipped.length,
|
||||
linkedVisit: parsed.data.encounterId ? 1 : 0,
|
||||
},
|
||||
});
|
||||
|
||||
// A lab panel just landed — resolve any "annual blood panel" reminders now
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
} from "@/lib/labs/serialise";
|
||||
import { linkTargets } from "@/lib/links";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) {
|
||||
takenAt,
|
||||
note,
|
||||
source,
|
||||
encounterId,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
|
||||
@@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) {
|
||||
noteEncrypted: note ? encryptNoteToBytes(note) : null,
|
||||
},
|
||||
});
|
||||
// The by-the-way half: file the reading against the visit the form offered,
|
||||
// if it offered one. Best-effort by design — the reading is the write that
|
||||
// matters, and a link that could not be made (a visit deleted between the
|
||||
// suggestion and the save) must not lose it. The link service narrows both
|
||||
// ends to this record, so an id naming nothing simply writes nothing.
|
||||
if (encounterId) {
|
||||
await linkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "encounter",
|
||||
sourceId: encounterId,
|
||||
targetKind: "labResult",
|
||||
targetIds: [created.id],
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
invalidateUserHealthScore(user.id);
|
||||
const dto = serialiseLabResult(created, biomarker);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* `GET /api/mood/prognosis` — the day's two readings, side by side.
|
||||
*
|
||||
* The person's own rating leads and is returned exactly as it was stored. The
|
||||
* forecast rides beside it with its band, the number of days it was built
|
||||
* from, and how old it is. The two are never merged: the distance between them
|
||||
* is the whole finding, and a single blended number would destroy it.
|
||||
*
|
||||
* **This route declares `record`, not `mind`, and that is the security
|
||||
* decision on it.** The payload is not only mood: the fit reads the day's
|
||||
* level-B context AND the figures the sleep, activity and recovery modules
|
||||
* hold, and the contributions it publishes name those features one by one — a
|
||||
* reader can see that sleep or steps or resting heart rate carried weight on a
|
||||
* given day, and roughly how much. Declared `mind` it would be a hole with a
|
||||
* mood-shaped label on it: a delegate holding only the mind section would
|
||||
* receive a derived view of two sections the owner had declined to share. The
|
||||
* convention for a read that crosses sections is `record`
|
||||
* (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest`
|
||||
* are the precedents) and a scoped grant never reaches one, so a scoped
|
||||
* delegate loses the forecast entirely rather than receiving a filtered
|
||||
* version of it. A filtered version would be worse: a contribution list with
|
||||
* the linked features quietly removed would tell the reader that the model
|
||||
* weighted only the mood fields, which is false.
|
||||
*
|
||||
* Nothing here writes. The rows come from the nightly job and the user cannot
|
||||
* overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts`
|
||||
* holds that, and this file is one of the ones it scans.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The parameter is declared although the handler body reads nothing off it,
|
||||
// and the reason is the exported type rather than the runtime. `apiHandler`
|
||||
// infers the type of `GET` from this callback, so a zero-argument handler
|
||||
// would type `GET` as taking no arguments — and Next.js, and the integration
|
||||
// tests, call it WITH the request. The wide event's method and path, and the
|
||||
// read-vs-write check `requireRecordAuth` runs, both come from the request the
|
||||
// wrapper receives from Next.js (not from this parameter), and both fall back
|
||||
// to `GET` when no request is present — so an absent request keeps a read a
|
||||
// read rather than escalating it.
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
// `record`: see the file header. A scoped grant is refused here by
|
||||
// construction rather than served a narrowed payload.
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "mood");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const reading = await readMoodPrognosis(user.id, new Date(), user.timezone);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.prognosis.read" },
|
||||
meta: reading.present
|
||||
? {
|
||||
present: true,
|
||||
day: reading.date,
|
||||
fit_rows: reading.n,
|
||||
age_days: reading.ageDays,
|
||||
current: reading.current,
|
||||
provisional: reading.provisional,
|
||||
deviation: reading.deviation,
|
||||
}
|
||||
: { present: false, reason: reading.reason, entries: reading.entries },
|
||||
});
|
||||
|
||||
return apiSuccess(reading);
|
||||
});
|
||||
@@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
// Both plaintext columns, because a person looks a practice up by
|
||||
// whichever of the two they remember: the doctor's name on the referral
|
||||
// or the practice name on the door. Searching only the name made the
|
||||
// second attempt return nothing and read as "not in the address book".
|
||||
...(parsed.data.q
|
||||
? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: parsed.data.q,
|
||||
mode: "insensitive" as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
practice: {
|
||||
contains: parsed.data.q,
|
||||
mode: "insensitive" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
@@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
*
|
||||
* The page now holds two things, split by a segmented control rather than by a
|
||||
* second nav entry: the preventive-care list, and the visits. They answer the
|
||||
* same question from two directions — what do I still have to arrange, and what
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
*/
|
||||
export default function CheckupsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.measurementReminders(),
|
||||
}),
|
||||
invalidateKeys(queryClient, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.encounters(),
|
||||
]),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
@@ -41,7 +57,24 @@ export default function CheckupsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PractitionerList } from "@/components/practitioners/practitioner-list";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
|
||||
/**
|
||||
* The address book, one level under the checkups page the visits live on.
|
||||
*
|
||||
* Deliberately not in Settings. The Settings IA rule is that a settings surface
|
||||
* configures behaviour; a list of one's own doctors configures nothing and is
|
||||
* record content like every other clinical list. Sitting under `/checkups`
|
||||
* also means it inherits that page's route-family classification, so a
|
||||
* `profile`-scoped delegate reaches it exactly as they reach the visits.
|
||||
*/
|
||||
export default function PractitionersPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
|
||||
if (!mounted || isLoading) return <PageAuthGate />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PractitionerList enabled={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { EcgDetail } from "@/components/insights/ecg-detail";
|
||||
|
||||
/**
|
||||
* `/insights/ecg/[id]` — one ECG recording.
|
||||
*
|
||||
* The detail used to swap itself into the list card, with its own back
|
||||
* button sitting inside the card above the trace. That put the way back
|
||||
* BELOW the page heading, which is the one place a back control never
|
||||
* belongs, and it left the recording without an address: nothing to deep
|
||||
* link, nothing for the browser's own Back to return to.
|
||||
*
|
||||
* It is a route now, shaped exactly like `/insights/workouts/[id]`: a
|
||||
* `BackLink` above the `<SubPageShell>` title, and a body that fetches by
|
||||
* id so a cold deep link works without the list.
|
||||
*/
|
||||
export default function InsightsEcgDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
// No description. The one the list page carries describes the list,
|
||||
// and a detail page must not claim to be the list.
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/ecg"
|
||||
label={t("insights.ecg.backToList")}
|
||||
dataSlot="ecg-detail-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EcgDetail recordingId={id} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -63,21 +63,51 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
|
||||
}
|
||||
|
||||
describe("/insights/ecg page (H1)", () => {
|
||||
it("renders the EcgSection with its disclaimer when recordings exist", () => {
|
||||
it("renders the EcgSection when recordings exist", () => {
|
||||
const html = render({
|
||||
recordings: [seededRecording()],
|
||||
hasRecordings: true,
|
||||
});
|
||||
// The reused section + its load-bearing non-diagnostic disclaimer render.
|
||||
expect(html).toContain('data-slot="ecg-section"');
|
||||
expect(html).toContain('data-slot="ecg-card"');
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
// The shell owns the page heading (`<h1>`); the section's own
|
||||
// `<SectionHeading>` (`<h2>`) is suppressed via `hideHeading`.
|
||||
expect(html).toContain('id="insights-subpage-title"');
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
|
||||
it("renders every recording — the full page passes no cap", () => {
|
||||
// The teaser on `/insights` caps at five; this dedicated page must not.
|
||||
// An account with many strips reaches all of them here, or the older ones
|
||||
// become unreachable with no pagination behind them. Eight in, eight out.
|
||||
const recordings = Array.from({ length: 8 }, (_, index) => ({
|
||||
...seededRecording(),
|
||||
id: `rec-${index}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 6, 10 - index, 8, 0)).toISOString(),
|
||||
}));
|
||||
const html = render({ recordings, hasRecordings: true });
|
||||
|
||||
const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
expect(rows).toBe(8);
|
||||
for (const rec of recordings) {
|
||||
expect(html, `${rec.id} must be reachable on the full page`).toContain(
|
||||
`/insights/ecg/${rec.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries no disclaimer paragraph above the list", () => {
|
||||
// Two muted lines used to lead this page — one from the shell's own
|
||||
// explainer slot and one hand-rolled beneath it, both saying HealthLog
|
||||
// does not read the curve. The page opens on the recordings now.
|
||||
const html = render({
|
||||
recordings: [seededRecording()],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).not.toContain("is not interpreted");
|
||||
});
|
||||
|
||||
it("shows the empty state (no section) when the account has no recordings", () => {
|
||||
const html = render({ recordings: [], hasRecordings: false });
|
||||
expect(html).not.toContain('data-slot="ecg-section"');
|
||||
|
||||
@@ -22,13 +22,14 @@ import { EcgSection } from "@/components/insights/ecg-section";
|
||||
* all. This page gives ECG its own address, reached from the Heart-group
|
||||
* pill in the tab strip (gated on `hasRecordings`) and the metric catalog.
|
||||
*
|
||||
* The surface itself is the existing `<EcgSection>`, reused verbatim — its
|
||||
* non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
|
||||
* We only suppress its internal `<SectionHeading>` (via `hideHeading`) because
|
||||
* The surface itself is `<EcgSection>` — the list of strips. Its internal
|
||||
* `<SectionHeading>` is suppressed (via `hideHeading`) because
|
||||
* `<SubPageShell>` already renders the page heading, mirroring how every other
|
||||
* routed sub-page lets the shell own the `<h1>`.
|
||||
*
|
||||
* The overview `<EcgSection>` teaser on `/insights` stays as-is.
|
||||
* A row opens `/insights/ecg/[id]`, which is where the trace, the metadata and
|
||||
* the device's own result live. The overview `<EcgSection>` teaser on
|
||||
* `/insights` links to the same place.
|
||||
*/
|
||||
export default function InsightsEcgPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -76,11 +77,6 @@ export default function InsightsEcgPage() {
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
description={t("insights.subPage.ecgDescription")}
|
||||
>
|
||||
{/* What the app does and does not claim about a trace. Its own
|
||||
paragraph, so the page explainer above stays one sentence. */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.subPage.ecgDisclaimer")}
|
||||
</p>
|
||||
<EcgSection enabled={isAuthenticated} hideHeading />
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,21 @@ const MoodChart = dynamic(
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
/**
|
||||
* The day's two readings. Deferred like every other block on this route that
|
||||
* is not the first paint: it reads its own endpoint and renders below the
|
||||
* calendar, so nothing about it belongs in the eager graph of a page whose
|
||||
* first paint is a heatmap. This route sits close to its bundle ceiling and
|
||||
* every addition to it goes through the same treatment.
|
||||
*/
|
||||
const MoodPrognosisCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({
|
||||
default: mod.MoodPrognosisCard,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
interface ComprehensiveMoodData {
|
||||
moodSummary: { count: number } | null;
|
||||
}
|
||||
@@ -137,6 +152,12 @@ export default function InsightsStimmungPage() {
|
||||
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
{/* The day's own rating beside what the account's past days imply. It
|
||||
sits high because the self-assessment is the leading value on this
|
||||
page, and directly under the line chart because that is where a
|
||||
reader has just seen the rating it leads with. */}
|
||||
<MoodPrognosisCard />
|
||||
|
||||
{/* The better-days Einschätzung sits directly under the Ziel card,
|
||||
ahead of the classification tiles and breakdowns. */}
|
||||
<MoodInsightsSections region="assessment" />
|
||||
|
||||
@@ -9,6 +9,9 @@ import { SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
// Pure constant — importing it here does NOT pull the dynamically-loaded ECG
|
||||
// component into this bundle, which is exactly why it lives in the lib module.
|
||||
import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-classification";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
@@ -527,7 +530,7 @@ export default function InsightsPageClient() {
|
||||
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
|
||||
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
|
||||
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
|
||||
ecg: <EcgSection enabled={isAuthenticated} />,
|
||||
ecg: <EcgSection enabled={isAuthenticated} limit={ECG_OVERVIEW_LIMIT} />,
|
||||
};
|
||||
|
||||
const orderedSectionIds = orderedVisibleSectionIds(layout);
|
||||
|
||||
@@ -53,6 +53,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -36,6 +36,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "write" as const,
|
||||
level: "write" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
CalendarCheck,
|
||||
Award,
|
||||
CalendarClock,
|
||||
Footprints,
|
||||
@@ -55,6 +56,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
|
||||
ecg_new_recording: HeartPulse,
|
||||
tension_window: Activity,
|
||||
same_time_baseline: Footprints,
|
||||
upcoming_visit: CalendarCheck,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
/**
|
||||
* The floating bulk bar, pinned via single-pass static renders: a labelled
|
||||
* toolbar carrying the selected count and the bulk verbs (set type, link
|
||||
* condition, share, delete, clear). The link-condition menu only renders when
|
||||
* the account actually has episodes to link — no dead affordance on an
|
||||
* episode-free account.
|
||||
* condition, file against a visit, share, delete, clear). Each link menu only
|
||||
* renders when the account actually has something to link to — no dead
|
||||
* affordance on an account with no episodes, and none on an account with no
|
||||
* visits either.
|
||||
*/
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentBulkBar } from "../document-bulk-bar";
|
||||
|
||||
function render(episodes: { id: string; label: string }[]) {
|
||||
function render(
|
||||
episodes: { id: string; label: string }[],
|
||||
encounters: { id: string; label: string }[] = [
|
||||
{ id: "v1", label: "Routine visit · 01/08/2026" },
|
||||
],
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentBulkBar
|
||||
selectedCount={3}
|
||||
episodes={episodes}
|
||||
encounters={encounters}
|
||||
busy={false}
|
||||
onSetKind={() => {}}
|
||||
onLinkEpisode={() => {}}
|
||||
onLinkEncounter={() => {}}
|
||||
onShare={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClear={() => {}}
|
||||
@@ -36,6 +44,7 @@ describe("<DocumentBulkBar>", () => {
|
||||
expect(html).toContain("3 selected");
|
||||
expect(html).toContain("Change type");
|
||||
expect(html).toContain("Link condition");
|
||||
expect(html).toContain("File against visit");
|
||||
expect(html).toContain('data-slot="document-bulk-share"');
|
||||
expect(html).toContain("Share");
|
||||
expect(html).toContain("Delete");
|
||||
@@ -47,4 +56,13 @@ describe("<DocumentBulkBar>", () => {
|
||||
expect(html).not.toContain("Link condition");
|
||||
expect(html).toContain("Change type");
|
||||
});
|
||||
|
||||
it("omits the file-against-visit verb when the account has no visits", () => {
|
||||
const html = render([{ id: "ep1", label: "Knee" }], []);
|
||||
expect(html).not.toContain("File against visit");
|
||||
expect(html).not.toContain('data-slot="document-bulk-link-visit"');
|
||||
// The positive control: the neighbouring verb is still there, so the
|
||||
// assertion above is about the visit menu and not about an empty render.
|
||||
expect(html).toContain("Link condition");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -38,6 +38,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
* Floating bulk-action bar for the vault's multi-select mode. Appears once
|
||||
* at least one document is selected, pinned above the bottom nav on phones
|
||||
* and near the bottom edge on desktop. Carries the selected count and the
|
||||
* bulk verbs — set type, link condition, share (one link for the whole
|
||||
* selection), delete (undo-able), clear — all driving the page's handlers.
|
||||
* bulk verbs — set type, link condition, file against a visit, share (one
|
||||
* link for the whole selection), delete (undo-able), clear — all driving the
|
||||
* page's handlers.
|
||||
*
|
||||
* The two link verbs are the same shape and stay that way: each renders only
|
||||
* when the account HAS something to link to, so an empty menu is never
|
||||
* offered.
|
||||
*
|
||||
* A `role="toolbar"` with proper labels; the destructive verb sits last
|
||||
* before Clear so a stray tap sequence never ends on Delete. Share sits just
|
||||
@@ -13,7 +18,14 @@
|
||||
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
|
||||
* per the standards.
|
||||
*/
|
||||
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
FolderPlus,
|
||||
Share2,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,12 +44,20 @@ export interface BulkEpisodeOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** One visit the selection can be filed against. Label resolved by the page. */
|
||||
export interface BulkEncounterOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DocumentBulkBar({
|
||||
selectedCount,
|
||||
episodes,
|
||||
encounters,
|
||||
busy,
|
||||
onSetKind,
|
||||
onLinkEpisode,
|
||||
onLinkEncounter,
|
||||
onShare,
|
||||
onDelete,
|
||||
onClear,
|
||||
@@ -45,10 +65,13 @@ export function DocumentBulkBar({
|
||||
selectedCount: number;
|
||||
/** The caller's live illness episodes — the link-condition targets. */
|
||||
episodes: BulkEpisodeOption[];
|
||||
/** The caller's recent visits — the file-against-a-visit targets. */
|
||||
encounters: BulkEncounterOption[];
|
||||
/** A bulk call is in flight — the verbs disable, Clear stays live. */
|
||||
busy: boolean;
|
||||
onSetKind: (kind: InboundDocumentKindValue) => void;
|
||||
onLinkEpisode: (episodeId: string) => void;
|
||||
onLinkEncounter: (encounterId: string) => void;
|
||||
/**
|
||||
* Share the whole selection as ONE documents-only link. The page caps the
|
||||
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
|
||||
@@ -135,6 +158,35 @@ export function DocumentBulkBar({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{encounters.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
data-slot="document-bulk-link-visit"
|
||||
aria-label={t("documents.bulk.linkVisit")}
|
||||
>
|
||||
<CalendarClock className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.bulk.linkVisit")}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{encounters.map((encounter) => (
|
||||
<DropdownMenuItem
|
||||
key={encounter.id}
|
||||
onSelect={() => onLinkEncounter(encounter.id)}
|
||||
>
|
||||
<span className="max-w-56 truncate">{encounter.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -77,6 +79,7 @@ import {
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "./document-ai-section";
|
||||
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
|
||||
import { DocumentSummaryBlock } from "./document-summary-block";
|
||||
import type { DocumentAiTarget } from "./document-ai-transport";
|
||||
import { DocumentShareSheet } from "./document-share-sheet";
|
||||
@@ -96,6 +99,7 @@ type PatchInput = {
|
||||
kind?: InboundDocumentKindValue;
|
||||
documentDate?: string | null;
|
||||
episodeIds?: string[];
|
||||
encounterIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -396,6 +400,17 @@ export function DocumentDetailSheet({
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
/**
|
||||
* The visit this SESSION filed the document against, if any.
|
||||
*
|
||||
* Kept beside the server state rather than derived from it, because the two
|
||||
* answer different questions: `doc.encounterLinks` says what is filed, this
|
||||
* says what the person just did here — which is what an undo can take back
|
||||
* without touching a filing they made somewhere else.
|
||||
*/
|
||||
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
|
||||
null,
|
||||
@@ -499,6 +514,25 @@ export function DocumentDetailSheet({
|
||||
patch.mutate({ episodeIds: next });
|
||||
};
|
||||
|
||||
/**
|
||||
* File this document against a visit, or clear that filing again.
|
||||
*
|
||||
* A replace-set PATCH like the condition links, so the undo is the same
|
||||
* write with the id removed rather than a second endpoint. Nothing here can
|
||||
* fail the document: an unknown id is refused by the route before anything
|
||||
* is written, and the sheet keeps showing what the server last confirmed.
|
||||
*/
|
||||
const setSuggestedEncounter = (encounterId: string | null) => {
|
||||
if (!doc) return;
|
||||
const current = doc.encounterLinks.map((l) => l.encounterId);
|
||||
const next =
|
||||
encounterId === null
|
||||
? current.filter((id) => id !== pickedEncounterId)
|
||||
: [...new Set([...current, encounterId])];
|
||||
setPickedEncounterId(encounterId);
|
||||
patch.mutate({ encounterIds: next });
|
||||
};
|
||||
|
||||
// The affordance gate falls back to the capability probe when the sheet is
|
||||
// rendered without the usage-derived props (deep link before usage loads).
|
||||
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
|
||||
@@ -1001,6 +1035,47 @@ export function DocumentDetailSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentEncounterSuggestion
|
||||
anchor={doc.reportDate ?? doc.documentDate}
|
||||
alreadyLinked={
|
||||
doc.encounterLinks.length > 0 && pickedEncounterId === null
|
||||
}
|
||||
disabled={!canManage}
|
||||
value={pickedEncounterId}
|
||||
onChange={setSuggestedEncounter}
|
||||
/>
|
||||
|
||||
{/* "Belongs to visit" — read-only here on purpose. The filing
|
||||
happens at the moment the document arrives, or from the
|
||||
visit's own sheet; a second editor for the same link would
|
||||
be a second place the set can be changed. Each row deep-links
|
||||
to the vault filtered to that visit. */}
|
||||
{doc.encounterLinks.length > 0 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{labsModuleEnabled && labFactCount > 0 ? (
|
||||
<Link
|
||||
href="/labs"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The document moment of the visit suggestion.
|
||||
*
|
||||
* The rule and the rendering both live elsewhere — `suggest-window.ts` on the
|
||||
* server and `EncounterSuggestionField` in the browser — so this file is only
|
||||
* the wiring: which date to anchor on, and where the chosen id goes.
|
||||
*
|
||||
* The anchor is `reportDate ?? documentDate`. `reportDate` is what the model
|
||||
* transcribed off the letter itself and is never inferred, so it is the truer
|
||||
* of the two; `documentDate` is the person's own filing date and is the
|
||||
* fallback.
|
||||
*
|
||||
* It renders nothing once the document already names a visit that this session
|
||||
* did not choose: an offer to file something already filed is noise.
|
||||
*/
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
|
||||
export function DocumentEncounterSuggestion({
|
||||
anchor,
|
||||
alreadyLinked,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
anchor: string | null;
|
||||
alreadyLinked: boolean;
|
||||
disabled?: boolean;
|
||||
value: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
}) {
|
||||
if (alreadyLinked) return null;
|
||||
return (
|
||||
<EncounterSuggestionField
|
||||
anchor={anchor}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slot="document-encounter-suggestion"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,10 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import {
|
||||
type CoachCloseIntent,
|
||||
useCoachLaunch,
|
||||
@@ -85,6 +88,13 @@ interface ListPage {
|
||||
}
|
||||
|
||||
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
|
||||
/**
|
||||
* How many visits the bulk bar offers. A menu is a picker, not a history: past
|
||||
* the first screenful a person scrolls rather than reads, and filing against a
|
||||
* visit older than this belongs on the visit's own sheet.
|
||||
*/
|
||||
const BULK_VISIT_OPTIONS = 12;
|
||||
|
||||
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
||||
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
||||
|
||||
@@ -226,6 +236,7 @@ export function closeDocumentSelectionAfterCoachHandoff(
|
||||
|
||||
export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
@@ -476,6 +487,27 @@ export function DocumentsView() {
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
|
||||
|
||||
// The visits the bulk bar can file a selection against: the account's own,
|
||||
// read through the same list the checkups page shows. Labelled here rather
|
||||
// than in the bar so the label uses the reader's locale for the kind and the
|
||||
// reader's timezone for the date — both presentation, both this side's job.
|
||||
const visits = useEncounters(
|
||||
!inSharedRecord || sections === null || sections.includes("profile"),
|
||||
);
|
||||
const bulkEncounterOptions = useMemo(
|
||||
() =>
|
||||
[...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])]
|
||||
.slice(0, BULK_VISIT_OPTIONS)
|
||||
.map((visit) => ({
|
||||
id: visit.id,
|
||||
label: `${
|
||||
visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)
|
||||
} · ${format.date(visit.occurredAt)}`,
|
||||
})),
|
||||
[visits.data, t, format],
|
||||
);
|
||||
|
||||
// Year segmenter: years present in the loaded corpus (+ the active year).
|
||||
const years = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
@@ -589,7 +621,11 @@ export function DocumentsView() {
|
||||
const runBulk = useCallback(
|
||||
(
|
||||
action: Exclude<DocumentBulkAction, "delete" | "restore">,
|
||||
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
|
||||
extra: {
|
||||
kind?: InboundDocumentKindValue;
|
||||
episodeId?: string;
|
||||
encounterId?: string;
|
||||
},
|
||||
) => {
|
||||
const ids = [...selectedIds];
|
||||
bulk.mutate(
|
||||
@@ -957,6 +993,10 @@ export function DocumentsView() {
|
||||
busy={bulk.isPending}
|
||||
onSetKind={(kind) => runBulk("setKind", { kind })}
|
||||
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
|
||||
encounters={bulkEncounterOptions}
|
||||
onLinkEncounter={(encounterId) =>
|
||||
runBulk("linkEncounter", { encounterId })
|
||||
}
|
||||
onShare={openBulkShare}
|
||||
onDelete={() => deleteBulk([...selectedIds])}
|
||||
onClear={clearSelection}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
|
||||
|
||||
/**
|
||||
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
|
||||
* Parse the vault's URL search params (`?q&kind&episode&encounter&year`) into the
|
||||
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
|
||||
* page), `kind` accepts repeats AND comma-separated values, and the result is
|
||||
* normalised (kinds sorted, empties omitted) so the same view always produces
|
||||
@@ -42,6 +42,13 @@ export function parseVaultSearchParams(
|
||||
const episode = params.get("episode")?.trim();
|
||||
if (episode) filters.episodeId = episode.slice(0, 40);
|
||||
|
||||
// `?encounter=` is how a visit's own detail row reaches the vault filtered
|
||||
// to what that visit produced. No chip in the filter bar backs it: the facet
|
||||
// arrives by deep link from the visit and clears with the clear-all pill,
|
||||
// which is exactly what a one-way link needs and no more.
|
||||
const encounter = params.get("encounter")?.trim();
|
||||
if (encounter) filters.encounterId = encounter.slice(0, 40);
|
||||
|
||||
const yearRaw = params.get("year");
|
||||
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
|
||||
const year = Number(yearRaw);
|
||||
@@ -64,6 +71,7 @@ export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
|
||||
sp.set("kind", [...filters.kinds].sort().join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episode", filters.episodeId);
|
||||
if (filters.encounterId) sp.set("encounter", filters.encounterId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
return sp.toString();
|
||||
}
|
||||
@@ -74,6 +82,7 @@ export function countActiveFilters(filters: DocumentVaultFilters): number {
|
||||
if (filters.q) count += 1;
|
||||
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
|
||||
if (filters.episodeId) count += 1;
|
||||
if (filters.encounterId) count += 1;
|
||||
if (filters.year !== undefined) count += 1;
|
||||
return count;
|
||||
}
|
||||
@@ -95,6 +104,7 @@ export function buildVaultListApiSearch(
|
||||
sp.set("kind", filters.kinds.join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
|
||||
if (filters.encounterId) sp.set("encounterId", filters.encounterId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The one product constraint this whole feature exists to hold: **a visit
|
||||
* saves with a date and nothing else.**
|
||||
*
|
||||
* Three properties, each of which has an obvious way to get broken by a later
|
||||
* "improvement":
|
||||
*
|
||||
* - no field but the date can block Save;
|
||||
* - the status is DERIVED from the date on a new visit, never asked;
|
||||
* - a link picker whose module is off is BLANK, not empty — an empty picker
|
||||
* for a switched-off module advertises a feature that is not there.
|
||||
*/
|
||||
|
||||
let modules: Record<string, boolean> = {};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
EncounterForm,
|
||||
draftFromEncounter,
|
||||
derivedStatus,
|
||||
draftInstant,
|
||||
draftToBody,
|
||||
emptyDraft,
|
||||
} from "../encounter-form";
|
||||
|
||||
/**
|
||||
* The form body, rendered directly rather than through its sheet: the sheet is
|
||||
* a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a
|
||||
* test that went through it would assert against an empty string and pass for
|
||||
* the wrong reason.
|
||||
*/
|
||||
function renderForm(over?: {
|
||||
modules?: Record<string, boolean>;
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
modules = over?.modules ?? {};
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<EncounterForm
|
||||
draft={emptyDraft({ occurredAt: "2026-08-08T10:00" })}
|
||||
onChange={() => {}}
|
||||
isEdit={over?.isEdit ?? false}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("nothing but the date is required", () => {
|
||||
it("admits a draft carrying only a date", () => {
|
||||
// `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft
|
||||
// with no practitioner, no reason, no outcome and no link is saveable.
|
||||
const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" });
|
||||
expect(draft.practitioner).toBeNull();
|
||||
expect(draft.reason).toBe("");
|
||||
expect(draft.documentIds).toEqual([]);
|
||||
expect(draftInstant(draft.occurredAt)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses only when the date itself cannot be read", () => {
|
||||
expect(draftInstant("not a date")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the date field required and nothing else", () => {
|
||||
const html = renderForm();
|
||||
const required = html.match(/aria-required="true"/g) ?? [];
|
||||
expect(required).toHaveLength(1);
|
||||
expect(html).toContain('data-testid="encounter-occurred-at"');
|
||||
});
|
||||
|
||||
it("turns a date-only draft into a complete write body", () => {
|
||||
const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), {
|
||||
isEdit: false,
|
||||
});
|
||||
expect(body).not.toBeNull();
|
||||
expect(body?.practitionerId).toBeNull();
|
||||
expect(body?.reason).toBeNull();
|
||||
expect(body?.outcome).toBeNull();
|
||||
expect(body?.documentIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to build a body from a date that does not parse", () => {
|
||||
expect(
|
||||
draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing never unfiles what the visit already collected", () => {
|
||||
it("seeds the link arrays from the row's own links", () => {
|
||||
// The defect this pins: an edit seeded from a row that did not know its
|
||||
// links would save a replace-set of three EMPTY arrays and silently
|
||||
// unfile everything. The list carries the links so this cannot happen.
|
||||
const draft = draftFromEncounter({
|
||||
id: "e1",
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
links: {
|
||||
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
|
||||
labResults: [{ id: "l1", label: "LDL", date: null, redacted: false }],
|
||||
conditions: [{ id: "c1", label: "Knee", date: null, redacted: false }],
|
||||
},
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
});
|
||||
expect(draft.documentIds).toEqual(["d1"]);
|
||||
expect(draft.labResultIds).toEqual(["l1"]);
|
||||
expect(draft.episodeIds).toEqual(["c1"]);
|
||||
|
||||
const body = draftToBody(draft, { isEdit: true });
|
||||
expect(body?.documentIds).toEqual(["d1"]);
|
||||
expect(body?.labResultIds).toEqual(["l1"]);
|
||||
expect(body?.episodeIds).toEqual(["c1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the status follows the date on a new visit", () => {
|
||||
const now = Date.parse("2026-08-08T12:00:00.000Z");
|
||||
|
||||
it("reads a future instant as a booked appointment", () => {
|
||||
expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED");
|
||||
});
|
||||
|
||||
it("reads a past instant as a visit that happened", () => {
|
||||
expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE");
|
||||
});
|
||||
|
||||
it("never asks for the status on a new visit", () => {
|
||||
// The picker is what "asked" would look like; its absence is the property.
|
||||
expect(renderForm()).not.toContain('id="encounter-status"');
|
||||
});
|
||||
|
||||
it("carries the derived status into the body rather than the draft default", () => {
|
||||
const body = draftToBody(
|
||||
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }),
|
||||
{ isEdit: false },
|
||||
);
|
||||
expect(body?.status).toBe("PLANNED");
|
||||
});
|
||||
|
||||
it("uses the draft's own status on an edit, where it is editable", () => {
|
||||
const body = draftToBody(
|
||||
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }),
|
||||
{ isEdit: true },
|
||||
);
|
||||
expect(body?.status).toBe("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the link pickers follow the modules that own their targets", () => {
|
||||
it("renders no link block at all when all three modules are off", () => {
|
||||
const html = renderForm({ modules: {} });
|
||||
expect(html).not.toContain('data-slot="encounter-link-pickers"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-documents"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-conditions"');
|
||||
});
|
||||
|
||||
it("blanks the block of a disabled module while keeping its neighbours", () => {
|
||||
const html = renderForm({
|
||||
modules: { inboundDocuments: true, labs: false, illness: false },
|
||||
});
|
||||
expect(html).toContain('data-slot="encounter-link-documents"');
|
||||
// Not "renders an empty list" — absent.
|
||||
expect(html).not.toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-conditions"');
|
||||
});
|
||||
|
||||
it("offers all three when all three modules are on", () => {
|
||||
const html = renderForm({
|
||||
modules: { inboundDocuments: true, labs: true, illness: true },
|
||||
});
|
||||
expect(html).toContain('data-slot="encounter-link-documents"');
|
||||
expect(html).toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).toContain('data-slot="encounter-link-conditions"');
|
||||
// The positive control: without it the three assertions above would pass
|
||||
// against a component that never renders any block.
|
||||
expect(html).toContain('data-slot="encounter-link-pickers"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The visits surface's designed states, pinned by single-pass static renders.
|
||||
*
|
||||
* - an account with no visits paints the empty state and one action, not an
|
||||
* error and not a teaching wall;
|
||||
* - upcoming sorts above past, in the two lists the SERVER split — the
|
||||
* client renders the order it was given rather than re-deriving one;
|
||||
* - a soft-deleted visit never arrives, so it cannot be rendered. That is a
|
||||
* server property and the API test owns it; what this file pins is that
|
||||
* the section renders exactly what the response carried, with nothing
|
||||
* filtered or re-sorted on this side that could disagree with it.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
// Never resolves inside a synchronous render, so an unseeded query stays
|
||||
// pending and cannot accidentally satisfy an assertion.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { VisitsSection } from "../visits-section";
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
list,
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<VisitsSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<VisitsSection>", () => {
|
||||
it("paints the empty state, with one action, when there is no visit", () => {
|
||||
const html = render({ upcoming: [], past: [] });
|
||||
expect(html).toContain("No visit recorded yet");
|
||||
expect(html).toContain("A date is enough");
|
||||
expect(html).toContain('data-slot="visits-add"');
|
||||
// An empty account is not an error, and the two states never share copy.
|
||||
expect(html).not.toContain("Visits could not be loaded.");
|
||||
expect(html).not.toContain('data-slot="visits-upcoming"');
|
||||
});
|
||||
|
||||
it("puts the upcoming list above the past one", () => {
|
||||
const html = render({
|
||||
upcoming: [visit({ id: "later", status: "PLANNED" })],
|
||||
past: [visit({ id: "earlier" })],
|
||||
});
|
||||
const upcomingAt = html.indexOf('data-slot="visits-upcoming"');
|
||||
const pastAt = html.indexOf('data-slot="visits-past"');
|
||||
expect(upcomingAt).toBeGreaterThan(-1);
|
||||
expect(pastAt).toBeGreaterThan(-1);
|
||||
expect(upcomingAt).toBeLessThan(pastAt);
|
||||
expect(html.indexOf('data-encounter-id="later"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="earlier"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the server's order inside a list rather than re-sorting it", () => {
|
||||
// Two rows deliberately handed over in an order the client has no reason
|
||||
// to like. Re-sorting here would be the second place the ordering lives.
|
||||
const html = render({
|
||||
upcoming: [],
|
||||
past: [
|
||||
visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }),
|
||||
visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }),
|
||||
],
|
||||
});
|
||||
expect(html.indexOf('data-encounter-id="second"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="first"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts what a visit produced, from the links the LIST carried", () => {
|
||||
// The counts come off `links`, and the list used to omit them — every card
|
||||
// then read zero for a visit with three documents. The fixture carries a
|
||||
// populated `links` because the response now does.
|
||||
const html = render({
|
||||
upcoming: [],
|
||||
past: [
|
||||
visit({
|
||||
id: "linked",
|
||||
links: {
|
||||
documents: [
|
||||
{ id: "d1", label: "Letter", date: null, redacted: false },
|
||||
{ id: "d2", label: "Scan", date: null, redacted: false },
|
||||
],
|
||||
labResults: [
|
||||
{ id: "l1", label: "LDL", date: null, redacted: false },
|
||||
],
|
||||
conditions: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(html).toContain("2 linked documents");
|
||||
expect(html).toContain("1 linked lab results");
|
||||
// The empty family renders no counter rather than a zero.
|
||||
expect(html).not.toContain("0 linked conditions");
|
||||
});
|
||||
|
||||
it("offers the address book beside the add action", () => {
|
||||
const html = render({ upcoming: [], past: [] });
|
||||
expect(html).toContain('href="/checkups/practitioners"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The visit form.
|
||||
*
|
||||
* **A visit saves with a date and nothing else.** No required practitioner, no
|
||||
* required kind, no required link, and no Save button disabled until something
|
||||
* is picked. That is the whole product constraint this feature exists to hold:
|
||||
* a required field on a form somebody fills after a doctor's appointment is the
|
||||
* bureaucracy it was built to avoid. The only thing that can block Save is a
|
||||
* missing or unparseable date, because a visit with no instant is not a record
|
||||
* of anything.
|
||||
*
|
||||
* Status is DERIVED from the date on a new visit, never asked: a future instant
|
||||
* means PLANNED and a past one means DONE. A person booking an appointment
|
||||
* should not have to also tell the app that it is in the future. On an existing
|
||||
* visit the status becomes editable, because moving a planned visit to done,
|
||||
* cancelled or a no-show is the transition that closes a checkup.
|
||||
*
|
||||
* The three link pickers are gated by the modules that own what they point at,
|
||||
* and the gate BLANKS the block rather than post-filtering it — an empty
|
||||
* picker for a module the account turned off advertises a feature that is not
|
||||
* there.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
|
||||
import type { Practitioner } from "@/hooks/use-practitioners";
|
||||
import {
|
||||
ENCOUNTER_KINDS,
|
||||
ENCOUNTER_STATUSES,
|
||||
encounterKindText,
|
||||
encounterStatusText,
|
||||
} from "./encounter-labels";
|
||||
import { PractitionerCombobox } from "./practitioner-combobox";
|
||||
import { EncounterLinkPickers } from "./encounter-link-pickers";
|
||||
|
||||
export interface EncounterDraft {
|
||||
/** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
|
||||
occurredAt: string;
|
||||
status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind: string;
|
||||
practitioner: Practitioner | null;
|
||||
reason: string;
|
||||
outcome: string;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
}
|
||||
|
||||
function localValue(instant: Date): string {
|
||||
const offset = instant.getTimezoneOffset();
|
||||
return new Date(instant.getTime() - offset * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
export function nowLocalValue(): string {
|
||||
return localValue(new Date());
|
||||
}
|
||||
|
||||
/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
|
||||
export function draftInstant(value: string): string | null {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function emptyDraft(
|
||||
overrides?: Partial<EncounterDraft>,
|
||||
): EncounterDraft {
|
||||
return {
|
||||
occurredAt: nowLocalValue(),
|
||||
status: "DONE",
|
||||
kind: "OTHER",
|
||||
practitioner: null,
|
||||
reason: "",
|
||||
outcome: "",
|
||||
documentIds: [],
|
||||
labResultIds: [],
|
||||
episodeIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromEncounter(row: Encounter): EncounterDraft {
|
||||
return {
|
||||
occurredAt: localValue(new Date(row.occurredAt)),
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitioner: row.practitioner,
|
||||
reason: row.reason ?? "",
|
||||
outcome: row.outcome ?? "",
|
||||
documentIds: row.links?.documents.map((link) => link.id) ?? [],
|
||||
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
|
||||
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The status a NEW visit carries, read off its own date.
|
||||
*
|
||||
* Exported so the sheet and its tests agree on the derivation rather than each
|
||||
* spelling out the comparison.
|
||||
*/
|
||||
export function derivedStatus(
|
||||
occurredAt: string,
|
||||
now = Date.now(),
|
||||
): "PLANNED" | "DONE" {
|
||||
const parsed = new Date(occurredAt).getTime();
|
||||
return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
|
||||
}
|
||||
|
||||
/** The body a draft becomes, with the empty strings turned into absences. */
|
||||
export function draftToBody(
|
||||
draft: EncounterDraft,
|
||||
options: { isEdit: boolean; reminderId?: string | null },
|
||||
): EncounterWriteBody | null {
|
||||
const occurredAt = draftInstant(draft.occurredAt);
|
||||
if (!occurredAt) return null;
|
||||
return {
|
||||
occurredAt,
|
||||
status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
|
||||
kind: draft.kind,
|
||||
practitionerId: draft.practitioner?.id ?? null,
|
||||
reason: draft.reason.trim() || null,
|
||||
outcome: draft.outcome.trim() || null,
|
||||
...(options.reminderId ? { reminderId: options.reminderId } : {}),
|
||||
documentIds: draft.documentIds,
|
||||
labResultIds: draft.labResultIds,
|
||||
episodeIds: draft.episodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function EncounterForm({
|
||||
draft,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: {
|
||||
draft: EncounterDraft;
|
||||
onChange: (next: EncounterDraft) => void;
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const patch = (part: Partial<EncounterDraft>) =>
|
||||
onChange({ ...draft, ...part });
|
||||
|
||||
const planned = useMemo(
|
||||
() =>
|
||||
isEdit
|
||||
? draft.status === "PLANNED"
|
||||
: derivedStatus(draft.occurredAt) === "PLANNED",
|
||||
[isEdit, draft.status, draft.occurredAt],
|
||||
);
|
||||
|
||||
// The outcome field starts collapsed on a planned visit: there is nothing to
|
||||
// write about a thing that has not happened. Derived rather than
|
||||
// synchronised — a visit that already carries an outcome is never collapsed
|
||||
// over it, and expressing that as an effect would be a second source of
|
||||
// truth for something the draft already knows.
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="encounter-form">
|
||||
<FieldGroup
|
||||
htmlFor="encounter-occurred-at"
|
||||
label={t("encounters.form.date")}
|
||||
hint={isEdit ? undefined : t("encounters.form.dateDerivesStatusHint")}
|
||||
>
|
||||
<DateTimeField
|
||||
id="encounter-occurred-at"
|
||||
value={draft.occurredAt}
|
||||
onChange={(value) => patch({ occurredAt: value })}
|
||||
required
|
||||
aria-required="true"
|
||||
data-testid="encounter-occurred-at"
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{isEdit ? (
|
||||
<FieldGroup
|
||||
htmlFor="encounter-status"
|
||||
label={t("encounters.form.status")}
|
||||
>
|
||||
<NativeSelect
|
||||
id="encounter-status"
|
||||
value={draft.status}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
status: event.target.value as EncounterDraft["status"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{ENCOUNTER_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{encounterStatusText(t, status)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-practitioner"
|
||||
label={t("encounters.form.practitioner")}
|
||||
hint={t("encounters.form.practitionerHint")}
|
||||
>
|
||||
<PractitionerCombobox
|
||||
value={draft.practitioner?.id ?? null}
|
||||
selected={draft.practitioner}
|
||||
onChange={(next) => patch({ practitioner: next })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup htmlFor="encounter-kind" label={t("encounters.form.kind")}>
|
||||
<NativeSelect
|
||||
id="encounter-kind"
|
||||
value={draft.kind}
|
||||
onChange={(event) => patch({ kind: event.target.value })}
|
||||
>
|
||||
{ENCOUNTER_KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{encounterKindText(t, kind)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-reason"
|
||||
label={t("encounters.form.reason")}
|
||||
>
|
||||
<Textarea
|
||||
id="encounter-reason"
|
||||
rows={2}
|
||||
value={draft.reason}
|
||||
onChange={(event) => patch({ reason: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{outcomeOpen ? (
|
||||
<FieldGroup
|
||||
htmlFor="encounter-outcome"
|
||||
label={t("encounters.form.outcome")}
|
||||
>
|
||||
<Textarea
|
||||
id="encounter-outcome"
|
||||
rows={3}
|
||||
value={draft.outcome}
|
||||
onChange={(event) => patch({ outcome: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-outcome-reveal"
|
||||
onClick={() => setRevealed(true)}
|
||||
>
|
||||
{t("encounters.form.outcomeReveal")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<EncounterLinkPickers
|
||||
modules={user?.modules}
|
||||
documentIds={draft.documentIds}
|
||||
labResultIds={draft.labResultIds}
|
||||
episodeIds={draft.episodeIds}
|
||||
onChange={patch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Browser-side labels for a visit's kind and status.
|
||||
*
|
||||
* The server has its own `encounterKindLabel` and the two do not share code on
|
||||
* purpose: the server's answer becomes a push body in the RECORD OWNER's
|
||||
* language, and this one renders in the language of whoever is looking. Sharing
|
||||
* a resolver would force one of the two to read the wrong locale.
|
||||
*
|
||||
* Both switches spell out literal `t()` calls rather than interpolating a key.
|
||||
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
|
||||
* or missing label would ship as raw dot notation instead of failing CI.
|
||||
*/
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
/** Every kind, in the order the picker offers them. */
|
||||
export const ENCOUNTER_KINDS: EncounterKind[] = [
|
||||
"ROUTINE",
|
||||
"ACUTE",
|
||||
"SPECIALIST",
|
||||
"PREVENTIVE",
|
||||
"EMERGENCY",
|
||||
"HOSPITAL",
|
||||
"THERAPY",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
/** Every status a visit can be edited into. */
|
||||
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
|
||||
"PLANNED",
|
||||
"DONE",
|
||||
"CANCELLED",
|
||||
"NO_SHOW",
|
||||
];
|
||||
|
||||
export function encounterKindText(t: Translate, kind: EncounterKind): string {
|
||||
switch (kind) {
|
||||
case "ROUTINE":
|
||||
return t("encounters.kind.routine");
|
||||
case "ACUTE":
|
||||
return t("encounters.kind.acute");
|
||||
case "SPECIALIST":
|
||||
return t("encounters.kind.specialist");
|
||||
case "PREVENTIVE":
|
||||
return t("encounters.kind.preventive");
|
||||
case "EMERGENCY":
|
||||
return t("encounters.kind.emergency");
|
||||
case "HOSPITAL":
|
||||
return t("encounters.kind.hospital");
|
||||
case "THERAPY":
|
||||
return t("encounters.kind.therapy");
|
||||
default:
|
||||
return t("encounters.kind.other");
|
||||
}
|
||||
}
|
||||
|
||||
export function encounterStatusText(
|
||||
t: Translate,
|
||||
status: EncounterStatus,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "PLANNED":
|
||||
return t("encounters.status.planned");
|
||||
case "DONE":
|
||||
return t("encounters.status.done");
|
||||
case "CANCELLED":
|
||||
return t("encounters.status.cancelled");
|
||||
default:
|
||||
return t("encounters.status.noShow");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The visit form's three optional link blocks: documents, lab results,
|
||||
* conditions.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** A module the
|
||||
* account turned off leaves nothing behind here — no heading, no empty list,
|
||||
* no "0 selected". An empty picker for a switched-off module advertises a
|
||||
* feature that is not there and invites a person to wonder what is wrong. This
|
||||
* is the same shape the dashboard snapshot uses for a disabled module.
|
||||
*
|
||||
* The encounter itself carries NO module gate. A visit is core, like the
|
||||
* checkups page it lives on; what it may point AT follows the modules that own
|
||||
* those things, which is why the gate lives here and not on the route.
|
||||
*
|
||||
* Nothing in this block can block a save. Every list starts empty and stays
|
||||
* valid empty.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** How many candidates one block offers before the person must use the sheet. */
|
||||
const OPTION_LIMIT = 25;
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
/** ISO-8601, or null for a target with no date of its own. */
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
function LinkBlock({
|
||||
icon: Icon,
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
pending,
|
||||
slot,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onToggle: (id: string) => void;
|
||||
pending: boolean;
|
||||
slot: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot={slot}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{selected.length > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{selected.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{pending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("encounters.form.linkNothingToOffer")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{options.map((option) => {
|
||||
const on = selected.includes(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onToggle(option.id)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.date ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(option.date)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(list: string[], id: string): string[] {
|
||||
return list.includes(id)
|
||||
? list.filter((entry) => entry !== id)
|
||||
: [...list, id];
|
||||
}
|
||||
|
||||
interface LabListPage {
|
||||
results: Array<{
|
||||
id: string;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
takenAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
export function EncounterLinkPickers({
|
||||
modules,
|
||||
documentIds,
|
||||
labResultIds,
|
||||
episodeIds,
|
||||
onChange,
|
||||
}: {
|
||||
modules: Partial<Record<ModuleKey, boolean>> | undefined;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
onChange: (part: {
|
||||
documentIds?: string[];
|
||||
labResultIds?: string[];
|
||||
episodeIds?: string[];
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const documentsOn = modules?.inboundDocuments === true;
|
||||
const labsOn = modules?.labs === true;
|
||||
const illnessOn = modules?.illness === true;
|
||||
|
||||
const documents = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
|
||||
enabled: documentsOn,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
const labs = useQuery({
|
||||
queryKey: queryKeys.labResultsList({
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
}),
|
||||
enabled: labsOn,
|
||||
queryFn: () =>
|
||||
apiGet<LabListPage>(
|
||||
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
|
||||
),
|
||||
});
|
||||
|
||||
const episodes = useQuery({
|
||||
queryKey: queryKeys.illnessEpisodes(true),
|
||||
enabled: illnessOn,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!documentsOn && !labsOn && !illnessOn) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
|
||||
{documentsOn ? (
|
||||
<LinkBlock
|
||||
icon={FolderOpen}
|
||||
title={t("encounters.form.linkDocuments")}
|
||||
slot="encounter-link-documents"
|
||||
pending={documents.isPending}
|
||||
selected={documentIds}
|
||||
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
|
||||
options={(documents.data?.documents ?? []).map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{labsOn ? (
|
||||
<LinkBlock
|
||||
icon={FlaskConical}
|
||||
title={t("encounters.form.linkLabResults")}
|
||||
slot="encounter-link-labs"
|
||||
pending={labs.isPending}
|
||||
selected={labResultIds}
|
||||
onToggle={(id) =>
|
||||
onChange({ labResultIds: toggle(labResultIds, id) })
|
||||
}
|
||||
options={(labs.data?.results ?? []).map((result) => ({
|
||||
id: result.id,
|
||||
label: result.panel
|
||||
? `${result.panel} · ${result.analyte}`
|
||||
: result.analyte,
|
||||
date: result.takenAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{illnessOn ? (
|
||||
<LinkBlock
|
||||
icon={Stethoscope}
|
||||
title={t("encounters.form.linkConditions")}
|
||||
slot="encounter-link-conditions"
|
||||
pending={episodes.isPending}
|
||||
selected={episodeIds}
|
||||
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
|
||||
options={(episodes.data ?? []).map((episode) => ({
|
||||
id: episode.id,
|
||||
label: episode.label,
|
||||
date: episode.onsetAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The sheet the visit form lives in — create and edit, one surface.
|
||||
*
|
||||
* Save is disabled only when the date cannot be parsed. Nothing else on the
|
||||
* form can block it, which is the property the whole feature is checked
|
||||
* against.
|
||||
*
|
||||
* Two entry points pre-fill it and neither changes what is required:
|
||||
*
|
||||
* - `reminderId` — opened from a due checkup. Filing the visit as DONE closes
|
||||
* that checkup through the server's forward-only, idempotent path, so a
|
||||
* person who both ticks the box and files a visit gets one satisfaction and
|
||||
* no error.
|
||||
* - `episodeId` — opened from a condition's "Visits about this" card, with
|
||||
* the episode already linked.
|
||||
*
|
||||
* A delegate who may file the visit but not close the checkup gets the visit
|
||||
* saved and a plain sentence saying the checkup stayed open. That is reported
|
||||
* rather than skipped in silence: somebody who believes a checkup was closed
|
||||
* will not look at it again.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
|
||||
import {
|
||||
EncounterForm,
|
||||
draftFromEncounter,
|
||||
draftInstant,
|
||||
draftToBody,
|
||||
emptyDraft,
|
||||
nowLocalValue,
|
||||
type EncounterDraft,
|
||||
} from "./encounter-form";
|
||||
|
||||
export function EncounterSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
encounter,
|
||||
reminderId,
|
||||
episodeId,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** `null` creates a visit; a row edits it. */
|
||||
encounter: Encounter | null;
|
||||
/** The due checkup this visit is being filed against, when there is one. */
|
||||
reminderId?: string | null;
|
||||
/** A condition to pre-link, when the sheet was opened from an episode. */
|
||||
episodeId?: string | null;
|
||||
onSaved?: (saved: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update, remove } = useEncounterMutations();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
// Seeded once, at mount. The caller remounts this component on every open by
|
||||
// varying its `key`, so a second edit never shows the first one's values and
|
||||
// a create after an edit starts empty — without an effect that copies props
|
||||
// into state and briefly renders the stale pair.
|
||||
const [draft, setDraft] = useState<EncounterDraft>(() =>
|
||||
encounter
|
||||
? draftFromEncounter(encounter)
|
||||
: emptyDraft({
|
||||
occurredAt: nowLocalValue(),
|
||||
episodeIds: episodeId ? [episodeId] : [],
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = encounter !== null;
|
||||
const pending = create.isPending || update.isPending;
|
||||
// The one gate. A date that does not parse is not a visit; everything else
|
||||
// on this form is optional by design.
|
||||
const dateUsable = draftInstant(draft.occurredAt) !== null;
|
||||
|
||||
const submit = async () => {
|
||||
const body = draftToBody(draft, { isEdit, reminderId });
|
||||
if (!body) return;
|
||||
try {
|
||||
const saved = isEdit
|
||||
? await update.mutateAsync({ id: encounter.id, body })
|
||||
: await create.mutateAsync(body);
|
||||
// The server names what its grant could not reach. Today that is only a
|
||||
// checkup closure, and a delegate seeing nothing would believe it closed
|
||||
// — so a write that did part of what was asked reports `partial`, not a
|
||||
// green tick, through the one module that owns the affordance.
|
||||
const wroteEverything = (saved.skipped?.links ?? 0) === 0;
|
||||
toastWrittenOutcome(
|
||||
wroteEverything ? "success" : "partial",
|
||||
wroteEverything
|
||||
? t(isEdit ? "encounters.updated" : "encounters.created")
|
||||
: t("encounters.checkupNotClosed"),
|
||||
);
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("encounters.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(isEdit ? "encounters.editTitle" : "encounters.createTitle")}
|
||||
description={t("encounters.formDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-save"
|
||||
disabled={!dateUsable || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isEdit ? (
|
||||
<AlertDialog
|
||||
open={confirmingDelete}
|
||||
onOpenChange={setConfirmingDelete}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-delete"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("encounters.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
{/* Says what goes with it. Deleting a booked visit retires its
|
||||
reminder, and a person who booked one deserves to know the
|
||||
nudge stops rather than discover it by silence. */}
|
||||
<AlertDialogDescription>
|
||||
{t("encounters.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
remove.mutate(encounter.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: () => toast.error(t("encounters.deleteFailed")),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Gehört zu Termin …" — the one rendering of the suggestion, for all three
|
||||
* moments that ask it.
|
||||
*
|
||||
* The RULE is not here. It lives in `src/lib/encounters/suggest-window.ts` and
|
||||
* is resolved server-side, so a document arriving in the vault, a lab panel
|
||||
* committed from an extraction and a panel typed by hand cannot answer the same
|
||||
* question differently. What this component owns is only how the three verdicts
|
||||
* look, and it owns that once:
|
||||
*
|
||||
* one candidate → pre-selected, visibly, with one undo;
|
||||
* two or more → a picker with NOTHING pre-selected;
|
||||
* none → nothing at all. No empty picker, no "create a visit?" nudge.
|
||||
*
|
||||
* The middle branch is the point. Pre-selecting the first of two is the failure
|
||||
* that teaches a person to stop reading suggestions, and the component cannot
|
||||
* do it: the "many" verdict carries no pre-selection and this file never picks
|
||||
* one.
|
||||
*
|
||||
* It never blocks a save — every caller treats the value as optional — and it
|
||||
* is never behind an AI call: the match is a date-window query over the
|
||||
* person's own rows and resolves on an account with no provider configured.
|
||||
*
|
||||
* Controlled. The caller owns where the chosen id goes: straight into a PATCH
|
||||
* on a document, or into the body of a lab write that has not happened yet.
|
||||
*/
|
||||
import { CalendarClock, Check, Undo2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounterSuggestion } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
|
||||
import { encounterKindText } from "./encounter-labels";
|
||||
|
||||
export function EncounterSuggestionField({
|
||||
anchor,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
slot = "encounter-suggestion",
|
||||
}: {
|
||||
/** The date to resolve around, ISO-8601. `null` means there is nothing to ask. */
|
||||
anchor: string | null;
|
||||
/** The visit currently chosen here, or null. */
|
||||
value: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** `data-slot` prefix, so a caller's tests can address its own instance. */
|
||||
slot?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const suggestion = useEncounterSuggestion(anchor, !disabled);
|
||||
|
||||
if (disabled) return null;
|
||||
const result = suggestion.data;
|
||||
if (!result || result.kind === "none") return null;
|
||||
|
||||
const label = (candidate: EncounterSuggestion) =>
|
||||
`${
|
||||
candidate.practitionerName ??
|
||||
encounterKindText(t, candidate.kind as EncounterKind)
|
||||
} · ${format.date(candidate.occurredAt)}`;
|
||||
|
||||
const candidates =
|
||||
result.kind === "one" ? [result.encounter] : result.encounters;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
data-slot={slot}
|
||||
data-verdict={result.kind}
|
||||
// The count is on the element so a test can prove the rule reached the
|
||||
// surface rather than inferring it from how many buttons rendered.
|
||||
data-candidates={candidates.length}
|
||||
>
|
||||
{/* A content sentence, not meta (§3): it is the offer itself. */}
|
||||
<p className="text-foreground flex items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
{t(
|
||||
result.kind === "one"
|
||||
? "encounters.suggestion.single"
|
||||
: "encounters.suggestion.choose",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{candidates.map((candidate) => {
|
||||
const chosen = value === candidate.id;
|
||||
return (
|
||||
<Button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
variant={chosen ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
aria-pressed={chosen}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onChange(chosen ? null : candidate.id)}
|
||||
>
|
||||
{result.kind === "one" ? (
|
||||
<Check
|
||||
className={cn("size-4", chosen ? "opacity-100" : "opacity-0")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{label(candidate)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{value !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot={`${slot}-undo`}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<Undo2 className="size-4" aria-hidden />
|
||||
{t("common.undo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The practice picker on the visit form.
|
||||
*
|
||||
* A combobox rather than a select, because the address book grows past the
|
||||
* length a dropdown reads well at and a person types the first three letters
|
||||
* of a practice name faster than they scroll to it. The search runs in SQL over
|
||||
* the two plaintext columns — that is why they are plaintext.
|
||||
*
|
||||
* "Add new" opens a nested sheet rather than navigating away. A person filling
|
||||
* a half-finished visit form who is sent to another page to add a doctor comes
|
||||
* back to an empty form, so the affordance that looks helpful is the one that
|
||||
* loses their work.
|
||||
*
|
||||
* The field is OPTIONAL and says so: a person can log "the emergency room on
|
||||
* the 3rd" before they know whose name was on the door.
|
||||
*/
|
||||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { usePractitioners, type Practitioner } from "@/hooks/use-practitioners";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PractitionerSheet } from "@/components/practitioners/practitioner-sheet";
|
||||
|
||||
export function PractitionerCombobox({
|
||||
value,
|
||||
onChange,
|
||||
/** The already-resolved entry, so the closed trigger names it without a read. */
|
||||
selected,
|
||||
disabled,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (next: Practitioner | null) => void;
|
||||
selected: Practitioner | null;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
// Bumped on each open so the nested sheet remounts empty rather than showing
|
||||
// whatever the last attempt left in it.
|
||||
const [session, setSession] = useState(0);
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
|
||||
const list = usePractitioners(debounced, open);
|
||||
const rows = list.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-slot="encounter-practitioner-trigger"
|
||||
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
selected ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selected?.name ?? t("encounters.form.practitionerNone")}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className="size-4 shrink-0 opacity-50"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
|
||||
<Input
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={t("encounters.form.practitionerSearch")}
|
||||
aria-label={t("encounters.form.practitionerSearch")}
|
||||
data-slot="encounter-practitioner-search"
|
||||
/>
|
||||
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{list.isPending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-3 text-sm">
|
||||
{t("encounters.form.practitionerNoMatch")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
data-slot="encounter-practitioner-option"
|
||||
onClick={() => {
|
||||
onChange(row);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
row.id === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate">
|
||||
{row.name}
|
||||
</span>
|
||||
{row.practice ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{row.practice}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 min-h-11 w-full"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setAdding(true);
|
||||
setSession((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.form.practitionerAdd")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selected ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0"
|
||||
aria-label={t("encounters.form.practitionerClear")}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Nested, so the half-filled visit form behind it stays mounted. */}
|
||||
<PractitionerSheet
|
||||
key={session}
|
||||
open={adding}
|
||||
onOpenChange={setAdding}
|
||||
practitioner={null}
|
||||
onSaved={(created) => onChange(created)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One visit, as a row in the list.
|
||||
*
|
||||
* The date, the practice and the reason are what the person wrote, so all three
|
||||
* are `text-foreground`; the counts and the relative distance are meta and
|
||||
* muted. Status is carried by the SECTION rather than repeated on every card —
|
||||
* a card in the upcoming list is planned by construction, and a badge saying so
|
||||
* on every row is noise that hides the two rows where the status is a surprise.
|
||||
* A cancelled or missed visit is the exception and says so, because those are
|
||||
* the ones a person is scanning for.
|
||||
*/
|
||||
import {
|
||||
CalendarClock,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Encounter } from "@/hooks/use-encounters";
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
import { encounterKindText, encounterStatusText } from "./encounter-labels";
|
||||
|
||||
/** The two statuses worth calling out on a row; the rest the section implies. */
|
||||
function isNoteworthy(status: EncounterStatus): boolean {
|
||||
return status === "CANCELLED" || status === "NO_SHOW";
|
||||
}
|
||||
|
||||
export function VisitCard({
|
||||
encounter,
|
||||
onOpen,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
onOpen: (encounter: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
const counts = [
|
||||
{
|
||||
icon: FileText,
|
||||
n: encounter.links?.documents.length ?? 0,
|
||||
label: t("encounters.card.documentCount"),
|
||||
},
|
||||
{
|
||||
icon: FlaskConical,
|
||||
n: encounter.links?.labResults.length ?? 0,
|
||||
label: t("encounters.card.labCount"),
|
||||
},
|
||||
{
|
||||
icon: Stethoscope,
|
||||
n: encounter.links?.conditions.length ?? 0,
|
||||
label: t("encounters.card.conditionCount"),
|
||||
},
|
||||
].filter((entry) => entry.n > 0);
|
||||
|
||||
// The practice, or the kind when there is none. A visit with neither is not
|
||||
// nameless — the kind always resolves, because it defaults to one.
|
||||
const heading =
|
||||
encounter.practitioner?.name ??
|
||||
encounterKindText(t, encounter.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="visit-card"
|
||||
data-encounter-id={encounter.id}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{heading}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{format.dateTime(encounter.occurredAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{encounterKindText(t, encounter.kind as EncounterKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{encounter.reason ? (
|
||||
<p className="text-foreground line-clamp-1 text-sm">
|
||||
{encounter.reason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ||
|
||||
counts.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{encounterStatusText(t, encounter.status as EncounterStatus)}
|
||||
</span>
|
||||
) : null}
|
||||
{counts.map(({ icon: Icon, n, label }) => (
|
||||
<span
|
||||
key={label}
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums"
|
||||
aria-label={`${n} ${label}`}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Termine" — the visits half of the checkups page.
|
||||
*
|
||||
* Two lists rather than one, and the split is the server's: upcoming reads
|
||||
* soonest-first, past reads newest-first, and the API resolves both so the
|
||||
* ordering lives in one place. A client that split and re-sorted one array
|
||||
* would be the second place it lives.
|
||||
*
|
||||
* No new nav entry and no new route. `/checkups` already owns exactly the right
|
||||
* sentence — when do I have to do what, where — and is deliberately not module
|
||||
* gated, which is also why a visit carries no module key of its own.
|
||||
*/
|
||||
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EncounterSheet } from "./encounter-sheet";
|
||||
import { VisitCard } from "./visit-card";
|
||||
|
||||
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const list = useEncounters(enabled);
|
||||
|
||||
// `null` closed, `"new"` create, a row edits it.
|
||||
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
|
||||
const [session, setSession] = useState(0);
|
||||
const openSheet = (target: Encounter | "new") => {
|
||||
setEditing(target);
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const upcoming = list.data?.upcoming ?? [];
|
||||
const past = list.data?.past ?? [];
|
||||
const empty = upcoming.length === 0 && past.length === 0;
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="visits-add"
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="visits-section-title" className="space-y-4">
|
||||
<PageHeader
|
||||
titleId="visits-section-title"
|
||||
title={t("encounters.sectionTitle")}
|
||||
description={t("encounters.sectionDescription")}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/checkups/practitioners">
|
||||
<ContactRound className="size-4" aria-hidden />
|
||||
{t("practitioners.linkLabel")}
|
||||
</Link>
|
||||
</Button>
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : empty ? (
|
||||
<EmptyState
|
||||
icon={<CalendarClock className="size-6" aria-hidden />}
|
||||
title={t("encounters.emptyTitle")}
|
||||
description={t("encounters.emptyDescription")}
|
||||
action={addButton ?? undefined}
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{upcoming.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="visits-upcoming">
|
||||
<SectionHeading
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.upcomingHeading")}
|
||||
/>
|
||||
{upcoming.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{past.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="visits-past">
|
||||
<SectionHeading
|
||||
icon={History}
|
||||
title={t("encounters.pastHeading")}
|
||||
/>
|
||||
{past.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* `key` changes on every open, so the sheet remounts with a fresh draft
|
||||
instead of copying props into state from an effect. It stays mounted
|
||||
while closing, which is what keeps the exit animation. */}
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
encounter={editing === "new" ? null : editing}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Termine dazu" — the visits filed against one condition episode.
|
||||
*
|
||||
* **From the episode side only, and that is a decision rather than an
|
||||
* omission.** An episode spans weeks and a visit is a point, so matching from
|
||||
* the VISIT side would produce false positives at a rate that teaches a person
|
||||
* to ignore every suggestion in the product. Here the person already has the
|
||||
* condition in front of them and is the one who knows; the card offers an
|
||||
* action instead of a guess.
|
||||
*
|
||||
* Modelled on the episode's documents card, which does exactly this for the
|
||||
* vault. Renders nothing when the illness module is off — a gated surface never
|
||||
* leaks, and an episode page is unreachable in that state anyway.
|
||||
*/
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [session, setSession] = useState(0);
|
||||
|
||||
const moduleEnabled = user?.modules?.illness === true;
|
||||
// The filter runs in SQL: the two arms of the response are separately
|
||||
// bounded, so narrowing here would page wrongly on a long history.
|
||||
const list = useEncounters(moduleEnabled, { episodeId });
|
||||
|
||||
if (!moduleEnabled) return null;
|
||||
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card data-slot="episode-visits-card">
|
||||
<CardContent className="space-y-4">
|
||||
<TileHeader
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.episodeCard.title")}
|
||||
right={
|
||||
visits.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{visits.length}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : visits.length === 0 ? (
|
||||
// A quiet one-line absence, not a teaching empty state: the person
|
||||
// came here about the condition, not to be taught about visits.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("encounters.episodeCard.emptyLine")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{visits.map((visit) => (
|
||||
<li key={visit.id}>
|
||||
<ListRow asChild>
|
||||
<Link
|
||||
href="/checkups"
|
||||
data-encounter-id={visit.id}
|
||||
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<CalendarClock
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(visit.occurredAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</ListRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="episode-visits-add"
|
||||
onClick={() => {
|
||||
setSheetOpen(true);
|
||||
setSession((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.episodeCard.add")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
encounter={null}
|
||||
// Pre-linked, so the person never has to find this condition again
|
||||
// in a picker they already navigated away from.
|
||||
episodeId={episodeId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -156,6 +157,12 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
nothing when the documents module is off for this account. */}
|
||||
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
|
||||
suggests an episode: an episode spans weeks and a visit is a point,
|
||||
so the reverse match would be wrong often enough to train the person
|
||||
to ignore every suggestion in the product. */}
|
||||
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{episode ? (
|
||||
<>
|
||||
<LogDaySheet
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* `<EcgDetail>` — one recording, addressed by id.
|
||||
*
|
||||
* The non-diagnostic framing is the load-bearing part and it is unchanged by
|
||||
* the move to a route: the result shown is the DEVICE's, attributed to the
|
||||
* device, and a non-normal device result adds the "discuss with a clinician"
|
||||
* note while a normal one does not.
|
||||
*
|
||||
* What did change: the component reads by id rather than being handed a row
|
||||
* from the list, so a cold deep link works — the positive control below is
|
||||
* that a render with only an id in hand still paints the trace.
|
||||
*/
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { EcgDetail } = await import("../ecg-detail");
|
||||
type EcgDetailResponse = import("../ecg-detail").EcgDetailResponse;
|
||||
|
||||
const DETAIL: EcgDetailResponse = {
|
||||
recordedAt: "2026-06-01T09:15:00.000Z",
|
||||
durationSeconds: 30,
|
||||
samplingFrequency: 300,
|
||||
averageHeartRate: 72,
|
||||
lead: null,
|
||||
classification: "IRREGULAR",
|
||||
source: "WITHINGS",
|
||||
samples: [0, 10, -5, 40, -20, 5, 0],
|
||||
decimated: true,
|
||||
};
|
||||
|
||||
function renderDetail(detail: Partial<EcgDetailResponse> = {}) {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { ...DETAIL, ...detail },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail recordingId="ecg_1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<EcgDetail> — non-diagnostic framing", () => {
|
||||
it("attributes the result to the recording device and draws the waveform", () => {
|
||||
const html = renderDetail();
|
||||
expect(html).toContain('data-slot="ecg-detail"');
|
||||
expect(html).toContain('data-slot="ecg-result"');
|
||||
// Load-bearing: the verdict is the device's, attributed to the device.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
expect(html).toContain('data-slot="ecg-trace"');
|
||||
});
|
||||
|
||||
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
|
||||
const html = renderDetail();
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
expect(html).toContain("discuss this recording with a clinician");
|
||||
});
|
||||
|
||||
it("adds the clinician note for an INCONCLUSIVE result", () => {
|
||||
const html = renderDetail({ classification: "INCONCLUSIVE" });
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
});
|
||||
|
||||
it("does NOT add the clinician note for a normal (not-detected) result", () => {
|
||||
const html = renderDetail({ classification: "NOT_DETECTED" });
|
||||
expect(html).not.toContain('data-slot="ecg-clinician-note"');
|
||||
// The device attribution is still there.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
});
|
||||
|
||||
it("says a failed read failed instead of painting an empty recording", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail recordingId="ecg_1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="ecg-detail-error"');
|
||||
expect(html).not.toContain('data-slot="ecg-trace"');
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,19 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.28.50 — `<EcgSection>` / `<EcgDetail>` unit tests.
|
||||
* `<EcgSection>` — the list of recorded strips.
|
||||
*
|
||||
* The load-bearing behaviour under test:
|
||||
* - data-availability gating: the section un-mounts entirely when the
|
||||
* user has no recordings or while the payload is in flight;
|
||||
* - the NON-DIAGNOSTIC framing (mirrors `RhythmEventsCard`): the device's
|
||||
* result is shown attributed to the device, a permanent disclaimer
|
||||
* states HealthLog does not interpret / diagnose, and any non-normal
|
||||
* device result adds the "discuss with a clinician" note — while a
|
||||
* normal result does NOT.
|
||||
* - data-availability gating: the section un-mounts entirely when the user
|
||||
* has no recordings or while the payload is in flight;
|
||||
* - the reading cap: a device that records every morning accumulates
|
||||
* hundreds of strips, and the list paints the most recent handful;
|
||||
* - the device's verdict rides the timestamp as a tag, and a row that has
|
||||
* a waveform opens the recording's own address.
|
||||
*
|
||||
* `useAuth` + TanStack Query are mocked and the assertions run through SSR
|
||||
* (the suite's node environment has no DOM), exactly like the sibling
|
||||
* `rhythm-events-card` test.
|
||||
* `rhythm-events-card` test. `<EcgDetail>` has its own spec.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -29,20 +28,8 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { EcgSection, EcgDetail } = await import("../ecg-section");
|
||||
|
||||
interface EcgRecordingListItem {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
sampleCount: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
|
||||
source: string;
|
||||
hasWaveform: boolean;
|
||||
}
|
||||
const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section");
|
||||
type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem;
|
||||
|
||||
const IRREGULAR_REC: EcgRecordingListItem = {
|
||||
id: "ecg_1",
|
||||
@@ -63,44 +50,21 @@ const NORMAL_REC: EcgRecordingListItem = {
|
||||
classification: "NOT_DETECTED",
|
||||
};
|
||||
|
||||
const DETAIL = {
|
||||
recordedAt: "2026-06-01T09:15:00.000Z",
|
||||
durationSeconds: 30,
|
||||
samplingFrequency: 300,
|
||||
averageHeartRate: 72,
|
||||
lead: null,
|
||||
classification: "IRREGULAR" as const,
|
||||
source: "WITHINGS",
|
||||
samples: [0, 10, -5, 40, -20, 5, 0],
|
||||
decimated: true,
|
||||
};
|
||||
|
||||
function renderSection(
|
||||
data:
|
||||
{ recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined,
|
||||
props: { limit?: number } = {},
|
||||
) {
|
||||
useQueryMock.mockReturnValue({ data, isLoading: false });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgSection />
|
||||
<EcgSection {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderDetail(
|
||||
recording: EcgRecordingListItem,
|
||||
resultLabel: string | null,
|
||||
) {
|
||||
useQueryMock.mockReturnValue({ data: DETAIL, isLoading: false });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail
|
||||
recording={recording}
|
||||
resultLabel={resultLabel}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
function countRows(html: string): number {
|
||||
return (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
}
|
||||
|
||||
describe("<EcgSection>", () => {
|
||||
@@ -119,65 +83,95 @@ describe("<EcgSection>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the recording list with a device-result label per row", () => {
|
||||
it("renders one row per recording with the device result as a tag", () => {
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC, NORMAL_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="ecg-card"');
|
||||
expect(html).toContain('data-slot="ecg-list"');
|
||||
const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
expect(rows).toBe(2);
|
||||
expect(countRows(html)).toBe(2);
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
expect(html).toContain("No signs of atrial fibrillation");
|
||||
// The verdict sits beside the timestamp inside the row's own header
|
||||
// line, not on a line of its own beneath it.
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*data-slot="ecg-row-result"[^>]*class="[^"]*rounded-full/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the permanent non-diagnostic disclaimer on the list", () => {
|
||||
const eightRecordings = () =>
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
...NORMAL_REC,
|
||||
id: `ecg_${index}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(),
|
||||
}));
|
||||
|
||||
it("caps the OVERVIEW teaser at the most recent handful", () => {
|
||||
// The teaser passes `limit`. Eight strips in, five out — and the five are
|
||||
// the ones the route handed back first, which it orders `recordedAt desc`.
|
||||
const recordings = eightRecordings();
|
||||
const html = renderSection(
|
||||
{ recordings, hasRecordings: true },
|
||||
{ limit: ECG_OVERVIEW_LIMIT },
|
||||
);
|
||||
|
||||
expect(ECG_OVERVIEW_LIMIT).toBe(5);
|
||||
expect(countRows(html)).toBe(ECG_OVERVIEW_LIMIT);
|
||||
for (const kept of recordings.slice(0, ECG_OVERVIEW_LIMIT)) {
|
||||
expect(html, `${kept.id} should be listed`).toContain(
|
||||
`/insights/ecg/${kept.id}`,
|
||||
);
|
||||
}
|
||||
for (const dropped of recordings.slice(ECG_OVERVIEW_LIMIT)) {
|
||||
expect(html, `${dropped.id} is past the teaser cap`).not.toContain(
|
||||
`/insights/ecg/${dropped.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders every recording when NO limit is given (the full page)", () => {
|
||||
// The dedicated `/insights/ecg` page passes no `limit`, so an account with
|
||||
// many strips reaches all of them — the cap is a teaser affordance, never
|
||||
// a wall on the full surface. Eight seeded, eight rendered, every one
|
||||
// reachable by its own address.
|
||||
const recordings = eightRecordings();
|
||||
const html = renderSection({ recordings, hasRecordings: true });
|
||||
|
||||
expect(countRows(html)).toBe(8);
|
||||
for (const rec of recordings) {
|
||||
expect(html, `${rec.id} must be reachable on the full page`).toContain(
|
||||
`/insights/ecg/${rec.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the recording's own address when it has a waveform", () => {
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).toContain(
|
||||
"HealthLog does not read or interpret ECG recordings",
|
||||
);
|
||||
expect(html).toContain("does not provide a diagnosis");
|
||||
expect(html).toContain('href="/insights/ecg/ecg_1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<EcgDetail> — non-diagnostic framing", () => {
|
||||
it("attributes the result to the recording device and draws the waveform", () => {
|
||||
const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected");
|
||||
expect(html).toContain('data-slot="ecg-detail"');
|
||||
expect(html).toContain('data-slot="ecg-result"');
|
||||
// Load-bearing: the verdict is the device's, attributed to the device.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
// The waveform strip is rendered.
|
||||
expect(html).toContain('data-slot="ecg-trace"');
|
||||
// And the permanent disclaimer is present here too.
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
it("leaves a waveform-less recording unlinked", () => {
|
||||
const html = renderSection({
|
||||
recordings: [{ ...IRREGULAR_REC, hasWaveform: false }],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(countRows(html)).toBe(1);
|
||||
expect(html).not.toContain('href="/insights/ecg/ecg_1"');
|
||||
});
|
||||
|
||||
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
|
||||
const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected");
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
expect(html).toContain("discuss this recording with a clinician");
|
||||
});
|
||||
|
||||
it("adds the clinician note for an INCONCLUSIVE result", () => {
|
||||
const html = renderDetail(
|
||||
{ ...IRREGULAR_REC, classification: "INCONCLUSIVE" },
|
||||
"Inconclusive recording",
|
||||
);
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
});
|
||||
|
||||
it("does NOT add the clinician note for a normal (not-detected) result", () => {
|
||||
const html = renderDetail(NORMAL_REC, "No signs of atrial fibrillation");
|
||||
expect(html).not.toContain('data-slot="ecg-clinician-note"');
|
||||
// But the disclaimer + device attribution are still present.
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
it("carries no intro paragraph and no disclaimer block", () => {
|
||||
// Both were removed: the page heading already says what the surface is,
|
||||
// and the device attribution on the detail page is where the "this is
|
||||
// the device's result" statement belongs.
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).not.toContain("Single-lead ECG strips");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import { InsightSectionCard } from "@/components/insights/insight-section-card";
|
||||
import {
|
||||
ecgResultLabel,
|
||||
isNonNormalEcg,
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
|
||||
/**
|
||||
* One ECG recording: the trace, the metadata, and the DEVICE's own result.
|
||||
*
|
||||
* NON-DIAGNOSTIC: the result shown is the recording device's, attributed to
|
||||
* the device ("Recorded result: …, as reported by the recording device").
|
||||
* HealthLog reads nothing off the curve. A non-normal device result adds the
|
||||
* "discuss with a clinician" note. Plain React text children throughout.
|
||||
*
|
||||
* The component owns its own read, addressed by id, so the detail page needs
|
||||
* nothing from the list it was opened from — a deep link works cold.
|
||||
*/
|
||||
|
||||
export interface EcgDetailResponse {
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: EcgClassification;
|
||||
source: string;
|
||||
samples: number[];
|
||||
decimated: boolean;
|
||||
}
|
||||
|
||||
export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<EcgDetailResponse>(
|
||||
`/api/insights/ecg/${recordingId}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(t("insights.ecg.loadError"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
// A failed read must never read as "no data" (UI-STANDARDS §6).
|
||||
return (
|
||||
<p
|
||||
data-slot="ecg-detail-error"
|
||||
role="alert"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("insights.ecg.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div
|
||||
data-slot="ecg-waveform-skeleton"
|
||||
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const resultLabel = ecgResultLabel(data.classification, t);
|
||||
const leadLabel = data.lead ?? t("insights.ecg.meta.leadSingle");
|
||||
const durationLabel =
|
||||
data.durationSeconds != null
|
||||
? t("insights.ecg.meta.durationValue", {
|
||||
seconds: Math.round(data.durationSeconds),
|
||||
})
|
||||
: t("insights.ecg.meta.unknown");
|
||||
|
||||
return (
|
||||
<InsightSectionCard slot="ecg-detail" className="border-border">
|
||||
{/* The DEVICE's result, attributed to the device. This is the ONLY
|
||||
verdict shown — HealthLog produces none. */}
|
||||
{resultLabel && (
|
||||
<p
|
||||
data-slot="ecg-result"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.resultAttribution", { result: resultLabel })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<EcgWaveform
|
||||
samples={data.samples}
|
||||
recordedAt={data.recordedAt}
|
||||
durationSeconds={data.durationSeconds}
|
||||
averageHeartRate={data.averageHeartRate}
|
||||
resultLabel={resultLabel}
|
||||
/>
|
||||
|
||||
<dl
|
||||
data-slot="ecg-meta"
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
|
||||
>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.recorded")}
|
||||
value={fmt.dateTime(new Date(data.recordedAt))}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.duration")}
|
||||
value={durationLabel}
|
||||
/>
|
||||
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.averageHeartRate")}
|
||||
value={
|
||||
data.averageHeartRate != null
|
||||
? t("insights.ecg.meta.bpmValue", { bpm: data.averageHeartRate })
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.samplingRate")}
|
||||
value={
|
||||
data.samplingFrequency > 0
|
||||
? t("insights.ecg.meta.hzValue", { hz: data.samplingFrequency })
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{/* "Discuss with a clinician" — only on a non-normal device result. */}
|
||||
{isNonNormalEcg(data.classification) && (
|
||||
<p
|
||||
data-slot="ecg-clinician-note"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.clinicianNote")}
|
||||
</p>
|
||||
)}
|
||||
</InsightSectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ChevronLeft, HeartPulse, Info } from "lucide-react";
|
||||
import { Activity, ChevronRight, HeartPulse } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -10,35 +10,41 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import {
|
||||
ecgResultLabel,
|
||||
ECG_OVERVIEW_LIMIT,
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
// Re-exported so the tests (and any teaser call site) can keep importing the
|
||||
// overview cap from the component they are exercising; the value itself lives
|
||||
// in the React-free lib module.
|
||||
export { ECG_OVERVIEW_LIMIT };
|
||||
|
||||
/**
|
||||
* v1.28.50 — ECG recording surface (list → detail).
|
||||
* v1.28.50 — ECG recording list.
|
||||
*
|
||||
* Renders the user's ECG recordings synced from a single-lead device
|
||||
* (Withings ScanWatch today): a list of strips, each opening a detail view
|
||||
* with the `EcgWaveform` trace, the recording metadata, and the DEVICE's
|
||||
* own classification.
|
||||
* The user's ECG recordings synced from a single-lead device (Withings
|
||||
* ScanWatch today): one row per strip, each opening `/insights/ecg/<id>`
|
||||
* with the trace, the metadata and the DEVICE's own classification.
|
||||
*
|
||||
* NON-DIAGNOSTIC (load-bearing, do not soften — mirrors `RhythmEventsCard`):
|
||||
* the surface shows only the waveform (raw data), the metadata, and the
|
||||
* recording device's own result, attributed unambiguously to the device
|
||||
* ("Recorded result: …, as reported by the recording device"). HealthLog
|
||||
* generates NO interpretation of the waveform — no measured intervals, no
|
||||
* beat/P/QRS/T annotation, no risk score, no verdict of its own. A
|
||||
* permanent, non-dismissible disclaimer states this, and any non-normal
|
||||
* device result additionally carries a "discuss with a clinician" note.
|
||||
* All copy renders as plain React text children (no markdown library — the
|
||||
* standing XSS rule).
|
||||
* NON-DIAGNOSTIC: the surface shows the waveform (raw data), the metadata
|
||||
* and the recording device's own result, attributed to the device on the
|
||||
* detail page. HealthLog generates NO interpretation of the waveform — no
|
||||
* measured intervals, no beat/P/QRS/T annotation, no risk score, no verdict
|
||||
* of its own. All copy renders as plain React text children (no markdown
|
||||
* library — the standing XSS rule).
|
||||
*
|
||||
* The list is capped: a device that records every morning accumulates
|
||||
* hundreds of strips, and a page that paints all of them is a scroll, not a
|
||||
* history. The most recent handful is what the surface is for.
|
||||
*
|
||||
* Data-availability-gated: the section un-mounts entirely (`return null`)
|
||||
* when the user has no recordings — never an empty / alarming card.
|
||||
*/
|
||||
|
||||
type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
|
||||
|
||||
interface EcgRecordingListItem {
|
||||
export interface EcgRecordingListItem {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
@@ -56,30 +62,6 @@ interface EcgListResponse {
|
||||
hasRecordings: boolean;
|
||||
}
|
||||
|
||||
interface EcgDetailResponse {
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: EcgClassification;
|
||||
source: string;
|
||||
samples: number[];
|
||||
decimated: boolean;
|
||||
}
|
||||
|
||||
/** The device verdict, surfaced verbatim and attributed to the device. */
|
||||
const RESULT_LABEL_KEYS: Record<string, string> = {
|
||||
IRREGULAR: "insights.ecg.result.irregular",
|
||||
NOT_DETECTED: "insights.ecg.result.notDetected",
|
||||
INCONCLUSIVE: "insights.ecg.result.inconclusive",
|
||||
};
|
||||
|
||||
/** Non-normal device results trigger the "discuss with a clinician" note. */
|
||||
function isNonNormal(classification: EcgClassification): boolean {
|
||||
return classification === "IRREGULAR" || classification === "INCONCLUSIVE";
|
||||
}
|
||||
|
||||
interface EcgSectionProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
@@ -87,21 +69,27 @@ interface EcgSectionProps {
|
||||
* v1.30 — suppress the internal `<SectionHeading>` when the section is
|
||||
* hosted on the routed `/insights/ecg` sub-page, whose `<SubPageShell>`
|
||||
* already renders the page `<h1>`. Defaults to `false` so the overview
|
||||
* teaser keeps its own heading unchanged. Purely presentational — the
|
||||
* non-diagnostic disclaimer + waveform logic are untouched.
|
||||
* teaser keeps its own heading unchanged.
|
||||
*/
|
||||
hideHeading?: boolean;
|
||||
/**
|
||||
* Cap the number of strips shown. The overview teaser passes
|
||||
* `ECG_OVERVIEW_LIMIT`; the dedicated `/insights/ecg` page passes nothing,
|
||||
* so it renders the full list. Omitted = no cap: a full surface must never
|
||||
* silently hide a recording behind a limit with no way to reach it.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function EcgSection({
|
||||
enabled = true,
|
||||
className,
|
||||
hideHeading = false,
|
||||
limit,
|
||||
}: EcgSectionProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgList(),
|
||||
@@ -118,16 +106,11 @@ export function EcgSection({
|
||||
// Data-availability gate — never paint an empty card.
|
||||
if (isLoading || !data || !data.hasRecordings) return null;
|
||||
|
||||
const selected =
|
||||
selectedId != null
|
||||
? (data.recordings.find((r) => r.id === selectedId) ?? null)
|
||||
: null;
|
||||
|
||||
function resultLabel(classification: EcgClassification): string | null {
|
||||
if (!classification) return null;
|
||||
const key = RESULT_LABEL_KEYS[classification];
|
||||
return key ? t(key) : null;
|
||||
}
|
||||
// The route hands them back most-recent-first. A `limit` (the overview
|
||||
// teaser) keeps the newest few; no limit (the dedicated page) shows them
|
||||
// all, so an older strip is never made unreachable.
|
||||
const recordings =
|
||||
limit != null ? data.recordings.slice(0, limit) : data.recordings;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -143,230 +126,67 @@ export function EcgSection({
|
||||
/>
|
||||
)}
|
||||
<InsightSectionCard slot="ecg-card" className="border-border">
|
||||
{selected ? (
|
||||
<EcgDetail
|
||||
recording={selected}
|
||||
resultLabel={resultLabel(selected.classification)}
|
||||
onBack={() => setSelectedId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.ecg.sectionIntro")}
|
||||
</p>
|
||||
<ol data-slot="ecg-list" className="space-y-3">
|
||||
{data.recordings.map((rec) => {
|
||||
const label = resultLabel(rec.classification);
|
||||
const rowInner = (
|
||||
<>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<HeartPulse className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
</p>
|
||||
{label && (
|
||||
<p
|
||||
data-slot="ecg-row-result"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
{rec.averageHeartRate != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.ecg.meta.bpmValue", {
|
||||
bpm: rec.averageHeartRate,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={rec.id}
|
||||
data-slot="ecg-row"
|
||||
data-classification={rec.classification ?? "NONE"}
|
||||
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{rec.hasWaveform ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(rec.id)}
|
||||
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
|
||||
<ol data-slot="ecg-list" className="space-y-3">
|
||||
{recordings.map((rec) => {
|
||||
const label = ecgResultLabel(rec.classification, t);
|
||||
const rowInner = (
|
||||
<>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<HeartPulse className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
{/* The device's verdict rides the timestamp as a tag
|
||||
rather than claiming a line of its own — it qualifies
|
||||
the recording, it is not a second fact about it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
</p>
|
||||
{label && (
|
||||
<span
|
||||
data-slot="ecg-row-result"
|
||||
className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Load-bearing regulatory disclaimer — permanent,
|
||||
non-dismissible. Plain React text children (no markdown). */}
|
||||
<EcgDisclaimer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{rec.averageHeartRate != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.ecg.meta.bpmValue", {
|
||||
bpm: rec.averageHeartRate,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={rec.id}
|
||||
data-slot="ecg-row"
|
||||
data-classification={rec.classification ?? "NONE"}
|
||||
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{rec.hasWaveform ? (
|
||||
<Link
|
||||
href={`/insights/ecg/${rec.id}`}
|
||||
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
|
||||
>
|
||||
{rowInner}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground mt-1.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail view: waveform + metadata + device-attributed result +
|
||||
* permanent disclaimer + clinician note on a non-normal device result.
|
||||
* Exported so the non-diagnostic framing can be unit-tested directly.
|
||||
*/
|
||||
export function EcgDetail({
|
||||
recording,
|
||||
resultLabel,
|
||||
onBack,
|
||||
}: {
|
||||
recording: EcgRecordingListItem;
|
||||
resultLabel: string | null;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: detail, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recording.id, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<EcgDetailResponse>(
|
||||
`/api/insights/ecg/${recording.id}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(t("insights.ecg.loadError"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const nonNormal = isNonNormal(recording.classification);
|
||||
const leadLabel = recording.lead ?? t("insights.ecg.meta.leadSingle");
|
||||
const durationLabel =
|
||||
recording.durationSeconds != null
|
||||
? t("insights.ecg.meta.durationValue", {
|
||||
seconds: Math.round(recording.durationSeconds),
|
||||
})
|
||||
: t("insights.ecg.meta.unknown");
|
||||
|
||||
return (
|
||||
<div data-slot="ecg-detail" className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground hover:text-foreground -ml-1 flex items-center gap-1 text-sm transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t("insights.ecg.backToList")}
|
||||
</button>
|
||||
|
||||
{/* The DEVICE's result, attributed to the device. This is the ONLY
|
||||
verdict shown — HealthLog produces none. */}
|
||||
{resultLabel && (
|
||||
<p
|
||||
data-slot="ecg-result"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.resultAttribution", { result: resultLabel })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading || !detail ? (
|
||||
<div
|
||||
data-slot="ecg-waveform-skeleton"
|
||||
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<EcgWaveform
|
||||
samples={detail.samples}
|
||||
recordedAt={detail.recordedAt}
|
||||
durationSeconds={detail.durationSeconds}
|
||||
averageHeartRate={detail.averageHeartRate}
|
||||
resultLabel={resultLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<dl
|
||||
data-slot="ecg-meta"
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
|
||||
>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.recorded")}
|
||||
value={fmt.dateTime(new Date(recording.recordedAt))}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.duration")}
|
||||
value={durationLabel}
|
||||
/>
|
||||
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.averageHeartRate")}
|
||||
value={
|
||||
recording.averageHeartRate != null
|
||||
? t("insights.ecg.meta.bpmValue", {
|
||||
bpm: recording.averageHeartRate,
|
||||
})
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.samplingRate")}
|
||||
value={
|
||||
recording.samplingFrequency > 0
|
||||
? t("insights.ecg.meta.hzValue", {
|
||||
hz: recording.samplingFrequency,
|
||||
})
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{/* "Discuss with a clinician" — only on a non-normal device result. */}
|
||||
{nonNormal && (
|
||||
<p
|
||||
data-slot="ecg-clinician-note"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.clinicianNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<EcgDisclaimer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The permanent, non-dismissible non-diagnostic disclaimer. States that the
|
||||
* displayed result is the device's and that HealthLog does not read,
|
||||
* interpret, or diagnose ECG recordings.
|
||||
*/
|
||||
function EcgDisclaimer() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="ecg-disclaimer"
|
||||
role="note"
|
||||
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
|
||||
>
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.ecg.disclaimer")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* What reaches the screen, not what the strings say.
|
||||
*
|
||||
* The copy guard reads the bundles; this reads the render. Between them they
|
||||
* cover the two halves of the same rule — that the two readings of a day are
|
||||
* shown side by side and never merged, that the forecast never appears without
|
||||
* its band and its count, and that the deviation is measured against the band
|
||||
* rather than against the midpoint.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
|
||||
|
||||
import { MoodPrognosisView } from "../mood-prognosis-card";
|
||||
|
||||
function render(reading: PrognosisReading): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodPrognosisView reading={reading} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function present(over: Partial<PrognosisReading> = {}): PrognosisReading {
|
||||
return {
|
||||
present: true,
|
||||
date: "2026-08-07",
|
||||
predicted: 5.4,
|
||||
ciLow: 4.1,
|
||||
ciHigh: 6.8,
|
||||
n: 42,
|
||||
modelVersion: "mood-ridge-1",
|
||||
computedAt: "2026-08-08T02:35:00.000Z",
|
||||
ageDays: 1,
|
||||
current: true,
|
||||
provisional: false,
|
||||
stage: "regular",
|
||||
entries: 64,
|
||||
seasonalUnlocked: false,
|
||||
selfAssessment: 6,
|
||||
deviation: "within",
|
||||
deviationAmount: 0,
|
||||
contributions: [
|
||||
{ feature: "dimension:a2", contribution: 0.9 },
|
||||
{ feature: "linked:steps", contribution: -0.4 },
|
||||
],
|
||||
...over,
|
||||
} as PrognosisReading;
|
||||
}
|
||||
|
||||
describe("<MoodPrognosisView>", () => {
|
||||
it("shows both readings, and shows the person's own one first", () => {
|
||||
const html = render(present());
|
||||
expect(html).toContain('data-slot="mood-prognosis-self"');
|
||||
expect(html).toContain('data-slot="mood-prognosis-expected"');
|
||||
// The self block precedes the expected block in the document, which is
|
||||
// what "the self-assessment leads" means to a screen reader.
|
||||
expect(html.indexOf('data-slot="mood-prognosis-self"')).toBeLessThan(
|
||||
html.indexOf('data-slot="mood-prognosis-expected"'),
|
||||
);
|
||||
// Both values are on screen as themselves. A merged single figure is the
|
||||
// failure this whole feature is shaped to avoid.
|
||||
expect(html).toContain("6.0");
|
||||
expect(html).toContain("5.4");
|
||||
});
|
||||
|
||||
it("never shows the value without its band and its count", () => {
|
||||
const html = render(present());
|
||||
expect(html).toContain("4.1");
|
||||
expect(html).toContain("6.8");
|
||||
expect(html).toContain("42");
|
||||
});
|
||||
|
||||
it("phrases the forecast as a counterfactual", () => {
|
||||
const html = render(present());
|
||||
expect(html).toContain("would have been expected");
|
||||
expect(html).not.toContain("your mood is");
|
||||
});
|
||||
|
||||
it("measures the deviation against the band, not the midpoint", () => {
|
||||
// 6.0 is above the 5.4 midpoint and inside the 4.1-6.8 band, so it is not
|
||||
// a finding. A midpoint comparison would have called it one.
|
||||
expect(render(present())).toContain("inside that range");
|
||||
|
||||
const above = render(
|
||||
present({ selfAssessment: 8, deviation: "above", deviationAmount: 1.2 }),
|
||||
);
|
||||
expect(above).toContain("1.2 points above");
|
||||
});
|
||||
|
||||
it("labels a provisional forecast as provisional", () => {
|
||||
expect(render(present({ provisional: true }))).toContain(
|
||||
'data-slot="mood-prognosis-provisional"',
|
||||
);
|
||||
expect(render(present())).not.toContain(
|
||||
'data-slot="mood-prognosis-provisional"',
|
||||
);
|
||||
});
|
||||
|
||||
it("says which day it is about when that day is not today", () => {
|
||||
const stale = render(present({ current: false, ageDays: 4 }));
|
||||
expect(stale).toContain('data-slot="mood-prognosis-age"');
|
||||
// Rendered through the account's own date-order preference rather than as
|
||||
// the raw day key — under this render's `en` locale and the default AUTO
|
||||
// preference that is month-first. The assertion is on the formatted form
|
||||
// precisely because a raw `2026-08-07` on the screen would pass a looser
|
||||
// one.
|
||||
expect(stale).toContain("08/07/2026");
|
||||
expect(stale).not.toContain("2026-08-07");
|
||||
});
|
||||
|
||||
it("names what the model weighted, in the reader's own words", () => {
|
||||
const html = render(present());
|
||||
// The dimension's own label, the one the slider carries.
|
||||
expect(html).toContain("Stress");
|
||||
expect(html).toContain("Steps");
|
||||
expect(html).toContain("counted towards a higher expected value");
|
||||
expect(html).toContain("counted towards a lower expected value");
|
||||
});
|
||||
|
||||
it("skips a feature key this build no longer knows", () => {
|
||||
const html = render(
|
||||
present({
|
||||
contributions: [
|
||||
{ feature: "retired:whatever", contribution: 2 },
|
||||
{ feature: "dimension:a3", contribution: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).not.toContain("retired:whatever");
|
||||
expect(html).toContain('data-slot="mood-prognosis-explanation"');
|
||||
});
|
||||
|
||||
it("renders nothing at all below the first rung", () => {
|
||||
expect(
|
||||
render({
|
||||
present: false,
|
||||
reason: "no-output-yet",
|
||||
entries: 4,
|
||||
nextThreshold: 15,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("explains the learning phase with the count and the next step", () => {
|
||||
const html = render({
|
||||
present: false,
|
||||
reason: "learning-phase",
|
||||
entries: 22,
|
||||
nextThreshold: 30,
|
||||
});
|
||||
expect(html).toContain("22");
|
||||
expect(html).toContain("30");
|
||||
// Absence, not an error and not a zero.
|
||||
expect(html).not.toContain('data-slot="mood-prognosis-expected"');
|
||||
});
|
||||
|
||||
it("refuses honestly when there are days but no pattern", () => {
|
||||
const html = render({
|
||||
present: false,
|
||||
reason: "no-pattern",
|
||||
entries: 140,
|
||||
nextThreshold: null,
|
||||
});
|
||||
expect(html).toContain("140");
|
||||
expect(html).not.toContain('data-slot="mood-prognosis-statement"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The day's two readings, side by side, with the person's own one leading.
|
||||
*
|
||||
* The copy rules this component exists to hold, stated once so nobody has to
|
||||
* infer them from the strings:
|
||||
*
|
||||
* * **No sentence names a cause.** Not "overtime lowered your mood", not
|
||||
* "poor sleep is why today was hard". What the data supports is "on your
|
||||
* own past days, a value around X would have been expected", and that is
|
||||
* what it says. The ban is a test, not a habit:
|
||||
* `src/__tests__/copy-matches-behaviour-guard.test.ts` reads the six
|
||||
* bundles and fails on a causal construction in any of them.
|
||||
* * **The forecast is a counterfactual and the sentence says so.** Never
|
||||
* "your mood is 5.4" — always "a value around 5.4 would have been
|
||||
* expected". The difference is the whole distinction between the two
|
||||
* readings.
|
||||
* * **The count is on the same surface as the number**, not in a tooltip. A
|
||||
* forecast built from thirty days and one built from three hundred look
|
||||
* identical otherwise, and a reader who cannot see which one they are
|
||||
* looking at cannot weigh it.
|
||||
* * **The deviation is measured against the band, never against the
|
||||
* midpoint.** Inside the band is "as expected"; outside it is the finding.
|
||||
*
|
||||
* The explanation is generated from the contributions the job stored for that
|
||||
* day, so it names what the model actually weighted rather than a fresh
|
||||
* calculation against a model that may since have been re-fitted.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Compass } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
|
||||
|
||||
import { prognosisFeatureLabel } from "./prognosis-feature-label";
|
||||
|
||||
/** How many weighted features the explanation names. */
|
||||
const EXPLAINED_FEATURES = 3;
|
||||
|
||||
function formatValue(value: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function MoodPrognosisCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.moodPrognosis(),
|
||||
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="min-h-24 w-full rounded-xl" />;
|
||||
}
|
||||
if (!data) return null;
|
||||
return <MoodPrognosisView reading={data} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The card itself, over a resolved reading.
|
||||
*
|
||||
* Split from the query so the copy rules above can be asserted against real
|
||||
* renders rather than against the strings alone: "the two values are never
|
||||
* merged" is a property of what reaches the screen, and a bundle check cannot
|
||||
* see it.
|
||||
*/
|
||||
export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const data = reading;
|
||||
// The day key is a plain calendar date and is rendered as one, through the
|
||||
// account's own date-order preference and pinned to UTC — the same treatment
|
||||
// every other day key in the app gets, so this card cannot show a different
|
||||
// day from the list beside it.
|
||||
const day = data.present ? formatDate(data.date, dateFormat, locale) : "";
|
||||
|
||||
// Below the first rung there is nothing to say and the surface says nothing
|
||||
// rather than showing an empty frame with an apology in it.
|
||||
if (!data.present && data.reason === "no-output-yet") return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="mood-prognosis" className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader icon={Compass} title={t("insights.mood.prognosis.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!data.present ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="mood-prognosis-absent"
|
||||
>
|
||||
{data.reason === "learning-phase"
|
||||
? t("insights.mood.prognosis.learning", {
|
||||
entries: String(data.entries),
|
||||
threshold: String(data.nextThreshold ?? 0),
|
||||
})
|
||||
: t("insights.mood.prognosis.noPattern", {
|
||||
entries: String(data.entries),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-x-8 gap-y-3">
|
||||
{/* The self-assessment leads: it is the larger number, it comes
|
||||
first, and it is the only one of the two the person gave. */}
|
||||
<div data-slot="mood-prognosis-self">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.selfLabel")}
|
||||
</p>
|
||||
<p className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{data.selfAssessment === null
|
||||
? t("mood.dayContext.notRecorded")
|
||||
: formatValue(data.selfAssessment, locale)}
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="mood-prognosis-expected">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.expectedLabel")}
|
||||
</p>
|
||||
<p className="text-foreground text-xl font-medium tabular-nums">
|
||||
{formatValue(data.predicted, locale)}
|
||||
</p>
|
||||
{/* The band and the count ride with the value, in the block a
|
||||
screen reader reaches in the same breath. */}
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{data.ciLow !== null && data.ciHigh !== null
|
||||
? t("insights.mood.prognosis.band", {
|
||||
low: formatValue(data.ciLow, locale),
|
||||
high: formatValue(data.ciHigh, locale),
|
||||
})
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-foreground text-sm"
|
||||
data-slot="mood-prognosis-statement"
|
||||
>
|
||||
{t("insights.mood.prognosis.statement", {
|
||||
value: formatValue(data.predicted, locale),
|
||||
date: day,
|
||||
n: String(data.n),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{data.provisional ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="mood-prognosis-provisional"
|
||||
>
|
||||
{t("insights.mood.prognosis.provisional")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!data.current ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="mood-prognosis-age"
|
||||
>
|
||||
{t("insights.mood.prognosis.notToday", { date: day })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.deviation !== null && data.selfAssessment !== null ? (
|
||||
<p
|
||||
className="text-foreground text-sm"
|
||||
data-slot="mood-prognosis-deviation"
|
||||
>
|
||||
{data.deviation === "within"
|
||||
? t("insights.mood.prognosis.deviationWithin")
|
||||
: data.deviation === "above"
|
||||
? t("insights.mood.prognosis.deviationAbove", {
|
||||
amount: formatValue(data.deviationAmount ?? 0, locale),
|
||||
})
|
||||
: t("insights.mood.prognosis.deviationBelow", {
|
||||
amount: formatValue(data.deviationAmount ?? 0, locale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<PrognosisExplanation contributions={data.contributions} />
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.caveat")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PrognosisExplanation({
|
||||
contributions,
|
||||
}: {
|
||||
contributions: Array<{ feature: string; contribution: number }>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const named = contributions
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: prognosisFeatureLabel(item.feature, t),
|
||||
}))
|
||||
.filter(
|
||||
(item): item is typeof item & { label: string } =>
|
||||
item.label !== null && item.contribution !== 0,
|
||||
)
|
||||
.slice(0, EXPLAINED_FEATURES);
|
||||
|
||||
if (named.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1" data-slot="mood-prognosis-explanation">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.weightedTitle")}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{named.map((item) => (
|
||||
<li
|
||||
key={item.feature}
|
||||
className="text-foreground text-sm"
|
||||
data-slot="mood-prognosis-weighted-item"
|
||||
>
|
||||
{item.contribution > 0
|
||||
? t("insights.mood.prognosis.weightedUp", { label: item.label })
|
||||
: t("insights.mood.prognosis.weightedDown", {
|
||||
label: item.label,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* A stored feature key, in the reader's own language.
|
||||
*
|
||||
* The keys in `MoodPrediction.features` are stable identifiers, not copy. This
|
||||
* turns one into a label, and it does so out of the strings the capture
|
||||
* surfaces already use: a person reads "Workload" here because that is what
|
||||
* the slider they moved was called, not because a second wording was invented
|
||||
* for the explanation.
|
||||
*
|
||||
* Every key it cannot place returns `null`, and that path is real rather than
|
||||
* defensive: a row written by an older `modelVersion` can name a feature this
|
||||
* build has dropped, and skipping it is better than rendering an identifier at
|
||||
* somebody.
|
||||
*/
|
||||
import { parseFeatureKey } from "@/lib/analytics/mood-prognosis/feature-keys";
|
||||
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
|
||||
import { getMoodDimension } from "@/lib/mood/dimensions";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* The five linked figures, resolved through literal keys.
|
||||
*
|
||||
* A switch rather than a computed key so the call-site coverage guard can see
|
||||
* every string this file can ask for. The keys are the capture sheet's own
|
||||
* (`mood.dayContext.linked*`), which is the point: the label a figure carries
|
||||
* in the explanation is the label it carries where it was read.
|
||||
*/
|
||||
function linkedLabel(metric: string, t: Translate): string | null {
|
||||
switch (metric) {
|
||||
case "sleepAsleep":
|
||||
return t("mood.dayContext.linkedSleep");
|
||||
case "steps":
|
||||
return t("mood.dayContext.linkedSteps");
|
||||
case "activeEnergy":
|
||||
return t("mood.dayContext.linkedActiveEnergy");
|
||||
case "restingHeartRate":
|
||||
return t("mood.dayContext.linkedRestingHeartRate");
|
||||
case "heartRateVariability":
|
||||
return t("mood.dayContext.linkedHrv");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function prognosisFeatureLabel(
|
||||
key: string,
|
||||
t: Translate,
|
||||
): string | null {
|
||||
const parsed = parseFeatureKey(key);
|
||||
if (parsed === null) return null;
|
||||
|
||||
if (parsed.family === "dimension") {
|
||||
const dimension = getMoodDimension(parsed.dimension);
|
||||
return dimension ? t(dimension.labelKey) : null;
|
||||
}
|
||||
if (parsed.family === "linked") return linkedLabel(parsed.metric, t);
|
||||
|
||||
const field = t(`mood.context.field.${parsed.field}`);
|
||||
if (parsed.value === null) return field;
|
||||
// "Working day: longer than usual" — the field and the value the person
|
||||
// picked, joined by a pattern rather than by a hard-coded colon, because the
|
||||
// punctuation between them is a locale's business.
|
||||
return t("insights.mood.prognosis.featureValue", {
|
||||
field,
|
||||
value: t(contextValueLabelKey(parsed.field, parsed.value)),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -25,6 +25,7 @@ import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -118,6 +119,21 @@ export function LabForm({
|
||||
const [value, setValue] = useState("");
|
||||
const [valueText, setValueText] = useState("");
|
||||
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
|
||||
/**
|
||||
* The visit this reading came out of, when the suggestion offered one.
|
||||
*
|
||||
* Deliberately NOT cleared by the keep-open reset below: a real lab report
|
||||
* shares one draw date and one visit across every analyte, so the next row
|
||||
* off the same report keeps both rather than asking again.
|
||||
*/
|
||||
const [visitId, setVisitId] = useState<string | null>(null);
|
||||
// The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion
|
||||
// asks the server in ISO-8601, so it is converted once here rather than in
|
||||
// the shared field, which must not know about this form's input format.
|
||||
const takenAtAnchor = useMemo(() => {
|
||||
const parsed = new Date(takenAt);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}, [takenAt]);
|
||||
const [note, setNote] = useState("");
|
||||
// The window the paper report printed for THIS reading. Optional and folded
|
||||
// away by default: most entries are typed against a catalog marker and never
|
||||
@@ -224,6 +240,7 @@ export function LabForm({
|
||||
: {}),
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
...(visitId ? { encounterId: visitId } : {}),
|
||||
});
|
||||
toast.success(
|
||||
t("labs.form.savedToast"),
|
||||
@@ -449,6 +466,15 @@ export function LabForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anchored on the draw date, not on now: backdating a reading moves
|
||||
the offer with it. */}
|
||||
<EncounterSuggestionField
|
||||
anchor={takenAtAnchor}
|
||||
value={visitId}
|
||||
onChange={setVisitId}
|
||||
slot="lab-form-encounter-suggestion"
|
||||
/>
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { OcrRowEditor } from "./ocr-row-editor";
|
||||
@@ -128,6 +129,12 @@ export function OcrReviewDialog({
|
||||
// filed into the Documents vault and cross-linked to the committed labs. Text
|
||||
// mode keeps the image on-device, so nothing is retained there.
|
||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||
/**
|
||||
* The visit this panel came out of, if the review step offered one and the
|
||||
* person took the offer. Never blocks the save and never pre-fills anything
|
||||
* else: the panel commits with it unset exactly as before.
|
||||
*/
|
||||
const [visitId, setVisitId] = useState<string | null>(null);
|
||||
|
||||
// Text mode OCR's the image in the browser then POSTs the text; vision mode
|
||||
// uploads the image. Both resolve with the same proposed-rows DTO.
|
||||
@@ -143,10 +150,24 @@ export function OcrReviewDialog({
|
||||
[rows],
|
||||
);
|
||||
|
||||
/**
|
||||
* The draw date the whole panel hangs off — the earliest `takenAt` the scan
|
||||
* read. A panel is one draw; asking the suggestion about each row separately
|
||||
* would ask the same question a dozen times and could answer it a dozen ways.
|
||||
*/
|
||||
const panelAnchor = useMemo(() => {
|
||||
const dates = rows
|
||||
.map((row) => row.takenAt)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort();
|
||||
return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null;
|
||||
}, [rows]);
|
||||
|
||||
function reset() {
|
||||
setStage("pick");
|
||||
setRows([]);
|
||||
setPickedFile(null);
|
||||
setVisitId(null);
|
||||
extract.reset();
|
||||
commit.reset();
|
||||
}
|
||||
@@ -183,7 +204,7 @@ export function OcrReviewDialog({
|
||||
return;
|
||||
}
|
||||
commit.mutate(
|
||||
{ rows: payload, file: pickedFile },
|
||||
{ rows: payload, file: pickedFile, encounterId: visitId },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
// The commit re-checks each confirmed row against what is already
|
||||
@@ -307,6 +328,16 @@ export function OcrReviewDialog({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("labs.ocr.foundCount", { count: rows.length })}
|
||||
</p>
|
||||
|
||||
{/* Anchored on the draw date the scan read, so the offer is about
|
||||
the panel rather than about today. */}
|
||||
<EncounterSuggestionField
|
||||
anchor={panelAnchor}
|
||||
value={visitId}
|
||||
onChange={setVisitId}
|
||||
slot="labs-ocr-encounter-suggestion"
|
||||
/>
|
||||
|
||||
{rows.map((row) => (
|
||||
<OcrRowEditor
|
||||
key={row.key}
|
||||
|
||||
@@ -143,6 +143,12 @@ export interface OcrCommitInput {
|
||||
* image stays on-device.
|
||||
*/
|
||||
file?: File | null;
|
||||
/**
|
||||
* The visit this panel came out of, when the review step offered one and the
|
||||
* person took the offer. Optional always — a commit with none named succeeds
|
||||
* unchanged.
|
||||
*/
|
||||
encounterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,13 +172,14 @@ async function fileScanToVault(file: File): Promise<string | undefined> {
|
||||
export function useOcrCommit() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
|
||||
mutationFn: async ({ rows, file }: OcrCommitInput) => {
|
||||
mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => {
|
||||
const documentId = file
|
||||
? await fileScanToVault(file).catch(() => undefined)
|
||||
: undefined;
|
||||
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
|
||||
rows,
|
||||
...(documentId ? { documentId } : {}),
|
||||
...(encounterId ? { encounterId } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: (_result, { file }) => {
|
||||
|
||||
@@ -132,6 +132,7 @@ const OWNER_READ: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
username: "grandma",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
@@ -148,6 +149,7 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed profile",
|
||||
fullName: null,
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -56,6 +56,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "grandma",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
@@ -108,6 +109,25 @@ describe("<SharedRecordBanner>", () => {
|
||||
expect(html).toContain("grandma");
|
||||
});
|
||||
|
||||
it("names the owner in full when they have a full name, to a read-only delegate", () => {
|
||||
// v1.37.2 — the banner is the account-access view that renders server-side,
|
||||
// and this OWNER is READ (`canWrite: false`). The full name leads over the
|
||||
// greeting name: a delegate about to log a reading sees whose record it is
|
||||
// as who they are, not by a nickname. A deliberate disclosure to everyone
|
||||
// shared with, read-only included (maintainer decision 2026-08-08).
|
||||
const named = {
|
||||
...OWNER,
|
||||
fullName: "Test Full Name",
|
||||
displayName: "Margarethe",
|
||||
};
|
||||
const html = render(
|
||||
{ accounts: [named], active: named, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
expect(html).toContain('data-slot="shared-record-banner"');
|
||||
expect(html).toContain("Test Full Name");
|
||||
});
|
||||
|
||||
it("says the access is read-only, and carries the way out", () => {
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: OWNER, canSwitch: true },
|
||||
|
||||
@@ -101,11 +101,15 @@ export function AccountSwitcherMenuItems() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Two lines, not three. The access level had a line of its
|
||||
own here and it never fit — a 240 px menu truncated
|
||||
"Kann verwalten" as often as it showed it, and the entry
|
||||
still said which kind of record this is on the line below.
|
||||
The level is what the banner states once you are inside,
|
||||
where it governs what the page lets you do. It stays on
|
||||
the row as `data-access-level` for the browser journeys. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{accessLabel(presentation.access, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{recordKindLabel(presentation.recordKind, t)}
|
||||
</span>
|
||||
@@ -122,16 +126,6 @@ export function AccountSwitcherMenuItems() {
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(
|
||||
access: ReturnType<typeof resolveRecordPresentation>["access"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (access === "manage") return t("recordSharing.row.canManage");
|
||||
return access === "view-and-add"
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
|
||||
@@ -20,7 +20,14 @@ import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -38,6 +45,15 @@ import {
|
||||
|
||||
const SUMMARY_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* How far ahead an appointment has to be before this card stops mentioning it.
|
||||
*
|
||||
* A dashboard summary answers "what is coming", and two weeks is the horizon a
|
||||
* person plans in. Further out and the line is noise that pushes the checkups
|
||||
* it sits under off the card.
|
||||
*/
|
||||
const APPOINTMENT_HORIZON_DAYS = 14;
|
||||
|
||||
function resolveLabel(
|
||||
reminder: MeasurementReminder,
|
||||
t: (key: string) => string,
|
||||
@@ -54,6 +70,7 @@ export function VorsorgeDashboardCard() {
|
||||
// Issue #490 — the same profile zone the checkups page derives its due
|
||||
// phrase in, so the two screens still agree when the device sits elsewhere.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const format = useFormatters();
|
||||
const router = useRouter();
|
||||
// The same answer `/checkups` gives the same action. Satisfying a reminder
|
||||
// is not one of the verbs a delegation admits at any level
|
||||
@@ -72,6 +89,26 @@ export function VorsorgeDashboardCard() {
|
||||
const showLoading = !reminders && !isError;
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
/**
|
||||
* The next appointment, read from the VISIT list and never from the reminder
|
||||
* table.
|
||||
*
|
||||
* An ENCOUNTER-origin reminder is excluded from every preventive-care read,
|
||||
* which is correct — an appointment is not a checkup, and relaxing that
|
||||
* filter here would put one in the due list. But a notification about
|
||||
* something invisible in the app is worse than no notification, so the
|
||||
* appointment appears where a person looks, sourced from the record it
|
||||
* actually lives in. A diff in this file that touches `origin` is the wrong
|
||||
* fix.
|
||||
*/
|
||||
const visits = useEncounters();
|
||||
const nextAppointment = (visits.data?.upcoming ?? []).find(
|
||||
(visit) =>
|
||||
visit.status === "PLANNED" &&
|
||||
new Date(visit.occurredAt).getTime() - now <=
|
||||
APPOINTMENT_HORIZON_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
|
||||
const [captureFooterEl, setCaptureFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
@@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Visually distinct from a due checkup: a quiet line under the block
|
||||
rather than a fourth row with an action, because there is nothing
|
||||
to do about an appointment except keep it. */}
|
||||
{nextAppointment ? (
|
||||
<Link
|
||||
href="/checkups"
|
||||
data-slot="vorsorge-dashboard-next-visit"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<CalendarClock className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("encounters.dashboard.nextVisit", {
|
||||
what:
|
||||
nextAppointment.practitioner?.name ??
|
||||
encounterKindText(t, nextAppointment.kind as EncounterKind),
|
||||
when: format.date(nextAppointment.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<ResponsiveSheet
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -50,6 +51,7 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -238,6 +240,19 @@ export function VorsorgeSection({
|
||||
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
|
||||
null,
|
||||
);
|
||||
/**
|
||||
* The due checkup a visit is being filed against, if any.
|
||||
*
|
||||
* The visit sheet does the rest: saving it as DONE satisfies this reminder
|
||||
* through the server's forward-only, idempotent path, so filing a visit AND
|
||||
* ticking the box is one satisfaction rather than an error.
|
||||
*/
|
||||
const [visitForReminder, setVisitForReminder] = useState<string | null>(null);
|
||||
const [visitSession, setVisitSession] = useState(0);
|
||||
const openVisitFor = (reminder: MeasurementReminder) => {
|
||||
setVisitForReminder(reminder.id);
|
||||
setVisitSession((n) => n + 1);
|
||||
};
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
// The value-capture sheet: holds the reminder whose measurement we are
|
||||
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
|
||||
@@ -738,6 +753,7 @@ export function VorsorgeSection({
|
||||
now={now}
|
||||
view={prefs.view}
|
||||
onPrimaryAction={() => onPrimaryAction(reminder)}
|
||||
onFileVisit={() => openVisitFor(reminder)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
busy={
|
||||
@@ -749,6 +765,18 @@ export function VorsorgeSection({
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<EncounterSheet
|
||||
key={visitSession}
|
||||
open={visitForReminder !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVisitForReminder(null);
|
||||
}}
|
||||
encounter={null}
|
||||
reminderId={visitForReminder}
|
||||
// The reminder list repaints from the encounter write's own
|
||||
// invalidation; nothing extra to do here.
|
||||
onSaved={() => setVisitForReminder(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -779,6 +807,7 @@ function VorsorgeCard({
|
||||
now,
|
||||
view,
|
||||
onPrimaryAction,
|
||||
onFileVisit,
|
||||
onRemove,
|
||||
onEdit,
|
||||
busy,
|
||||
@@ -787,6 +816,8 @@ function VorsorgeCard({
|
||||
now: number;
|
||||
view: "cards" | "list";
|
||||
onPrimaryAction: () => void;
|
||||
/** Open the visit sheet with this checkup pre-filled, closing it on save. */
|
||||
onFileVisit: () => void;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
busy: boolean;
|
||||
@@ -912,6 +943,30 @@ function VorsorgeCard({
|
||||
// "do it now" wash; a button that changes colour by schedule state reads as
|
||||
// a different control). Due-ness is communicated by the discreet coloured
|
||||
// next-due text above.
|
||||
/**
|
||||
* "Termin eintragen" — the second way to close a checkup.
|
||||
*
|
||||
* Beside "erledigt" rather than instead of it, because the two answer
|
||||
* different questions: one says the thing happened, the other says WHERE it
|
||||
* happened and files the record of it. Saving the visit as DONE satisfies
|
||||
* this reminder through the same forward-only, idempotent primitive the
|
||||
* checkbox uses, so a person who does both gets one satisfaction and no
|
||||
* error.
|
||||
*/
|
||||
const fileVisitButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
data-slot="vorsorge-file-visit"
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{t("measurementReminders.fileVisit")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const primaryButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1003,6 +1058,24 @@ function VorsorgeCard({
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Icon-only in the dense list, labelled in the cards branch —
|
||||
the same action either way, never one branch offering what
|
||||
the other withholds. */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
data-slot="vorsorge-file-visit"
|
||||
aria-label={t("measurementReminders.fileVisit")}
|
||||
title={t("measurementReminders.fileVisit")}
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
{headerActions}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1129,7 +1202,10 @@ function VorsorgeCard({
|
||||
|
||||
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
|
||||
{primaryButton ? (
|
||||
<div className="mt-auto pt-0">{primaryButton}</div>
|
||||
<div className="mt-auto space-y-1.5 pt-0">
|
||||
{primaryButton}
|
||||
{fileVisitButton}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The Radix Dialog portals at runtime, so its body never materialises in
|
||||
// static markup. Collapse the primitives to plain wrappers (same trick as
|
||||
// the ResearchModeAcknowledgmentDialog suite) so the footer is reachable.
|
||||
// the dose-history dialog suite) so the footer is reachable.
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — Research-mode acknowledgment dialog tests.
|
||||
*
|
||||
* The dialog is rendered controlled by Settings (commit 3) and gates
|
||||
* the GLP-1 drug-level chart (commit 2). The contract is:
|
||||
*
|
||||
* 1. Multi-section MDR copy renders (what-is, what-isn't, why,
|
||||
* MDR boundary, citations).
|
||||
* 2. Acknowledge CTA fires a `POST /api/auth/me/research-mode` with
|
||||
* the exact server-supplied version string.
|
||||
* 3. Cancel CTA fires `onOpenChange(false)` without an API call.
|
||||
* 4. The stale-version error (server 400 with
|
||||
* `research-mode.version.stale`) surfaces a localised error toast
|
||||
* and keeps the dialog open.
|
||||
*
|
||||
* Setup mirrors the existing `coach-settings-sheet.test.tsx` pattern:
|
||||
* SSR via `renderToStaticMarkup` so the Radix Dialog primitives are
|
||||
* mocked to plain wrappers (Portals don't materialise under SSR), and
|
||||
* the TanStack Query mutation is captured so we can drive the
|
||||
* onSuccess / onError branches by hand.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const mutationCallbacks: {
|
||||
mutationFn: ((version: string) => Promise<unknown>) | null;
|
||||
onSuccess: ((data: unknown) => void) | null;
|
||||
onError: ((err: unknown) => void) | null;
|
||||
} = { mutationFn: null, onSuccess: null, onError: null };
|
||||
|
||||
const lastMutateCall: { args: unknown[] } = { args: [] };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: (opts: {
|
||||
mutationFn: (version: string) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown) => void;
|
||||
onError?: (err: unknown) => void;
|
||||
}) => {
|
||||
mutationCallbacks.mutationFn = opts.mutationFn;
|
||||
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
|
||||
mutationCallbacks.onError = opts.onError ?? null;
|
||||
return {
|
||||
mutate: (version: string) => {
|
||||
lastMutateCall.args = [version];
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.27 R4 RC2 — the dialog now mounts <ResponsiveSheet>. Its
|
||||
// internals (Sheet on `<md`, Dialog on `md+`) both portal at runtime;
|
||||
// we collapse the primitive to a plain wrapper so the body of the
|
||||
// dialog stays reachable in the static markup.
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-responsive-sheet-header">
|
||||
<h2 data-slot="mock-responsive-sheet-title">{title}</h2>
|
||||
{description ? (
|
||||
<p data-slot="mock-responsive-sheet-description">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-slot="mock-responsive-sheet-body">{children}</div>
|
||||
{footer ? (
|
||||
<div data-slot="mock-responsive-sheet-footer">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { ResearchModeAcknowledgmentDialog } from "../research-mode-acknowledgment-dialog";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mutationCallbacks.mutationFn = null;
|
||||
mutationCallbacks.onSuccess = null;
|
||||
mutationCallbacks.onError = null;
|
||||
lastMutateCall.args = [];
|
||||
vi.mocked(toast.success).mockClear();
|
||||
vi.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
describe("<ResearchModeAcknowledgmentDialog>", () => {
|
||||
it("renders nothing while `open === false`", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={false}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the title + the five MDR-copy sections while open", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Title + primary CTAs.
|
||||
expect(html).toContain("Enable Research Mode");
|
||||
expect(html).toContain("I understand — enable");
|
||||
expect(html).toContain("Cancel");
|
||||
|
||||
// Each of the five sections renders its own headline.
|
||||
expect(html).toContain("What this is");
|
||||
expect(html).toContain("What this isn't");
|
||||
expect(html).toContain("Why it's an estimate");
|
||||
expect(html).toContain("Regulatory boundary");
|
||||
expect(html).toContain("Sources");
|
||||
|
||||
// Cited regulatory references must appear verbatim in the
|
||||
// user-facing copy (project-voice + research §11 + §12.4).
|
||||
expect(html).toContain("2017/745");
|
||||
expect(html).toContain("MDCG 2021-24");
|
||||
// Peer-reviewed citation.
|
||||
expect(html).toContain("10.1002/psp4.13099");
|
||||
|
||||
// Version line surfaces the live server value.
|
||||
expect(html).toContain("Disclaimer version: 2026-05-14.1");
|
||||
});
|
||||
|
||||
it("acknowledge CTA invokes the mutation with the supplied version", async () => {
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// SSR markup can't fire DOM events; invoke the mutation directly
|
||||
// (the production component calls `mutation.mutate(version)` from
|
||||
// the click handler with `currentDisclaimerVersion`).
|
||||
expect(mutationCallbacks.mutationFn).not.toBeNull();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ data: { enabled: true } }),
|
||||
// The apiFetch wrapper reads the envelope via `text()`.
|
||||
text: async () => JSON.stringify({ data: { enabled: true } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await mutationCallbacks.mutationFn?.("2026-05-14.1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/auth/me/research-mode");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
acknowledged: true,
|
||||
version: "2026-05-14.1",
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("onSuccess fires the localised success toast", () => {
|
||||
const onAcknowledged = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
onAcknowledged={onAcknowledged}
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onSuccess?.({ enabled: true });
|
||||
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0][0]).toContain(
|
||||
"Research Mode enabled",
|
||||
);
|
||||
expect(onAcknowledged).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stale-version error surfaces the localised error toast and keeps the dialog open", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onError?.(new Error("research-mode.version.stale"));
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
|
||||
"disclaimer was updated",
|
||||
);
|
||||
// The error path never closes the dialog — the caller stays put
|
||||
// so the user can reload and re-read the new copy.
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generic error path surfaces the generic error toast", () => {
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onError?.(new Error("http-500"));
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
|
||||
"Could not enable Research Mode",
|
||||
);
|
||||
});
|
||||
|
||||
it("acknowledge CTA is disabled until the server-supplied version arrives", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The disabled flag stamps as `disabled=""` on a server-rendered button.
|
||||
// The acknowledge button carries `data-slot="research-mode-acknowledge"`.
|
||||
const acknowledgeFragment = html.match(
|
||||
/<button[^>]*data-slot="research-mode-acknowledge"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(acknowledgeFragment).toBeDefined();
|
||||
expect(acknowledgeFragment).toMatch(/\sdisabled(="|\s|>)/);
|
||||
|
||||
// The version line is hidden when null — guard against leaking
|
||||
// `version: null` text into the UI.
|
||||
expect(html).not.toContain("Disclaimer version:");
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Forschungsmodus aktivieren");
|
||||
expect(html).toContain("Verstanden — aktivieren");
|
||||
expect(html).toContain("Abbrechen");
|
||||
expect(html).toContain("2017/745");
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog.
|
||||
*
|
||||
* Opt-in gate in front of the GLP-1 drug-level chart. The chart paints
|
||||
* a unit-less Bateman PK curve over the user's logged intakes (research
|
||||
* §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU
|
||||
* MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4),
|
||||
* the chart never paints until the user has explicitly read and
|
||||
* acknowledged the disclaimer below. The acknowledgment record is
|
||||
* server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy
|
||||
* changes the user re-acknowledges before the chart unhides.
|
||||
*
|
||||
* The dialog is rendered controlled by the Settings toggle and the
|
||||
* "re-prompt" banner (commit 3 of this phase). It accepts:
|
||||
* - `open` / `onOpenChange` for full external control
|
||||
* - `currentDisclaimerVersion` — the server's authoritative version
|
||||
* string. Acknowledgment posts THIS value, not the imported
|
||||
* constant — server redeploys that bump the constant must be
|
||||
* reflected without a client reload (W19c-Backend phase report).
|
||||
* - `onAcknowledged()` — fires after a successful 200; the caller
|
||||
* re-fetches the research-mode state and renders the toggle in
|
||||
* the new state.
|
||||
*
|
||||
* Copy direction (project-voice, English, no AI/phase/wave mentions):
|
||||
* - "What this is" — single-paragraph framing: the chart is an
|
||||
* estimate from EMA-published population PK.
|
||||
* - "What this isn't" — explicit boundary: not a measurement, not
|
||||
* personalised, not medical advice.
|
||||
* - "Why it's an estimate" — population PK + IIV; per-patient
|
||||
* variation is large.
|
||||
* - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim.
|
||||
* - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099).
|
||||
*
|
||||
* Toast surface uses sonner (the project convention) so the user gets
|
||||
* a transient confirmation on either success or rejection.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface ResearchModeAcknowledgmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
/**
|
||||
* The version string from `GET /api/auth/me/research-mode`. The
|
||||
* server compares it byte-for-byte against the current constant; a
|
||||
* stale value forces a 400 from the API which we surface as an
|
||||
* error toast and keep the dialog open. Pass `null` while the
|
||||
* GET is in flight; the Acknowledge CTA stays disabled.
|
||||
*/
|
||||
currentDisclaimerVersion: string | null;
|
||||
/**
|
||||
* Fires after the POST returns 200. Caller invalidates the
|
||||
* `research-mode` query so the toggle reflects the new state.
|
||||
*/
|
||||
onAcknowledged?: () => void;
|
||||
}
|
||||
|
||||
export function ResearchModeAcknowledgmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentDisclaimerVersion,
|
||||
onAcknowledged,
|
||||
}: ResearchModeAcknowledgmentDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (version: string) => {
|
||||
// ApiError.message carries the envelope `error` string verbatim, so
|
||||
// the stale-version code below still matches on `err.message`.
|
||||
return (
|
||||
(await apiPost("/api/auth/me/research-mode", {
|
||||
acknowledged: true,
|
||||
version,
|
||||
})) ?? null
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setErrorMessage(null);
|
||||
toast.success(t("medications.researchMode.dialog.successToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
onAcknowledged?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// The 400 stale-version path is the only one we surface
|
||||
// explicitly — every other failure mode (401, 429, 500, network)
|
||||
// collapses to a generic error so the dialog stays open but the
|
||||
// user knows the acknowledgment didn't persist.
|
||||
const code = err instanceof Error ? err.message : "unknown";
|
||||
const isStale = code === "research-mode.version.stale";
|
||||
const message = isStale
|
||||
? t("medications.researchMode.dialog.staleVersionToast")
|
||||
: t("medications.researchMode.dialog.errorToast");
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleAcknowledge() {
|
||||
if (!currentDisclaimerVersion) return;
|
||||
mutation.mutate(currentDisclaimerVersion);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setErrorMessage(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
|
||||
{t("medications.researchMode.dialog.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("medications.researchMode.dialog.intro")}
|
||||
className="sm:max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={mutation.isPending}
|
||||
data-slot="research-mode-cancel"
|
||||
>
|
||||
{t("medications.researchMode.dialog.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAcknowledge}
|
||||
disabled={mutation.isPending || !currentDisclaimerVersion}
|
||||
data-slot="research-mode-acknowledge"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("medications.researchMode.dialog.acknowledgeCta")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="research-mode-acknowledgment-dialog"
|
||||
className="space-y-4 text-sm"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="research-mode-what-it-is"
|
||||
data-slot="research-mode-what-it-is"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-what-it-is"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whatItIsHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whatItIs")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-what-it-isnt"
|
||||
data-slot="research-mode-what-it-isnt"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-what-it-isnt"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whatItIsntHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whatItIsnt")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-why-estimate"
|
||||
data-slot="research-mode-why-estimate"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-why-estimate"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whyEstimateHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whyEstimate")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-mdr"
|
||||
data-slot="research-mode-mdr-boundary"
|
||||
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-mdr"
|
||||
className="text-foreground flex items-center gap-2 font-medium"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-warning h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
|
||||
</h3>
|
||||
<p className="text-foreground/85 mt-1">
|
||||
{t("medications.researchMode.dialog.mdrBoundary")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-citations"
|
||||
data-slot="research-mode-citations"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-citations"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.citationsHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.citations")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{currentDisclaimerVersion && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs italic"
|
||||
data-slot="research-mode-version"
|
||||
>
|
||||
{t("medications.researchMode.dialog.versionLine", {
|
||||
version: currentDisclaimerVersion,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,17 @@ export function MentalWellbeing() {
|
||||
description={t("mentalHealth.pageDescription")}
|
||||
/>
|
||||
|
||||
{/* What happens to the answers. The Modules hub used to carry this
|
||||
as the second sentence of the module's one-line description,
|
||||
which was the only place in the app that said it — so it moved
|
||||
to the screen where somebody is about to answer. */}
|
||||
<p
|
||||
data-slot="mental-health-privacy-note"
|
||||
className="text-muted-foreground -mt-3 text-xs"
|
||||
>
|
||||
{t("mentalHealth.privacyNote")}
|
||||
</p>
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
{historyError ? (
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The account's own address book of doctors and practices.
|
||||
*
|
||||
* **Record content, not configuration**, so it does not live in Settings: a
|
||||
* settings surface configures behaviour, and a list of one's own doctors
|
||||
* configures nothing. It sits beside the visits that name it.
|
||||
*
|
||||
* Deleting is a soft delete and the visits that referenced the entry keep it.
|
||||
* The confirmation says so in one sentence, because a person deleting a doctor
|
||||
* has every reason to fear their history goes with it.
|
||||
*/
|
||||
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
usePractitioners,
|
||||
type Practitioner,
|
||||
} from "@/hooks/use-practitioners";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
const list = usePractitioners(debounced, enabled);
|
||||
const { remove } = usePractitionerMutations();
|
||||
|
||||
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Practitioner | null>(null);
|
||||
// Bumped on every open so the sheet remounts with a fresh draft rather than
|
||||
// copying props into state from an effect.
|
||||
const [session, setSession] = useState(0);
|
||||
const openSheet = (target: Practitioner | "new") => {
|
||||
setEditing(target);
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const rows = list.data ?? [];
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="practitioner-add"
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("practitioners.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<PageHeader
|
||||
title={t("practitioners.title")}
|
||||
description={t("practitioners.description")}
|
||||
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={t("practitioners.searchPlaceholder")}
|
||||
aria-label={t("practitioners.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
data-slot="practitioner-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("practitioners.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ContactRound className="size-6" aria-hidden />}
|
||||
title={t(
|
||||
debounced
|
||||
? "practitioners.noMatchTitle"
|
||||
: "practitioners.emptyTitle",
|
||||
)}
|
||||
description={t(
|
||||
debounced
|
||||
? "practitioners.noMatchDescription"
|
||||
: "practitioners.emptyDescription",
|
||||
)}
|
||||
action={debounced ? undefined : (addButton ?? undefined)}
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2" data-slot="practitioner-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id}>
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{row.name}
|
||||
</p>
|
||||
{row.practice || row.specialty ? (
|
||||
<p className="text-foreground truncate text-sm">
|
||||
{[row.practice, row.specialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{row.location ? (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{row.location}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => openSheet(row)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<PractitionerSheet
|
||||
key={session}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
practitioner={editing === "new" ? null : editing}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("practitioners.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
{/* The one sentence that matters: the old visits stay. */}
|
||||
<AlertDialogDescription>
|
||||
{t("practitioners.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!deleting) return;
|
||||
const id = deleting.id;
|
||||
setDeleting(null);
|
||||
remove.mutate(id, {
|
||||
onError: () => toast.error(t("practitioners.deleteFailed")),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Add or correct one address-book entry.
|
||||
*
|
||||
* Six plaintext-ish fields and one encrypted note. `name` is the only required
|
||||
* one, for the same reason the visit only requires a date: a person typing a
|
||||
* practice into a form after a doctor's appointment should not be stopped by a
|
||||
* phone number they do not have to hand.
|
||||
*
|
||||
* Reachable two ways — from the address-book page and nested inside the visit
|
||||
* form's picker — so it owns no navigation of its own and reports what it
|
||||
* created back to whoever opened it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
type Practitioner,
|
||||
type PractitionerWriteBody,
|
||||
} from "@/hooks/use-practitioners";
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
specialty: string;
|
||||
practice: string;
|
||||
location: string;
|
||||
phone: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const EMPTY: Draft = {
|
||||
name: "",
|
||||
specialty: "",
|
||||
practice: "",
|
||||
location: "",
|
||||
phone: "",
|
||||
note: "",
|
||||
};
|
||||
|
||||
function draftFrom(row: Practitioner | null): Draft {
|
||||
if (!row) return EMPTY;
|
||||
return {
|
||||
name: row.name,
|
||||
specialty: row.specialty ?? "",
|
||||
practice: row.practice ?? "",
|
||||
location: row.location ?? "",
|
||||
phone: row.phone ?? "",
|
||||
note: row.note ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** An empty field clears the column rather than writing an empty string. */
|
||||
function orNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function PractitionerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
practitioner,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** `null` creates; a row edits it. */
|
||||
practitioner: Practitioner | null;
|
||||
onSaved?: (row: Practitioner) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update } = usePractitionerMutations();
|
||||
// Seeded once, at mount. The caller varies this component's `key` on every
|
||||
// open, so a second edit never shows the first one's values and a create
|
||||
// after an edit starts empty — without an effect that copies props into
|
||||
// state and briefly renders the stale pair.
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pending = create.isPending || update.isPending;
|
||||
const nameGiven = draft.name.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!nameGiven) return;
|
||||
const body: PractitionerWriteBody = {
|
||||
name: draft.name.trim(),
|
||||
specialty: orNull(draft.specialty),
|
||||
practice: orNull(draft.practice),
|
||||
location: orNull(draft.location),
|
||||
phone: orNull(draft.phone),
|
||||
note: orNull(draft.note),
|
||||
};
|
||||
try {
|
||||
const saved = practitioner
|
||||
? await update.mutateAsync({ id: practitioner.id, body })
|
||||
: await create.mutateAsync(body);
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("practitioners.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (key: keyof Draft) => ({
|
||||
value: draft[key],
|
||||
onChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => setDraft((prev) => ({ ...prev, [key]: event.target.value })),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(
|
||||
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
|
||||
)}
|
||||
description={t("practitioners.formDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!nameGiven || pending}
|
||||
onClick={() => void submit()}
|
||||
data-slot="practitioner-save"
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="practitioner-form">
|
||||
<FieldGroup htmlFor="practitioner-name" label={t("practitioners.name")}>
|
||||
<Input
|
||||
id="practitioner-name"
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
{...field("name")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-specialty"
|
||||
label={t("practitioners.specialty")}
|
||||
>
|
||||
<Input id="practitioner-specialty" {...field("specialty")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-practice"
|
||||
label={t("practitioners.practice")}
|
||||
>
|
||||
<Input id="practitioner-practice" {...field("practice")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-location"
|
||||
label={t("practitioners.location")}
|
||||
>
|
||||
<Input id="practitioner-location" {...field("location")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-phone"
|
||||
label={t("practitioners.phone")}
|
||||
>
|
||||
<Input id="practitioner-phone" type="tel" {...field("phone")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-note"
|
||||
label={t("practitioners.note")}
|
||||
hint={t("practitioners.noteHint")}
|
||||
>
|
||||
<Textarea id="practitioner-note" rows={3} {...field("note")} />
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,6 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
|
||||
*
|
||||
* The card lives next to the danger-zone delete and drives three
|
||||
* server interactions:
|
||||
*
|
||||
* GET /api/auth/me/research-mode on mount, via TanStack Query
|
||||
* POST /api/auth/me/research-mode via the acknowledgment dialog
|
||||
* DELETE /api/auth/me/research-mode directly when the user
|
||||
* toggles OFF
|
||||
*
|
||||
* The toggle ON path opens the dialog; only the dialog's CTA fires
|
||||
* the POST (so the user reads the disclaimer first). OFF fires
|
||||
* DELETE directly. A version-mismatch banner renders above the
|
||||
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
|
||||
* even when `enabled === true`.
|
||||
*
|
||||
* The dialog itself is covered by
|
||||
* `research-mode-acknowledgment-dialog.test.tsx`; we mock it down to a
|
||||
* marker here so the toggle behaviour is the only contract under
|
||||
* test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const queryResults: Record<string, unknown> = {};
|
||||
|
||||
function setQueryResult(keyJoined: string, data: unknown) {
|
||||
queryResults[keyJoined] = data;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = (queryKey as Array<string | number>).join("/");
|
||||
return {
|
||||
data: queryResults[key],
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The acknowledgment dialog is exercised in its own spec; mock the
|
||||
// import to a transparent marker so we can assert "the toggle ON path
|
||||
// renders the dialog with the right props" without re-running the
|
||||
// dialog tree.
|
||||
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: ({
|
||||
open,
|
||||
currentDisclaimerVersion,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentDisclaimerVersion: string | null;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-ack-dialog"
|
||||
data-open={open ? "true" : "false"}
|
||||
data-version={currentDisclaimerVersion ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
||||
});
|
||||
|
||||
describe("<AdvancedSection> → Research Mode card", () => {
|
||||
it("renders the section title + subtitle + toggle in OFF state", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-card"');
|
||||
expect(html).toContain("Research Mode");
|
||||
expect(html).toContain("Show the estimated drug-level chart");
|
||||
// OFF state status line.
|
||||
expect(html).toContain("Disabled. The drug-level chart is hidden.");
|
||||
// No re-prompt banner when versions already align (or the user has
|
||||
// never acknowledged in the first place).
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is unchecked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toBeDefined();
|
||||
expect(switchTag).not.toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the acknowledged-on status when enabled + versions aligned", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-05-14T08:30:00Z",
|
||||
acknowledgedVersion: "2026-05-14.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// Acknowledged-on copy carries the formatted date (the i18n
|
||||
// template uses `{date}`; the formatter renders the day).
|
||||
expect(html).toMatch(/Acknowledged on/);
|
||||
// Banner stays hidden when versions match.
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is checked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the re-prompt banner when versions diverge", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-04-01T00:00:00Z",
|
||||
acknowledgedVersion: "2026-04-01.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
|
||||
expect(html).toContain("Disclaimer updated");
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
|
||||
expect(html).toContain("Re-acknowledge disclaimer");
|
||||
// Status line reflects the stale state without claiming
|
||||
// "acknowledged on …" — that copy would mis-represent that the
|
||||
// chart is currently painting.
|
||||
expect(html).not.toMatch(/Acknowledged on/);
|
||||
expect(html).toContain(
|
||||
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
);
|
||||
// The toggle stays "on" — we surface the previous choice; the
|
||||
// banner explains why the chart isn't painting.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("forwards the current server-supplied version to the dialog", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-06-01.2",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// The dialog renders even when closed (its `open` prop is what
|
||||
// controls visibility); the data-version attribute lets us verify
|
||||
// the parent passes the live version string through.
|
||||
expect(html).toContain('data-slot="mock-ack-dialog"');
|
||||
expect(html).toMatch(/data-version="2026-06-01\.2"/);
|
||||
expect(html).toMatch(/data-open="false"/);
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />, "de");
|
||||
|
||||
expect(html).toContain("Forschungsmodus");
|
||||
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
|
||||
expect(html).toContain("Deaktiviert");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* A module row in the "What you track" hub gets ONE sentence, same as a card
|
||||
* header's description slot (design standards §3).
|
||||
*
|
||||
* Five of the eighteen rows had grown a second sentence — always the same
|
||||
* one, "Off by default", and in one case a third clause after it. The
|
||||
* neighbours sat at 30-75 characters and those five at 120-165, so the hub
|
||||
* read as two different lists stacked on each other. The switch beside the
|
||||
* row already says whether the module is on, so the sentence was restating
|
||||
* what the control shows.
|
||||
*
|
||||
* The single-sentence half of the rule is checked in every locale: it is a
|
||||
* structural claim, not a length one, and a translation that re-adds the
|
||||
* clause is the same defect. The character cap is checked in EN and DE, the
|
||||
* two the maintainer reads, matching `card-description-length.test.ts`.
|
||||
*/
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const MAX_CHARS = 120;
|
||||
|
||||
function bundle(locale: string): Record<string, { description?: string }> {
|
||||
const raw = readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8");
|
||||
return JSON.parse(raw).modules;
|
||||
}
|
||||
|
||||
describe("module hub descriptions", () => {
|
||||
it("names a description for every module in every locale", () => {
|
||||
// Positive control: without it, a renamed `modules` namespace would make
|
||||
// the two checks below iterate an empty set and report green.
|
||||
for (const locale of locales) {
|
||||
const modules = bundle(locale);
|
||||
const missing = MODULE_KEYS.filter(
|
||||
(key) => !modules[key]?.description?.trim(),
|
||||
);
|
||||
expect(missing, `${locale} is missing descriptions`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds each one to a single sentence, in every locale", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const locale of locales) {
|
||||
const modules = bundle(locale);
|
||||
for (const key of MODULE_KEYS) {
|
||||
const text = modules[key].description!;
|
||||
// Drop the closing terminator, then any sentence break left inside
|
||||
// is a second sentence. A semicolon splice counts — it is how the
|
||||
// longest of the five carried its third clause.
|
||||
const body = text.replace(/[.!?…]\s*$/, "");
|
||||
if (/[.!?;]\s/.test(body)) {
|
||||
offenders.push(`${locale}/${key}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds each one under the character guideline in EN and DE", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
const modules = bundle(locale);
|
||||
for (const key of MODULE_KEYS) {
|
||||
const text = modules[key].description!;
|
||||
if (text.length > MAX_CHARS) {
|
||||
offenders.push(`${locale}/${key}: ${text.length} chars`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing
|
||||
* per-column endpoints — never the module-allowlist the gate ignores for them.
|
||||
*
|
||||
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
|
||||
* Test strategy mirrors `advanced-account-delete.test.tsx`: mock
|
||||
* `@tanstack/react-query` so the rendered tree executes under SSR
|
||||
* (`renderToStaticMarkup`), capture the `useMutation` config to assert the
|
||||
* PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to
|
||||
@@ -163,11 +163,20 @@ describe("<ModulesSection>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
|
||||
it("keeps a manage deep-link beside the delegated coach switch", () => {
|
||||
const html = render();
|
||||
// Coach → Coach settings; cycle → the Account cycle-tracking card.
|
||||
// Coach → Coach settings. Coach is the only delegated module with more
|
||||
// to configure than on/off (cadence, memory) at a settings address.
|
||||
expect(html).toContain('href="/settings/coach"');
|
||||
expect(html).toContain('href="/settings/account#cycle-tracking"');
|
||||
});
|
||||
|
||||
it("offers no Account deep-link beside the cycle switch", () => {
|
||||
// Cycle used to carry a second enable surface in Settings → Account,
|
||||
// and this link pointed at it. The switch here drives the same column,
|
||||
// so the Account card went and the link with it — a deep-link to an
|
||||
// anchor nothing renders is a door into a wall.
|
||||
const html = render();
|
||||
expect(html).not.toContain("/settings/account#cycle-tracking");
|
||||
});
|
||||
|
||||
it("no longer renders the 'Always on' core-domains card", () => {
|
||||
|
||||
@@ -67,6 +67,20 @@ describe("SettingsCard — the container owns the rhythm", () => {
|
||||
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
|
||||
expect(html).toContain("gap-2");
|
||||
});
|
||||
|
||||
it("drops the gap as well as the padding on a flush ledger card", () => {
|
||||
// Dropping only the padding leaves the card's own gap standing between
|
||||
// the rows as dead space the first row does not have. Both breakpoints
|
||||
// are named because the `<Card>` primitive carries a `md:` gap that a
|
||||
// bare `gap-0` loses to.
|
||||
const html = render(<SettingsCard flush>rows</SettingsCard>);
|
||||
expect(html).toContain("gap-0");
|
||||
expect(html).toContain("md:gap-0");
|
||||
expect(html).toContain("p-0");
|
||||
expect(html).toContain("md:p-0");
|
||||
expect(html).not.toMatch(/class="[^"]*\bgap-4\b/);
|
||||
expect(html).not.toMatch(/class="[^"]*\bp-4\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsCardHeader — one description sentence, one text run", () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Where the change-password card lives.
|
||||
*
|
||||
* It used to sit at the bottom of Settings → Account, under the profile form.
|
||||
* Changing a password is the same kind of decision as enrolling a second
|
||||
* factor, so it moved to Settings → Security. Two things have to hold for
|
||||
* that move to be real rather than a duplicate:
|
||||
*
|
||||
* 1. Security mounts it — and mounts it BEFORE the factor status resolves,
|
||||
* because it is now the only way to change a password and must not
|
||||
* depend on a query it does not need.
|
||||
* 2. Account does not, and no second copy of the dialog exists anywhere.
|
||||
*
|
||||
* The second claim is checked against the source tree rather than a render,
|
||||
* because a render of `<AccountSection>` proves the card is absent from the
|
||||
* one branch the test happened to reach. Sweeping the files proves there is
|
||||
* exactly one place in the app that can paint it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined, isLoading: true }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PasswordCard } from "../password-card";
|
||||
import { SecuritySection } from "../security-section";
|
||||
|
||||
const SLOT = 'data-slot="settings-password-card"';
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
walk(full, out);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("change-password card placement", () => {
|
||||
it("paints its slot", () => {
|
||||
// Positive control for the two assertions below — without it, a renamed
|
||||
// slot would make every "is it here / is it gone" check read green.
|
||||
expect(render(<PasswordCard />)).toContain(SLOT);
|
||||
});
|
||||
|
||||
it("is mounted by Settings → Security before the factor status lands", () => {
|
||||
// The mocked query never resolves, which is the case that matters: the
|
||||
// MFA read is still in flight (or failing) and the password card is
|
||||
// reachable regardless.
|
||||
const html = render(<SecuritySection />);
|
||||
expect(html).toContain(SLOT);
|
||||
});
|
||||
|
||||
it("has exactly one home in the tree", () => {
|
||||
const owners = walk(join(ROOT, "src"))
|
||||
.filter((file) => readFileSync(file, "utf8").includes("PasswordCard"))
|
||||
.map((file) => file.slice(ROOT.length + 1))
|
||||
.sort();
|
||||
expect(owners).toEqual([
|
||||
"src/components/settings/password-card.tsx",
|
||||
"src/components/settings/security-section/index.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("left no password form behind in the Account section", () => {
|
||||
const account = readFileSync(
|
||||
join(ROOT, "src/components/settings/account-section/index.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"settings.passwordReset",
|
||||
"settings.changePassword",
|
||||
"/api/auth/password",
|
||||
"current-password",
|
||||
]) {
|
||||
expect(account, `Account still carries ${marker}`).not.toContain(marker);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
// The panel's visit offer reads the encounter list. Pending forever here:
|
||||
// this file is about the panel's own state, and a resolved read would put
|
||||
// an offer in the markup every assertion below would have to know about.
|
||||
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -188,7 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
expect(groupChip(html, "history")).toBe("0/2");
|
||||
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
|
||||
expect(groupChip(html, "history")).toBe("0/3");
|
||||
expect(groupChip(html, "cardio")).toBe("0/14");
|
||||
expect(groupChip(html, "activity")).toBe("0/8");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
|
||||
@@ -213,6 +213,7 @@ function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
|
||||
accountId: "p1",
|
||||
username: "managed-abc",
|
||||
displayName: "Managed record",
|
||||
fullName: null,
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
import { Loader2, Save, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SeededFormDiscardDialog,
|
||||
useSeededFormDismissal,
|
||||
@@ -15,7 +14,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { HeightFieldControl } from "@/components/profile/height-field-control";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
@@ -35,7 +33,6 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -94,17 +91,6 @@ export function AccountSection() {
|
||||
"success" | "warning" | "error" | null
|
||||
>(null);
|
||||
|
||||
// Password change dialog state.
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordMsg, setPasswordMsg] = useState<StatusMessage | null>(null);
|
||||
const [passwordMsgType, setPasswordMsgType] = useState<
|
||||
"success" | "error" | null
|
||||
>(null);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
// v1.23 — passkey + second-factor management moved to the dedicated
|
||||
// /settings/security hub so "how I secure my account" reads as one place.
|
||||
|
||||
@@ -298,52 +284,6 @@ export function AccountSection() {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPasswordSaving(true);
|
||||
setPasswordMsg(null);
|
||||
setPasswordMsgType(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ key: "settings.passwordMismatch" });
|
||||
setPasswordMsgType("error");
|
||||
setPasswordSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setPasswordMsg(
|
||||
json.error ? { text: json.error } : { key: "settings.savingError" },
|
||||
);
|
||||
setPasswordMsgType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordMsg({ key: "settings.passwordUpdated" });
|
||||
setPasswordMsgType("success");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setPasswordMsg({ key: "common.networkError" });
|
||||
setPasswordMsgType("error");
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -584,32 +524,12 @@ export function AccountSection() {
|
||||
provider-configuration screen. */}
|
||||
<AboutMeSection isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
|
||||
lets any account opt in (or opt out) before the gated /cycle page is
|
||||
reachable. */}
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
{/* Cycle tracking is a module, and the Modules hub carries its switch.
|
||||
The second enable surface that used to sit here asked the same
|
||||
question in a second place. */}
|
||||
|
||||
{/* Password card. The action is this card's only one, so it sits in
|
||||
the card's action row like every other primary. It stays full-width
|
||||
under `sm` — the German "Passwort ändern" is long enough that a
|
||||
right-hugging button used to break through the card's right border
|
||||
on a narrow phone. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
/>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
{/* Changing the password is a security control, so the card lives in
|
||||
Settings → Security beside the second factors and the passkeys. */}
|
||||
|
||||
{/* v1.25.7 — active-session management + the security-activity feed
|
||||
live only under Settings → Data & Privacy now; the duplicate cards
|
||||
@@ -619,85 +539,6 @@ export function AccountSection() {
|
||||
Erweitert. It is a maintenance / reset action, not a profile or
|
||||
security control, so it sits beside Research Mode + the danger zone. */}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPasswordDialogOpen(open);
|
||||
if (!open) {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordMsg(null);
|
||||
setPasswordMsgType(null);
|
||||
}
|
||||
}}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
className="sm:max-w-xl"
|
||||
>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
passwordMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(passwordMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={passwordSaving}
|
||||
>
|
||||
{passwordSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
<SeededFormDiscardDialog
|
||||
open={profileDismissal.discardDialogOpen}
|
||||
onConfirm={profileDismissal.confirmDiscard}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,19 +16,10 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
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 {
|
||||
type ResearchModeStatus,
|
||||
researchModeGateState,
|
||||
} from "@/lib/medications/research-mode-types";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
@@ -40,179 +31,28 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
* data" surface that should never live next to a single-click export
|
||||
* button.
|
||||
*
|
||||
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
|
||||
* Research Mode and the data-reset are both opt-in / version-gated
|
||||
* controls; they share a semantic shelf even though they touch
|
||||
* different parts of the user record. Toggle ON opens the
|
||||
* acknowledgment dialog (the user reads + confirms before the server
|
||||
* stamps the version). Toggle OFF fires DELETE directly. A separate
|
||||
* amber banner above the toggle catches the version-mismatch case
|
||||
* (server bumped the disclaimer while the user was still
|
||||
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
|
||||
* dialog.
|
||||
* v1.4.25 added a Research Mode opt-in here, gating the estimated GLP-1
|
||||
* drug-level curve behind an acknowledged disclaimer version. The chart
|
||||
* stopped reading the flag some releases ago and has painted for everyone
|
||||
* since, so the toggle governed nothing: flipping it off changed no screen.
|
||||
* The curve is simply part of the medication page now, and the switch, its
|
||||
* acknowledgment dialog, the endpoint behind it and the three user columns
|
||||
* it stamped are gone. This page is the destructive controls.
|
||||
*/
|
||||
export function AdvancedSection() {
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
|
||||
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
|
||||
// only as the first-time auto-start after onboarding. This page is the
|
||||
// research-mode + data/account destructive controls.
|
||||
// data/account destructive controls.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
|
||||
* the toggle's caption out of the JSX. The four-way ladder (loading /
|
||||
* enabled-open / enabled-stale / disabled) is dense to read inline,
|
||||
* and the surface stays open to a fifth state ("never queried") when
|
||||
* we wire the offline shell in v1.4.26.
|
||||
*/
|
||||
function researchModeStatusLabel(
|
||||
status: ResearchModeStatus | null | undefined,
|
||||
isLoading: boolean,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
if (isLoading) return t("common.loading");
|
||||
const gate = researchModeGateState(status);
|
||||
if (gate === "off") return t("settings.researchMode.disabledStatus");
|
||||
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
|
||||
return t("settings.researchMode.acknowledgedOn", {
|
||||
date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—",
|
||||
});
|
||||
}
|
||||
|
||||
function ResearchModeCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [toggleBusy, setToggleBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
|
||||
queryKey: queryKeys.researchMode(),
|
||||
queryFn: async () => {
|
||||
const res = await apiFetchRaw("/api/auth/me/research-mode");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as ResearchModeStatus;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const gateState = researchModeGateState(status);
|
||||
const showRePrompt = gateState === "stale";
|
||||
// The Switch's `checked` mirrors the server flag exactly — even when
|
||||
// the version is stale we keep the toggle "on" so the user sees that
|
||||
// their previous choice is preserved; the banner above the toggle
|
||||
// explains why the chart isn't painting.
|
||||
const switchChecked = !!status?.enabled;
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
if (toggleBusy) return;
|
||||
setErrorMessage(null);
|
||||
if (next) {
|
||||
// Toggle ON → open the acknowledgment dialog. The dialog owns
|
||||
// the POST; on success it invalidates the `research-mode` query
|
||||
// and the toggle reflects the new state.
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
// Toggle OFF → fire DELETE directly.
|
||||
setToggleBusy(true);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
} catch {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
} finally {
|
||||
setToggleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-research-mode-card">
|
||||
<SettingsCardHeader
|
||||
icon={BookOpenCheck}
|
||||
title={t("settings.researchMode.sectionTitle")}
|
||||
description={t("settings.researchMode.subtitle")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.researchMode.subtitleDetail")}</p>
|
||||
|
||||
{showRePrompt && (
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="settings-research-mode-reprompt"
|
||||
title={t("settings.researchMode.rePromptTitle")}
|
||||
>
|
||||
<p>{t("settings.researchMode.rePromptBody")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2 min-h-11 sm:min-h-9"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
data-slot="settings-research-mode-reprompt-cta"
|
||||
>
|
||||
{t("settings.researchMode.rePromptCta")}
|
||||
</Button>
|
||||
</SettingsInfoTile>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.researchMode.toggleLabel")}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="settings-research-mode-status"
|
||||
>
|
||||
{researchModeStatusLabel(status, isLoading, t)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={switchChecked}
|
||||
disabled={isLoading || toggleBusy}
|
||||
onCheckedChange={(next) => {
|
||||
void handleToggle(next);
|
||||
}}
|
||||
aria-label={t("settings.researchMode.toggleLabel")}
|
||||
data-slot="settings-research-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="settings-research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DataResetCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { CalendarHeart } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the cycle-tracking enable on-ramp.
|
||||
*
|
||||
* The cycle vertical auto-enables for accounts set to female (gender-derived
|
||||
* default), but everyone else needs a way IN: the gated `/cycle` page bounces
|
||||
* them home and the in-page settings (where the goal/prefs live) sit behind
|
||||
* that same gate. This card reads the UNGATED `/api/auth/me/cycle-prefs` so any
|
||||
* account can flip `cycleTrackingEnabled` on (or a female account can opt out)
|
||||
* before the gated page is reachable. PATCHes the same prefs route the iOS app
|
||||
* uses; invalidates the nav gate so the sidebar/bottom-nav entry appears.
|
||||
*/
|
||||
|
||||
interface CyclePrefsShape {
|
||||
cycleTrackingEnabled: boolean;
|
||||
}
|
||||
|
||||
export function CycleTrackingCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
return apiGet<CyclePrefsShape>("/api/auth/me/cycle-prefs");
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
return apiPatch<CyclePrefsShape>("/api/auth/me/cycle-prefs", { enabled });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
||||
// The nav gate reads the resolved enable state off /auth/me.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = data?.cycleTrackingEnabled ?? false;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.cycleTracking.title")}
|
||||
description={t("settings.cycleTracking.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.cycleTracking.detail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label htmlFor="cycle-tracking-enable" className="text-sm">
|
||||
{t("settings.cycleTracking.enable")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-tracking-enable"
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending || data == null}
|
||||
onCheckedChange={(v) => toggle.mutate(v)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
asChild
|
||||
>
|
||||
<Link href="/cycle">{t("settings.cycleTracking.manage")}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{toggle.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("settings.cycleTracking.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
|
||||
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
|
||||
tension_window: "dashboard.heroItems.tension_window",
|
||||
same_time_baseline: "dashboard.heroItems.same_time_baseline",
|
||||
upcoming_visit: "dashboard.heroItems.upcoming_visit",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function LayoutSection() {
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard className="divide-border divide-y overflow-hidden p-0 md:p-0">
|
||||
<SettingsCard flush className="divide-border divide-y">
|
||||
{visibleGroups.map((group) => (
|
||||
<Link
|
||||
key={group.id}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Save, Shield } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
statusText,
|
||||
type StatusMessage,
|
||||
} from "@/components/settings/account-section/account-section-utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Change-password card.
|
||||
*
|
||||
* It used to sit at the bottom of Settings → Account, under the profile form
|
||||
* and the identity fields. Changing a password is not a profile edit — it is
|
||||
* the same decision as enrolling a second factor or adding a passkey, and
|
||||
* those live in Settings → Security. The card moved to sit with them so
|
||||
* "how I get into this account" reads as one place.
|
||||
*
|
||||
* The status hint stores the i18n KEY, not the translated string, so a locale
|
||||
* switch re-renders it in the new language — the settings-wide convention
|
||||
* `statusText` exists for.
|
||||
*/
|
||||
export function PasswordCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMsg({ key: "settings.passwordMismatch" });
|
||||
setMsgType("error");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setMsg(
|
||||
json.error ? { text: json.error } : { key: "settings.savingError" },
|
||||
);
|
||||
setMsgType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMsg({ key: "settings.passwordUpdated" });
|
||||
setMsgType("success");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setMsg({ key: "common.networkError" });
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The action is this card's only one, so it sits in the card's action
|
||||
row like every other primary. It stays full-width under `sm` — the
|
||||
German "Passwort ändern" is long enough that a right-hugging button
|
||||
used to break through the card's right border on a narrow phone. */}
|
||||
<SettingsCard data-slot="settings-password-card">
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
/>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
}
|
||||
}}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
className="sm:max-w-xl"
|
||||
>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(msg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -179,7 +179,9 @@ export function PrivacySection() {
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Privacy posture (AI privacy mode + research mode live on their pages) */}
|
||||
{/* Privacy posture. The AI privacy mode is what is left to decide here:
|
||||
research mode is gone, so the second button pointed at a page that
|
||||
no longer carries the control it named. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
@@ -192,11 +194,6 @@ export function PrivacySection() {
|
||||
{t("settings.privacy.posture.openAi")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/advanced">
|
||||
{t("settings.privacy.posture.openResearch")}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The report's reason, pinned on the two properties that make it trustworthy:
|
||||
*
|
||||
* - it appears for an appointment inside the horizon and for nothing else;
|
||||
* - it NEVER auto-applies. Applying is a press, and the component calls
|
||||
* nothing on render — a window control that moves on its own is what makes
|
||||
* an export panel untrustworthy.
|
||||
*
|
||||
* The fixtures are relative to the moment the test runs, so the horizon cannot
|
||||
* rot out from under them as the calendar moves.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return { ...actual, apiGet: () => new Promise(() => {}) };
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { ReportVisitOffer } from "../report-visit-offer";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(),
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(upcoming: EncounterDTO[], onUse = () => {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
{
|
||||
upcoming,
|
||||
past: [],
|
||||
},
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ReportVisitOffer onUseVisitDate={onUse} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ReportVisitOffer>", () => {
|
||||
it("offers an appointment five days out, naming its own source", () => {
|
||||
const html = render([visit({ id: "soon" })]);
|
||||
expect(html).toContain('data-slot="report-visit-offer"');
|
||||
expect(html).toContain("An appointment is coming up");
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
});
|
||||
|
||||
it("stays silent for an appointment thirty days out", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "far",
|
||||
occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
|
||||
}),
|
||||
]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("stays silent when there is no appointment at all", () => {
|
||||
expect(render([])).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("ignores a cancelled appointment inside the horizon", () => {
|
||||
const html = render([visit({ id: "off", status: "CANCELLED" })]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("changes the window only when pressed", () => {
|
||||
const onUse = vi.fn();
|
||||
const html = render([visit({ id: "soon" })], onUse);
|
||||
// The offer rendered — so the assertion below is about restraint, not
|
||||
// about a component that produced nothing.
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
expect(onUse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Bericht für den Termin am … erstellen" — the report knows why it is being
|
||||
* generated.
|
||||
*
|
||||
* The cheapest integration in this milestone and the one that changes the most:
|
||||
* a person opening the export panel a week before an appointment is almost
|
||||
* always opening it BECAUSE of the appointment, and the window they want ends
|
||||
* on that day. One query, one prefilled field, one dismissible offer.
|
||||
*
|
||||
* **It never auto-applies.** The window is not silently changed and then left
|
||||
* for the person to discover; the offer names its own source ("weil am … ein
|
||||
* Termin ansteht") and does nothing until pressed. A control that moves on its
|
||||
* own is the thing that makes an export panel untrustworthy.
|
||||
*
|
||||
* Nothing about the stored selection changes: this sets the custom-range end
|
||||
* date and nothing else.
|
||||
*/
|
||||
import { CalendarClock, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* How far ahead a booked visit is treated as the reason for this report.
|
||||
*
|
||||
* Two weeks: far enough that somebody preparing in advance is offered it, near
|
||||
* enough that a routine check-up six months out does not colonise the panel.
|
||||
*/
|
||||
export const REPORT_VISIT_HORIZON_DAYS = 14;
|
||||
|
||||
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
|
||||
function localDay(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
const offset = at.getTimezoneOffset();
|
||||
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
onUseVisitDate,
|
||||
}: {
|
||||
/** Sets the custom window's END date. Called only on an explicit press. */
|
||||
onUseVisitDate: (endDay: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const visits = useEncounters();
|
||||
|
||||
// Read once, at mount. A clock read during render is impure — and here it
|
||||
// would also mean the offer could vanish mid-interaction as a visit crossed
|
||||
// the horizon between two renders.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000;
|
||||
const next = (visits.data?.upcoming ?? []).find((visit) => {
|
||||
if (visit.status !== "PLANNED") return false;
|
||||
const at = new Date(visit.occurredAt).getTime();
|
||||
return at > now && at <= horizon;
|
||||
});
|
||||
if (!next) return null;
|
||||
|
||||
const what =
|
||||
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="report-visit-offer"
|
||||
className="border-border flex flex-wrap items-center gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{/* Content, not meta: it is the offer, and it names its own source. */}
|
||||
<p className="text-foreground flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="report-visit-offer-apply"
|
||||
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
|
||||
>
|
||||
{t("settings.healthRecord.visitOfferApply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 sm:size-9"
|
||||
aria-label={t("common.dismiss")}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ReportVisitOffer } from "./report-visit-offer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
@@ -253,6 +254,15 @@ export function HealthRecordExportPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Never auto-applies: pressing it is what opens the custom window and
|
||||
sets its end date. */}
|
||||
<ReportVisitOffer
|
||||
onUseVisitDate={(endDay) => {
|
||||
setCustomRange(true);
|
||||
setEndDate(endDay);
|
||||
}}
|
||||
/>
|
||||
|
||||
{customRange ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { PasswordCard } from "@/components/settings/password-card";
|
||||
import { TotpCard } from "./totp-card";
|
||||
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
@@ -45,28 +46,35 @@ export function SecuritySection() {
|
||||
passkeys != null &&
|
||||
passkeys.length === 0;
|
||||
|
||||
if (isLoading || !status) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
{/* The password leads, and it renders before the factor status has
|
||||
landed — it depends on nothing this page fetches. It is also the
|
||||
only way to change a password now that the Account card is gone, so
|
||||
a `/api/auth/me/mfa` that never answers must not take it down with
|
||||
the cards that do need it. */}
|
||||
<PasswordCard />
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
{isLoading || !status ? (
|
||||
<>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,10 +28,22 @@ import { cn } from "@/lib/utils";
|
||||
*
|
||||
* `as="section"` keeps a card's semantic landmark element (a `<section>` with
|
||||
* an `aria-labelledby`) while painting the same shape, rhythm, and padding.
|
||||
*
|
||||
* `flush` is for the other body shape: an edge-to-edge `divide-y` ledger whose
|
||||
* rows carry their own inset. Such a card drops BOTH the padding and the gap.
|
||||
* Dropping only the padding is the trap — the gap survives as dead,
|
||||
* unclickable space between the rows that the first row does not have, so the
|
||||
* list reads a step off itself. `<InsightSectionCard flush>` names the same
|
||||
* shape on the Insights side.
|
||||
*/
|
||||
const SETTINGS_CARD_SHELL = "flex flex-col gap-4 p-4 md:p-6";
|
||||
// Both breakpoints are named explicitly: the `<Card>` primitive carries
|
||||
// `md:gap-6` / `md:py-6`, and a bare `gap-0` / `p-0` loses to a `md:` variant
|
||||
// from `md` up — which is exactly the width the dead space showed at.
|
||||
const SETTINGS_CARD_FLUSH_SHELL =
|
||||
"flex flex-col gap-0 overflow-hidden p-0 md:gap-0 md:p-0";
|
||||
|
||||
type SettingsCardOwnProps = { className?: string };
|
||||
type SettingsCardOwnProps = { className?: string; flush?: boolean };
|
||||
|
||||
type SettingsCardProps<E extends React.ElementType> = SettingsCardOwnProps & {
|
||||
as?: E;
|
||||
@@ -40,6 +52,7 @@ type SettingsCardProps<E extends React.ElementType> = SettingsCardOwnProps & {
|
||||
export function SettingsCard<E extends React.ElementType = typeof Card>({
|
||||
as,
|
||||
className,
|
||||
flush = false,
|
||||
...props
|
||||
}: SettingsCardProps<E>) {
|
||||
if (as) {
|
||||
@@ -50,7 +63,10 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
|
||||
// Mirror the ui Card shell so a semantic landmark card paints
|
||||
// identically to the default <Card>-backed one.
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-4 rounded-xl border p-4 shadow-sm md:gap-6 md:p-6",
|
||||
"bg-card text-card-foreground rounded-xl border shadow-sm",
|
||||
flush
|
||||
? SETTINGS_CARD_FLUSH_SHELL
|
||||
: "flex flex-col gap-4 p-4 md:gap-6 md:p-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,7 +77,10 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
|
||||
<Card
|
||||
// Reset the primitive's flex/gap layout and apply the shared
|
||||
// `p-4 md:p-6` padding; Settings bodies own their internal spacing.
|
||||
className={cn(SETTINGS_CARD_SHELL, className)}
|
||||
className={cn(
|
||||
flush ? SETTINGS_CARD_FLUSH_SHELL : SETTINGS_CARD_SHELL,
|
||||
className,
|
||||
)}
|
||||
{...(props as React.ComponentProps<typeof Card>)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ const canonicalEntry = {
|
||||
accountId: "record-a",
|
||||
username: "record-a",
|
||||
displayName: "Record A",
|
||||
fullName: null,
|
||||
access: "read",
|
||||
level: "read",
|
||||
recordKind: "shared",
|
||||
|
||||
@@ -27,6 +27,7 @@ const READ_ONLY: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read",
|
||||
level: "read",
|
||||
sections: null,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Data hooks for visits and the address book behind them.
|
||||
*
|
||||
* Every value the surfaces render arrives resolved from the server — the
|
||||
* practitioner as an object, the links with a label and a date, the
|
||||
* appointment's next-due instant computed. Nothing here re-derives one. A
|
||||
* client that recomputed a server-side derivation drifts from it the first
|
||||
* time the derivation changes on one side only.
|
||||
*
|
||||
* Writes reach two roots (`encounterDependentKeys`), because renaming a
|
||||
* practice changes the label every visit that names it renders.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
|
||||
|
||||
export type Encounter = EncounterDTO;
|
||||
export type EncounterList = EncounterListDTO;
|
||||
|
||||
export interface EncounterWriteBody {
|
||||
occurredAt?: string;
|
||||
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind?: string;
|
||||
practitionerId?: string | null;
|
||||
reason?: string | null;
|
||||
outcome?: string | null;
|
||||
/** The checkup this visit closes, when it was filed from a due one. */
|
||||
reminderId?: string | null;
|
||||
documentIds?: string[];
|
||||
labResultIds?: string[];
|
||||
episodeIds?: string[];
|
||||
}
|
||||
|
||||
const BASE = "/api/encounters";
|
||||
|
||||
export function useEncounters(
|
||||
enabled = true,
|
||||
window?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
status?: string;
|
||||
/** Only visits filed against this condition episode. */
|
||||
episodeId?: string;
|
||||
},
|
||||
) {
|
||||
const from = window?.from ?? null;
|
||||
const to = window?.to ?? null;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.encounterList(
|
||||
from,
|
||||
to,
|
||||
window?.status,
|
||||
window?.episodeId,
|
||||
),
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (from) sp.set("from", from);
|
||||
if (to) sp.set("to", to);
|
||||
if (window?.status) sp.set("status", window.status);
|
||||
if (window?.episodeId) sp.set("episodeId", window.episodeId);
|
||||
const qs = sp.toString();
|
||||
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Does this belong to a visit?" for one anchor date.
|
||||
*
|
||||
* The verdict is resolved server-side so the document moment and the two lab
|
||||
* moments cannot answer the question differently. `anchor` being null means
|
||||
* the surface has no date yet and there is nothing to ask about.
|
||||
*/
|
||||
export function useEncounterSuggestion(anchor: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.encounterSuggestion(anchor ?? ""),
|
||||
queryFn: () =>
|
||||
apiGet<EncounterSuggestionResult>(
|
||||
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
|
||||
),
|
||||
enabled: enabled && Boolean(anchor),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEncounterMutations() {
|
||||
const qc = useQueryClient();
|
||||
/**
|
||||
* A visit write reaches further than the visit list.
|
||||
*
|
||||
* Filing one against a due checkup satisfies that reminder, which moves its
|
||||
* next-due date on the preventive-care list, on the dashboard card and on the
|
||||
* Today rail — three surfaces that read the reminder table and would
|
||||
* otherwise keep showing it due until their own poll. Booking one moves the
|
||||
* same reads the other way: a new appointment is a row the digest surfaces.
|
||||
*
|
||||
* So the fan-out is the visit roots PLUS the reminder reads, through the same
|
||||
* helper the Vorsorge surfaces use, rather than a second sweep that could
|
||||
* drift from it.
|
||||
*/
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, encounterDependentKeys),
|
||||
invalidateReminderReads(qc),
|
||||
]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.encounterCreate(),
|
||||
mutationFn: (body: EncounterWriteBody) => apiPost<Encounter>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.encounterUpdate(),
|
||||
mutationFn: ({ id, body }: { id: string; body: EncounterWriteBody }) =>
|
||||
apiPatch<Encounter>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.encounterDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Data hooks for the account's own address book of doctors and practices.
|
||||
*
|
||||
* The search runs in SQL over the two plaintext columns rather than in the
|
||||
* browser over a downloaded list: the columns are plaintext precisely so the
|
||||
* picker can search and sort them there, and a client-side filter would need
|
||||
* the whole book in memory to do it.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type { PractitionerDTO } from "@/lib/practitioners/dto";
|
||||
|
||||
export type Practitioner = PractitionerDTO;
|
||||
|
||||
export interface PractitionerWriteBody {
|
||||
name?: string;
|
||||
specialty?: string | null;
|
||||
practice?: string | null;
|
||||
location?: string | null;
|
||||
phone?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
const BASE = "/api/practitioners";
|
||||
|
||||
export function usePractitioners(q?: string, enabled = true) {
|
||||
const term = q?.trim() || undefined;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.practitionerList(term),
|
||||
queryFn: () =>
|
||||
apiGet<Practitioner[]>(
|
||||
term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
|
||||
),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePractitionerMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.practitionerCreate(),
|
||||
mutationFn: (body: PractitionerWriteBody) =>
|
||||
apiPost<Practitioner>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.practitionerUpdate(),
|
||||
mutationFn: ({ id, body }: { id: string; body: PractitionerWriteBody }) =>
|
||||
apiPatch<Practitioner>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.practitionerDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user