diff --git a/backend/src/services/coverage.ts b/backend/src/services/coverage.ts index 9a1959c..c0b93d1 100644 --- a/backend/src/services/coverage.ts +++ b/backend/src/services/coverage.ts @@ -20,6 +20,8 @@ export type SharedMedicationOverviewItem = { imageUrl: string | null; packageType: string; packCount: number; + packageAmountValue: number | null; + packageAmountUnit: "ml" | "g" | null; blistersPerPack: number; pillsPerBlister: number; totalPills: number | null; @@ -194,6 +196,11 @@ export function buildSharedMedicationOverview(options: { imageUrl: medication.imageUrl, packageType: medication.packageType, packCount: medication.packCount, + packageAmountValue: medication.packageAmountValue, + packageAmountUnit: + medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml" + ? medication.packageAmountUnit + : null, blistersPerPack: medication.blistersPerPack, pillsPerBlister: medication.pillsPerBlister, totalPills: medication.totalPills, diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 9d8f774..e62751a 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -539,6 +539,14 @@ describe("E2E Tests with Real Routes", () => { it("should return shared medication overview for a valid token", async () => { await createMedication(testClient, userId, "Aspirin", ["Daniel"]); + await testClient.execute({ + sql: `INSERT INTO medications ( + user_id, name, taken_by_json, package_type, pack_count, blisters_per_pack, pills_per_blister, + package_amount_value, package_amount_unit, total_pills, loose_tablets, medication_form, + usage_json, every_json, start_json + ) VALUES (?, ?, ?, 'tube', 2, 1, 1, 40, 'g', 80, 80, 'topical', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`, + args: [userId, "Hydrogel", JSON.stringify(["Daniel"])], + }); const token = "abcdef0123456789"; await createShareToken(testClient, userId, "Daniel", token); @@ -554,9 +562,17 @@ describe("E2E Tests with Real Routes", () => { expect(data.takenBy).toBe("Daniel"); expect(data.sharedBy).toBe("__anonymous__"); expect(Array.isArray(data.medications)).toBe(true); - expect(data.medications).toHaveLength(1); + expect(data.medications).toHaveLength(2); expect(data.medications[0].name).toBe("Aspirin"); expect(data.medications[0].currentStock).toBeTypeOf("number"); + const hydrogel = data.medications.find((med: { name: string }) => med.name === "Hydrogel"); + expect(hydrogel).toMatchObject({ + packageType: "tube", + packCount: 2, + packageAmountValue: 40, + packageAmountUnit: "g", + totalPills: 80, + }); }); it("should return 404 for unknown overview token", async () => { diff --git a/frontend/src/components/SharedMedicationOverviewSection.tsx b/frontend/src/components/SharedMedicationOverviewSection.tsx index 83fbe78..8add02e 100644 --- a/frontend/src/components/SharedMedicationOverviewSection.tsx +++ b/frontend/src/components/SharedMedicationOverviewSection.tsx @@ -1,18 +1,50 @@ import { useTranslation } from "react-i18next"; -import type { SharedMedicationOverviewItem } from "../types"; +import { + getPackageSize, + isLiquidContainerPackageType, + isTubePackageType, + type SharedMedicationOverviewItem, +} from "../types"; import { formatDate } from "../utils/formatters"; import { MedicationAvatar } from "./MedicationAvatar"; -function formatPackageInfo(medication: SharedMedicationOverviewItem): string { +function formatPackageAmountUnit(medication: SharedMedicationOverviewItem, t: (key: string) => string): string | null { + if (isTubePackageType(medication.packageType)) { + return t("form.packageAmountUnitG"); + } + + if (isLiquidContainerPackageType(medication.packageType)) { + return t("form.packageAmountUnitMl"); + } + + if (medication.packageAmountUnit === "g") { + return t("form.packageAmountUnitG"); + } + + if (medication.packageAmountUnit === "ml") { + return t("form.packageAmountUnitMl"); + } + + return null; +} + +function formatPackageInfo(medication: SharedMedicationOverviewItem, t: (key: string) => string): string { if (medication.packageType === "blister") { return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`; } - if (medication.totalPills !== null) { - return `${medication.packCount} x ${medication.totalPills}`; + const unitLabel = formatPackageAmountUnit(medication, t); + if (unitLabel && medication.packageAmountValue && medication.packageAmountValue > 0) { + const sizeLabel = `${medication.packageAmountValue} ${unitLabel}`; + return medication.packCount > 1 ? `${medication.packCount} x ${sizeLabel}` : sizeLabel; } - return `${medication.packCount}`; + const packageSize = getPackageSize(medication); + if (packageSize > 0) { + return medication.packCount > 1 ? `${medication.packCount} x ${packageSize}` : `${packageSize}`; + } + + return `${Math.max(medication.packCount, 1)}`; } function getOverviewStatus( @@ -105,7 +137,7 @@ export function SharedMedicationOverviewSection({ - {formatPackageInfo(medication)} + {formatPackageInfo(medication, t)} {medication.currentStock === null || medication.capacity === null @@ -158,7 +190,7 @@ export function SharedMedicationOverviewSection({
{t("sharedOverview.columns.package")} - {formatPackageInfo(medication)} + {formatPackageInfo(medication, t)} {t("sharedOverview.columns.stock")} diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 8e0998c..8ad9a67 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -15,9 +15,10 @@ import { getMedTotal, isLiquidContainerPackageType, isTubePackageType, + type StockThresholds, } from "../types"; import { getSystemLocale } from "../utils/formatters"; -import { isDoseDismissed, parseLocalDateTime } from "../utils/schedule"; +import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule"; import { loadCollapsedDaysFromStorage } from "../utils/storage"; import { MedicationAvatar } from "./MedicationAvatar"; import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection"; @@ -401,8 +402,7 @@ export function SharedSchedule() { fetchData(); }, [token, t]); - // Build schedule from medications - matches buildSchedulePreview logic exactly - const schedule = useMemo(() => { + function buildGroupedSchedule() { if (!data) return []; // Use same logic as buildSchedulePreview in main app @@ -514,6 +514,11 @@ export function SharedSchedule() { isPast: d.isPast, meds: Array.from(d.meds.values()), })); + } + + // Visible schedule respects share-person filtering. + const schedule = useMemo(() => { + return buildGroupedSchedule(); }, [data, i18n.language]); // Split into past, today, and future - matches main app logic @@ -676,26 +681,42 @@ export function SharedSchedule() { return coverage; }, [data, takenDoses]); - const outOfStockMedicationIds = useMemo( - () => - new Set( - (data?.medications ?? []) - .filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0) - .map((med) => med.id) - ), - [data, coverageByMed] - ); + const sharedStockThresholds = useMemo(() => { + if (!data?.stockThresholds) return null; + return { + lowStockDays: data.stockThresholds.lowStockDays, + normalStockDays: data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays, + highStockDays: + data.stockThresholds.highStockDays ?? + Math.max( + (data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays) + 1, + data.stockThresholds.lowStockDays + 1 + ), + criticalStockDays: + data.stockThresholds.reminderDaysBefore ?? Math.max(1, Math.ceil(data.stockThresholds.lowStockDays / 2)), + expiryWarningDays: data.stockThresholds.expiryWarningDays ?? 30, + }; + }, [data?.stockThresholds]); - const isDoseTakenForDisplay = useCallback( - (doseId: string) => { - const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10); - if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) { - return false; + const medicationOverviewByName = useMemo(() => { + const overview = new Map[number]>(); + for (const item of data?.medicationOverview ?? []) { + overview.set(item.name, item); + } + return overview; + }, [data?.medicationOverview]); + + const emptyByOverviewName = useMemo(() => { + const emptyNames = new Set(); + for (const item of data?.medicationOverview ?? []) { + if ((item.currentStock ?? 0) <= 0) { + emptyNames.add(item.name); } - return takenDoses.has(doseId); - }, - [outOfStockMedicationIds, takenDoses] - ); + } + return emptyNames; + }, [data?.medicationOverview]); + + const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]); const showMedicationOverview = data?.shareMedicationOverview === true && data?.medicationOverview !== null; const showOnlyToday = data?.shareScheduleTodayOnly === true; @@ -942,10 +963,35 @@ export function SharedSchedule() { day.meds.map((item) => { const med = data.medications.find((m) => getMedDisplayName(m) === item.medName); const medCoverage = coverageByMed[item.medName]; - const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; + const isEmpty = + emptyByOverviewName.has(item.medName) || + (medCoverage ? medCoverage.medsLeft <= 0 : false); + const medOverview = medicationOverviewByName.get(item.medName); + let stockStatus = null; + if (!isEmpty && sharedStockThresholds) { + if (medOverview && medOverview.currentStock !== null) { + stockStatus = getStockStatus( + medOverview.daysLeft, + medOverview.currentStock, + sharedStockThresholds, + med?.packageType + ); + } else if (medCoverage) { + stockStatus = getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + sharedStockThresholds, + med?.packageType + ); + } + } + const isLowStock = stockStatus?.className === "warning"; + const rowClasses = ["time-row"]; + if (isEmpty) rowClasses.push("med-empty"); + else if (isLowStock) rowClasses.push("med-low"); return ( -
+
{formatTotalUsageLabel(med, item.total, item.doses)} + {isLowStock && {t("status.lowStock")}}
@@ -979,8 +1026,12 @@ export function SharedSchedule() { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); + const doseClasses = ["dose-item", "past"]; + if (isTaken) doseClasses.push("all-taken"); + if (isEmpty) doseClasses.push("med-empty"); + else if (isLowStock) doseClasses.push("med-low"); return ( -
+
{dose.timeStr} {renderDoseUsage(med, dose)} @@ -1128,9 +1179,34 @@ export function SharedSchedule() { day.meds.map((item) => { const med = data.medications.find((m) => getMedDisplayName(m) === item.medName); const medCoverage = coverageByMed[item.medName]; - const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; + const isEmpty = + emptyByOverviewName.has(item.medName) || + (medCoverage ? medCoverage.medsLeft <= 0 : false); + const medOverview = medicationOverviewByName.get(item.medName); + let stockStatus = null; + if (!isEmpty && sharedStockThresholds) { + if (medOverview && medOverview.currentStock !== null) { + stockStatus = getStockStatus( + medOverview.daysLeft, + medOverview.currentStock, + sharedStockThresholds, + med?.packageType + ); + } else if (medCoverage) { + stockStatus = getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + sharedStockThresholds, + med?.packageType + ); + } + } + const isLowStock = stockStatus?.className === "warning"; + const rowClasses = ["time-row"]; + if (isEmpty) rowClasses.push("med-empty"); + else if (isLowStock) rowClasses.push("med-low"); return ( -
+
{formatTotalUsageLabel(med, item.total, item.doses)} + {isLowStock && {t("status.lowStock")}}
@@ -1165,11 +1242,13 @@ export function SharedSchedule() { const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); const isOverdue = dose.when < Date.now() && !isTaken; + const doseClasses = ["dose-item"]; + if (isOverdue) doseClasses.push("overdue"); + if (isTaken) doseClasses.push("all-taken"); + if (isEmpty) doseClasses.push("med-empty"); + else if (isLowStock) doseClasses.push("med-low"); return ( -
+
{dose.timeStr} {renderDoseUsage(med, dose)} @@ -1302,9 +1381,34 @@ export function SharedSchedule() { day.meds.map((item) => { const med = data.medications.find((m) => getMedDisplayName(m) === item.medName); const medCoverage = coverageByMed[item.medName]; - const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; + const isEmpty = + emptyByOverviewName.has(item.medName) || + (medCoverage ? medCoverage.medsLeft <= 0 : false); + const medOverview = medicationOverviewByName.get(item.medName); + let stockStatus = null; + if (!isEmpty && sharedStockThresholds) { + if (medOverview && medOverview.currentStock !== null) { + stockStatus = getStockStatus( + medOverview.daysLeft, + medOverview.currentStock, + sharedStockThresholds, + med?.packageType + ); + } else if (medCoverage) { + stockStatus = getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + sharedStockThresholds, + med?.packageType + ); + } + } + const isLowStock = stockStatus?.className === "warning"; + const rowClasses = ["time-row"]; + if (isEmpty) rowClasses.push("med-empty"); + else if (isLowStock) rowClasses.push("med-low"); return ( -
+
{formatTotalUsageLabel(med, item.total, item.doses)} + {isLowStock && {t("status.lowStock")}}
@@ -1338,8 +1443,12 @@ export function SharedSchedule() { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); + const doseClasses = ["dose-item", "future"]; + if (isTaken) doseClasses.push("all-taken"); + if (isEmpty) doseClasses.push("med-empty"); + else if (isLowStock) doseClasses.push("med-low"); return ( -
+
{dose.timeStr} {renderDoseUsage(med, dose)} diff --git a/frontend/src/test/components/SharedSchedule.test.tsx b/frontend/src/test/components/SharedSchedule.test.tsx index 40b3d01..b5dac66 100644 --- a/frontend/src/test/components/SharedSchedule.test.tsx +++ b/frontend/src/test/components/SharedSchedule.test.tsx @@ -34,6 +34,8 @@ function createSharedDataWithEmbeddedOverview() { imageUrl: null, packageType: "blister", packCount: 1, + packageAmountValue: null, + packageAmountUnit: null, blistersPerPack: 2, pillsPerBlister: 10, totalPills: null, @@ -49,6 +51,75 @@ function createSharedDataWithEmbeddedOverview() { prescriptionEnabled: false, prescriptionRemainingRefills: null, }, + { + name: "Vitamin D", + genericName: null, + imageUrl: null, + packageType: "bottle", + packCount: 0, + packageAmountValue: null, + packageAmountUnit: null, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 100, + looseTablets: 100, + currentStock: 40, + capacity: 100, + daysLeft: 40, + nextIntakeDate: null, + depletionDate: "2026-02-21", + priority: "normal", + expiryDate: null, + medicationStartDate: null, + prescriptionEnabled: false, + prescriptionRemainingRefills: null, + }, + { + name: "Hydrogel", + genericName: null, + imageUrl: null, + packageType: "tube", + packCount: 2, + packageAmountValue: 40, + packageAmountUnit: "g", + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 80, + looseTablets: 80, + currentStock: 80, + capacity: 80, + daysLeft: null, + nextIntakeDate: null, + depletionDate: null, + priority: "normal", + expiryDate: null, + medicationStartDate: null, + prescriptionEnabled: false, + prescriptionRemainingRefills: null, + }, + { + name: "Cough Syrup", + genericName: null, + imageUrl: null, + packageType: "liquid_container", + packCount: 3, + packageAmountValue: 150, + packageAmountUnit: "ml", + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 450, + looseTablets: 450, + currentStock: 450, + capacity: 450, + daysLeft: null, + nextIntakeDate: null, + depletionDate: null, + priority: "normal", + expiryDate: null, + medicationStartDate: null, + prescriptionEnabled: false, + prescriptionRemainingRefills: null, + }, ], }; } @@ -242,6 +313,9 @@ describe("SharedSchedule", () => { }); expect(screen.getByText("sharedOverview.columns.priority")).toBeInTheDocument(); + expect(screen.getAllByText("100").length).toBeGreaterThan(0); + expect(screen.getAllByText("2 x 40 form.packageAmountUnitG").length).toBeGreaterThan(0); + expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0); expect(screen.getByText("share.noSchedule")).toBeInTheDocument(); }); }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ff7d6c1..6c33c91 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -271,6 +271,8 @@ export type SharedMedicationOverviewItem = { imageUrl: string | null; packageType: PackageType; packCount: number; + packageAmountValue: number | null; + packageAmountUnit: PackageAmountUnit | null; blistersPerPack: number; pillsPerBlister: number; totalPills: number | null;