fix: keep topical stock non-depleting in planner flows (#359)
* fix: keep topical stock non-depleting in planner and reports * test: stabilize e2e selectors for updated medication semantics * fix(backend): add missing planner translation keys
This commit is contained in:
@@ -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<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
units: "units",
|
||||
ml: "ml",
|
||||
blister: "blister",
|
||||
blisters: "blisters",
|
||||
day: "day",
|
||||
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
units: "Einheiten",
|
||||
ml: "ml",
|
||||
blister: "Blister",
|
||||
blisters: "Blister",
|
||||
day: "Tag",
|
||||
|
||||
@@ -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<typeof getTranslations>): 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 `
|
||||
<tr style="${rowBg}">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${tr.common.pills}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${getPlannerUnit(row.packageType, tr)}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
||||
@@ -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}`;
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+71
-29
@@ -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 });
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
async function isAuthEnabled(page: Page): Promise<boolean> {
|
||||
interface AuthStateResponse {
|
||||
authEnabled: boolean;
|
||||
formLoginEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string;
|
||||
registrationEnabled: boolean;
|
||||
}
|
||||
|
||||
async function getAuthState(page: Page): Promise<AuthStateResponse | null> {
|
||||
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<boolean> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,25 +103,43 @@ export const test = base.extend<object>({
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 <strong> 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);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
|
||||
+19
-3
@@ -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 (
|
||||
<div className="auth-container">
|
||||
<div className="auth-container" data-theme={authTheme}>
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<p>Loading...</p>
|
||||
@@ -55,7 +71,7 @@ function AppRouter() {
|
||||
// Show error if we couldn't connect to the server
|
||||
if (authError) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-container" data-theme={authTheme}>
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||
@@ -77,7 +93,7 @@ function AppRouter() {
|
||||
// If auth state is null (shouldn't happen after loading, but be safe)
|
||||
if (!authState) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-container" data-theme={authTheme}>
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<p>Initializing...</p>
|
||||
|
||||
@@ -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({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{selectedMed.packageType === "bottle" && (
|
||||
{(selectedMed.packageType === "bottle" ||
|
||||
selectedMed.packageType === "tube" ||
|
||||
selectedMed.packageType === "liquid_container") && (
|
||||
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
||||
)}
|
||||
{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({
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.currentTotal")}:</span>
|
||||
<span>
|
||||
{currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")}
|
||||
{currentTotal}
|
||||
{isTube ? ` ${stockUnitLabel}` : ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.newTotal")}:</span>
|
||||
<span>
|
||||
{newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
|
||||
{newTotal}
|
||||
{isTube ? ` ${stockUnitLabel}` : ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`summary-row difference ${differenceClass}`}>
|
||||
<span>{t("editStock.difference")}:</span>
|
||||
<span>
|
||||
{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")}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -737,7 +759,14 @@ export function MedDetailModal({
|
||||
<div className="med-detail-section">
|
||||
<h3>
|
||||
{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")}
|
||||
)
|
||||
</h3>
|
||||
<div className="med-detail-grid">
|
||||
{selectedMed.packageType === "blister" ? (
|
||||
@@ -757,7 +786,7 @@ export function MedDetailModal({
|
||||
</>
|
||||
) : (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
||||
<span className="med-detail-label">{isTube ? t("form.totalAmount") : t("form.totalCapacity")}</span>
|
||||
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -816,7 +845,8 @@ export function MedDetailModal({
|
||||
return (
|
||||
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
|
||||
<span className="med-schedule-usage">
|
||||
{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"})`}
|
||||
</span>
|
||||
@@ -955,11 +985,13 @@ export function MedDetailModal({
|
||||
<span className="refill-amount">
|
||||
{(() => {
|
||||
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 && (
|
||||
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
||||
@@ -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 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
||||
+{totalRefill}
|
||||
{isTube ? ` ${stockUnitLabel}` : ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
@@ -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 += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
||||
s += `<table><tbody>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(
|
||||
med.packageType === "bottle"
|
||||
? t("report.docBottle")
|
||||
: med.packageType === "tube"
|
||||
? t("report.docTube")
|
||||
: med.packageType === "liquid_container"
|
||||
? t("form.packageTypeLiquidContainer")
|
||||
: t("report.docBlister")
|
||||
)}</td></tr>`;
|
||||
if (med.packageType === "blister") {
|
||||
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
||||
@@ -547,10 +591,10 @@ function buildPrintHtml(
|
||||
if (med.looseTablets > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||
} else {
|
||||
s += `<tr><td class="label">${escHtml(t("report.docTotalCapacity"))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||
}
|
||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${getPackageSize(med)} ${escHtml(t("common.pills"))}</td></tr>`;
|
||||
if (med.pillWeightMg)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||
if (med.expiryDate)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||
@@ -567,7 +611,7 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
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 += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
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 += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</span>
|
||||
</span>
|
||||
<span data-label={t("table.stock")} className={textClass}>
|
||||
{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)}
|
||||
</span>
|
||||
<span
|
||||
data-label={t("table.stockDetails")}
|
||||
className={`${textClass}${med?.packageType === "bottle" ? " hide-on-card" : ""}`}
|
||||
className={`${textClass}${med?.packageType === "bottle" || med?.packageType === "tube" || med?.packageType === "liquid_container" ? " hide-on-card" : ""}`}
|
||||
>
|
||||
{med?.packageType === "bottle"
|
||||
{med?.packageType === "bottle" ||
|
||||
med?.packageType === "tube" ||
|
||||
med?.packageType === "liquid_container"
|
||||
? "—"
|
||||
: formatOpenBlisterAndLoose(
|
||||
stock.openBlisterPills,
|
||||
@@ -772,7 +812,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
<span className="tag subtle">{formatTotalUsageLabel(med, item.total)}</span>
|
||||
{status && (
|
||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||
)}
|
||||
@@ -786,12 +826,12 @@ export function DashboardPage() {
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
<span className="dose-usage-main">{formatDoseUsageLabel(med, dose.usage)}</span>
|
||||
{med?.packageType !== "tube" &&
|
||||
med?.packageType !== "liquid_container" &&
|
||||
med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
@@ -1032,7 +1072,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
<span className="tag subtle">{formatTotalUsageLabel(med, item.total)}</span>
|
||||
{status && (
|
||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||
)}
|
||||
@@ -1050,12 +1090,12 @@ export function DashboardPage() {
|
||||
>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
<span className="dose-usage-main">{formatDoseUsageLabel(med, dose.usage)}</span>
|
||||
{med?.packageType !== "tube" &&
|
||||
med?.packageType !== "liquid_container" &&
|
||||
med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
@@ -1263,7 +1303,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
<span className="tag subtle">{formatTotalUsageLabel(med, item.total)}</span>
|
||||
{status && (
|
||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||
)}
|
||||
@@ -1277,12 +1317,12 @@ export function DashboardPage() {
|
||||
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
<span className="dose-usage-main">{formatDoseUsageLabel(med, dose.usage)}</span>
|
||||
{med?.packageType !== "tube" &&
|
||||
med?.packageType !== "liquid_container" &&
|
||||
med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
|
||||
@@ -122,6 +122,30 @@ export function PlannerPage() {
|
||||
const canSendNotification =
|
||||
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
|
||||
|
||||
const getUsageUnitLabel = (medicationId: number, count: number): string => {
|
||||
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() {
|
||||
<span data-label={t("planner.table.usage")}>
|
||||
<span>
|
||||
<strong>{row.plannerUsage}</strong>
|
||||
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
|
||||
{getUsageUnitLabel(row.medicationId, row.plannerUsage)}
|
||||
</span>
|
||||
</span>
|
||||
<span data-label={t("planner.table.blisters")}>
|
||||
{row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||
{row.packageType === "bottle" ||
|
||||
row.packageType === "tube" ||
|
||||
row.packageType === "liquid_container"
|
||||
? "–"
|
||||
: `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||
</span>
|
||||
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? "–"}</span>
|
||||
<span data-label={t("planner.table.available")}>
|
||||
{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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user