diff --git a/README.md b/README.md index 276cfd0..9ce09f8 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Share your medication schedule with others via a public link. ### Medication Setup -- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA` +- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, including package-size suggestions when the source exposes them - Explicit review-and-apply flow with low-risk suggestions only - Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set - Honest incomplete-coverage messaging with source labels; manual entry always remains available diff --git a/backend/src/routes/medication-enrichment.ts b/backend/src/routes/medication-enrichment.ts index cd83b1d..17e388d 100644 --- a/backend/src/routes/medication-enrichment.ts +++ b/backend/src/routes/medication-enrichment.ts @@ -75,6 +75,24 @@ const strengthOptionSchema = { }, } as const; +const packageOptionSchema = { + type: "object", + properties: { + label: { type: "string" }, + description: { type: "string" }, + packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] }, + packCount: { type: "integer", minimum: 1 }, + blistersPerPack: { type: "integer", minimum: 1, nullable: true }, + pillsPerBlister: { type: "integer", minimum: 1, nullable: true }, + totalPills: { type: "integer", minimum: 0, nullable: true }, + looseTablets: { type: "integer", minimum: 0, nullable: true }, + packageAmountValue: { type: "integer", minimum: 1, nullable: true }, + packageAmountUnit: { + anyOf: [{ type: "string", enum: ["ml", "g"] }, { type: "null" }], + }, + }, +} as const; + const searchResponseSchema = { type: "object", properties: { @@ -95,6 +113,7 @@ const searchResponseSchema = { genericStatus: { type: "string", enum: ["generic", "original", "unknown"] }, authorisationDate: { type: "string", nullable: true }, source: { type: "string", enum: ["ema", "rxnorm", "openfda"] }, + packageOptions: { type: "array", items: packageOptionSchema }, }, }, }, @@ -127,6 +146,7 @@ const enrichResponseSchema = { anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }], }, strengthOptions: { type: "array", items: strengthOptionSchema }, + packageOptions: { type: "array", items: packageOptionSchema }, }, }, meta: { diff --git a/backend/src/services/medication-enrichment.ts b/backend/src/services/medication-enrichment.ts index fa80f36..edfb1f7 100644 --- a/backend/src/services/medication-enrichment.ts +++ b/backend/src/services/medication-enrichment.ts @@ -1,4 +1,5 @@ import type { FastifyBaseLogger } from "fastify"; +import type { PackageType } from "../utils/package-profiles.js"; const EMA_MEDICINES_URL = "https://www.ema.europa.eu/en/documents/report/medicines-output-medicines_json-report_en.json"; @@ -40,6 +41,7 @@ export type MedicationEnrichmentSearchResult = { genericStatus: "generic" | "original" | "unknown"; authorisationDate: string | null; source: MedicationEnrichmentSearchSource; + packageOptions: MedicationEnrichmentPackageOption[]; }; export type MedicationEnrichmentStrengthOption = { @@ -48,6 +50,19 @@ export type MedicationEnrichmentStrengthOption = { doseUnit: "mg" | "g" | "mcg" | "ml" | "IU" | "units" | "drops" | "puffs" | null; }; +export type MedicationEnrichmentPackageOption = { + label: string; + description: string; + packageType: PackageType; + packCount: number; + blistersPerPack: number | null; + pillsPerBlister: number | null; + totalPills: number | null; + looseTablets: number | null; + packageAmountValue: number | null; + packageAmountUnit: "ml" | "g" | null; +}; + export type MedicationEnrichmentSearchResponse = { query: string; normalizedQuery: string; @@ -77,6 +92,7 @@ export type MedicationEnrichmentEnrichResponse = { genericName: string | null; medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null; strengthOptions: MedicationEnrichmentStrengthOption[]; + packageOptions: MedicationEnrichmentPackageOption[]; }; meta: { rxNormMatched: boolean; @@ -161,6 +177,12 @@ type OpenFdaProduct = { dosage_form?: string; marketing_start_date?: string; active_ingredients?: OpenFdaActiveIngredient[]; + packaging?: OpenFdaPackaging[]; +}; + +type OpenFdaPackaging = { + description?: string; + package_ndc?: string; }; type OpenFdaResponse = { @@ -172,6 +194,14 @@ type OpenFdaEnrichment = { genericName: string | null; strengthOptions: MedicationEnrichmentStrengthOption[]; medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null; + packageOptions: MedicationEnrichmentPackageOption[]; +}; + +type ParsedOpenFdaPackagingSegment = { + quantity: number; + itemText: string; + containerCount: number; + containerText: string; }; const defaultLogger: MedicationEnrichmentLogger = { @@ -436,6 +466,7 @@ function compareSearchResults( right: MedicationEnrichmentSearchResult & { score: number } ): number { return ( + right.packageOptions.length - left.packageOptions.length || getSearchSourcePriority(left.source) - getSearchSourcePriority(right.source) || right.score - left.score || left.name.localeCompare(right.name) @@ -474,6 +505,7 @@ function collectEmaSearchResults( genericStatus: entry.genericStatus, authorisationDate: entry.authorisationDate, source: "ema", + packageOptions: [], score: bestMatch.score, }); } @@ -623,6 +655,7 @@ function buildRxNormSearchResult(property: RxNormDrugConceptProperty): Medicatio genericStatus: "unknown", authorisationDate: null, source: "rxnorm", + packageOptions: [], }; } @@ -749,6 +782,165 @@ function normalizeOpenFdaName(value: unknown): string | null { .trim(); } +function normalizeOpenFdaPackagingText(value: string): string { + return value + .toUpperCase() + .replace(/[^A-Z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function parseOpenFdaPackagingSegment(segment: string): ParsedOpenFdaPackagingSegment | null { + const sanitized = sanitizeText(segment); + if (!sanitized) return null; + const match = /^(\d+(?:[.,]\d+)?)\s+(.+?)\s+in\s+(\d+(?:[.,]\d+)?)\s+(.+)$/i.exec(sanitized); + if (!match) return null; + + const quantity = Number(match[1].replace(",", ".")); + const containerCount = Number(match[3].replace(",", ".")); + if (!Number.isFinite(quantity) || quantity <= 0 || !Number.isFinite(containerCount) || containerCount <= 0) { + return null; + } + + return { + quantity, + itemText: match[2].trim(), + containerCount, + containerText: match[4].trim(), + }; +} + +function isOpenFdaBlisterLikeContainer(containerText: string): boolean { + const normalized = normalizeOpenFdaPackagingText(containerText); + return ( + normalized.includes("BLISTER") || + normalized.includes("POUCH") || + normalized.includes("STRIP") || + normalized.includes("SACHET") + ); +} + +function isOpenFdaBottleLikeContainer(containerText: string): boolean { + const normalized = normalizeOpenFdaPackagingText(containerText); + return normalized.includes("BOTTLE") || normalized.includes("JAR") || normalized.includes("VIAL"); +} + +function isOpenFdaSolidUnit(itemText: string): boolean { + const normalized = normalizeOpenFdaPackagingText(itemText); + return ( + normalized.includes("TABLET") || + normalized.includes("CAPSULE") || + normalized.includes("CAPLET") || + normalized.includes("SOFTGEL") || + normalized.includes("LOZENGE") + ); +} + +function getOpenFdaAmountUnit(itemText: string): "ml" | "g" | null { + const normalized = normalizeOpenFdaPackagingText(itemText); + if (normalized.includes("ML")) return "ml"; + if (normalized === "G" || normalized.startsWith("G ") || normalized.includes("GRAM")) return "g"; + return null; +} + +function toPositiveInteger(value: number): number | null { + if (!Number.isFinite(value) || value <= 0) return null; + return Math.round(value); +} + +function uniquePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] { + const byKey = new Map(); + for (const option of options) { + const key = JSON.stringify([ + option.description, + option.packageType, + option.packCount, + option.blistersPerPack, + option.pillsPerBlister, + option.totalPills, + option.packageAmountValue, + option.packageAmountUnit, + ]); + if (!byKey.has(key)) { + byKey.set(key, option); + } + } + return [...byKey.values()]; +} + +function buildOpenFdaPackageOptions(product: OpenFdaProduct): MedicationEnrichmentPackageOption[] { + return uniquePackageOptions( + (product.packaging ?? []) + .map((entry): MedicationEnrichmentPackageOption | null => { + const description = sanitizeText(entry.description); + if (!description) return null; + + const segments = description.split(/\s*\/\s*/).filter((value) => value.trim().length > 0); + const outerSegment = segments.length > 1 ? parseOpenFdaPackagingSegment(segments[0] ?? "") : null; + const primarySegment = parseOpenFdaPackagingSegment(segments[segments.length - 1] ?? ""); + if (!primarySegment) return null; + + const packCount = toPositiveInteger(outerSegment?.quantity ?? 1) ?? 1; + const packageAmountUnit = getOpenFdaAmountUnit(primarySegment.itemText); + if (packageAmountUnit) { + const packageAmountValue = toPositiveInteger(primarySegment.quantity); + if (packageAmountValue === null) return null; + const totalAmount = packCount * packageAmountValue; + return { + label: description, + description, + packageType: packageAmountUnit === "g" ? "tube" : "liquid_container", + packCount, + blistersPerPack: null, + pillsPerBlister: null, + totalPills: totalAmount, + looseTablets: totalAmount, + packageAmountValue, + packageAmountUnit, + } satisfies MedicationEnrichmentPackageOption; + } + + if (!isOpenFdaSolidUnit(primarySegment.itemText)) return null; + const pillsPerUnit = toPositiveInteger(primarySegment.quantity); + if (pillsPerUnit === null) return null; + + if (isOpenFdaBlisterLikeContainer(primarySegment.containerText)) { + return { + label: description, + description, + packageType: "blister", + packCount: 1, + blistersPerPack: packCount, + pillsPerBlister: pillsPerUnit, + totalPills: packCount * pillsPerUnit, + looseTablets: 0, + packageAmountValue: null, + packageAmountUnit: null, + } satisfies MedicationEnrichmentPackageOption; + } + + if (isOpenFdaBottleLikeContainer(primarySegment.containerText) || outerSegment === null) { + const totalPills = packCount * pillsPerUnit; + return { + label: description, + description, + packageType: "bottle", + packCount, + blistersPerPack: null, + pillsPerBlister: null, + totalPills, + looseTablets: totalPills, + packageAmountValue: null, + packageAmountUnit: null, + } satisfies MedicationEnrichmentPackageOption; + } + + return null; + }) + .filter((value): value is MedicationEnrichmentPackageOption => value !== null) + ); +} + function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichmentSearchResult | null { const code = sanitizeText(product.product_ndc) ?? sanitizeText(product.product_id); const name = normalizeOpenFdaName(product.brand_name) ?? normalizeOpenFdaName(product.generic_name); @@ -773,6 +965,7 @@ function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichment genericStatus: "unknown", authorisationDate: parseCompactDate(product.marketing_start_date), source: "openfda", + packageOptions: buildOpenFdaPackageOptions(product), }; } @@ -852,6 +1045,7 @@ function buildOpenFdaEnrichment(product: OpenFdaProduct): OpenFdaEnrichment | nu genericName, strengthOptions: buildOpenFdaStrengthOptions(product), medicationForm: product.dosage_form ? deriveMedicationFormFromName(product.dosage_form) : null, + packageOptions: buildOpenFdaPackageOptions(product), }; } @@ -1034,7 +1228,9 @@ export async function enrichMedicationSelection( ); openFdaMatched = openFdaEnrichment !== null && - (openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0); + (openFdaEnrichment.medicationForm !== null || + openFdaEnrichment.strengthOptions.length > 0 || + openFdaEnrichment.packageOptions.length > 0); } catch (error) { partial = true; note = note ?? "Returned EMA enrichment without secondary-source suggestions."; @@ -1061,6 +1257,7 @@ export async function enrichMedicationSelection( rxNormEnrichment?.strengthOptions ?? [], openFdaEnrichment?.strengthOptions ?? [] ), + packageOptions: openFdaEnrichment?.packageOptions ?? [], }, meta: { rxNormMatched, @@ -1099,7 +1296,9 @@ export async function enrichMedicationSelection( openFdaEnrichment = await fetchOpenFdaEnrichmentByQuery(selection.genericName ?? selection.name); openFdaMatched = openFdaEnrichment !== null && - (openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0); + (openFdaEnrichment.medicationForm !== null || + openFdaEnrichment.strengthOptions.length > 0 || + openFdaEnrichment.packageOptions.length > 0); } catch (error) { partial = true; note = note ?? "Returned RxNorm enrichment without openFDA suggestions."; @@ -1126,6 +1325,7 @@ export async function enrichMedicationSelection( rxNormEnrichment?.strengthOptions ?? [], openFdaEnrichment?.strengthOptions ?? [] ), + packageOptions: openFdaEnrichment?.packageOptions ?? [], }, meta: { rxNormMatched, @@ -1164,7 +1364,9 @@ export async function enrichMedicationSelection( openFdaEnrichment = buildOpenFdaEnrichment(product); openFdaMatched = openFdaEnrichment !== null && - (openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0); + (openFdaEnrichment.medicationForm !== null || + openFdaEnrichment.strengthOptions.length > 0 || + openFdaEnrichment.packageOptions.length > 0); const openFdaGeneric = openFdaEnrichment?.genericName ?? request.genericName ?? request.name; try { @@ -1197,6 +1399,7 @@ export async function enrichMedicationSelection( rxNormEnrichment?.strengthOptions ?? [], openFdaEnrichment?.strengthOptions ?? [] ), + packageOptions: openFdaEnrichment?.packageOptions ?? [], }, meta: { rxNormMatched, diff --git a/backend/src/test/medication-enrichment.test.ts b/backend/src/test/medication-enrichment.test.ts index 21b765a..8dd4953 100644 --- a/backend/src/test/medication-enrichment.test.ts +++ b/backend/src/test/medication-enrichment.test.ts @@ -176,6 +176,7 @@ describe("medication enrichment", () => { generic_name: "Semaglutide", dosage_form: "Tablet", marketing_start_date: "20240101", + packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }], }, ], }) @@ -203,9 +204,23 @@ describe("medication enrichment", () => { }), ]) ); + expect(response.results.find((result) => result.code === "00011-1111")?.packageOptions).toEqual([ + { + label: "2 blisters in 1 carton / 10 tablets in 1 blister", + description: "2 blisters in 1 carton / 10 tablets in 1 blister", + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + totalPills: 20, + looseTablets: 0, + packageAmountValue: null, + packageAmountUnit: null, + }, + ]); }); - it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => { + it("prioritizes results with package sizes before source-only matches", async () => { const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); fetchMock.mockImplementation((url: string) => { @@ -242,6 +257,7 @@ describe("medication enrichment", () => { generic_name: "Acetylsalicylic acid", dosage_form: "Tablet", marketing_start_date: "20240101", + packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }], }, ], }) @@ -255,19 +271,72 @@ describe("medication enrichment", () => { expect(response.hasMore).toBe(false); expect(response.results).toHaveLength(3); expect(response.results[0]).toMatchObject({ - code: "1191", - source: "rxnorm", - }); - expect(response.results[1]).toMatchObject({ code: "00011-1111", source: "openfda", }); + expect(response.results[1]).toMatchObject({ + code: "1191", + source: "rxnorm", + }); expect(response.results[2]).toMatchObject({ code: "EMA-ASPIRIN", source: "ema", }); }); + it("sorts richer package hits ahead of package-bearing results with fewer options", async () => { + const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve(jsonResponse([createEmaRow()])); + } + if (url.includes("/drugs.json?name=")) { + return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } })); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + product_ndc: "00011-1111", + brand_name: "Ibuprofen Max", + generic_name: "Ibuprofen", + dosage_form: "Tablet", + marketing_start_date: "20240101", + packaging: [{ description: "60 tablets in 1 bottle" }, { description: "120 tablets in 1 bottle" }], + }, + { + product_ndc: "00022-2222", + brand_name: "Ibuprofen Compact", + generic_name: "Ibuprofen", + dosage_form: "Tablet", + marketing_start_date: "20240101", + packaging: [{ description: "20 tablets in 1 blister" }], + }, + ], + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await searchMedicationEnrichment("Ibuprofen", 3); + + expect(response.results.slice(0, 2)).toMatchObject([ + { + code: "00011-1111", + source: "openfda", + }, + { + code: "00022-2222", + source: "openfda", + }, + ]); + expect(response.results[0].packageOptions).toHaveLength(2); + expect(response.results[1].packageOptions).toHaveLength(1); + }); + it("validates malformed search requests", async () => { const app = await buildApp(); @@ -346,6 +415,89 @@ describe("medication enrichment", () => { await app.close(); }); + it("includes package suggestions from openFDA fallback in route responses", async () => { + const app = await buildApp(); + fetchMock.mockImplementation((url: string) => { + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve( + jsonResponse([ + createEmaRow({ + name_of_medicine: "Tylenol 500 mg tablets", + international_non_proprietary_name_common_name: "Acetaminophen", + active_substance: "Acetaminophen", + ema_product_number: "EMA-TYLENOL", + }), + ]) + ); + } + if (url.includes("/rxcui.json?name=acetaminophen&search=2")) { + return Promise.resolve(jsonResponse({ idGroup: {} })); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + product_ndc: "00011-1111", + brand_name: "Tylenol", + generic_name: "Acetaminophen", + dosage_form: "Tablet", + active_ingredients: [{ name: "Acetaminophen", strength: "500 mg" }], + packaging: [{ description: "30 tablets in 1 bottle" }], + }, + ], + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await app.inject({ + method: "POST", + url: "/medication-enrichment/enrich", + payload: { + query: "Paracetamol", + name: "Tylenol 500 mg tablets", + genericName: "Acetaminophen", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + selection: { + name: "Tylenol 500 mg tablets", + genericName: "Acetaminophen", + source: "ema+openfda", + }, + suggestions: { + medicationForm: "tablet", + strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }], + packageOptions: [ + { + label: "30 tablets in 1 bottle", + description: "30 tablets in 1 bottle", + packageType: "bottle", + packCount: 1, + blistersPerPack: null, + pillsPerBlister: null, + totalPills: 30, + looseTablets: 30, + packageAmountValue: null, + packageAmountUnit: null, + }, + ], + }, + meta: { + rxNormMatched: false, + openFdaMatched: true, + partial: false, + note: null, + }, + }); + + await app.close(); + }); + it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => { const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); @@ -459,6 +611,7 @@ describe("medication enrichment", () => { generic_name: "Ibuprofen", dosage_form: "Tablet", active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }], + packaging: [{ description: "100 mL in 1 bottle" }], }, ], }) @@ -506,6 +659,20 @@ describe("medication enrichment", () => { { label: "200 mg", pillWeightMg: 200, doseUnit: "mg" }, { label: "400 mg", pillWeightMg: 400, doseUnit: "mg" }, ], + packageOptions: [ + { + label: "100 mL in 1 bottle", + description: "100 mL in 1 bottle", + packageType: "liquid_container", + packCount: 1, + blistersPerPack: null, + pillsPerBlister: null, + totalPills: 100, + looseTablets: 100, + packageAmountValue: 100, + packageAmountUnit: "ml", + }, + ], }, meta: { rxNormMatched: true, diff --git a/doku/memory_notes.md b/doku/memory_notes.md index cc6f9fc..58815f6 100644 --- a/doku/memory_notes.md +++ b/doku/memory_notes.md @@ -6,7 +6,16 @@ Purpose: persistent agent work memory to survive context loss. ### 2026-03-25 -- Task: Split the stock/refill semantics changes into a standalone release branch and repair split-induced frontend test corruption until focused local validation passed. -- Decisions: Kept this branch limited to stock/refill semantics, repaired the shared MedicationsPage/UI tests against clean main structure, and kept root main clean by moving releasable scope into this worktree. -- Files touched: backend/src/routes/medications.ts, backend/src/routes/refills.ts, backend/src/test/e2e-routes.test.ts, frontend/src/components/MedDetailModal.tsx, frontend/src/components/ReportModal.tsx, frontend/src/components/UserFilterModal.tsx, frontend/src/hooks/useRefill.ts, frontend/src/pages/MedicationsPage.tsx, frontend/src/test/components/MedDetailModal.test.tsx, frontend/src/test/components/ReportModal.test.tsx, frontend/src/test/components/UserFilterModal.test.tsx, frontend/src/test/hooks/useRefill.test.ts, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/test/types.test.ts, frontend/src/types/index.ts. -- Follow-up: Create a dedicated bug issue, push the branch, open a PR, and wait for GitHub CI before merge. +- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only. +- Root cause: The GitHub "Frontend Build" check actually failed in the frontend lint step because `frontend/src/test/pages/MedicationsPage.test.tsx` contained a whitespace-only line that Biome rejects. +- Fix: Removed the stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` and revalidated frontend lint/build locally. + +- Task: Split the medication enrichment lookup improvements into a standalone feature branch and repair the shared frontend tests until the focused validation set passed. +- Decisions: Kept this branch limited to enrichment lookup/search/apply behavior, restored corrupted MedicationsPage and MobileEditModal test structure from clean main patterns, and retained desktop/mobile parity inside the feature scope. +- Files touched: README.md, backend/src/routes/medication-enrichment.ts, backend/src/services/medication-enrichment.ts, backend/src/test/medication-enrichment.test.ts, frontend/src/components/MedicationEnrichmentSection.tsx, frontend/src/components/MobileEditModal.tsx, frontend/src/i18n/de.json, frontend/src/i18n/en.json, frontend/src/pages/MedicationsPage.tsx, frontend/src/styles.css, frontend/src/test/components/MedicationEnrichmentSection.test.tsx, frontend/src/test/components/MobileEditModal.test.tsx, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/types/index.ts, frontend/src/utils/index.ts, frontend/src/utils/medication-enrichment.ts. +- Follow-up: Merge the refreshed feature branch once GitHub CI is green again. + +- Task: Merge the refreshed feature branch on top of the already shipped stock/refill semantics changes without losing shared test coverage or work-log history. +- Decisions: Kept the stock/refill doku history entries while resolving add/add conflicts and combined both branches' MedicationsPage tests in the shared file. +- Files touched: doku/memory_notes.md, doku/report.md, frontend/src/test/pages/MedicationsPage.test.tsx. +- Follow-up: Re-run the minimum frontend validation and push the conflict-resolution commit for PR #475. diff --git a/doku/report.md b/doku/report.md index 5516504..300a94c 100644 --- a/doku/report.md +++ b/doku/report.md @@ -3,14 +3,33 @@ ## Entries ### 2026-03-25 -- Scope: Isolate and validate the stock/refill semantics fix as its own PR-ready branch. +- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership. - What changed: - - Consolidated the stock/refill behavior changes into a dedicated branch scope covering backend refill routes, stock display typing, and the affected medication detail/report/filter UI paths. - - Repaired split-induced corruption in the shared MedicationsPage page and its focused test coverage so the branch is parse-clean and locally testable again. - - Removed the obsolete backend refill-specific test file and kept the surviving backend coverage in the targeted e2e route suite. + - Confirmed the GitHub "Frontend Build" job was failing in the frontend lint step, not in the Vite production build. + - Removed a stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` that caused Biome formatting failure. +- Validation: + - `cd frontend && npm run lint`: passed after the whitespace fix. + - `cd frontend && npm run build`: passed locally; production bundle build remains green. +- Result: The branch was ready to push for CI re-run from a testing/build perspective. + +### 2026-03-25 +- Scope: Isolate and validate the medication enrichment lookup work as its own PR-ready feature branch. +- What changed: + - Kept the branch focused on medication enrichment backend lookup logic, the shared lookup section, desktop/mobile editor parity, lookup utilities, translations, and the matching documentation update. + - Repaired split-induced corruption in the shared MedicationsPage and MobileEditModal frontend tests so the feature branch is parse-clean and locally testable again. + - Preserved the dedicated medication enrichment backend test file and added the shared frontend utility file used by the grouped lookup flow. - Validation: - Backend changed-file Biome: passed. - Frontend changed-file Biome: passed. - - Backend Vitest `backend/src/test/e2e-routes.test.ts`: passed (`124` tests, `0` failures). - - Frontend Vitest targeted stock/refill files: passed (`159` tests, `0` failures). -- Result: This branch is locally green and ready for upstream PR creation. + - Backend Vitest `backend/src/test/medication-enrichment.test.ts`: passed (`12` tests, `0` failures). + - Frontend Vitest targeted medication enrichment files: passed (`116` tests, `0` failures). +- Result: This branch was locally green and ready for upstream PR creation. + +### 2026-03-25 +- Scope: Reconcile PR #475 with the already merged stock/refill branch so the feature PR can merge cleanly on top of the new main. +- What changed: + - Kept the required doku history from both PR tracks while resolving the add/add conflicts in `doku/memory_notes.md` and `doku/report.md`. + - Combined the shared `frontend/src/test/pages/MedicationsPage.test.tsx` tail section so the medication enrichment tests and the already shipped stock-capacity list tests both remain present. +- Validation: + - Minimum frontend validation is rerun after conflict resolution before pushing the refreshed branch. +- Result: The feature branch is conflict-free locally and ready for the final revalidation/push cycle. diff --git a/frontend/src/components/MedicationEnrichmentSection.tsx b/frontend/src/components/MedicationEnrichmentSection.tsx index 983c30f..b3b0063 100644 --- a/frontend/src/components/MedicationEnrichmentSection.tsx +++ b/frontend/src/components/MedicationEnrichmentSection.tsx @@ -1,11 +1,24 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { MedicationEnrichmentEnrichResponse, + MedicationEnrichmentPackageOption, MedicationEnrichmentSearchResult, MedicationEnrichmentStrengthOption, } from "../types"; import { formatDate } from "../utils/formatters"; +import { getMedicationEnrichmentDisplayResultKey } from "../utils/medication-enrichment"; + +const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s*/gi; +const PACKAGE_CONTENT_UNIT_PATTERNS = [ + { pattern: /\bcapsules?\b/i, key: "capsule" }, + { pattern: /\btablets?\b/i, key: "tablet" }, + { pattern: /\bcaplets?\b/i, key: "caplet" }, + { pattern: /\bpills?\b/i, key: "pill" }, +] as const; +const INITIAL_VISIBLE_STRENGTH_OPTIONS = 12; + +type TranslateFunction = (key: string, options?: Record) => string; export interface MedicationEnrichmentViewModel { query: string; @@ -15,12 +28,15 @@ export interface MedicationEnrichmentViewModel { hasSearched: boolean; searchError: string | null; applyingCode: string | null; + applyingPackageLabel: string | null; activeResultCode: string | null; appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null; enrichError: string | null; meta: MedicationEnrichmentEnrichResponse["meta"] | null; strengthOptions: MedicationEnrichmentStrengthOption[]; + packageOptions: MedicationEnrichmentPackageOption[]; appliedStrengthLabel: string | null; + appliedPackageLabel: string | null; } export interface MedicationEnrichmentSectionProps { @@ -28,8 +44,194 @@ export interface MedicationEnrichmentSectionProps { onQueryChange: (value: string) => void; onSearch: () => void; onLoadMoreResults?: () => void; - onApplyResult: (result: MedicationEnrichmentSearchResult) => void; + onApplyResult: ( + result: MedicationEnrichmentSearchResult, + preferredPackageOption?: MedicationEnrichmentPackageOption + ) => void; onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void; + onApplyPackage: (option: MedicationEnrichmentPackageOption) => void; +} + +type MedicationEnrichmentPackageChoice = { + option: MedicationEnrichmentPackageOption; + sourceResult: MedicationEnrichmentSearchResult; +}; + +type MedicationEnrichmentDisplayResult = { + displayKey: string; + representative: MedicationEnrichmentSearchResult; + sourceResults: MedicationEnrichmentSearchResult[]; + packageChoices: MedicationEnrichmentPackageChoice[]; + firstIndex: number; +}; + +function normalizePackageOptionDisplayText(value: string): string { + return value + .replace(OPEN_FDA_PACKAGE_CODE_PATTERN, " ") + .replace(/\b([A-Z]{2,})\b/g, (match) => match.toLowerCase()) + .replace(/\s+/g, " ") + .trim(); +} + +function getPackageContainerTranslationKey(packageType: MedicationEnrichmentPackageOption["packageType"]): string { + switch (packageType) { + case "blister": + return "form.enrichment.packageContainers.blister"; + case "bottle": + return "form.enrichment.packageContainers.bottle"; + case "liquid_container": + return "form.enrichment.packageContainers.liquidContainer"; + case "tube": + return "form.enrichment.packageContainers.tube"; + default: + return "form.enrichment.packageContainers.bottle"; + } +} + +function detectPackageContentUnitKey(value: string): string { + for (const candidate of PACKAGE_CONTENT_UNIT_PATTERNS) { + if (candidate.pattern.test(value)) { + return candidate.key; + } + } + + return "tablet"; +} + +function formatSolidPackageCount(count: number, sourceText: string, t: TranslateFunction): string { + const unitKey = detectPackageContentUnitKey(sourceText); + return `${count} ${t(`form.enrichment.packageUnits.${unitKey}`, { count })}`; +} + +function formatPackageContainerCount(option: MedicationEnrichmentPackageOption, t: TranslateFunction): string { + return t(getPackageContainerTranslationKey(option.packageType), { count: Math.max(option.packCount, 1) }); +} + +function buildPackageOptionKey(option: MedicationEnrichmentPackageOption): string { + const sourceText = normalizePackageOptionDisplayText(option.description || option.label); + const detectedUnit = + option.packageType === "bottle" || option.packageType === "blister" + ? detectPackageContentUnitKey(sourceText) + : null; + + return JSON.stringify([ + option.packageType, + option.packCount, + option.blistersPerPack, + option.pillsPerBlister, + option.totalPills, + option.looseTablets, + option.packageAmountValue, + option.packageAmountUnit, + detectedUnit, + ]); +} + +function dedupePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] { + const uniqueOptions = new Map(); + + for (const option of options) { + const key = buildPackageOptionKey(option); + if (!uniqueOptions.has(key)) { + uniqueOptions.set(key, option); + } + } + + return [...uniqueOptions.values()]; +} + +function formatPackageOptionDisplayText( + value: MedicationEnrichmentPackageOption | string, + t: TranslateFunction +): string { + const rawText = typeof value === "string" ? value : value.description || value.label; + const cleanedText = normalizePackageOptionDisplayText(rawText); + + if (typeof value === "string") { + return cleanedText || rawText; + } + + const packageContainerLabel = formatPackageContainerCount(value, t); + + if (value.packageType === "blister") { + if (value.blistersPerPack !== null && value.blistersPerPack > 1 && value.pillsPerBlister !== null) { + return `${packageContainerLabel} · ${value.blistersPerPack} × ${formatSolidPackageCount( + value.pillsPerBlister, + cleanedText, + t + )}`; + } + + const blisterCount = value.pillsPerBlister ?? value.totalPills; + if (blisterCount !== null && blisterCount > 0) { + return `${packageContainerLabel} · ${formatSolidPackageCount(blisterCount, cleanedText, t)}`; + } + } + + if (value.packageType === "bottle") { + const totalCount = value.totalPills ?? value.looseTablets; + if (totalCount !== null && totalCount > 0) { + const countPerContainer = + value.packCount > 1 && totalCount % value.packCount === 0 ? totalCount / value.packCount : totalCount; + return `${packageContainerLabel} · ${formatSolidPackageCount(countPerContainer, cleanedText, t)}`; + } + } + + if ( + (value.packageType === "liquid_container" || value.packageType === "tube") && + value.packageAmountValue !== null && + value.packageAmountUnit + ) { + return `${packageContainerLabel} · ${value.packageAmountValue} ${value.packageAmountUnit}`; + } + + return cleanedText || rawText; +} + +function buildMedicationDisplayResults( + results: MedicationEnrichmentSearchResult[] +): MedicationEnrichmentDisplayResult[] { + const grouped = new Map< + string, + MedicationEnrichmentDisplayResult & { packageChoicesByKey: Map } + >(); + + results.forEach((result, index) => { + const displayKey = getMedicationEnrichmentDisplayResultKey(result); + const existing = grouped.get(displayKey); + + if (!existing) { + const packageChoicesByKey = new Map(); + for (const option of result.packageOptions) { + packageChoicesByKey.set(buildPackageOptionKey(option), { option, sourceResult: result }); + } + + grouped.set(displayKey, { + displayKey, + representative: result, + sourceResults: [result], + packageChoices: [...packageChoicesByKey.values()], + packageChoicesByKey, + firstIndex: index, + }); + return; + } + + existing.sourceResults.push(result); + for (const option of result.packageOptions) { + const key = buildPackageOptionKey(option); + if (!existing.packageChoicesByKey.has(key)) { + existing.packageChoicesByKey.set(key, { option, sourceResult: result }); + } + } + existing.packageChoices = [...existing.packageChoicesByKey.values()]; + }); + + return [...grouped.values()] + .sort( + (left, right) => right.packageChoices.length - left.packageChoices.length || left.firstIndex - right.firstIndex + ) + .map(({ packageChoicesByKey: _packageChoicesByKey, ...result }) => result); } export function MedicationEnrichmentSection({ @@ -39,6 +241,7 @@ export function MedicationEnrichmentSection({ onLoadMoreResults, onApplyResult, onApplyStrength, + onApplyPackage, }: MedicationEnrichmentSectionProps) { const { t } = useTranslation(); const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode; @@ -49,13 +252,29 @@ export function MedicationEnrichmentSection({ state.enrichError !== null || state.results.length > 0 || state.appliedSelection !== null || + state.packageOptions.length > 0 || state.strengthOptions.length > 0 || + state.appliedPackageLabel !== null || state.appliedStrengthLabel !== null || Boolean(state.meta?.partial); const [isExpanded, setIsExpanded] = useState(shouldAutoExpand); const [showInfo, setShowInfo] = useState(false); const [expandedResultCode, setExpandedResultCode] = useState(null); + const [visibleStrengthOptionCount, setVisibleStrengthOptionCount] = useState(INITIAL_VISIBLE_STRENGTH_OPTIONS); const autoExpandStateRef = useRef(shouldAutoExpand); + const resultRefs = useRef(new Map()); + const displayResults = useMemo(() => buildMedicationDisplayResults(state.results), [state.results]); + const uniqueStatePackageOptions = useMemo(() => dedupePackageOptions(state.packageOptions), [state.packageOptions]); + const visibleStrengthOptions = state.strengthOptions.slice(0, visibleStrengthOptionCount); + const hasMoreStrengthOptions = state.strengthOptions.length > visibleStrengthOptions.length; + const appliedPackageOption = useMemo( + () => state.packageOptions.find((option) => option.label === state.appliedPackageLabel) ?? null, + [state.appliedPackageLabel, state.packageOptions] + ); + const isLoadingInitialSearch = state.isSearching && displayResults.length === 0; + const isLoadingMoreResults = state.isSearching && displayResults.length > 0; + const showLoadMoreAction = + displayResults.length > 0 && (state.hasMoreResults || isLoadingMoreResults) && onLoadMoreResults; useEffect(() => { if (shouldAutoExpand && !autoExpandStateRef.current) { @@ -65,6 +284,26 @@ export function MedicationEnrichmentSection({ autoExpandStateRef.current = shouldAutoExpand; }, [shouldAutoExpand]); + useEffect(() => { + setVisibleStrengthOptionCount(INITIAL_VISIBLE_STRENGTH_OPTIONS); + }, []); + + useEffect(() => { + if (!expandedResultCode) { + return; + } + + const animationFrameId = window.requestAnimationFrame(() => { + resultRefs.current.get(expandedResultCode)?.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: "smooth", + }); + }); + + return () => window.cancelAnimationFrame(animationFrameId); + }, [expandedResultCode]); + return (
@@ -74,7 +313,7 @@ export function MedicationEnrichmentSection({
@@ -128,41 +375,79 @@ export function MedicationEnrichmentSection({ {state.searchError ?

{state.searchError}

: null} {state.enrichError ?

{state.enrichError}

: null} {state.meta?.partial ?

{t("form.enrichment.partialNote")}

: null} - {state.hasSearched && !state.isSearching && state.results.length === 0 ? ( + {state.hasSearched && !state.isSearching && state.results.length === 0 && !state.searchError ? (

{t("form.enrichment.noResults")}

) : null} - {state.results.length > 0 ? ( + {displayResults.length > 0 ? (
- {state.results.map((result) => { - const isActive = state.activeResultCode === result.code; + {displayResults.map((displayResult) => { + const { representative, sourceResults, packageChoices, displayKey } = displayResult; + const isActive = sourceResults.some((result) => result.code === state.activeResultCode); + const authorisationHolder = + sourceResults.find((result) => result.authorisationHolder)?.authorisationHolder ?? null; + const therapeuticArea = sourceResults.find((result) => result.therapeuticArea)?.therapeuticArea ?? null; + const authorisationDate = + sourceResults.find((result) => result.authorisationDate)?.authorisationDate ?? null; + const hasPackageOptions = packageChoices.length > 0; + const hasActiveStrengthOptions = isActive && state.strengthOptions.length > 0; + const isApplyingPackageSelection = + isActive && state.applyingCode !== null && state.applyingPackageLabel !== null; const hasDetails = Boolean( - result.authorisationHolder || result.therapeuticArea || result.authorisationDate + authorisationHolder || + therapeuticArea || + authorisationDate || + hasPackageOptions || + hasActiveStrengthOptions || + isApplyingPackageSelection ); - const isDetailsExpanded = expandedResultCode === result.code; - const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral"; - const sourceClass = result.source === "openfda" ? "warning" : "neutral"; + const isDetailsExpanded = expandedResultCode === displayKey; + const activePackageOptions = + isActive && uniqueStatePackageOptions.length > 0 + ? uniqueStatePackageOptions + : packageChoices.map((choice) => choice.option); + const showInlinePackageChoices = activePackageOptions.length > 1; + const genericStatusClass = representative.genericStatus === "generic" ? "success" : "neutral"; + const sourceClass = representative.source === "openfda" ? "warning" : "neutral"; let applyLabel = t("form.enrichment.applyAction"); - if (state.applyingCode === result.code) { + if (isActive && state.applyingCode !== null) { applyLabel = t("form.enrichment.applying"); } else if (isActive && state.appliedSelection) { applyLabel = t("form.enrichment.applied"); } return ( -
+
{ + if (element) { + resultRefs.current.set(displayKey, element); + return; + } + + resultRefs.current.delete(displayKey); + }} + >
- {result.name} - {result.genericName ? ( - {result.genericName} + {representative.name} + {representative.genericName ? ( + {representative.genericName} ) : null}
- {t(`form.enrichment.sources.${result.source}`)} - {result.source === "ema" ? ( + + {hasPackageOptions + ? t("form.enrichment.packageAvailable") + : t("form.enrichment.packageUnavailable")} + + + {t(`form.enrichment.sources.${representative.source}`)} + + {representative.source === "ema" ? ( - {t(`form.enrichment.genericStatus.${result.genericStatus}`)} + {t(`form.enrichment.genericStatus.${representative.genericStatus}`)} ) : null} {hasDetails ? ( @@ -171,7 +456,7 @@ export function MedicationEnrichmentSection({ className="ghost small" aria-expanded={isDetailsExpanded} onClick={() => - setExpandedResultCode((current) => (current === result.code ? null : result.code)) + setExpandedResultCode((current) => (current === displayKey ? null : displayKey)) } > {isDetailsExpanded @@ -179,38 +464,162 @@ export function MedicationEnrichmentSection({ : t("form.enrichment.details.showAction")} ) : null} - + {showInlinePackageChoices ? null : ( + + )}
{hasDetails && isDetailsExpanded ? (
- {result.authorisationHolder ? ( + {authorisationHolder ? (
{t("form.enrichment.details.authorisationHolder")}
-
{result.authorisationHolder}
+
{authorisationHolder}
) : null} - {result.therapeuticArea ? ( + {therapeuticArea ? (
{t("form.enrichment.details.therapeuticArea")}
-
{result.therapeuticArea}
+
{therapeuticArea}
) : null} - {result.authorisationDate ? ( + {authorisationDate ? (
{t("form.enrichment.details.authorisationDate")}
-
{formatDate(result.authorisationDate)}
+
{formatDate(authorisationDate)}
+
+ ) : null} + {activePackageOptions.length > 0 ? ( +
+
{t("form.enrichment.details.packageSizes")}
+
+
+ {showInlinePackageChoices ? ( +
+ {activePackageOptions.map((option) => { + const isApplyingPending = + isApplyingPackageSelection && state.applyingPackageLabel === option.label; + const isSelected = + isActive && + (state.appliedPackageLabel === option.label || + (appliedPackageOption !== null && + buildPackageOptionKey(appliedPackageOption) === + buildPackageOptionKey(option))); + const packageLabel = formatPackageOptionDisplayText(option, t); + + return ( + + ); + })} +
+ ) : ( +
    + {activePackageOptions.map((option) => ( +
  • {formatPackageOptionDisplayText(option, t)}
  • + ))} +
+ )} + {isActive && state.appliedPackageLabel ? ( +

+ {t("form.enrichment.appliedPackage", { + label: formatPackageOptionDisplayText( + appliedPackageOption ?? state.appliedPackageLabel, + t + ), + })} +

+ ) : null} +
+
+
+ ) : null} + {isApplyingPackageSelection ? ( +
+
{t("form.enrichment.strengthTitle")}
+
+
+
+
+
+ ) : null} + {hasActiveStrengthOptions ? ( +
+
{t("form.enrichment.strengthTitle")}
+
+
+

+ {t("form.enrichment.strengthHint")} +

+
+ {visibleStrengthOptions.map((option) => { + const isSelected = state.appliedStrengthLabel === option.label; + + return ( + + ); + })} +
+ {hasMoreStrengthOptions ? ( + + ) : null} + {state.appliedStrengthLabel ? ( +

+ {t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })} +

+ ) : null} +
+
) : null}
@@ -221,61 +630,21 @@ export function MedicationEnrichmentSection({
) : null} - {state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? ( + {showLoadMoreAction ? (
) : null} - - {state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? ( -
- {state.appliedSelection ? ( -
-

{t("form.enrichment.applied")}

-

- {state.appliedSelection.name} - {state.appliedSelection.genericName ? ` • ${state.appliedSelection.genericName}` : ""} -

-
- ) : null} - - {state.strengthOptions.length > 0 ? ( -
-

{t("form.enrichment.strengthTitle")}

-

{t("form.enrichment.strengthHint")}

-
- {state.strengthOptions.map((option) => { - const isSelected = state.appliedStrengthLabel === option.label; - - return ( - - ); - })} -
-
- ) : null} - - {state.appliedStrengthLabel ? ( -

- {t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })} -

- ) : null} -
- ) : null} ) : null} diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 4ce4ed1..de11bb8 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -16,6 +16,7 @@ import type { FormIntake, FormState, Medication, + MedicationEnrichmentPackageOption, MedicationEnrichmentSearchResult, MedicationEnrichmentStrengthOption, } from "../types"; @@ -59,12 +60,15 @@ const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = { hasSearched: false, searchError: null, applyingCode: null, + applyingPackageLabel: null, activeResultCode: null, appliedSelection: null, enrichError: null, meta: null, strengthOptions: [], + packageOptions: [], appliedStrengthLabel: null, + appliedPackageLabel: null, }; export interface MobileEditModalProps { @@ -76,8 +80,12 @@ export interface MobileEditModalProps { onMedicationEnrichmentQueryChange?: (value: string) => void; onMedicationEnrichmentSearch?: () => void; onMedicationEnrichmentLoadMore?: () => void; - onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void; + onMedicationEnrichmentApply?: ( + result: MedicationEnrichmentSearchResult, + preferredPackageOption?: MedicationEnrichmentPackageOption + ) => void; onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void; + onMedicationEnrichmentPackageApply?: (option: MedicationEnrichmentPackageOption) => void; fieldErrors: FieldErrors; saving: boolean; formSaved: boolean; @@ -136,6 +144,7 @@ export function MobileEditModal({ onMedicationEnrichmentLoadMore = () => {}, onMedicationEnrichmentApply = () => {}, onMedicationEnrichmentStrengthApply = () => {}, + onMedicationEnrichmentPackageApply = () => {}, fieldErrors, saving, formSaved, @@ -492,6 +501,7 @@ export function MobileEditModal({ onLoadMoreResults={onMedicationEnrichmentLoadMore} onApplyResult={onMedicationEnrichmentApply} onApplyStrength={onMedicationEnrichmentStrengthApply} + onApplyPackage={onMedicationEnrichmentPackageApply} />