diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts index 65fed2f..7dfcb43 100644 --- a/backend/src/i18n/translations.ts +++ b/backend/src/i18n/translations.ts @@ -179,6 +179,8 @@ type TranslationKeys = { common: { pill: string; pills: string; + units: string; + ml: string; blister: string; blisters: string; day: string; @@ -299,6 +301,8 @@ const translations: Record = { common: { pill: "pill", pills: "pills", + units: "units", + ml: "ml", blister: "blister", blisters: "blisters", day: "day", @@ -420,6 +424,8 @@ const translations: Record = { common: { pill: "Tablette", pills: "Tabletten", + units: "Einheiten", + ml: "ml", blister: "Blister", blisters: "Blister", day: "Tag", diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 6be7d23..d4aec31 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -42,6 +42,16 @@ type PlannerRow = { packageType?: string; }; +function isContainerPackage(packageType?: string): boolean { + return packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"; +} + +function getPlannerUnit(packageType: string | undefined, tr: ReturnType): string { + if (packageType === "tube") return tr.common.units; + if (packageType === "liquid_container") return tr.common.ml; + return tr.common.pills; +} + type SendEmailBody = { email: string; from: string; @@ -168,16 +178,18 @@ ${summaryText} ${activeRows .map((r) => { - const isBottle = r.packageType === "bottle"; - const usage = `${r.plannerUsage} ${tr.common.pills}`; + const isBottle = isContainerPackage(r.packageType); + const usageUnit = getPlannerUnit(r.packageType, tr); + const usage = `${r.plannerUsage} ${usageUnit}`; const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`; const medPrescription = prescriptionMap.get(r.medicationId); const rxRefills = medPrescription?.prescriptionEnabled ? String(medPrescription.prescriptionRemainingRefills ?? 0) : dc.prescriptionNotApplicable; const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10; + const availableUnit = getPlannerUnit(r.packageType, tr); const available = isBottle - ? `${loosePills} ${tr.common.pills}` + ? `${loosePills} ${availableUnit}` : `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`; const status = r.enough ? dc.statusEnough : dc.statusEmpty; return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`; @@ -209,7 +221,7 @@ ${getFooterPlain(language)}`; const safeBlisterSize = Number(row.blisterSize) || 0; const safeFullBlisters = Number(row.fullBlisters) || 0; const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10; - const isBottle = row.packageType === "bottle"; + const isBottle = isContainerPackage(row.packageType); // "Blisters needed" column: dash for bottles const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`; @@ -223,7 +235,8 @@ ${getFooterPlain(language)}`; // "Available" column: match frontend format let availableCell: string; if (isBottle) { - availableCell = `${safeLoosePills} ${tr.common.pills}`; + const availableUnit = getPlannerUnit(row.packageType, tr); + availableCell = `${safeLoosePills} ${availableUnit}`; } else { availableCell = `${safeFullBlisters} ${tr.common.blisters}`; if (safeLoosePills > 0) { @@ -236,7 +249,7 @@ ${getFooterPlain(language)}`; return ` ${safeName} - ${safePlannerUsage} ${tr.common.pills} + ${safePlannerUsage} ${getPlannerUnit(row.packageType, tr)} ${neededCell} ${rxCell} ${availableCell} @@ -324,7 +337,7 @@ ${getFooterPlain(language)}`; const pushTitle = t(dc.subject, { from: fromDate, until: untilDate }); const pushMessage = `${summaryText}\n\n${activeRows .map((r) => { - const usage = `${r.plannerUsage} ${tr.common.pills}`; + const usage = `${r.plannerUsage} ${getPlannerUnit(r.packageType, tr)}`; const status = r.enough ? dc.statusEnough : dc.statusEmpty; return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`; }) diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 3489ed6..9b08229 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -120,7 +120,9 @@ export async function shareRoutes(app: FastifyInstance) { const takenByArray = parseTakenByJson(med.takenByJson); const totalPills = - (med.packageType ?? "blister") === "bottle" + (med.packageType ?? "blister") === "bottle" || + (med.packageType ?? "blister") === "tube" || + (med.packageType ?? "blister") === "liquid_container" ? med.looseTablets + (med.stockAdjustment ?? 0) : med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); return { diff --git a/doku/memory_notes.md b/doku/memory_notes.md index d8d5520..0191b0e 100644 --- a/doku/memory_notes.md +++ b/doku/memory_notes.md @@ -23,6 +23,21 @@ Use this block for each meaningful task: ## Entries +### 2026-02-28 (PR #359 backend CI failure triage) + +- 🧩 Task: Reproduce and fix failing backend CI check on `feat/topical-no-depletion-planner`. +- ✅ Decisions: + - Reproduced CI sequence locally (`lint`, `tsc --noEmit`, `test:coverage`) and identified TypeScript failure in `backend/src/routes/planner.ts`. + - Root cause: route referenced `tr.common.units` and `tr.common.ml`, but these keys were missing from `TranslationKeys.common` and both language dictionaries. + - Applied minimal backend-only fix by adding `units` and `ml` to `backend/src/i18n/translations.ts` type + EN/DE values. + - Revalidated backend CI steps locally: lint pass, typecheck pass, full coverage suite pass. +- 📁 Files touched: + - `backend/src/i18n/translations.ts` + - `doku/memory_notes.md` + - `doku/report.md` +- 🔜 Follow-up/open points: + - Branch is ready for release-manager handoff for remote push/PR operations (not performed by this agent). + ### 2026-02-28 (CI triage: Backend Tests failure on PR #356) - 🧩 Task: Reproduce and fix `Backend Tests` CI failure on `feat/package-amount-backend`. diff --git a/doku/report.md b/doku/report.md index c966cba..2df1e16 100644 --- a/doku/report.md +++ b/doku/report.md @@ -28,6 +28,26 @@ For each task, add: ## Entries +### 2026-02-28 (PR #359 backend CI fix) + +- **🧩 Scope**: Triage and resolve failing backend CI check on branch `feat/topical-no-depletion-planner` with minimal change scope. +- **🛠️ What changed**: + - Reproduced backend CI locally with the same command sequence as `.github/workflows/test.yml`. + - Identified TypeScript compile failure: + - `backend/src/routes/planner.ts` references `tr.common.units` and `tr.common.ml`. + - `backend/src/i18n/translations.ts` did not define those keys in `common`. + - Added missing `common.units` and `common.ml` keys to the translation type and both language maps (`en`, `de`). +- **📁 Files touched**: + - `backend/src/i18n/translations.ts` + - `doku/memory_notes.md` + - `doku/report.md` +- **🔬 Validation run**: + - `cd backend && npm run lint` -> passed + - `cd backend && npx tsc --noEmit` -> passed + - `cd backend && CI=true npm run test:coverage` -> **21 files passed, 572 tests passed** +- **🔜 Follow-ups**: + - Remote push/PR update must be performed by `@release-manager` per repository governance. + ### 2026-02-28 (PR #356 backend CI failure triage) - **🧩 Scope**: Reproduce and fix failing `Backend Tests` check on branch `feat/package-amount-backend`. diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts index f7a6d03..f2d93a8 100644 --- a/frontend/e2e/auth.setup.ts +++ b/frontend/e2e/auth.setup.ts @@ -70,40 +70,82 @@ setup("authenticate", async ({ page }) => { // Wait for auth container await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 }); - // ---- 3. Ensure the test user exists ---- + // ---- 3. Query auth state to determine login method ---- const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173"; - await page.request - .post(`${baseURL}/api/auth/register`, { - data: { username: TEST_USER.username, password: TEST_USER.password }, - }) - .catch(() => {}); - - // ---- 4. Log in via UI ---- - const usernameField = page.locator("#username"); - const passwordField = page.locator("#password"); - - // Make sure we're on the login form (not register) - const isOnRegister = await page - .locator(".auth-subtitle") - .filter({ hasText: /Create Account/i }) - .isVisible() - .catch(() => false); - - if (isOnRegister) { - const switchBtn = page.locator("button.auth-link-btn"); - if (await switchBtn.isVisible().catch(() => false)) { - await switchBtn.click(); - await page.waitForTimeout(500); + let formLoginEnabled = true; + let oidcEnabled = false; + try { + const stateRes = await page.request.get(`${baseURL}/api/auth/state`); + if (stateRes.ok()) { + const state = await stateRes.json(); + formLoginEnabled = state.formLoginEnabled !== false; + oidcEnabled = state.oidcEnabled === true; } + } catch { + // Fallback: assume form login is available } - await usernameField.clear(); - await usernameField.fill(TEST_USER.username); - await passwordField.clear(); - await passwordField.fill(TEST_USER.password); + // ---- 4. Ensure the test user exists (only if form login is available) ---- + if (formLoginEnabled) { + await page.request + .post(`${baseURL}/api/auth/register`, { + data: { username: TEST_USER.username, password: TEST_USER.password }, + }) + .catch(() => {}); + } - // Click the submit button (not the SSO button) - await page.locator('button.auth-submit[type="submit"]').click(); + // ---- 5. Log in via the appropriate method ---- + if (formLoginEnabled) { + // Form login path: username/password + const usernameField = page.locator("#username"); + const passwordField = page.locator("#password"); + + // Make sure we're on the login form (not register) + const isOnRegister = await page + .locator(".auth-subtitle") + .filter({ hasText: /Create Account/i }) + .isVisible() + .catch(() => false); + + if (isOnRegister) { + const switchBtn = page.locator("button.auth-link-btn"); + if (await switchBtn.isVisible().catch(() => false)) { + await switchBtn.click(); + await page.waitForTimeout(500); + } + } + + await usernameField.clear(); + await usernameField.fill(TEST_USER.username); + await passwordField.clear(); + await passwordField.fill(TEST_USER.password); + + // Click the submit button (not the SSO button) + await page.locator('button.auth-submit[type="submit"]').click(); + } else if (oidcEnabled) { + // SSO-only path: click the SSO button and let the OIDC provider handle login. + // This requires the OIDC provider to be configured with test credentials + // (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars) + // or to auto-approve the test user. + await page.locator("button.sso-btn").click(); + + // Wait for OIDC redirect and callback — the provider may show its own login form + const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME; + const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD; + if (oidcUsername && oidcPassword) { + // Fill OIDC provider login form (generic selectors — override if needed) + await page.waitForURL(/.*/, { timeout: 15000 }); + const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first(); + const oidcPassField = page.locator('input[name="password"], input[type="password"]').first(); + if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) { + await oidcUserField.fill(oidcUsername); + await oidcPassField.fill(oidcPassword); + await page.locator('button[type="submit"]').first().click(); + } + } + } else { + throw new Error("No login method available: form login and OIDC are both disabled"); + } // Wait for successful auth — app header should appear await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 }); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index ee42df9..ff54544 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,16 +1,28 @@ import { expect, type Page, test } from "@playwright/test"; -async function isAuthEnabled(page: Page): Promise { +interface AuthStateResponse { + authEnabled: boolean; + formLoginEnabled: boolean; + oidcEnabled: boolean; + oidcProviderName: string; + registrationEnabled: boolean; +} + +async function getAuthState(page: Page): Promise { try { const response = await page.request.get("/api/auth/state"); - if (!response.ok()) return true; - const state = await response.json(); - return state?.authEnabled !== false; + if (!response.ok()) return null; + return (await response.json()) as AuthStateResponse; } catch { - return true; + return null; } } +async function isAuthEnabled(page: Page): Promise { + const state = await getAuthState(page); + return state?.authEnabled !== false; +} + /** * Authentication E2E Tests * @@ -110,4 +122,48 @@ test.describe("Authentication", () => { const newText = await subtitle.textContent(); expect(newText).not.toBe(initialText); }); + + test("should show SSO button when OIDC is enabled", async ({ page }) => { + const state = await getAuthState(page); + test.skip(!state?.authEnabled, "Auth is disabled in this environment"); + test.skip(!state?.oidcEnabled, "OIDC is not enabled in this environment"); + + await page.goto("/"); + await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 }); + + const ssoButton = page.locator("button.sso-btn"); + await expect(ssoButton).toBeVisible(); + await expect(ssoButton).toContainText(state.oidcProviderName || "SSO"); + }); + + test("should hide form login when formLoginEnabled is false", async ({ page }) => { + const state = await getAuthState(page); + test.skip(!state?.authEnabled, "Auth is disabled in this environment"); + test.skip(state?.formLoginEnabled !== false, "Form login is enabled — cannot test hidden state"); + + await page.goto("/"); + await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 }); + + // Username/password fields should not be visible + await expect(page.locator("#username")).not.toBeVisible(); + await expect(page.locator("#password")).not.toBeVisible(); + + // SSO button should be the only login method + await expect(page.locator("button.sso-btn")).toBeVisible(); + }); + + test("should show both login methods when OIDC and form login are enabled", async ({ page }) => { + const state = await getAuthState(page); + test.skip(!state?.authEnabled, "Auth is disabled in this environment"); + test.skip(!state?.oidcEnabled, "OIDC is not enabled"); + test.skip(!state?.formLoginEnabled, "Form login is not enabled"); + + await page.goto("/"); + await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 }); + + // Both login methods visible + await expect(page.locator("#username")).toBeVisible(); + await expect(page.locator("#password")).toBeVisible(); + await expect(page.locator("button.sso-btn")).toBeVisible(); + }); }); diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts index 91c7dd2..177a7a0 100644 --- a/frontend/e2e/fixtures/index.ts +++ b/frontend/e2e/fixtures/index.ts @@ -103,25 +103,43 @@ export const test = base.extend({ /** * Wait for the app to be fully loaded past any loading/initializing screens. - * Includes a single retry with page reload to handle transient auth failures - * (e.g. brief race between context setup and cookie application). + * Retries up to 2 times with page reload to handle transient auth or + * rate-limit failures. */ export async function waitForAppReady(page: Page): Promise { const hero = page.locator("header.hero"); - try { - await expect(hero).toBeVisible({ timeout: 15000 }); - } catch { - // Auth might have failed transiently — reload and retry once - await page.reload(); - await expect(hero).toBeVisible({ timeout: 15000 }); + for (let attempt = 0; attempt < 3; attempt++) { + try { + await expect(hero).toBeVisible({ timeout: 15000 }); + return; + } catch { + if (attempt === 2) throw new Error("App failed to become ready after 3 attempts"); + // Check for rate-limit error displayed in UI + const rateLimited = await page + .locator("text=rate limit, text=429, text=too many") + .first() + .isVisible() + .catch(() => false); + if (rateLimited) { + // Wait longer before retrying if rate-limited + await page.waitForTimeout(5000); + } + await page.reload(); + } } } /** * Navigate to a page and wait for it to be ready. + * Handles transient navigation failures with a single retry. */ export async function navigateTo(page: Page, path: string): Promise { - await page.goto(path); + const response = await page.goto(path); + if (response && response.status() === 429) { + // Rate-limited — wait and retry once + await page.waitForTimeout(5000); + await page.goto(path); + } await waitForAppReady(page); await page.waitForLoadState("networkidle"); } @@ -259,13 +277,21 @@ export async function createMedicationViaAPI(data: { /** * Delete a medication via the backend API. + * Includes retry for rate-limited responses. */ export async function deleteMedicationViaAPI(id: number): Promise { const token = getAuthCookie(); - await fetch(`${API_BASE}/api/medications/${id}`, { - method: "DELETE", - headers: token ? { Cookie: `access_token=${token}` } : {}, - }); + for (let attempt = 0; attempt < 3; attempt++) { + const res = await fetch(`${API_BASE}/api/medications/${id}`, { + method: "DELETE", + headers: token ? { Cookie: `access_token=${token}` } : {}, + }); + if (res.status === 429) { + await new Promise((r) => setTimeout(r, 3000 * (attempt + 1))); + continue; + } + return; + } } /** diff --git a/frontend/e2e/medication-edit.spec.ts b/frontend/e2e/medication-edit.spec.ts index b558214..1ff9246 100644 --- a/frontend/e2e/medication-edit.spec.ts +++ b/frontend/e2e/medication-edit.spec.ts @@ -233,7 +233,7 @@ test.describe("Medication Editing", () => { // Change intake from 1 pill daily to 2 pills every 7 days const intakeRow = page.locator(".blister-row").first(); - const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i); + const usageField = intakeRow.getByLabel(/(Usage|form\.blisters\.usage)/i); const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i); await usageField.fill("2"); @@ -247,7 +247,7 @@ test.describe("Medication Editing", () => { // Verify the changes persisted await clickEditMed(page, "Edit Intake Med"); const savedRow = page.locator(".blister-row").first(); - await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2"); + await expect(savedRow.getByLabel(/(Usage|form\.blisters\.usage)/i)).toHaveValue("2"); await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7"); }); @@ -279,7 +279,7 @@ test.describe("Medication Editing", () => { // Fill the new intake row const secondRow = page.locator(".blister-row").nth(1); - await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5"); + await secondRow.getByLabel(/(Usage|form\.blisters\.usage)/i).fill("0.5"); await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7"); await saveEditAndVerify(page, "Add Intake Med"); diff --git a/frontend/e2e/medication-lifecycle.spec.ts b/frontend/e2e/medication-lifecycle.spec.ts new file mode 100644 index 0000000..18e9dc5 --- /dev/null +++ b/frontend/e2e/medication-lifecycle.spec.ts @@ -0,0 +1,193 @@ +import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures"; + +/** + * Medication Lifecycle Integration Tests + * + * End-to-end workflows that verify changes propagate across pages: + * create → verify on medications → check in planner → check in schedule → edit → delete + */ +test.describe("Medication lifecycle", () => { + test.use({ storageState: authFile }); + test.describe.configure({ timeout: 90000 }); + + const MED_NAME = "Lifecycle TestMed"; + const MED_EDITED = "Lifecycle Edited"; + + test.beforeAll(async () => { + await deleteAllMedicationsViaAPI(); + }); + + test.afterAll(async () => { + await deleteAllMedicationsViaAPI(); + }); + + test("create medication via API and verify it appears on all pages", async ({ page }) => { + const todayMorning = (() => { + const d = new Date(); + d.setHours(8, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + // Step 1: Create medication + const created = await createMedicationViaAPI({ + name: MED_NAME, + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }], + }); + expect(created.id).toBeTruthy(); + + // Step 2: Verify on medications page + await navigateTo(page, "/medications"); + await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 }); + + // Step 3: Verify in planner + await navigateTo(page, "/planner"); + await page.waitForLoadState("networkidle"); + await page.locator('form.planner button[type="submit"]').click(); + await expect(page.locator(".table")).toBeVisible({ timeout: 15000 }); + await expect(page.locator(".table").getByText(MED_NAME)).toBeVisible(); + + // Step 4: Verify in schedule + await navigateTo(page, "/schedule"); + await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 }); + }); + + test("edit medication name via UI and verify update propagates", async ({ page }) => { + await deleteAllMedicationsViaAPI(); + + const todayMorning = (() => { + const d = new Date(); + d.setHours(8, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + // Create a fresh medication for this test + await createMedicationViaAPI({ + name: MED_NAME, + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }], + }); + + // Navigate to medications page + await navigateTo(page, "/medications"); + await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 }); + + // Open edit view from medication row actions + const medRow = page.locator(".med-row").filter({ hasText: MED_NAME }); + await expect(medRow.first()).toBeVisible({ timeout: 10000 }); + await medRow.first().locator("button.info").click(); + await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({ + timeout: 5000, + }); + + // Update the name + const form = page.locator("form.form-grid:visible").first(); + const nameInput = form.getByLabel(/(Commercial Name|Name|form\.name)/i).first(); + await nameInput.fill(MED_EDITED); + + // Save + const submitButton = form.locator('button[type="submit"]').first(); + await expect(submitButton).toBeEnabled({ timeout: 5000 }); + await submitButton.click(); + + // Wait for modal to close or save to complete + await page.waitForLoadState("networkidle"); + + // Verify edited name appears on medications page + await navigateTo(page, "/medications"); + await expect(page.getByText(MED_EDITED).first()).toBeVisible({ timeout: 10000 }); + // Old name should no longer appear + await expect(page.locator(".med-row").filter({ hasText: MED_NAME })).toHaveCount(0, { timeout: 5000 }); + }); + + test("delete medication via API and verify it disappears from all pages", async ({ page }) => { + const todayMorning = (() => { + const d = new Date(); + d.setHours(8, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + // Create and then delete + await deleteAllMedicationsViaAPI(); + await createMedicationViaAPI({ + name: MED_NAME, + packageType: "blister", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 5, + looseTablets: 0, + intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }], + }); + + // Verify it exists first + await navigateTo(page, "/medications"); + await expect(page.getByText(MED_NAME)).toBeVisible({ timeout: 10000 }); + + // Delete via API + await deleteAllMedicationsViaAPI(); + + // Verify gone from medications page + await navigateTo(page, "/medications"); + await expect(page.getByText(MED_NAME)).not.toBeVisible({ timeout: 5000 }); + + // Verify planner shows no results for this med + await navigateTo(page, "/planner"); + await page.waitForLoadState("networkidle"); + await page.locator('form.planner button[type="submit"]').click(); + // Either no table or table without the medication name + const table = page.locator(".table"); + const tableVisible = await table.isVisible().catch(() => false); + if (tableVisible) { + await expect(table.getByText(MED_NAME)).not.toBeVisible({ timeout: 3000 }); + } + }); + + test("medication with multiple intakes shows all schedule entries", async ({ page }) => { + const todayMorning = (() => { + const d = new Date(); + d.setHours(8, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + const todayEvening = (() => { + const d = new Date(); + d.setHours(20, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + await deleteAllMedicationsViaAPI(); + await createMedicationViaAPI({ + name: "MultiIntake Med", + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + intakes: [ + { usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }, + { usage: 2, every: 1, start: todayEvening, intakeRemindersEnabled: false }, + ], + }); + + // Verify schedule shows this medication + await navigateTo(page, "/schedule"); + await expect(page.getByText("MultiIntake Med").first()).toBeVisible({ timeout: 10000 }); + + // The medication should appear at least twice (morning + evening) + const medEntries = page.getByText("MultiIntake Med"); + expect(await medEntries.count()).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/frontend/e2e/performance.spec.ts b/frontend/e2e/performance.spec.ts new file mode 100644 index 0000000..cfac3b2 --- /dev/null +++ b/frontend/e2e/performance.spec.ts @@ -0,0 +1,98 @@ +import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures"; + +/** + * Performance Tests + * + * Verify the schedule timeline and planner render within acceptable + * time limits when many medications exist. + */ +test.describe("Performance with many medications", () => { + test.use({ storageState: authFile }); + test.describe.configure({ timeout: 120000 }); + + const MED_COUNT = 20; + const MED_PREFIX = "PerfTest Med"; + + test.beforeAll(async () => { + await deleteAllMedicationsViaAPI(); + + const todayMorning = (() => { + const d = new Date(); + d.setHours(8, 0, 0, 0); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + })(); + + // Create medications sequentially (API rate limits prevent parallel) + for (let i = 1; i <= MED_COUNT; i++) { + await createMedicationViaAPI({ + name: `${MED_PREFIX} ${i}`, + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }], + }); + } + }); + + test.afterAll(async () => { + await deleteAllMedicationsViaAPI(); + }); + + test("schedule page renders within 10 seconds with 20 medications", async ({ page }) => { + const start = Date.now(); + await navigateTo(page, "/schedule"); + + // Wait for schedule entries to render + const scheduleEntries = page.locator(".schedule-entry, .timeline-entry, .card"); + await expect(scheduleEntries.first()).toBeVisible({ timeout: 15000 }); + + const renderTime = Date.now() - start; + + // Verify all medications appear + for (let i = 1; i <= MED_COUNT; i++) { + await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 }); + } + + // Goal: render under 10 seconds + expect(renderTime).toBeLessThan(10000); + }); + + test("medications page renders within 10 seconds with 20 medications", async ({ page }) => { + const start = Date.now(); + await navigateTo(page, "/medications"); + + // Wait for medication cards to render + const medEntries = page.locator(".medication-card, .card, .table-row"); + await expect(medEntries.first()).toBeVisible({ timeout: 15000 }); + + const renderTime = Date.now() - start; + + // Verify count — all 20 should be visible + for (let i = 1; i <= MED_COUNT; i++) { + await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 }); + } + + expect(renderTime).toBeLessThan(10000); + }); + + test("planner calculates within 15 seconds with 20 medications", async ({ page }) => { + await navigateTo(page, "/planner"); + + const start = Date.now(); + await page.waitForLoadState("networkidle"); + await page.locator('form.planner button[type="submit"]').click(); + await expect(page.locator(".table")).toBeVisible({ timeout: 20000 }); + + const calcTime = Date.now() - start; + + // All medications should appear in the results + const rows = page.locator(".table .table-row"); + expect(await rows.count()).toBeGreaterThanOrEqual(MED_COUNT); + + // Goal: calculate and render under 15 seconds + expect(calcTime).toBeLessThan(15000); + }); +}); diff --git a/frontend/e2e/planner-data.spec.ts b/frontend/e2e/planner-data.spec.ts index 9f90f0a..c50721d 100644 --- a/frontend/e2e/planner-data.spec.ts +++ b/frontend/e2e/planner-data.spec.ts @@ -106,7 +106,7 @@ test.describe("Planner with medications", () => { expect(await statusChips.count()).toBeGreaterThanOrEqual(2); }); - test("should show usage data in results rows", async ({ page }) => { + test("should show correct usage values in results rows", async ({ page }) => { await navigateTo(page, "/planner"); await calculatePlanner(page); @@ -116,10 +116,15 @@ test.describe("Planner with medications", () => { const rows = resultsTable.locator(".table-row"); expect(await rows.count()).toBeGreaterThanOrEqual(2); - const firstRowText = await rows.first().textContent(); - expect(firstRowText).toBeTruthy(); - // Check for "pill" (matches both "pill" and "pills") - expect(firstRowText!.toLowerCase()).toContain("pill"); + // Each medication has usage=1, every=1 → plannerUsage should reflect the period + // Verify the usage column contains a numeric value and "pill(s)" + for (const row of await rows.all()) { + const usageCell = row.locator("[data-label]").nth(1); // Usage is 2nd column + const usageStrong = usageCell.locator("strong"); + await expect(usageStrong).toBeVisible(); + const usageText = await usageStrong.textContent(); + expect(Number(usageText)).toBeGreaterThan(0); + } }); test("should show danger status for low-stock medication over 90 days", async ({ page }) => { @@ -139,9 +144,16 @@ test.describe("Planner with medications", () => { const resultsTable = page.locator(".table"); await expect(resultsTable).toBeVisible({ timeout: 10000 }); - // Low-stock med (3 pills) should have a danger chip over 90 days + // Low-stock med (3 pills, usage 1/day, 90 days) should have danger status const dangerChips = resultsTable.locator(".status-chip.danger"); expect(await dangerChips.count()).toBeGreaterThanOrEqual(1); + + // Find the low-stock med row and verify its usage value ~90 pills + const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW }); + await expect(lowStockRow).toBeVisible(); + const lowUsage = await lowStockRow.locator("[data-label] strong").first().textContent(); + expect(Number(lowUsage)).toBeGreaterThanOrEqual(85); // ~90 pills needed + expect(Number(lowUsage)).toBeLessThanOrEqual(95); }); test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => { @@ -161,9 +173,16 @@ test.describe("Planner with medications", () => { const resultsTable = page.locator(".table"); await expect(resultsTable).toBeVisible({ timeout: 10000 }); - // With 60 pills and 7-day range, high-stock should be "Enough" - const successChips = resultsTable.locator(".status-chip.success"); - expect(await successChips.count()).toBeGreaterThanOrEqual(1); + // High-stock med (60 pills, usage 1/day, 7 days → needs ~7, has 60) should be "Enough" + const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH }); + await expect(highStockRow).toBeVisible(); + const highStatus = highStockRow.locator(".status-chip.success"); + await expect(highStatus).toBeVisible(); + + // Verify usage is ~7 pills for the 7-day range + const highUsage = await highStockRow.locator("[data-label] strong").first().textContent(); + expect(Number(highUsage)).toBeGreaterThanOrEqual(5); + expect(Number(highUsage)).toBeLessThanOrEqual(10); }); test("should show table header with correct columns", async ({ page }) => { @@ -180,6 +199,28 @@ test.describe("Planner with medications", () => { await expect(tableHead.getByText(/Status/i)).toBeVisible(); }); + test("should display available stock for each medication", async ({ page }) => { + await navigateTo(page, "/planner"); + await calculatePlanner(page); + + const resultsTable = page.locator(".table"); + await expect(resultsTable).toBeVisible({ timeout: 10000 }); + + // High-stock med should show a blister + loose-pill stock breakdown + const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH }); + await expect(highStockRow).toBeVisible(); + const highStockText = await highStockRow.textContent(); + expect(highStockText).toMatch(/\d+\s*(blisters|Blister)/i); + expect(highStockText).toMatch(/\d+\s*(pill|pills|Tablette|Tabletten)/i); + + // Low-stock med: 1 pack × 1 blister × 3 pills = 3 pills = 0 full blisters + 3 loose + const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW }); + await expect(lowStockRow).toBeVisible(); + const lowStockText = await lowStockRow.textContent(); + // Should show 3 loose pills + expect(lowStockText).toMatch(/3\s*(pill|pills|Tablette|Tabletten)/i); + }); + test("should reset form and clear results", async ({ page }) => { await navigateTo(page, "/planner"); await calculatePlanner(page); diff --git a/frontend/playwright.base.config.ts b/frontend/playwright.base.config.ts index 7bd035c..1821e0c 100644 --- a/frontend/playwright.base.config.ts +++ b/frontend/playwright.base.config.ts @@ -19,13 +19,13 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) { use: { ...devices["Desktop Chrome"], }, - testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/, dependencies: ["setup"], retries: 1, }, { name: "chromium-data", - testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + testMatch: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/, use: { ...devices["Desktop Chrome"], }, @@ -42,7 +42,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) { use: { ...devices["Desktop Firefox"], }, - testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/, dependencies: ["setup"], }, { @@ -50,7 +50,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) { use: { ...devices["Desktop Safari"], }, - testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/, + testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/, dependencies: ["setup"], }, ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index acd4ac4..d709df1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,13 +37,29 @@ export default function App() { ); } +function getInitialAuthTheme(): "light" | "dark" { + if (typeof window === "undefined") return "dark"; + + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") { + return stored; + } + + if (stored === "system") { + return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + + return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark"; +} + function AppRouter() { const { user, authState, loading, authError } = useAuth(); + const authTheme = getInitialAuthTheme(); // Show loading while checking auth state if (loading) { return ( -
+

💊 MedAssist-ng

Loading...

@@ -55,7 +71,7 @@ function AppRouter() { // Show error if we couldn't connect to the server if (authError) { return ( -
+

💊 MedAssist-ng

@@ -77,7 +93,7 @@ function AppRouter() { // If auth state is null (shouldn't happen after loading, but be safe) if (!authState) { return ( -
+

💊 MedAssist-ng

Initializing...

diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index 5b3472b..1160416 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -192,12 +192,20 @@ export function MedDetailModal({ ]); if (!selectedMed) return null; + const isTube = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container"; + const stockUnitLabel = isTube + ? selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid" + ? "ml" + : t("form.blisters.applications") + : null; const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed)); const packageSize = getPackageSize(selectedMed); // Structural max = sealed package capacity only (excludes pre-existing looseTablets). const structuralMax = - selectedMed.packageType === "bottle" + selectedMed.packageType === "bottle" || + selectedMed.packageType === "tube" || + selectedMed.packageType === "liquid_container" ? (selectedMed.totalPills ?? packageSize) : selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister; const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed); @@ -209,7 +217,11 @@ export function MedDetailModal({ const currentPartialPills = Math.max(0, stock.openBlisterPills); const currentLoosePills = Math.max(0, stock.loosePills); const stockDisplayTotal = - selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, structuralMax); + selectedMed.packageType === "bottle" || + selectedMed.packageType === "tube" || + selectedMed.packageType === "liquid_container" + ? (selectedMed.totalPills ?? packageSize) + : Math.max(0, structuralMax); const maxPartialPills = Math.min( Math.max(0, selectedMed.pillsPerBlister), Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister) @@ -392,7 +404,9 @@ export function MedDetailModal({ })}

)} - {selectedMed.packageType === "bottle" && ( + {(selectedMed.packageType === "bottle" || + selectedMed.packageType === "tube" || + selectedMed.packageType === "liquid_container") && (

{t("editStock.packageSize", { count: structuralMax })}

)} {showStockCapNotice && ( @@ -402,7 +416,10 @@ export function MedDetailModal({ {(() => { const dbTotal = getMedTotal(selectedMed); const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal; - const isBottle = selectedMed.packageType === "bottle"; + const isBottle = + selectedMed.packageType === "bottle" || + selectedMed.packageType === "tube" || + selectedMed.packageType === "liquid_container"; const enteredTotal = isBottle ? editStockPartialBlisterPills : editStockFullBlisters * selectedMed.pillsPerBlister + @@ -590,20 +607,25 @@ export function MedDetailModal({
{t("editStock.currentTotal")}: - {currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")} + {currentTotal} + {isTube ? ` ${stockUnitLabel}` : ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
{t("editStock.newTotal")}: - {newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")} + {newTotal} + {isTube ? ` ${stockUnitLabel}` : ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
{t("editStock.difference")}: {difference > 0 ? "+" : ""} - {difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")} + {difference} + {isTube + ? ` ${stockUnitLabel}` + : ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
@@ -737,7 +759,14 @@ export function MedDetailModal({

{t("modal.packageDetails")} ( - {selectedMed.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}) + {selectedMed.packageType === "bottle" + ? t("form.packageTypeBottle") + : selectedMed.packageType === "tube" + ? t("form.packageTypeTube") + : selectedMed.packageType === "liquid_container" + ? t("form.packageTypeLiquidContainer") + : t("form.packageTypeBlister")} + )

{selectedMed.packageType === "blister" ? ( @@ -757,7 +786,7 @@ export function MedDetailModal({ ) : (
- {t("form.totalCapacity")} + {isTube ? t("form.totalAmount") : t("form.totalCapacity")} {(selectedMed.totalPills ?? packageSize) || "—"}
)} @@ -816,7 +845,8 @@ export function MedDetailModal({ return (
- {totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")} + {totalUsage} + {isTube ? ` ${stockUnitLabel}` : ` ${totalUsage !== 1 ? t("common.pills") : t("common.pill")}`} {selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`} @@ -955,11 +985,13 @@ export function MedDetailModal({ {(() => { const total = - selectedMed.packageType === "bottle" + selectedMed.packageType === "bottle" || + selectedMed.packageType === "tube" || + selectedMed.packageType === "liquid_container" ? entry.loosePillsAdded : entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded; - return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`; + return `+${total}${isTube ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`; })()} {entry.usedPrescription && ( @@ -1128,7 +1160,9 @@ export function MedDetailModal({ className="success" onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)} disabled={ - (selectedMed.packageType === "bottle" + (selectedMed.packageType === "bottle" || + selectedMed.packageType === "tube" || + selectedMed.packageType === "liquid_container" ? refillLoose < 1 : cappedRefillPacks < 1 && refillLoose < 1) || exceedsPrescriptionPackLimit || @@ -1144,7 +1178,8 @@ export function MedDetailModal({ : refillLoose; return totalRefill > 0 ? ( - +{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")} + +{totalRefill} + {isTube ? ` ${stockUnitLabel}` : ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`} ) : null; })()} diff --git a/frontend/src/components/ReportModal.tsx b/frontend/src/components/ReportModal.tsx index cf237ca..482e277 100644 --- a/frontend/src/components/ReportModal.tsx +++ b/frontend/src/components/ReportModal.tsx @@ -298,6 +298,32 @@ function fmtDateTime(iso: string | null | undefined): string { return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`; } +function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" { + if (med.packageType === "liquid_container") return "form.ml"; + return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications"; +} + +function getUsageText(med: Medication, usage: number, t: TFn): string { + if (med.packageType === "tube" || med.packageType === "liquid_container") { + return `${usage} ${t(getTubeUnitKey(med))}`; + } + return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`; +} + +function getTotalCapacityLabel(med: Medication, t: TFn): string { + if (med.packageType === "tube" || med.packageType === "liquid_container") { + return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) }); + } + return t("report.docTotalCapacity"); +} + +function getCurrentStockText(med: Medication, t: TFn): string { + if (med.packageType === "tube" || med.packageType === "liquid_container") { + return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`; + } + return `${getPackageSize(med)} ${t("common.pills")}`; +} + function generateTextReport( meds: Medication[], reportData: ReportData, @@ -341,7 +367,16 @@ function generateTextReport( // Package / Stock lines.push(h3(t("report.docPackage"))); lines.push( - item(t("report.docPackageType"), med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister")) + item( + t("report.docPackageType"), + med.packageType === "bottle" + ? t("report.docBottle") + : med.packageType === "tube" + ? t("report.docTube") + : med.packageType === "liquid_container" + ? t("form.packageTypeLiquidContainer") + : t("report.docBlister") + ) ); if (med.packageType === "blister") { lines.push(item(t("report.docPacks"), String(med.packCount))); @@ -349,10 +384,11 @@ function generateTextReport( lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister))); if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets))); } else { - lines.push(item(t("report.docTotalCapacity"), String(med.totalPills ?? med.looseTablets))); + lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets))); } - lines.push(item(t("report.docCurrentStock"), `${getPackageSize(med)} ${t("common.pills")}`)); - if (med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`)); + lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t))); + if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg) + lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`)); if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate))); if (med.notes) lines.push(item(t("report.docNotes"), med.notes)); lines.push(""); @@ -365,7 +401,7 @@ function generateTextReport( if (intakes?.length) { lines.push(h3(t("report.docIntakeSchedule"))); for (const intake of intakes) { - let entry = `${intake.usage} ${intake.usage === 1 ? t("common.pill") : t("common.pills")}`; + let entry = getUsageText(med, intake.usage, t); entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`; entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`; if ("takenBy" in intake && intake.takenBy) @@ -407,7 +443,7 @@ function generateTextReport( if (data.refills.length > 0) { lines.push(h3(t("report.docRefillHistory"))); for (const r of data.refills) { - let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${t("common.pills")}`; + let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills")}`; if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`; lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`); } @@ -539,7 +575,15 @@ function buildPrintHtml( // Package / Stock s += `

${escHtml(t("report.docPackage"))}

`; s += ``; - s += ``; + s += ``; if (med.packageType === "blister") { s += ``; s += ``; @@ -547,10 +591,10 @@ function buildPrintHtml( if (med.looseTablets > 0) s += ``; } else { - s += ``; + s += ``; } - s += ``; - if (med.pillWeightMg) + s += ``; + if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg) s += ``; if (med.expiryDate) s += ``; @@ -567,7 +611,7 @@ function buildPrintHtml( s += `

${escHtml(t("report.docIntakeSchedule"))}

`; s += `
    `; for (const intake of filteredPrintIntakes) { - let entry = `${intake.usage} ${escHtml(intake.usage === 1 ? t("common.pill") : t("common.pills"))}`; + let entry = escHtml(getUsageText(med, intake.usage, t)); entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`; entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`; if ("takenBy" in intake && intake.takenBy) @@ -614,7 +658,7 @@ function buildPrintHtml( s += `

    ${escHtml(t("report.docRefillHistory"))}

    `; s += `
      `; for (const r of data.refills) { - let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(t("common.pills"))}`; + let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills"))}`; if (r.usedPrescription) entry += ` ${escHtml(t("report.docRefillPrescription"))}`; s += `
    • ${entry}
    • `; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 17f6850..018abb0 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -129,6 +129,42 @@ export function DashboardPage() { const showOnlyToday = settings.upcomingTodayOnly; const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length; + + const getTubeUnitLabel = (med: (typeof meds)[number] | undefined) => + med?.packageType === "liquid_container" || med?.medicationForm === "liquid" + ? t("form.ml") + : t("blisters.applications"); + + const formatStockLabel = (med: (typeof meds)[number] | undefined, medsLeft: number) => { + if (med?.packageType === "liquid_container") { + return `${formatNumber(medsLeft)} ${t("form.ml")}`; + } + if (med?.packageType === "tube") { + return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med)}`; + } + return t("table.pillsCount", { count: Math.round(medsLeft) }); + }; + + const formatDoseUsageLabel = (med: (typeof meds)[number] | undefined, usage: number) => { + if (med?.packageType === "liquid_container") { + return `${usage} ${t("form.ml")}`; + } + if (med?.packageType === "tube") { + return `${usage} ${getTubeUnitLabel(med)}`; + } + return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`; + }; + + const formatTotalUsageLabel = (med: (typeof meds)[number] | undefined, total: number) => { + if (med?.packageType === "liquid_container") { + return `${total} ${t("form.ml")}`; + } + if (med?.packageType === "tube") { + return `${total} ${getTubeUnitLabel(med)}`; + } + return t("common.pillsTotal", { count: total }); + }; + const prescriptionStatus = prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 ? { @@ -587,15 +623,19 @@ export function DashboardPage() { - {med?.packageType === "bottle" - ? t("table.pillsCount", { count: Math.round(row.medsLeft) }) + {med?.packageType === "bottle" || + med?.packageType === "tube" || + med?.packageType === "liquid_container" + ? formatStockLabel(med, row.medsLeft) : formatFullBlisters(stock.fullBlisters, t)} - {med?.packageType === "bottle" + {med?.packageType === "bottle" || + med?.packageType === "tube" || + med?.packageType === "liquid_container" ? "—" : formatOpenBlisterAndLoose( stock.openBlisterPills, @@ -772,7 +812,7 @@ export function DashboardPage() {
      - {t("common.pillsTotal", { count: item.total })} + {formatTotalUsageLabel(med, item.total)} {status && ( {t(status.label)} )} @@ -786,12 +826,12 @@ export function DashboardPage() {
      {dose.timeStr} - - {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - - {med?.pillWeightMg && ( - {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} - )} + {formatDoseUsageLabel(med, dose.usage)} + {med?.packageType !== "tube" && + med?.packageType !== "liquid_container" && + med?.pillWeightMg && ( + {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} + )} {dose.intakeRemindersEnabled && (
      - {t("common.pillsTotal", { count: item.total })} + {formatTotalUsageLabel(med, item.total)} {status && ( {t(status.label)} )} @@ -1050,12 +1090,12 @@ export function DashboardPage() { > {dose.timeStr} - - {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - - {med?.pillWeightMg && ( - {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} - )} + {formatDoseUsageLabel(med, dose.usage)} + {med?.packageType !== "tube" && + med?.packageType !== "liquid_container" && + med?.pillWeightMg && ( + {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} + )} {dose.intakeRemindersEnabled && (
      - {t("common.pillsTotal", { count: item.total })} + {formatTotalUsageLabel(med, item.total)} {status && ( {t(status.label)} )} @@ -1277,12 +1317,12 @@ export function DashboardPage() {
      {dose.timeStr} - - {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} - - {med?.pillWeightMg && ( - {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} - )} + {formatDoseUsageLabel(med, dose.usage)} + {med?.packageType !== "tube" && + med?.packageType !== "liquid_container" && + med?.pillWeightMg && ( + {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} + )} {dose.intakeRemindersEnabled && ( { + const med = meds.find((m) => m.id === medicationId); + if (med?.packageType === "liquid_container") { + return t("form.ml"); + } + if (med?.packageType === "tube") { + return med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications"); + } + return count === 1 ? t("common.pill") : t("common.pills"); + }; + + const getAvailableLabel = (medicationId: number, loosePills: number): string => { + const med = meds.find((m) => m.id === medicationId); + const roundedLoose = Math.round(loosePills * 10) / 10; + if (med?.packageType === "liquid_container") { + return `${roundedLoose} ${t("form.ml")}`; + } + if (med?.packageType === "tube") { + const unit = med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications"); + return `${roundedLoose} ${unit}`; + } + return `${roundedLoose} ${roundedLoose === 1 ? t("common.pill") : t("common.pills")}`; + }; + async function sendPlannerNotification() { if (!canSendNotification || plannerRows.length === 0) return; setSendingPlannerEmail(true); @@ -226,16 +250,22 @@ export function PlannerPage() { {row.plannerUsage}  - {row.plannerUsage === 1 ? t("common.pill") : t("common.pills")} + {getUsageUnitLabel(row.medicationId, row.plannerUsage)} - {row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`} + {row.packageType === "bottle" || + row.packageType === "tube" || + row.packageType === "liquid_container" + ? "–" + : `${row.blistersNeeded} × ${row.blisterSize}`} {remainingRefills ?? "–"} - {row.packageType === "bottle" ? ( - `${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}` + {row.packageType === "bottle" || + row.packageType === "tube" || + row.packageType === "liquid_container" ? ( + getAvailableLabel(row.medicationId, row.loosePills) ) : ( <> {row.fullBlisters} {t("common.blisters")} diff --git a/frontend/src/utils/stock.ts b/frontend/src/utils/stock.ts index 9c9bb92..10ff71a 100644 --- a/frontend/src/utils/stock.ts +++ b/frontend/src/utils/stock.ts @@ -35,7 +35,7 @@ export function splitCurrentBlisterStock( */ export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit { const total = - med.packageType === "bottle" + med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container" ? med.looseTablets + (med.stockAdjustment ?? 0) : med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
${escHtml(t("report.docPackageType"))}${escHtml(med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))}
${escHtml(t("report.docPackageType"))}${escHtml( + med.packageType === "bottle" + ? t("report.docBottle") + : med.packageType === "tube" + ? t("report.docTube") + : med.packageType === "liquid_container" + ? t("form.packageTypeLiquidContainer") + : t("report.docBlister") + )}
${escHtml(t("report.docPacks"))}${med.packCount}
${escHtml(t("report.docBlistersPerPack"))}${med.blistersPerPack}
${escHtml(t("report.docLoosePills"))}${med.looseTablets}
${escHtml(t("report.docTotalCapacity"))}${med.totalPills ?? med.looseTablets}
${escHtml(getTotalCapacityLabel(med, t))}${med.totalPills ?? med.looseTablets}
${escHtml(t("report.docCurrentStock"))}${getPackageSize(med)} ${escHtml(t("common.pills"))}
${escHtml(t("report.docCurrentStock"))}${escHtml(getCurrentStockText(med, t))}
${escHtml(t("report.docDosePerPill"))}${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}
${escHtml(t("report.docExpiryDate"))}${fmtDate(med.expiryDate)}