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;
|