mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(medications): scope a snooze to its slot and key report adherence by id
A snooze lives on the medication row, and the reminder tick applied it to every slot of that medication. A Telegram "skip" of the morning dose snoozes to the end of the day, so the evening dose of the same medication was neither reminded nor marked missed by the reminder path. A snooze now holds only slots whose reminder window had opened when it was set, dated by the row's updatedAt that the snooze write stamps (the same reading the skip and pin suppression already uses). A later edit to the row can only widen the hold back to the old behaviour. The doctor report kept adherence in a map keyed by medication name, so two medications with the same name (an ended course and a new one, two strengths) collapsed into one row and the second overwrote the first. The map is keyed by medication id now and carries the name as the label; the report's medication list carries the id. The PDF, the FHIR adherence observations, the MCP visit summary, the clinician view and the GLP-1 section read it that way.
This commit is contained in:
1 parent
91571d4674
commit
62cef974bf
15 files changed
+162
-29
No files matched your search
@@ -225,7 +225,9 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
MEDICATION_LIST: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }],
|
||||
medications: [
|
||||
{ id: "med-ramipril", name: "Ramipril", dose: "5 mg", schedules: [] },
|
||||
],
|
||||
},
|
||||
marker: "Ramipril",
|
||||
},
|
||||
@@ -252,7 +254,13 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
compliance: {
|
||||
Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 },
|
||||
"med-metformin": {
|
||||
name: "Metformin",
|
||||
total: 60,
|
||||
taken: 57,
|
||||
skipped: 1,
|
||||
missed: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
marker: "Metformin",
|
||||
|
||||
@@ -309,14 +309,14 @@ export function MedicationsSection({
|
||||
empty={medications.length === 0 && complianceEntries.length === 0}
|
||||
>
|
||||
{medications.map((med) => {
|
||||
const comp = report.compliance[med.name];
|
||||
const comp = report.compliance[med.id];
|
||||
const rate =
|
||||
complianceOn && comp && comp.total > 0
|
||||
? `${Math.round((comp.taken / comp.total) * 100)}%`
|
||||
: null;
|
||||
return (
|
||||
<StatRow
|
||||
key={med.name}
|
||||
key={med.id}
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
@@ -327,11 +327,11 @@ export function MedicationsSection({
|
||||
);
|
||||
})}
|
||||
{complianceEntries
|
||||
.filter(([name]) => !medications.some((m) => m.name === name))
|
||||
.map(([name, c]) => (
|
||||
.filter(([id]) => !medications.some((m) => m.id === id))
|
||||
.map(([id, c]) => (
|
||||
<StatRow
|
||||
key={name}
|
||||
label={name}
|
||||
key={id}
|
||||
label={c.name}
|
||||
value={t("clinicianView.adherence", {
|
||||
rate: `${Math.round((c.taken / c.total) * 100)}%`,
|
||||
})}
|
||||
|
||||
Binary file not shown.
@@ -71,10 +71,17 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 },
|
||||
"med-ramipril": {
|
||||
name: "Ramipril",
|
||||
total: 90,
|
||||
taken: 85,
|
||||
skipped: 3,
|
||||
missed: 2,
|
||||
},
|
||||
},
|
||||
medications: [
|
||||
{
|
||||
id: "med-ramipril",
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
schedules: [
|
||||
@@ -324,7 +331,8 @@ describe("doctor-report pagination", () => {
|
||||
it("paginates a long compliance table across pages without throwing", () => {
|
||||
const compliance: DoctorReportData["compliance"] = {};
|
||||
for (let i = 0; i < 60; i++) {
|
||||
compliance[`Medication ${i}`] = {
|
||||
compliance[`med-${i}`] = {
|
||||
name: `Medication ${i}`,
|
||||
total: 90,
|
||||
taken: 80,
|
||||
skipped: 5,
|
||||
@@ -899,7 +907,8 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
it("footer is drawn once on every dynamically added page", async () => {
|
||||
const compliance: DoctorReportData["compliance"] = {};
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
compliance[`Medication ${i}`] = {
|
||||
compliance[`med-${i}`] = {
|
||||
name: `Medication ${i}`,
|
||||
total: 90,
|
||||
taken: 80,
|
||||
skipped: 5,
|
||||
|
||||
@@ -344,7 +344,8 @@ export function normaliseDateRange(
|
||||
* schedule, no expected dose — a fabricated 100 % on a clinical report).
|
||||
*
|
||||
* Pure / synchronous — bands come from pre-fetched schedules + intake
|
||||
* instants. Keyed by medication name to match the renderer contract.
|
||||
* instants. Keyed by medication id: two medications can share a name, and
|
||||
* a name key let the second overwrite the first. The name rides in the value.
|
||||
*/
|
||||
export function buildLedgerCompliance(
|
||||
medications: DoctorReportComplianceMedication[],
|
||||
@@ -385,7 +386,8 @@ export function buildLedgerCompliance(
|
||||
// `total = taken + missed` (the ledger denominator) so the renderer's
|
||||
// `taken / total` equals `tally.rate` — the exact number the detail page
|
||||
// shows. Deliberate skips are reported separately but stay out of `total`.
|
||||
compliance[med.name] = {
|
||||
compliance[med.id] = {
|
||||
name: med.name,
|
||||
total: tally.denominator,
|
||||
taken: tally.taken,
|
||||
skipped: tally.skipped,
|
||||
|
||||
@@ -67,7 +67,7 @@ export function buildMedicationMoodWellnessSection(
|
||||
doc.text(t("doctorReport.complianceTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const compRows = complianceEntries.map(([, c]) => {
|
||||
// v1.17 W1a — `total` is the ledger rate denominator (taken + missed,
|
||||
// deliberate skips excluded), so `taken / total` matches the app's
|
||||
// detail-page adherence %. The column is labelled "Expected" rather
|
||||
@@ -77,7 +77,7 @@ export function buildMedicationMoodWellnessSection(
|
||||
const ratePct = adherenceRatePercent(c.taken, c.total);
|
||||
const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
c.name,
|
||||
String(c.taken),
|
||||
String(c.skipped),
|
||||
String(c.missed),
|
||||
|
||||
@@ -40,7 +40,14 @@ export interface DoctorReportStats {
|
||||
latest: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One medication's adherence over the report window. The report's
|
||||
* `compliance` map is keyed by medication id, because two medications may
|
||||
* share a name (an ended course and a new one, two strengths); `name` is the
|
||||
* label every renderer prints.
|
||||
*/
|
||||
export interface DoctorReportCompliance {
|
||||
name: string;
|
||||
total: number;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
@@ -102,8 +109,11 @@ export interface DoctorReportData {
|
||||
*/
|
||||
glucoseClinical: GlucoseClinicalMetrics;
|
||||
bmi: number | null;
|
||||
/** Keyed by medication id; see `DoctorReportCompliance`. */
|
||||
compliance: Record<string, DoctorReportCompliance>;
|
||||
medications: Array<{
|
||||
/** The medication id, the key into `compliance`. */
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
// v1.9.0 — optional user/clinician-asserted drug-classification
|
||||
|
||||
@@ -583,6 +583,7 @@ export async function collectDoctorReportData(
|
||||
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
|
||||
medications: gate.admits("MEDICATION_LIST")
|
||||
? medications.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
atcCode: m.atcCode,
|
||||
|
||||
@@ -186,7 +186,7 @@ export function buildGlp1Block(params: {
|
||||
medications: glp1Meds.map((m) => {
|
||||
const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
|
||||
const lastIntake = m.intakeEvents[0] ?? null;
|
||||
const comp = params.compliance[m.name] ?? {
|
||||
const comp = params.compliance[m.id] ?? {
|
||||
taken: 0,
|
||||
total: 0,
|
||||
skipped: 0,
|
||||
|
||||
@@ -111,10 +111,17 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
"Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 },
|
||||
"med-example": {
|
||||
name: "Example Drug",
|
||||
total: 90,
|
||||
taken: 85,
|
||||
skipped: 3,
|
||||
missed: 2,
|
||||
},
|
||||
},
|
||||
medications: [
|
||||
{
|
||||
id: "med-example-drug",
|
||||
name: "Example Drug",
|
||||
dose: "5mg",
|
||||
schedules: [
|
||||
@@ -472,6 +479,7 @@ describe("buildFhirDocumentBundle", () => {
|
||||
makeData({
|
||||
medications: [
|
||||
{
|
||||
id: "med-mounjaro",
|
||||
name: "Mounjaro",
|
||||
dose: "10mg",
|
||||
atcCode: "A10BX10",
|
||||
@@ -509,7 +517,13 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
medications: [
|
||||
{ name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] },
|
||||
{
|
||||
id: "med-ramipril",
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
atcCode: "C09AA05",
|
||||
schedules: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: null },
|
||||
@@ -781,6 +795,7 @@ describe("buildFhirDocumentBundle", () => {
|
||||
makeData({
|
||||
medications: [
|
||||
{
|
||||
id: "med-empagliflozin",
|
||||
name: "Empagliflozin",
|
||||
dose: "10mg",
|
||||
atcCode: "A10BK03",
|
||||
@@ -810,6 +825,7 @@ describe("buildFhirDocumentBundle", () => {
|
||||
makeData({
|
||||
medications: [
|
||||
{
|
||||
id: "med-empagliflozin",
|
||||
name: "Empagliflozin",
|
||||
dose: "10mg",
|
||||
atcCode: "A10BK03",
|
||||
@@ -867,7 +883,14 @@ describe("buildFhirDocumentBundle", () => {
|
||||
it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }],
|
||||
medications: [
|
||||
{
|
||||
id: "med-herbal",
|
||||
name: "Herbal mix",
|
||||
dose: "1 tab",
|
||||
schedules: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: null },
|
||||
FIXED_NOW,
|
||||
|
||||
@@ -65,9 +65,17 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
"Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 },
|
||||
"med-example": {
|
||||
name: "Example Drug",
|
||||
total: 90,
|
||||
taken: 85,
|
||||
skipped: 3,
|
||||
missed: 2,
|
||||
},
|
||||
},
|
||||
medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }],
|
||||
medications: [
|
||||
{ id: "med-example", name: "Example Drug", dose: "5mg", schedules: [] },
|
||||
],
|
||||
medicationAdministrations: [
|
||||
{
|
||||
medicationName: "Example Drug",
|
||||
|
||||
@@ -174,7 +174,7 @@ function adherenceObservations(
|
||||
): FhirObservation[] {
|
||||
const observations: FhirObservation[] = [];
|
||||
const period = reportingPeriod(data);
|
||||
for (const [name, comp] of Object.entries(data.compliance)) {
|
||||
for (const comp of Object.values(data.compliance)) {
|
||||
if (comp.total <= 0) continue;
|
||||
// Integer percent — the one canonical rounding the app card + PDF use, so
|
||||
// a clinician sees the same adherence figure on every surface. The helper
|
||||
@@ -197,7 +197,7 @@ function adherenceObservations(
|
||||
display: "Medication adherence",
|
||||
},
|
||||
],
|
||||
text: `Medication adherence — ${name}`,
|
||||
text: `Medication adherence — ${comp.name}`,
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: period,
|
||||
|
||||
@@ -851,3 +851,44 @@ describe("issue #664 — exact occurrence identity survives local midnight", ()
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleReminderCheck — a snooze holds only the slot it answered", () => {
|
||||
function snoozed(at: string): Record<string, unknown> {
|
||||
return {
|
||||
...medicationWithSchedule({ timesOfDay: ["08:00", "18:00"] }),
|
||||
// A Telegram "skip" of the morning dose snoozes to the end of the day;
|
||||
// the snooze write stamps the row's updatedAt.
|
||||
snoozedUntil: new Date("2026-07-28T23:59:59.000Z"),
|
||||
updatedAt: new Date(at),
|
||||
};
|
||||
}
|
||||
|
||||
it("still reminds the evening dose after the morning one was snoozed", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-28T18:40:00.000Z"));
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
snoozed("2026-07-28T08:30:00.000Z"),
|
||||
] as never);
|
||||
|
||||
await handleReminderCheck([]);
|
||||
|
||||
expect(dispatchNotification).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ timeOfDay: "18:00" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the slot whose reminder was open when the snooze was set", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-28T08:40:00.000Z"));
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
snoozed("2026-07-28T08:30:00.000Z"),
|
||||
] as never);
|
||||
|
||||
await handleReminderCheck([]);
|
||||
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,24 @@ export async function cleanupScheduledTelegramDeletions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a medication-level snooze holds the reminder of one slot. It
|
||||
* holds only while it runs (`now < snoozedUntil`) and only for a slot whose
|
||||
* reminder window had opened when the snooze was set (`snoozedAt`, the
|
||||
* medication row's @updatedAt of the snooze write). A later edit to the
|
||||
* row moves `snoozedAt` forward, which can only widen the hold back to the
|
||||
* old medication-wide behaviour, never lose a snooze.
|
||||
*/
|
||||
export function snoozeHoldsSlot(
|
||||
snoozedUntil: Date | null,
|
||||
snoozedAt: Date,
|
||||
slotWindowStart: Date,
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (!snoozedUntil || now.getTime() >= snoozedUntil.getTime()) return false;
|
||||
return slotWindowStart.getTime() <= snoozedAt.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all active medications for each user and determine reminder phases.
|
||||
* Uses phase-based logic (GREEN/YELLOW/ORANGE/RED) to send one notification
|
||||
@@ -477,6 +495,7 @@ export async function handleReminderCheck(
|
||||
return currentPhase
|
||||
? {
|
||||
slotScheduledFor: occurrence.at,
|
||||
windowStart: phaseWindow.start,
|
||||
minutesToEnd,
|
||||
currentPhase,
|
||||
}
|
||||
@@ -489,8 +508,12 @@ export async function handleReminderCheck(
|
||||
)[0];
|
||||
if (!phaseCandidate) continue;
|
||||
|
||||
const { slotScheduledFor, minutesToEnd, currentPhase } =
|
||||
phaseCandidate;
|
||||
const {
|
||||
slotScheduledFor,
|
||||
windowStart,
|
||||
minutesToEnd,
|
||||
currentPhase,
|
||||
} = phaseCandidate;
|
||||
const slotInstant = slotScheduledFor.getTime();
|
||||
if (liveEraStart !== null && slotInstant < liveEraStart) {
|
||||
continue;
|
||||
@@ -544,8 +567,16 @@ export async function handleReminderCheck(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if medication is snoozed
|
||||
if (med.snoozedUntil && now < med.snoozedUntil) {
|
||||
// A snooze answers the reminder of the slot that was open when it
|
||||
// was set, so it holds only slots whose window had opened by then.
|
||||
// `snoozedUntil` lives on the medication row and the snooze write
|
||||
// stamps the row's @updatedAt, which dates the decision (the same
|
||||
// reading the skip / pin suppression above uses). A later slot of
|
||||
// the same medication, such as the evening dose after a morning
|
||||
// "skip" that snoozes to the end of the day, is still reminded.
|
||||
if (
|
||||
snoozeHoldsSlot(med.snoozedUntil, med.updatedAt, windowStart, now)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export function summariseForVisit(
|
||||
};
|
||||
});
|
||||
|
||||
const compliance = Object.entries(data.compliance).map(([name, c]) => ({
|
||||
medication: name,
|
||||
const compliance = Object.values(data.compliance).map((c) => ({
|
||||
medication: c.name,
|
||||
takenDoses: c.taken,
|
||||
expectedDoses: c.total,
|
||||
missedDoses: c.missed,
|
||||
|
||||
Reference in new issue
Block a user