mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
A per-medication trackIntake flag (migration 0351, default on for every existing and new row). With it off the dose, dates and schedule stay stored, but nothing derives a due dose from them: the reminder worker, the manual reminder check, the today projector, the auto-miss pass, the low-stock pass, the doses card, the today list, the outstanding-dose badge, compliance and its heatmap, the doctor report, the Coach, the BP-status gate, insights, targets, achievements, the efficacy view and the MCP schedule tool all skip it. The medication wire serves such a medication with schedules: [] and the stored rows in an additive recordedSchedules field, so a client that arms local reminders from schedules stays silent without an update. Switching tracking off or back on archives a schedule revision at that instant, the untracked stretch with an empty payload, so it never counts as missed and the projector does not put a dose from before the switch back on the card. A structural guard freezes the set of schedule readers: each one filters on the flag, derives from dueSchedules, shapes the wire with scheduleWireFields, or is allowlisted with the reason it derives nothing due. The flag travels through backup, restore and the medication CSV export.
514 lines
17 KiB
TypeScript
514 lines
17 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
adherenceRatePercent,
|
|
buildLedgerCompliance,
|
|
collapseMeasurementsToCanonical,
|
|
normaliseDays,
|
|
normaliseDateRange,
|
|
resolveMaxMedicationAdministrations,
|
|
sanitisePracticeName,
|
|
type CanonicalCollapseRow,
|
|
type DoctorReportComplianceMedication,
|
|
type DoctorReportComplianceIntake,
|
|
} from "../doctor-report-data";
|
|
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
|
import { userDayKey } from "@/lib/tz/resolver";
|
|
import {
|
|
buildComplianceMedicationContext,
|
|
tallyComplianceFromLedger,
|
|
} from "@/lib/analytics/compliance";
|
|
import {
|
|
reconstructSleepNights,
|
|
summarizeSleepNights,
|
|
type SleepStageRow,
|
|
} from "@/lib/analytics/sleep-night";
|
|
|
|
describe("resolveMaxMedicationAdministrations", () => {
|
|
it("defaults to 5000 when unset or non-numeric", () => {
|
|
expect(resolveMaxMedicationAdministrations(undefined)).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("abc")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("1000.5")).toBe(5000);
|
|
});
|
|
|
|
it("accepts a valid integer within [1, 50000]", () => {
|
|
expect(resolveMaxMedicationAdministrations("1")).toBe(1);
|
|
expect(resolveMaxMedicationAdministrations("1000")).toBe(1000);
|
|
expect(resolveMaxMedicationAdministrations("50000")).toBe(50000);
|
|
});
|
|
|
|
it("falls back to 5000 for out-of-range values", () => {
|
|
expect(resolveMaxMedicationAdministrations("0")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("-1")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("50001")).toBe(5000);
|
|
});
|
|
});
|
|
|
|
describe("adherenceRatePercent", () => {
|
|
it("rounds to an integer percent capped at 100, matching the app card", () => {
|
|
// 87.3 → 87 (the divergence the export previously printed as 87.3).
|
|
expect(adherenceRatePercent(157, 180)).toBe(87);
|
|
// Exact half rounds up like the app's Math.round.
|
|
expect(adherenceRatePercent(1, 2)).toBe(50);
|
|
expect(adherenceRatePercent(5, 6)).toBe(83);
|
|
// Full adherence is a clean 100.
|
|
expect(adherenceRatePercent(10, 10)).toBe(100);
|
|
});
|
|
|
|
it("never exceeds 100 even when taken outruns the denominator", () => {
|
|
expect(adherenceRatePercent(12, 10)).toBe(100);
|
|
});
|
|
|
|
it("returns null when there is no expected dose", () => {
|
|
expect(adherenceRatePercent(0, 0)).toBeNull();
|
|
expect(adherenceRatePercent(3, 0)).toBeNull();
|
|
expect(adherenceRatePercent(1, -1)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("normaliseDays", () => {
|
|
it("returns the value when it's a valid integer in [1, 365]", () => {
|
|
expect(normaliseDays(1)).toBe(1);
|
|
expect(normaliseDays(90)).toBe(90);
|
|
expect(normaliseDays(365)).toBe(365);
|
|
});
|
|
|
|
it("falls back to 90 for invalid input", () => {
|
|
expect(normaliseDays(undefined)).toBe(90);
|
|
expect(normaliseDays(0)).toBe(90);
|
|
expect(normaliseDays(-1)).toBe(90);
|
|
expect(normaliseDays(366)).toBe(90);
|
|
expect(normaliseDays(7.5)).toBe(90);
|
|
expect(normaliseDays("90")).toBe(90);
|
|
expect(normaliseDays(null)).toBe(90);
|
|
});
|
|
|
|
it("respects a custom fallback", () => {
|
|
expect(normaliseDays(undefined, 30)).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe("normaliseDateRange", () => {
|
|
const NOW = new Date("2026-05-09T12:00:00.000Z");
|
|
|
|
it("uses the explicit range when both dates are valid and span <= 730 days", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2026-02-01T00:00:00.000Z",
|
|
endDate: "2026-05-01T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.start.toISOString()).toBe("2026-02-01T00:00:00.000Z");
|
|
expect(range.end.toISOString()).toBe("2026-05-01T00:00:00.000Z");
|
|
// 89-day span rounded up to the inclusive day-count.
|
|
expect(range.days).toBe(89);
|
|
});
|
|
|
|
it("falls back to last-90-days when range is missing", () => {
|
|
const range = normaliseDateRange({}, NOW);
|
|
expect(range.days).toBe(90);
|
|
expect(range.end.toISOString()).toBe(NOW.toISOString());
|
|
const expectedStart = new Date(NOW);
|
|
expectedStart.setDate(expectedStart.getDate() - 90);
|
|
expect(range.start.toISOString()).toBe(expectedStart.toISOString());
|
|
});
|
|
|
|
it("falls back when endDate is before startDate", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2026-05-01T00:00:00.000Z",
|
|
endDate: "2026-02-01T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
|
|
it("falls back when range exceeds 730 days", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2023-01-01T00:00:00.000Z",
|
|
endDate: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
|
|
it("accepts the maximum 730-day window", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2024-05-09T00:00:00.000Z",
|
|
endDate: "2026-05-09T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBeGreaterThan(700);
|
|
expect(range.days).toBeLessThanOrEqual(730);
|
|
});
|
|
|
|
it("falls back when startDate is unparseable", () => {
|
|
const range = normaliseDateRange(
|
|
{ startDate: "not-a-date", endDate: "2026-05-01T00:00:00.000Z" },
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
|
|
it("uses the legacy days fallback when only days is provided", () => {
|
|
const range = normaliseDateRange({ days: 30 }, NOW);
|
|
expect(range.days).toBe(30);
|
|
expect(range.end.toISOString()).toBe(NOW.toISOString());
|
|
});
|
|
|
|
it("equal start+end produces a 1-day window (not zero)", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2026-05-09T00:00:00.000Z",
|
|
endDate: "2026-05-09T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(1);
|
|
});
|
|
|
|
it("ignores non-object input", () => {
|
|
const range = normaliseDateRange("not-an-object", NOW);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
});
|
|
|
|
describe("sanitisePracticeName", () => {
|
|
it("returns null for non-string input", () => {
|
|
expect(sanitisePracticeName(undefined)).toBe(null);
|
|
expect(sanitisePracticeName(null)).toBe(null);
|
|
expect(sanitisePracticeName(42)).toBe(null);
|
|
expect(sanitisePracticeName({})).toBe(null);
|
|
});
|
|
|
|
it("returns null for whitespace-only or empty strings", () => {
|
|
expect(sanitisePracticeName("")).toBe(null);
|
|
expect(sanitisePracticeName(" ")).toBe(null);
|
|
expect(sanitisePracticeName("\t\n ")).toBe(null);
|
|
});
|
|
|
|
it("trims and collapses whitespace", () => {
|
|
expect(sanitisePracticeName(" Dr. Smith ")).toBe("Dr. Smith");
|
|
expect(sanitisePracticeName("Praxis\tDr.\nMüller")).toBe(
|
|
"Praxis Dr. Müller",
|
|
);
|
|
});
|
|
|
|
it("strips ASCII control characters", () => {
|
|
expect(sanitisePracticeName("Dr.Smith")).toBe("Dr. Smith");
|
|
expect(sanitisePracticeName(" |