diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 810bb4512..3fb131689 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -70,7 +70,6 @@ function deriveDrugNames(raw: string): { display: string; generic: string } { return { display, generic }; } -const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; function parseDaysOfWeek(raw: string | null): number[] { if (!raw) return []; @@ -417,5 +416,4 @@ export const __testables = { parseTagList, parseDaysOfWeek, predictNextInjection, - WEEKDAY_KEYS, }; diff --git a/src/lib/medications/glp1-knowledge.ts b/src/lib/medications/glp1-knowledge.ts index 3db2f9ded..70fb61a10 100644 --- a/src/lib/medications/glp1-knowledge.ts +++ b/src/lib/medications/glp1-knowledge.ts @@ -45,16 +45,16 @@ export type Glp1DrugId = * record AND a per-brand override map so a single drug-id can carry * mixed routes (semaglutide → Ozempic/Wegovy sc, Rybelsus oral). */ -export type Glp1Route = "subcutaneous" | "oral"; +type Glp1Route = "subcutaneous" | "oral"; /** * Drug class descriptor — Tirzepatide is the only dual GIP/GLP-1 * agonist among EMA-approved agents (2026-05); the rest are pure * GLP-1 receptor agonists. */ -export type Glp1DrugClass = "GIP-GLP1 dual agonist" | "GLP-1 receptor agonist"; +type Glp1DrugClass = "GIP-GLP1 dual agonist" | "GLP-1 receptor agonist"; -export type Glp1Pharmacology = { +type Glp1Pharmacology = { /** Terminal elimination half-life. Days for the long-acting agents, * hours for liraglutide / short-acting exenatide. */ halfLifeDays: number; @@ -83,7 +83,7 @@ export type Glp1Pharmacology = { compartmentModel: "one-compartment" | "two-compartment"; }; -export type Glp1Storage = { +type Glp1Storage = { /** Refrigerated stability — every GLP-1 product is shipped 2–8 °C * per EMA. */ unopened: { @@ -97,7 +97,7 @@ export type Glp1Storage = { }; }; -export type Glp1Pen = { +type Glp1Pen = { /** Pen device name as printed on the carton. */ type: string; /** Doses per pen / per cartridge — drives W19b inventory @@ -105,7 +105,7 @@ export type Glp1Pen = { dosesPerPen: number | null; }; -export type Glp1AdverseReactions = { +type Glp1AdverseReactions = { /** ≥ 1/10 frequency per EMA §4.8. */ veryCommon: readonly string[]; /** 1/100 to <1/10 per EMA §4.8. */ @@ -116,12 +116,12 @@ export type Glp1AdverseReactions = { rare: readonly string[]; }; -export type Glp1Indications = { +type Glp1Indications = { t2dm: { firstLine: boolean }; weightManagement: { bmiThreshold: number | null }; }; -export type Glp1SourceCitations = { +type Glp1SourceCitations = { /** Direct link to the EMA EPAR product-information PDF. */ sourceEMA: string; /** Peer-reviewed pharmacometric citation where one exists. */ @@ -548,7 +548,8 @@ export const GLP1_DRUGS: Readonly> = { /** * Resolve the route for a given drug + brand. Falls back to the - * record default when no per-brand override is registered. + * record default when no per-brand override is registered. Read by + * the glp1-knowledge test suite. */ export function routeForBrand( drugId: Glp1DrugId, @@ -597,8 +598,9 @@ export function findDrugIdByBrand(brand: string): Glp1DrugId | null { } /** - * The catalog's drug-ids in stable order. Useful for iteration in - * tests and UI surfaces that need a deterministic display order. + * The catalog's drug-ids in stable order. Read by the GLP-1 PK + + * ladder test suites so a future drug addition flows through the + * verification matrix without a hand-rolled list. */ export const GLP1_DRUG_IDS: readonly Glp1DrugId[] = [ "tirzepatide", @@ -607,3 +609,4 @@ export const GLP1_DRUG_IDS: readonly Glp1DrugId[] = [ "dulaglutide", "exenatide", ] as const; + diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index a9f71a0bb..332b59ed6 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -45,7 +45,7 @@ export interface IntakeEventLike { } /** One slot the schedule expected the user to dose. */ -export interface ExpectedDose { +interface ExpectedDose { /** Midnight of the local day this dose was expected. */ day: Date; /** Start of the dose window for this day (Date with HH:mm applied). */ @@ -308,16 +308,18 @@ export function pairDoses( now: Date, ): PairedDose[] { const claimed = new Set(); - const result: PairedDose[] = []; - // Sort slots by window centre so the earlier slot picks its match - // first; same shape as the reminder-worker's "process schedules in - // window order" loop. + // v1.4.27 B7 / simp-M6 — sort once by `windowStart` for the match + // pass and return the result in the same order. Slots that touch + // pairDoses are always strictly chronologically separable (the + // window radius is far smaller than the day grid step), so sorting + // by `windowStart` and sorting by the centre `(start+end)/2` + // produce the same order. Dropping the second `.sort()` at the + // end keeps the contract intact and removes the O(n log n) tail. const sorted = [...slots].sort( - (a, b) => - (a.windowStart.getTime() + a.windowEnd.getTime()) / 2 - - (b.windowStart.getTime() + b.windowEnd.getTime()) / 2, + (a, b) => a.windowStart.getTime() - b.windowStart.getTime(), ); + const result: PairedDose[] = []; for (const slot of sorted) { const centre = (slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2; @@ -352,8 +354,7 @@ export function pairDoses( result.push({ ...slot, match, status }); } - // Restore the chronological order callers expect. - return result.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime()); + return result; } /** diff --git a/src/lib/medications/titration/ladder.ts b/src/lib/medications/titration/ladder.ts index 8114ec246..c007541ce 100644 --- a/src/lib/medications/titration/ladder.ts +++ b/src/lib/medications/titration/ladder.ts @@ -183,7 +183,19 @@ export function weeksOnCurrentStep( /** * Display flag — true when the EMA-reference minimum dwell time has - * elapsed AND a next step exists. + * elapsed AND a next ladder step exists. + * + * Parameters: + * - `drugIdForCeiling`: identifies which ladder to walk so the + * "next step exists" guard knows when the user has hit the + * ceiling. The dwell-time check itself reads `typicalWeeks` + * straight off `currentStep`; the drug id is only needed to + * answer "is there a step above this one?". + * - `currentStep`: the step the user is sitting on today. Null + * when titration is paused or the user is at the bottom of the + * ladder with no recorded dose history. + * - `weeksOnStep`: how many complete weeks have elapsed since the + * user landed on `currentStep`. Computed by `weeksOnCurrentStep`. * * IMPORTANT: this is a *reference* signal, not advice. The UI copy * tied to this boolean is strictly observational ("ladder typically @@ -191,11 +203,11 @@ export function weeksOnCurrentStep( * the W19c safety ground-rules. */ export function escalationDue( - drugId: Glp1DrugId, + drugIdForCeiling: Glp1DrugId, currentStep: TitrationStep | null, weeksOnStep: number, ): boolean { if (!currentStep) return false; - if (!nextStep(drugId, currentStep)) return false; + if (!nextStep(drugIdForCeiling, currentStep)) return false; return weeksOnStep >= currentStep.typicalWeeks; }