fix: improve shared schedule stock overview display
This commit is contained in:
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPackageInfo(medication)}</td>
|
||||
<td>{formatPackageInfo(medication, t)}</td>
|
||||
<td>
|
||||
<span className="shared-overview-stock-value">
|
||||
{medication.currentStock === null || medication.capacity === null
|
||||
@@ -158,7 +190,7 @@ export function SharedMedicationOverviewSection({
|
||||
</div>
|
||||
<div className="shared-overview-card-grid">
|
||||
<span>{t("sharedOverview.columns.package")}</span>
|
||||
<strong>{formatPackageInfo(medication)}</strong>
|
||||
<strong>{formatPackageInfo(medication, t)}</strong>
|
||||
<span>{t("sharedOverview.columns.stock")}</span>
|
||||
<strong>
|
||||
<span className="shared-overview-stock-value">
|
||||
|
||||
@@ -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<StockThresholds | null>(() => {
|
||||
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<string, NonNullable<SharedScheduleData["medicationOverview"]>[number]>();
|
||||
for (const item of data?.medicationOverview ?? []) {
|
||||
overview.set(item.name, item);
|
||||
}
|
||||
return overview;
|
||||
}, [data?.medicationOverview]);
|
||||
|
||||
const emptyByOverviewName = useMemo(() => {
|
||||
const emptyNames = new Set<string>();
|
||||
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 (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -972,6 +1018,7 @@ export function SharedSchedule() {
|
||||
<span className="tag subtle">
|
||||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||
</span>
|
||||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
@@ -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 (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||
@@ -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 (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1157,6 +1233,7 @@ export function SharedSchedule() {
|
||||
<span className="tag subtle">
|
||||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||
</span>
|
||||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
@@ -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 (
|
||||
<div
|
||||
key={dose.id}
|
||||
className={`dose-item ${isOverdue ? "overdue" : ""} ${isTaken ? "all-taken" : ""}`}
|
||||
>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||
@@ -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 (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1331,6 +1435,7 @@ export function SharedSchedule() {
|
||||
<span className="tag subtle">
|
||||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||
</span>
|
||||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
@@ -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 (
|
||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user