feat: frontend improvements - shared schedule, bottle type, settings UI, planner notifications (#146)

- Rewrite SharedSchedule to match DashboardPage rendering with time-based consumption
- Add bottle package type support across all views (MedDetail, Refill, Planner, Dashboard)
- Redesign settings page with colored threshold chips, validation, and stock reminder display
- Add shareStockStatus toggle and send manual reminder button
- Pill/pills singular/plural consistency across all views
- Planner send notification via push (Shoutrrr) in addition to email
- Stock overflow warning and past-missed day styling
- Update README: bottles in Smart Inventory, push in Trip Planner, new ENV section
- 708 passing frontend tests including new coverage for all changes
This commit is contained in:
Daniel Volz
2026-02-09 19:33:54 +01:00
committed by GitHub
parent f56f2b7c88
commit 3ec1460c4e
24 changed files with 2115 additions and 572 deletions
+113 -58
View File
@@ -195,6 +195,16 @@ export function MedDetailModal({
<span className={`med-detail-value ${textClass}`}>
{currentStock} /{" "}
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
{currentStock >
(selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize) && (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
)}
</span>
</div>
</div>
@@ -266,7 +276,12 @@ export function MedDetailModal({
</h3>
<div className="med-detail-schedules">
{selectedMed.blisters.map((blister, idx) => {
const personCount = Math.max(1, selectedMed.takenBy?.length || 1);
// When using new intakes format with per-intake takenBy,
// each intake already represents one person's dose — don't multiply.
// For legacy intakes (no per-intake takenBy), multiply by personCount.
const intake = selectedMed.intakes?.[idx];
const hasPerIntakeTakenBy = !!intake?.takenBy;
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
const totalUsage = blister.usage * personCount;
return (
<div key={idx} className="med-schedule-item">
@@ -350,10 +365,14 @@ export function MedDetailModal({
})}
</span>
<span className="refill-amount">
+
{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded}{" "}
{t("common.pills")}
{(() => {
const total =
selectedMed.packageType === "bottle"
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
})()}
</span>
</div>
))}
@@ -408,24 +427,38 @@ export function MedDetailModal({
<p className="refill-med-name">{selectedMed.name}</p>
<div className="refill-form">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
{selectedMed.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
</div>
<div className="modal-footer">
@@ -440,12 +473,17 @@ export function MedDetailModal({
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose}{" "}
{t("common.pills")}
</span>
)}
{(() => {
const totalRefill =
selectedMed.packageType === "blister"
? refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
</div>
</div>
@@ -472,50 +510,67 @@ export function MedDetailModal({
{(() => {
const dbTotal = getMedTotal(selectedMed);
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
const isBottle = selectedMed.packageType === "bottle";
const newTotal = isBottle
? editStockPartialBlisterPills
: editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
const difference = newTotal - currentTotal;
return (
<>
<div className="edit-stock-form">
<label>
{t("editStock.fullBlisters")}{" "}
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
/>
</label>
{isBottle ? (
<label>
{t("editStock.totalPills")}
<input
type="number"
min="0"
value={editStockPartialBlisterPills}
onChange={(e) => onEditStockPartialBlisterPillsChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
) : (
<>
<label>
{t("editStock.fullBlisters")}{" "}
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
/>
</label>
</>
)}
</div>
<div className="edit-stock-summary">
<div className="summary-row">
<span>{t("editStock.currentTotal")}:</span>
<span>
{currentTotal} {t("common.pills")}
{currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")}
</span>
</div>
<div className="summary-row">
<span>{t("editStock.newTotal")}:</span>
<span>
{newTotal} {t("common.pills")}
{newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
</span>
</div>
<div
@@ -524,7 +579,7 @@ export function MedDetailModal({
<span>{t("editStock.difference")}:</span>
<span>
{difference > 0 ? "+" : ""}
{difference} {t("common.pills")}
{difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}
</span>
</div>
</div>
+46 -25
View File
@@ -266,7 +266,8 @@ export function MobileEditModal({
)}
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p>
</div>
<label className="full">
@@ -307,24 +308,38 @@ export function MobileEditModal({
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<button
type="button"
className="success"
@@ -333,12 +348,18 @@ export function MobileEditModal({
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
{t("common.pills")}
</span>
)}
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
</div>
)}
+313 -291
View File
@@ -12,6 +12,22 @@ import { isDoseDismissed } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
// =============================================================================
// Stock status helper — identical to DashboardPage's getStockStatus
// =============================================================================
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
) {
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
if (daysLeft <= thresholds.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
export function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
@@ -198,17 +214,6 @@ export function SharedSchedule() {
return doseId;
}
// Count taken doses for a day/item (simplified - per-intake takenBy means one person per dose)
function _countTakenDoses(doses: Array<{ id: string; takenBy: string | null }>): { total: number; taken: number } {
let total = 0;
let taken = 0;
for (const d of doses) {
total++;
if (takenDoses.has(d.id)) taken++;
}
return { total, taken };
}
async function markDoseTaken(doseId: string) {
// Optimistic update
setTakenDoses((prev) => {
@@ -419,96 +424,189 @@ export function SharedSchedule() {
return { todayDay: todayEntry || null, futureDays: future };
}, [schedule, data?.scheduleDays, i18n.language]);
// Build a map of medication name -> dismissedUntil date string
// This is robust against timestamp changes from schedule updates or timezone fixes
const dismissedUntilByMed = useMemo(() => {
if (!data) return new Map<string, string>();
const map = new Map<string, string>();
for (const med of data.medications) {
if (med.dismissedUntil) {
map.set(med.name, med.dismissedUntil);
}
}
return map;
}, [data]);
// Helper to check if a dose date is on or before the dismissedUntil date
function isDoseDismissedByName(doseTimestamp: number, medName: string): boolean {
const dismissedUntilDate = dismissedUntilByMed.get(medName);
if (!dismissedUntilDate) return false;
// Compare date strings (YYYY-MM-DD format sorts correctly)
const doseDate = new Date(doseTimestamp);
const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`;
return doseDateStr <= dismissedUntilDate;
}
// Calculate coverage for stock status colors (matches main app logic)
// This needs to account for taken doses and calculate depletion time
// Calculate coverage for stock status colors — matches main app's calculateCoverage logic
// Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
const { coverageByMed, depletionByMed } = useMemo(() => {
if (!data) return { coverageByMed: {}, depletionByMed: {} };
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const calcMode = data.stockCalculationMode ?? "automatic";
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
const depletion: Record<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses
// With per-intake takenBy, each dose.id is unique and already has person suffix if needed
const takenByMed: Record<string, number> = {};
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
if (takenDoses.has(dose.id)) {
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
}
}
for (const med of data.medications) {
const totalCount = getMedTotal(med);
const taken = takenByMed[med.name] || 0;
const currentCount = Math.max(0, totalCount - taken);
// Calculate daily usage from intakes (or blisters for legacy)
const intakes = med.intakes || med.blisters;
const dailyUsage = intakes.reduce((sum, b) => sum + b.usage / b.every, 0);
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
const blisters = med.blisters;
// Calculate depletion time (when medication will run out)
if (dailyUsage > 0 && currentCount > 0) {
const daysUntilEmpty = currentCount / dailyUsage;
depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000;
} else if (currentCount <= 0) {
depletion[med.name] = Date.now(); // Already empty
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
if (intake.takenBy) uniquePeople.add(intake.takenBy);
});
med.takenBy?.forEach((person) => uniquePeople.add(person));
const personCount = Math.max(1, uniquePeople.size || med.takenBy?.length || 1);
// Calculate daily consumption rate accounting for per-intake takenBy
let dailyRate = 0;
blisters.forEach((s, idx) => {
const baseRate = s.every > 0 ? s.usage / s.every : 0;
const intake = intakes[idx];
if (intake?.takenBy) {
dailyRate += baseRate; // Per-intake takenBy: 1 person
} else {
dailyRate += baseRate * personCount; // Legacy: all people
}
});
let consumed = 0;
const stockCorrectionCutoff = med.lastStockCorrectionAt ? med.lastStockCorrectionAt : 0;
if (calcMode === "automatic") {
// Time-based: every scheduled dose counts as consumed once its time has passed
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null];
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
// Early intakes: future doses already marked as taken
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
for (const doseId of takenDoses) {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += s.usage;
}
}
}
consumed += timeBasedConsumed + earlyTakenConsumed;
});
} else {
depletion[med.name] = null; // No usage schedule
// Manual mode: only count explicitly taken doses
takenDoses.forEach((doseId) => {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === med.id && blisters[blisterIdx]) {
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
).getTime();
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
consumed += blisters[blisterIdx].usage;
}
}
}
});
}
const totalPills = getMedTotal(med);
const medsLeft = Math.max(0, totalPills - consumed);
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
coverage[med.name] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
depletion[med.name] = depletionMs;
}
return { coverageByMed: coverage, depletionByMed: depletion };
}, [data, schedule, takenDoses]);
}, [data, takenDoses]);
// Stock thresholds from user settings (provided by API) or defaults
const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30;
// Stock thresholds from API — matches DashboardPage's StockThresholds type exactly
const stockThresholds = useMemo(
() => ({
lowStockDays: data?.stockThresholds?.lowStockDays ?? 30,
normalStockDays: data?.stockThresholds?.normalStockDays ?? 60,
highStockDays: data?.stockThresholds?.highStockDays ?? 90,
criticalStockDays: data?.stockThresholds?.reminderDaysBefore ?? 7,
expiryWarningDays: data?.stockThresholds?.expiryWarningDays ?? 90,
}),
[data]
);
// Get worst stock status for a day's medications (matches main app logic with depletion)
const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => {
// Get worst stock status for a day's medications — identical to DashboardPage
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
const statuses = meds.map((item) => {
const coverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
// Will be out of stock by this day?
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
return "danger";
}
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
if (!coverage) return "success";
const { daysLeft, medsLeft } = coverage;
// Currently out of stock
if (medsLeft <= 0 || daysLeft === 0) return "danger";
// No schedule (can't calculate)
if (daysLeft === null) return "success";
// Low stock: < lowStockDays (warning)
if (daysLeft < lowStockDays) return "warning";
// Normal/High stock
return "success";
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
return status.className;
});
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
};
}
// Whether to show stock status indicators on the shared schedule
const showStock = data?.shareStockStatus !== false;
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
function isDoseIdDone(doseId: string): boolean {
if (takenDoses.has(doseId)) return true;
if (dismissedDoses.has(doseId)) return true;
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
if (isDoseDismissed(doseId, med.dismissedUntil ?? undefined)) {
return true;
}
}
}
return false;
}
// Missed past dose IDs — matches DashboardPage's missedPastDoseIds logic
const missedPastDoseIds = useMemo(() => {
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
}, [pastDays, takenDoses, dismissedDoses, data]);
if (loading) {
return (
@@ -631,94 +729,54 @@ export function SharedSchedule() {
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
) : (
<>
{/* Past days toggle */}
{/* Past days toggle — identical to DashboardPage */}
{pastDays.length > 0 &&
(() => {
// Count all past doses (for display)
// With per-intake takenBy, each dose.id is unique
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
// Count missed doses (not taken AND not dismissed AND not from previous schedule)
// Check: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
const missedPastDoses = totalPastDoses.filter((id) => {
if (takenDoses.has(id)) return false;
// Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(id)) return false;
// Check if dismissed via medication-level dismissedUntil date
const parts = id.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
if (isDoseDismissed(id, med.dismissedUntil ?? undefined)) {
return false; // dismissed = not missed
}
}
}
return true; // not taken, not dismissed = missed
}).length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedPastDoses > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}
>
{missedPastDoses}
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
) : null}
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
</div>
);
})()}
{/* Past days (when expanded) */}
{/* Past days (when expanded) — identical to DashboardPage */}
{showPastDays &&
pastDays.map((day) => {
// Helper to check if a dose ID is "done" (taken or dismissed)
// Checks: per-dose dismissed flag and medication-level dismissedUntil
const isDoseIdDone = (doseId: string) => {
if (takenDoses.has(doseId)) return true;
// Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(doseId)) return true;
// Check if dismissed via medication-level dismissedUntil date
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
if (isDoseDismissed(doseId, med.dismissedUntil ?? undefined)) {
return true;
}
}
}
return false;
};
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
const doneCount = allDoseIds.filter(isDoseIdDone).length;
const allDayTaken =
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayDone ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -728,18 +786,18 @@ export function SharedSchedule() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayDone ? (
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - doneCount })}
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
>
</span>
<span className="day-progress">
{doneCount}/{allDoseIds.length}
{takenCount}/{allDoseIds.length}
</span>
</>
)}
@@ -749,61 +807,48 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
// Calculate status for this medication on this day
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const status = showStock
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null
: null;
const itemDoseIds = item.doses.map((d) => d.id);
// A dose is "done" if taken OR dismissed
const allDone = itemDoseIds.every(isDoseIdDone);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allDone ? "taken" : ""}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// Check: medication-level dismissedUntil and per-dose dismissed flag
const isMedLevelDismissed = isDoseDismissedByName(dose.when, dose.medName);
const isTaken = takenDoses.has(dose.id);
const isPerDoseDismissed = dismissedDoses.has(dose.id);
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
return (
<div key={dose.id} className={`dose-item past ${isDone ? "all-taken" : ""}`}>
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -811,26 +856,16 @@ export function SharedSchedule() {
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
<div className={`dose-person ${isDone ? "taken" : ""}`}>
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isDone ? (
isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
</button>
) : (
// Dismissed - show checkmark but no undo
<span
className="dose-btn dismissed"
title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
>
</span>
)
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
</button>
) : (
<button
className="dose-btn take"
@@ -871,7 +906,7 @@ export function SharedSchedule() {
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today stock-${worstStatus}`}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today stock-${showStock ? worstStatus : "success"}`}
>
<div
className="day-divider clickable"
@@ -894,23 +929,16 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const status = showStock
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null
: null;
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -921,20 +949,20 @@ export function SharedSchedule() {
>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
@@ -942,7 +970,10 @@ export function SharedSchedule() {
const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken;
return (
<div key={dose.id} className={`dose-item ${isTaken ? "all-taken" : ""}`}>
<div
key={dose.id}
className={`dose-item ${isOverdue ? "overdue" : ""} ${isTaken ? "all-taken" : ""}`}
>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -985,43 +1016,55 @@ export function SharedSchedule() {
);
})()}
{/* Future days toggle */}
{futureDays.length > 0 && (
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
{showFutureDays ? t("dashboard.schedules.hideFutureDays") : t("dashboard.schedules.showFutureDays")}
</span>
<span className="future-days-count">
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
</span>
</div>
)}
{/* Future days toggle — identical to DashboardPage */}
{futureDays.length > 0 &&
(() => {
const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
);
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
return (
<div className="future-days-header">
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
{showFutureDays
? t("dashboard.schedules.hideFutureDays")
: t("dashboard.schedules.showFutureDays")}
</span>
<span className="future-days-count">
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
</span>
{takenFutureDoses > 0 && totalFutureDoses.length > 0 && (
<span className="future-days-progress">
{takenFutureDoses}/{totalFutureDoses.length}
</span>
)}
</div>
</div>
);
})()}
{/* Future days (when expanded) */}
{/* Future days (when expanded) — identical to DashboardPage */}
{showFutureDays &&
futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
// Determine if day should be collapsed (auto-collapsed by default, manual override)
const isAutoCollapsed = allDayTaken;
// Future days: collapsed by default, manual override to expand
const isAutoCollapsed = true;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
const isCollapsed = !isManuallyExpanded;
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${showStock ? worstStatus : "success"}`}
>
<div
className="day-divider clickable"
@@ -1044,24 +1087,15 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
// Calculate status for this medication on this day
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const status = showStock
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null
: null;
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -1072,37 +1106,27 @@ export function SharedSchedule() {
>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id);
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
return (
<div
key={dose.id}
className={`dose-item ${isFutureDose ? "future" : ""} ${isTaken ? "all-taken" : ""}`}
>
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -1110,9 +1134,7 @@ export function SharedSchedule() {
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
<div
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? (
<button
@@ -1127,7 +1149,7 @@ export function SharedSchedule() {
className="dose-btn take"
onClick={() => markDoseTaken(dose.id)}
title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty}
disabled={true}
>
</button>
+2 -1
View File
@@ -66,7 +66,8 @@ export function UserFilterModal({
</div>
<div className="user-med-stats">
<span className="user-med-pills">
{currentStock}/{formatNumber(packageSize)} {t("common.pills")}
{currentStock}/{formatNumber(packageSize)}{" "}
{packageSize === 1 ? t("common.pill") : t("common.pills")}
</span>
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
</div>
+3 -1
View File
@@ -615,7 +615,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
);
}, [settingsHook.settings, settingsHook.savedSettings]);
+15
View File
@@ -30,6 +30,9 @@ export interface Settings {
lastNotificationChannel: "email" | "push" | "both" | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: "email" | "push" | "both" | null;
lastStockReminderMedNames: string | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
@@ -37,6 +40,7 @@ export interface Settings {
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
expiryWarningDays: number;
}
@@ -65,6 +69,9 @@ const defaultSettings: Settings = {
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
@@ -72,6 +79,7 @@ const defaultSettings: Settings = {
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30,
};
@@ -141,6 +149,9 @@ export function useSettings(): UseSettingsReturn {
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
}));
setSavedSettings((prev) => ({
...prev,
@@ -149,6 +160,9 @@ export function useSettings(): UseSettingsReturn {
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
}));
})
.catch(() => {});
@@ -198,6 +212,7 @@ export function useSettings(): UseSettingsReturn {
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
+41 -19
View File
@@ -21,11 +21,11 @@
"badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.",
"lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.",
"lowWarning": "Genug Vorrat, aber {{meds}} ist kritisch niedrig.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} sind kritisch niedrig.",
"lowWarningPrefix": "Genug Vorrat, aber",
"lowWarningSuffix": "wird knapp.",
"lowWarningSuffix_other": "werden knapp.",
"lowWarningSuffix": "ist kritisch niedrig.",
"lowWarningSuffix_other": "sind kritisch niedrig.",
"sendReminder": "🔔 Erinnerung jetzt senden"
},
"overview": {
@@ -59,10 +59,11 @@
"reminders": {
"active": "Automatische Erinnerungen aktiv",
"status": "Status",
"allStockOk": "Bestand OK",
"allOk": "Alles OK",
"allStockOk": "Bestand gut",
"allOk": "Alles gut",
"lastReminder": "Letzte Einnahme-Erinnerung",
"lastSent": "Letzte Einnahme-Erinnerung",
"lastStockSent": "Letzte Bestands-Erinnerung",
"next": "Nachbestell-Erinnerung",
"nextIn": "Nachbestell-Erinnerung",
"inDays": "in {{days}} Tagen",
@@ -73,8 +74,8 @@
"needRefill_other": "{{count}} Medikamente nachfüllen",
"emptyStock": "{{count}} Medikament leer",
"emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament wird knapp",
"lowWarning_other": "{{count}} Medikamente werden knapp",
"lowWarning": "{{count}} Medikament kritisch niedrig",
"lowWarning_other": "{{count}} Medikamente kritisch niedrig",
"waitingFirstCheck": "Warte auf erste Prüfung",
"type": "Typ",
"typeStock": "Bestand",
@@ -123,7 +124,9 @@
"pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose",
"total": "Gesamt",
"stock": "Bestand"
"stock": "Bestand",
"totalCapacity": "Kapazität",
"type": "Typ"
}
},
"form": {
@@ -181,6 +184,7 @@
"calculate": "Berechnen",
"calculating": "Wird berechnet...",
"sendEmail": "📧 Per E-Mail senden",
"sendNotification": "🔔 Bedarf senden",
"table": {
"medication": "Medikament",
"usage": "Verbrauch",
@@ -229,25 +233,34 @@
"intakeCheck": "Einnahmeprüfung",
"15minBefore": "15 Min. vor geplanter Zeit",
"nextCheck": "Nächste Bestandsprüfung",
"lastSent": "Zuletzt gesendet",
"lastSent": "Letzte Benachrichtigung",
"lastStockSent": "Letzte Bestands-Erinnerung",
"lastIntakeSent": "Letzte Einnahme-Erinnerung",
"envHint": "Diese Werte können über REMINDER_HOUR und REMINDER_MINUTES_BEFORE in .env konfiguriert werden"
},
"stock": {
"title": "Bestand",
"threshold": "Erinnerungsschwelle",
"remindWhen": "Erinnern wenn Vorrat unter",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand niedrig ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen.",
"calculationMode": "Bestandsberechnung",
"automatic": "Automatisch",
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
"manual": "Manuell",
"manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden",
"display": "Anzeige",
"lowStockDays": "Niedriger Bestand (Tage)",
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert",
"highStockDays": "Hoher Bestand (Tage)",
"highStockTooltip": "Grün mit Stern ab diesem Schwellenwert"
"thresholds": "Schwellenwerte",
"criticalStockDays": "Kritisch (Tage)",
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
"lowStockDays": "Niedrig (Tage)",
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
"highStockDays": "Hoch (Tage)",
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
"thresholdValidation": "Werte müssen sein: Kritisch < Niedrig < Hoch",
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
},
"stockReminder": {
"title": "Bestands-Erinnerung",
"description": "Benachrichtigung wenn Medikamentenbestand erreicht",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
},
"saveSettings": "Einstellungen speichern"
},
@@ -288,6 +301,7 @@
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
"hasNotes": "Hat Notizen",
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
"lightMode": "Zum hellen Modus wechseln",
"darkMode": "Zum dunklen Modus wechseln"
},
@@ -348,6 +362,9 @@
"common": {
"loading": "Wird geladen...",
"sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
"networkError": "Netzwerkfehler",
"saving": "Wird gespeichert...",
"unsavedChanges": {
"title": "Ungespeicherte Änderungen",
@@ -386,6 +403,9 @@
"fullBlisters": "volle Blister",
"inBlister": "in 1 Blister",
"total": "gesamt",
"pillsTotal": "{{count}} Tabletten gesamt",
"pillsTotal_one": "{{count}} Tablette gesamt",
"pillsTotal_other": "{{count}} Tabletten gesamt",
"max": "max"
},
"share": {
@@ -450,6 +470,7 @@
"refill": {
"title": "Nachfüllen",
"packs": "Packungen hinzufügen",
"pillsToAdd": "Tabletten hinzufügen",
"loosePills": "Lose Tabletten hinzufügen",
"pillsPerPack": "1 Packung = {{count}} Tabletten",
"addToStock": "Zum Bestand hinzufügen",
@@ -466,6 +487,7 @@
"editStock": {
"title": "Bestand korrigieren",
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
"totalPills": "Gesamte Tabletten",
"fullBlisters": "Volle Blister",
"partialBlisterPills": "Angebrochener Blister",
"pillsPerBlister": "(je {{count}} Tabletten)",
+41 -19
View File
@@ -21,11 +21,11 @@
"badge": "Stock watch",
"noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.",
"lowWarning": "Enough stock for now, but {{meds}} is running low.",
"lowWarning_other": "Enough stock for now, but {{meds}} are running low.",
"lowWarning": "Enough stock for now, but {{meds}} is running critically low.",
"lowWarning_other": "Enough stock for now, but {{meds}} are running critically low.",
"lowWarningPrefix": "Enough stock for now, but",
"lowWarningSuffix": "is running low.",
"lowWarningSuffix_other": "are running low.",
"lowWarningSuffix": "is running critically low.",
"lowWarningSuffix_other": "are running critically low.",
"sendReminder": "🔔 Send Reminder Now"
},
"overview": {
@@ -59,10 +59,11 @@
"reminders": {
"active": "Automatic reminders active",
"status": "Status",
"allStockOk": "All stock OK",
"allOk": "All OK",
"allStockOk": "All stock good",
"allOk": "All good",
"lastReminder": "Last intake reminder",
"lastSent": "Last intake reminder",
"lastStockSent": "Last stock reminder",
"next": "Refill reminder",
"nextIn": "Refill reminder",
"inDays": "in {{days}} days",
@@ -73,8 +74,8 @@
"needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty",
"lowWarning": "{{count}} medication running low",
"lowWarning_other": "{{count}} medications running low",
"lowWarning": "{{count}} medication running critically low",
"lowWarning_other": "{{count}} medications running critically low",
"waitingFirstCheck": "Waiting for first check",
"type": "Type",
"typeStock": "Stock",
@@ -123,7 +124,9 @@
"pillsPerBlister": "Pills per blister",
"loose": "Loose",
"total": "Total",
"stock": "Stock"
"stock": "Stock",
"totalCapacity": "Capacity",
"type": "Type"
}
},
"form": {
@@ -181,6 +184,7 @@
"calculate": "Calculate",
"calculating": "Calculating...",
"sendEmail": "📧 Send via Email",
"sendNotification": "🔔 Send Demand",
"table": {
"medication": "Medication",
"usage": "Usage",
@@ -229,25 +233,34 @@
"intakeCheck": "Intake check",
"15minBefore": "15 min before scheduled time",
"nextCheck": "Next stock check",
"lastSent": "Last sent",
"lastSent": "Last notification sent",
"lastStockSent": "Last stock reminder",
"lastIntakeSent": "Last intake reminder",
"envHint": "These values can be configured via REMINDER_HOUR and REMINDER_MINUTES_BEFORE in .env"
},
"stock": {
"title": "Stock",
"threshold": "Reminder Threshold",
"remindWhen": "Remind when supply drops below",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.",
"calculationMode": "Stock Calculation",
"automatic": "Automatic",
"automaticDesc": "Stock automatically decreases based on schedule",
"manual": "Manual",
"manualDesc": "Stock only decreases when doses are marked as taken",
"display": "Display",
"lowStockDays": "Low Stock (days)",
"lowStockTooltip": "Yellow warning color threshold",
"highStockDays": "High Stock (days)",
"highStockTooltip": "Green with star threshold"
"thresholds": "Thresholds",
"criticalStockDays": "Critical (days)",
"criticalStockTooltip": "Stock below this value is critical and needs immediate attention",
"lowStockDays": "Low (days)",
"lowStockTooltip": "Stock below this value means you should reorder soon",
"highStockDays": "High (days)",
"highStockTooltip": "Stock above this value means you are well supplied",
"thresholdValidation": "Values must be: Critical < Low < High",
"shareStockStatus": "Show Stock on Shared Links",
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
},
"stockReminder": {
"title": "Stock Reminder",
"description": "Sends notification when medication stock reaches",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
},
"saveSettings": "Save Settings"
},
@@ -288,6 +301,7 @@
"tooltips": {
"intakeReminders": "Intake reminders enabled",
"hasNotes": "Has notes",
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
"lightMode": "Switch to light mode",
"darkMode": "Switch to dark mode"
},
@@ -348,6 +362,9 @@
"common": {
"loading": "Loading...",
"sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
"networkError": "Network error",
"saving": "Saving...",
"unsavedChanges": {
"title": "Unsaved Changes",
@@ -386,6 +403,9 @@
"fullBlisters": "full blisters",
"inBlister": "in 1 blister",
"total": "total",
"pillsTotal": "{{count}} pills total",
"pillsTotal_one": "{{count}} pill total",
"pillsTotal_other": "{{count}} pills total",
"max": "max"
},
"share": {
@@ -450,6 +470,7 @@
"refill": {
"title": "Refill",
"packs": "Packs to add",
"pillsToAdd": "Pills to add",
"loosePills": "Loose pills to add",
"pillsPerPack": "1 pack = {{count}} pills",
"addToStock": "Add to Stock",
@@ -466,6 +487,7 @@
"editStock": {
"title": "Correct Stock",
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
"totalPills": "Total pills",
"fullBlisters": "Full blisters",
"partialBlisterPills": "Partial blister",
"pillsPerBlister": "({{count}} pills each)",
+130 -33
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
@@ -80,12 +81,16 @@ function getReminderStatusData(
_lastNotificationChannel: string | null,
lastReminderMedName: string | null,
lastReminderTakenBy: string | null,
lastStockReminderSent: string | null,
_lastStockReminderChannel: string | null,
lastStockReminderMedNames: string | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): {
status: { text: string; className: string };
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
lastSent: { date: string; medName: string | null; takenBy: string | null } | null;
lastStockSent: { date: string; medNames: string | null } | null;
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
} {
const criticalCount = lowCoverage.length;
const lowCount = allCoverage.filter((c) => {
@@ -141,25 +146,40 @@ function getReminderStatusData(
// Convert to array and sort by days left (most urgent first)
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
// Parse last sent info
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const lastSentDate = new Date(lastAutoEmailSent);
const formattedDate = lastSentDate.toLocaleDateString(locale, {
// Parse last stock reminder sent info (from dedicated stock tracking columns)
let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastStockReminderSent) {
const sentDate = new Date(lastStockReminderSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastStockSent = {
date: formattedDate,
medNames: lastStockReminderMedNames,
};
}
lastSent = {
// Parse last intake reminder sent info (from intake tracking columns)
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const sentDate = new Date(lastAutoEmailSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastIntakeSent = {
date: formattedDate,
medName: lastReminderMedName,
takenBy: lastReminderTakenBy,
};
}
return { status, lowStockMeds, lastSent };
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
}
export function DashboardPage() {
@@ -199,6 +219,7 @@ export function DashboardPage() {
openShareDialog,
openScheduleLightbox,
stockThresholds,
loadSettings,
} = useAppContext();
// Get structured reminder data
@@ -212,6 +233,9 @@ export function DashboardPage() {
settings.lastNotificationChannel,
settings.lastReminderMedName,
settings.lastReminderTakenBy,
settings.lastStockReminderSent,
settings.lastStockReminderChannel,
settings.lastStockReminderMedNames,
t,
getSystemLocale(i18n.language)
);
@@ -225,6 +249,50 @@ export function DashboardPage() {
(settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled;
// Manual reminder send state
const [sendingReminder, setSendingReminder] = useState(false);
const [reminderResult, setReminderResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendManualReminder() {
if (!stockRemindersEnabled || reminderData.lowStockMeds.length === 0) return;
setSendingReminder(true);
setReminderResult(null);
try {
const lowStock = reminderData.lowStockMeds.map((m) => {
const cov = coverage.all.find((c) => c.name === m.name);
return {
name: m.name,
medsLeft: cov?.medsLeft ?? 0,
daysLeft: m.daysLeft,
depletionDate: cov?.depletionDate ?? null,
isCritical: m.isCritical,
};
});
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
}),
});
const data = await res.json();
if (res.ok) {
setReminderResult({ success: true, message: data.message || t("common.sent") });
// Refresh settings so "Last stock reminder" row appears immediately
loadSettings();
} else {
setReminderResult({ success: false, message: data.error || t("common.sendFailed") });
}
} catch {
setReminderResult({ success: false, message: t("common.networkError") });
}
setSendingReminder(false);
}
return (
<>
{anyRemindersEnabled && (
@@ -234,14 +302,11 @@ export function DashboardPage() {
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
{reminderData.lowStockMeds.length === 0 && (
<span className={`reminder-status-badge ${reminderData.status.className}`}>
{reminderData.status.className === "success" && "✓ "}
{reminderData.status.text}
</span>
)}
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
</div>
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
{(reminderData.lowStockMeds.length > 0 ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row">
@@ -276,30 +341,68 @@ export function DashboardPage() {
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
{stockRemindersEnabled && reminderData.lastStockSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastStockSent.medNames &&
(() => {
// Extract first med name (medNames may be "Name (+N)")
const rawName = reminderData.lastStockSent!.medNames!;
const firstName = rawName.replace(/\s*\(\+\d+\)$/, "");
const suffix = rawName.includes("(+") ? rawName.slice(firstName.length) : "";
const medication = meds.find((m) => m.name === firstName);
return medication ? (
<>
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{firstName}
</span>
{suffix && <span className="reminder-med-name">{suffix}</span>}
</>
) : (
<span className="reminder-med-name">{rawName}</span>
);
})()}
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastIntakeSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastSent.medName &&
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find((m) => m.name === reminderData.lastSent!.medName);
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
return medication ? (
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{reminderData.lastSent!.medName}
{reminderData.lastIntakeSent!.medName}
</span>
) : (
<span className="reminder-med-name">{reminderData.lastSent!.medName}</span>
<span className="reminder-med-name">{reminderData.lastIntakeSent!.medName}</span>
);
})()}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastSent.takenBy})</span>
{reminderData.lastIntakeSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastIntakeSent.takenBy})</span>
)}
<span className="reminder-date"> {reminderData.lastSent.date}</span>
<span className="reminder-date"> {reminderData.lastIntakeSent.date}</span>
</span>
</div>
)}
</div>
)}
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-send-row">
<button type="button" className="ghost" onClick={sendManualReminder} disabled={sendingReminder}>
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderResult && (
<span className={`reminder-send-result ${reminderResult.success ? "success" : "error"}`}>
{reminderResult.message}
</span>
)}
</div>
)}
</section>
)}
{/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
@@ -568,7 +671,7 @@ export function DashboardPage() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -626,9 +729,7 @@ export function DashboardPage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
@@ -778,9 +879,7 @@ export function DashboardPage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
@@ -967,9 +1066,7 @@ export function DashboardPage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
+79 -35
View File
@@ -340,22 +340,46 @@ export function MedicationsPage() {
</div>
<div className="med-details">
<span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
</span>
<span>
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
{t("medications.details.type")}:{" "}
<strong>
{med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}
</strong>
</span>
{med.packageType === "blister" ? (
<>
<span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
</span>
<span>
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
</span>
</>
) : (
<span>
{t("medications.details.totalCapacity")}: <strong>{med.totalPills ?? med.looseTablets}</strong>
</span>
)}
</div>
<div className="med-total">
{t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
{getPackageSize(med)} {t("common.pills")}
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
{(coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)) >
getPackageSize(med) && (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
)}
</div>
</div>
<div className="med-actions">
@@ -569,24 +593,38 @@ export function MedicationsPage() {
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<button
type="button"
className="success"
@@ -595,12 +633,18 @@ export function MedicationsPage() {
>
{refillSaving ? t("refill.adding") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
{t("common.pills")}
</span>
)}
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
</div>
)}
+21 -11
View File
@@ -117,8 +117,11 @@ export function PlannerPage() {
}
}
async function sendPlannerEmail() {
if (!settings.notificationEmail || plannerRows.length === 0) return;
const canSendNotification =
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
async function sendPlannerNotification() {
if (!canSendNotification || plannerRows.length === 0) return;
setSendingPlannerEmail(true);
setPlannerEmailResult(null);
@@ -136,12 +139,12 @@ export function PlannerPage() {
});
const data = await res.json();
if (res.ok) {
setPlannerEmailResult({ success: true, message: data.message || "Email sent!" });
setPlannerEmailResult({ success: true, message: data.message || t("common.sent") });
} else {
setPlannerEmailResult({ success: false, message: data.error || "Failed to send" });
setPlannerEmailResult({ success: false, message: data.error || t("common.sendFailed") });
}
} catch {
setPlannerEmailResult({ success: false, message: "Network error" });
setPlannerEmailResult({ success: false, message: t("common.networkError") });
}
setSendingPlannerEmail(false);
}
@@ -210,18 +213,20 @@ export function PlannerPage() {
{row.medicationName}
</span>
<span data-label={t("planner.table.usage")}>
<strong>{row.plannerUsage}</strong>&nbsp;{t("common.pills")}
<strong>{row.plannerUsage}</strong>&nbsp;
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
</span>
<span data-label={t("planner.table.blisters")}>
{row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`}
</span>
<span data-label={t("planner.table.available")}>
{row.packageType === "bottle" ? (
`${Math.round(row.loosePills * 10) / 10} ${t("common.pills")}`
`${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`
) : (
<>
{row.fullBlisters} {t("common.blisters")}
{row.loosePills > 0 && ` + ${Math.round(row.loosePills * 10) / 10} ${t("common.pills")}`}
{row.loosePills > 0 &&
` + ${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`}
</>
)}
</span>
@@ -235,10 +240,15 @@ export function PlannerPage() {
);
})}
</div>
{settings.emailEnabled && settings.notificationEmail && (
{canSendNotification && (
<div className="planner-email-action">
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
{sendingPlannerEmail ? t("common.sending") : t("planner.sendEmail")}
<button
type="button"
className="ghost"
onClick={sendPlannerNotification}
disabled={sendingPlannerEmail}
>
{sendingPlannerEmail ? t("common.sending") : t("planner.sendNotification")}
</button>
{plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
+3 -7
View File
@@ -136,7 +136,7 @@ export function SchedulePage() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -186,9 +186,7 @@ export function SchedulePage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
</div>
</div>
<div className="doses-col">
@@ -285,9 +283,7 @@ export function SchedulePage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
+166 -60
View File
@@ -236,6 +236,75 @@ export function SettingsPage() {
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stockReminder.title")}</h3>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t("settings.stockReminder.description")}{" "}
<span className="status-chip small danger">{t("status.criticalStock")}</span>
</label>
<label
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders)
}
onChange={(e) => {
const newVal = e.target.checked;
if (newVal) {
setSettings({
...settings,
emailStockReminders: settings.emailEnabled ? true : settings.emailStockReminders,
shoutrrrStockReminders: settings.shoutrrrEnabled ? true : settings.shoutrrrStockReminders,
});
} else {
setSettings({
...settings,
emailStockReminders: false,
shoutrrrStockReminders: false,
repeatDailyReminders: false,
});
}
}}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="setting-row compact" style={{ marginTop: "4px" }}>
<label className="setting-label">
{t("settings.stockReminder.repeatDaily")}
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stockReminder.repeatTooltip")}
>
</span>
</label>
<label
className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders)) ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={
!(
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders)
)
}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.notifications.email")}</h3>
@@ -400,9 +469,23 @@ export function SettingsPage() {
</span>
</div>
)}
{settings.lastStockReminderSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
<span className="schedule-value">
{new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
{settings.lastAutoEmailSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastSent")}</span>
<span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span>
<span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
@@ -423,51 +506,6 @@ export function SettingsPage() {
<h2>{t("settings.stock.title")}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.threshold")}</h3>
</div>
<div className="threshold-input">
<label>
<span className="threshold-label">{t("settings.stock.remindWhen")}</span>
<div className="threshold-field">
<input
type="number"
min="1"
max="90"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
<span className="threshold-unit">{t("common.days")}</span>
</div>
</label>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t("settings.stock.repeatDaily")}
<span className="info-tooltip small" data-tooltip={t("settings.stock.repeatTooltip")}>
</span>
</label>
<label
className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={
!(
(settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)
)
}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.calculationMode")}</h3>
@@ -512,40 +550,100 @@ export function SettingsPage() {
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.display")}</h3>
<h3>{t("settings.stock.thresholds")}</h3>
</div>
<div className="setting-group">
<label>
<span className="field-label">{t("settings.stock.lowStockDays")}</span>
<div className="setting-group threshold-chips-group">
<label className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label">
<span className="status-chip small danger">{t("status.criticalStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.criticalStockTooltip")}
>
</span>
</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="364"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
</div>
</label>
<label
className={
settings.lowStockDays <= settings.reminderDaysBefore ||
settings.lowStockDays >= settings.highStockDays
? "threshold-invalid"
: ""
}
>
<span className="field-label threshold-chip-label">
<span className="status-chip small warning">{t("status.lowStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.lowStockTooltip")}
>
</span>
</span>
<div className="input-with-tooltip">
<input
type="number"
min="2"
max="365"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span className="info-tooltip" data-tooltip={t("settings.stock.lowStockTooltip")}>
</span>
</div>
</label>
<label>
<span className="field-label">{t("settings.stock.highStockDays")}</span>
<label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label">
<span className="status-chip small high">{t("status.highStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.highStockTooltip")}
>
</span>
</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
min="3"
max="730"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span className="info-tooltip" data-tooltip={t("settings.stock.highStockTooltip")}>
</span>
</div>
</label>
</div>
{(settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays) && (
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
)}
</div>
<div className="setting-section">
<div className="setting-row compact">
<div className="setting-label">
<span>{t("settings.stock.shareStockStatus")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.stock.shareStockStatusDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shareStockStatus}
onChange={(e) => setSettings({ ...settings, shareStockStatus: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</article>
@@ -651,7 +749,15 @@ export function SettingsPage() {
</article>
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
<button
type="submit"
disabled={
settingsSaving ||
(!settingsChanged && settingsSaved) ||
settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays
}
>
{settingsSaving
? t("common.saving")
: settingsSaved && !settingsChanged
+108 -1
View File
@@ -374,6 +374,31 @@ body.modal-open {
color: var(--danger);
}
.reminder-send-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding-left: 1.75rem;
padding-top: 0.25rem;
}
.reminder-send-row .ghost {
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
}
.reminder-send-result {
font-size: 0.8rem;
}
.reminder-send-result.success {
color: var(--success);
}
.reminder-send-result.error {
color: var(--danger);
}
.med-link {
font-weight: 600;
text-decoration: underline;
@@ -473,7 +498,6 @@ body.modal-open {
border-radius: 14px;
padding: 1.25rem;
box-shadow: 0 14px 36px var(--shadow);
overflow: hidden;
transition:
background 200ms ease,
border-color 200ms ease;
@@ -1416,6 +1440,20 @@ textarea.auto-resize {
}
.day-block.all-taken {
border-color: rgba(57, 217, 138, 0.3);
background: linear-gradient(135deg, rgba(57, 217, 138, 0.06) 0%, rgba(57, 217, 138, 0.015) 100%);
}
.day-block.all-taken .day-divider,
.day-block.all-taken.stock-warning .day-divider,
.day-block.all-taken.stock-danger .day-divider {
color: var(--success);
opacity: 0.8;
}
.day-block.past-missed {
border-color: rgba(252, 211, 77, 0.35);
}
.day-block.past-missed .day-divider {
color: var(--warning);
opacity: 0.8;
}
.day-block.today.all-taken {
border-color: var(--success);
@@ -2493,6 +2531,16 @@ textarea.auto-resize {
z-index: 101;
}
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
.info-tooltip.tooltip-align-left::after {
left: 0;
transform: none;
}
.info-tooltip.tooltip-align-left::before {
left: 50%;
transform: translateX(-50%);
}
.info-tooltip:hover::after,
.info-tooltip:hover::before,
.info-tooltip:focus::after,
@@ -2969,6 +3017,62 @@ textarea.auto-resize {
font-size: 0.9rem;
}
/* Threshold Chips Group - 3-column grid for Critical/Low/High */
.threshold-chips-group {
grid-template-columns: 1fr 1fr 1fr;
}
.threshold-chips-group label {
text-transform: none;
letter-spacing: normal;
}
.threshold-chip-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.threshold-chip-label .status-chip {
pointer-events: none;
}
.threshold-invalid input {
border-color: var(--danger) !important;
box-shadow: 0 0 0 1px var(--danger);
}
.threshold-validation-error {
margin: 0.75rem 0 0;
padding: 0.5rem 0.75rem;
background: rgba(255, 94, 94, 0.1);
border: 1px solid rgba(255, 94, 94, 0.3);
border-radius: 6px;
color: #fca5a5;
font-size: 0.8rem;
font-weight: 500;
}
/* Stock Reminder Trigger in Notifications */
.stock-reminder-trigger {
margin-bottom: 0.5rem;
}
.stock-reminder-trigger .setting-desc {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.stock-reminder-trigger .status-chip {
pointer-events: none;
}
/* Compact Setting Row - for inline toggles without card styling */
.setting-row.compact {
padding: 0.75rem 0;
@@ -3144,6 +3248,9 @@ textarea.auto-resize {
.setting-group {
grid-template-columns: 1fr;
}
.threshold-chips-group {
grid-template-columns: 1fr 1fr 1fr;
}
}
/* Medication Avatar */
@@ -15,6 +15,7 @@ const mockMedication: Medication = {
id: 1,
name: "Test Med",
genericName: "Generic Name",
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
@@ -385,3 +386,197 @@ describe("MedDetailModal with refill history", () => {
}
});
});
describe("MedDetailModal intake schedule usage display", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not multiply usage by personCount when intakes have per-intake takenBy", () => {
// Two people at medication level, but each intake has its own takenBy
const med: Medication = {
...mockMedication,
takenBy: ["Alice", "Bob"],
blisters: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00" },
{ usage: 1, every: 1, start: "2024-01-01T21:00:00" },
],
intakes: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false },
{ usage: 1, every: 1, start: "2024-01-01T21:00:00", takenBy: "Bob", intakeRemindersEnabled: false },
],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
// Each intake should show "1 pill" (not "2 pills")
usageElements.forEach((el) => {
expect(el.textContent).toContain("1");
expect(el.textContent).not.toMatch(/^2\b/);
});
});
it("multiplies usage by personCount for legacy blisters without per-intake takenBy", () => {
// Two people at medication level, legacy blisters without intakes
const med: Medication = {
...mockMedication,
takenBy: ["Alice", "Bob"],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
// No intakes array - legacy format
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
// Legacy: 1 pill * 2 people = "2 pills"
expect(usageElements.length).toBe(1);
expect(usageElements[0].textContent).toContain("2");
});
it("shows correct usage for single person with per-intake takenBy", () => {
const med: Medication = {
...mockMedication,
takenBy: ["Alice"],
pillWeightMg: 500,
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00" }],
intakes: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false }],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
expect(usageElements.length).toBe(1);
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
expect(usageElements[0].textContent).toContain("2");
expect(usageElements[0].textContent).toContain("1000");
});
});
describe("MedDetailModal stock overflow warning", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows warning icon when stock exceeds package capacity", () => {
const overflowCoverage: Coverage = {
name: "Test Med",
medsLeft: 49,
daysLeft: 49,
depletionDate: "2024-03-01",
depletionTime: Date.now() + 49 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
// packageSize = 1 * 1 * 30 + 0 = 30, currentStock = 49 > 30
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).toBeInTheDocument();
expect(warningIcon?.getAttribute("data-tooltip")).toBe("tooltips.stockExceedsCapacity");
});
it("does not show warning icon when stock is within package capacity", () => {
render(<MedDetailModal {...defaultProps} />);
// packageSize = 30, currentStock = 25 < 30
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
it("does not show warning icon when stock equals package capacity", () => {
const exactCoverage: Coverage = {
name: "Test Med",
medsLeft: 30,
daysLeft: 30,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 30 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [exactCoverage] }} />);
// packageSize = 30, currentStock = 30 — equal, no warning
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
});
describe("MedDetailModal bottle package type", () => {
const bottleMed: Medication = {
id: 2,
name: "Bottle Med",
genericName: null,
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 80,
totalPills: 100,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
updatedAt: null,
expiryDate: null,
notes: null,
};
const bottleCoverage: Coverage = {
name: "Bottle Med",
medsLeft: 80,
daysLeft: 80,
depletionDate: "2024-06-01",
depletionTime: Date.now() + 80 * 86400000,
nextDose: null,
};
const bottleProps = {
...defaultProps,
selectedMed: bottleMed,
coverage: { all: [bottleCoverage] },
};
beforeEach(() => {
vi.clearAllMocks();
});
it("does not show blister fields in stock info section", () => {
render(<MedDetailModal {...bottleProps} />);
// Should show current stock
expect(screen.getByText(/modal\.currentStock/i)).toBeInTheDocument();
// Should NOT show full blisters or open blister labels
expect(screen.queryByText(/table\.fullBlisters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/table\.openBlister/i)).not.toBeInTheDocument();
});
it("shows bottle type in package details section", () => {
render(<MedDetailModal {...bottleProps} />);
// Should show package type as bottle
expect(screen.getByText(/form\.packageTypeBottle/i)).toBeInTheDocument();
// Should show total capacity
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
});
it("shows pills-only refill modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showRefillModal={true} />);
// Should show pills to add label
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show packs label in refill
const refillModal = document.querySelector(".refill-modal");
// Packs label should not be present for bottle type
expect(screen.queryByText("refill.packs")).not.toBeInTheDocument();
});
it("shows total pills input in edit stock modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showEditStockModal={true} />);
// Should show total pills label
expect(screen.getByText(/editStock\.totalPills/i)).toBeInTheDocument();
// Should NOT show full blisters or partial blister labels
expect(screen.queryByText(/editStock\.fullBlisters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/editStock\.partialBlisterPills/i)).not.toBeInTheDocument();
});
});
@@ -541,3 +541,52 @@ describe("MobileEditModal optional fields", () => {
expect(toggle).toBeInTheDocument();
});
});
describe("MobileEditModal bottle package type", () => {
const bottleForm: FormState = {
...defaultForm,
packageType: "bottle",
packCount: "0",
blistersPerPack: "1",
pillsPerBlister: "1",
looseTablets: "80",
totalPills: "100",
};
it("shows pills-only refill form for bottle type when editing", () => {
render(<MobileEditModal {...defaultProps} form={bottleForm} editingId={1} />);
// Should show "pillsToAdd" label for bottle
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show "packs" label in refill section
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).not.toContain("refill.packs");
expect(refillSection!.textContent).not.toContain("refill.loosePills");
});
it("shows packs and loose refill form for blister type when editing", () => {
render(<MobileEditModal {...defaultProps} form={defaultForm} editingId={1} />);
// Should show "packs" and "loosePills" labels for blister
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).toContain("refill.packs");
expect(refillSection!.textContent).toContain("refill.loosePills");
});
it("shows totalCapacity and currentPills fields for bottle form", () => {
render(<MobileEditModal {...defaultProps} form={bottleForm} />);
// Should show total capacity field
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
// Should show current pills field
expect(screen.getByText(/form\.currentPills/i)).toBeInTheDocument();
// Should NOT show blister-specific fields
expect(screen.queryByText("form.packs")).not.toBeInTheDocument();
expect(screen.queryByText("form.blistersPerPack")).not.toBeInTheDocument();
expect(screen.queryByText("form.pillsPerBlister")).not.toBeInTheDocument();
});
});
+141 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "../../pages/DashboardPage";
@@ -160,6 +160,7 @@ const createMockAppContext = (overrides = {}) => ({
setShowClearMissedConfirm: vi.fn(),
clearingMissed: false,
dismissMissedDoses: vi.fn(),
loadSettings: vi.fn(),
...overrides,
});
@@ -592,7 +593,9 @@ describe("DashboardPage with email notifications", () => {
);
// Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
// The send reminder button IS shown in the reminder status bar (not the reorder card)
expect(document.querySelector(".reminder-status-bar")).toBeInTheDocument();
expect(screen.queryByText(/dashboard\.reorder\.title/i)).not.toBeInTheDocument();
});
});
@@ -622,6 +625,76 @@ describe("DashboardPage with shoutrrr notifications", () => {
const statusBar = document.querySelector(".reminder-status-bar");
expect(statusBar).toBeInTheDocument();
});
it("shows send reminder button when stock reminders are enabled and low stock exists", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
expect(screen.getByText("dashboard.reorder.sendReminder")).toBeInTheDocument();
});
it("sends manual reminder notification on button click", async () => {
global.fetch = vi.fn().mockImplementation((url: string) => {
if (url === "/api/reminder/send-email") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, message: "Notification sent via push" }),
});
}
// Settings refresh after successful send
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
});
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
fireEvent.click(sendButton);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/reminder/send-email",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
});
await waitFor(() => {
expect(screen.getByText("Notification sent via push")).toBeInTheDocument();
});
});
it("shows error message when manual reminder fails", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: "No notification channels configured" }),
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
fireEvent.click(sendButton);
await waitFor(() => {
expect(screen.getByText("No notification channels configured")).toBeInTheDocument();
});
});
});
describe("DashboardPage with past days", () => {
@@ -819,3 +892,69 @@ describe("DashboardPage good stock state", () => {
expect(screen.getByText(/dashboard\.reorder\.allGood/i)).toBeInTheDocument();
});
});
describe("DashboardPage bottle package type", () => {
const bottleMed = {
id: 3,
name: "Ibuprofen",
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 100,
totalPills: 200,
takenBy: [],
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00Z" }],
intakeRemindersEnabled: false,
notes: null,
expiryDate: null,
imageUrl: null,
updatedAt: null,
};
const bottleCoverage = {
name: "Ibuprofen",
medsLeft: 100,
daysLeft: 50,
depletionDate: "2025-04-01",
depletionTime: Date.now() + 50 * 86400000,
nextDose: null,
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockAppContext({
meds: [bottleMed],
coverage: { all: [bottleCoverage], low: [] },
coverageByMed: { Ibuprofen: bottleCoverage },
});
});
it("renders pill count instead of blisters for bottle type", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
// Should show medication name
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
// Should show pills count (bottle shows pillsCount, not blisters)
expect(screen.getByText(/table\.pillsCount/i)).toBeInTheDocument();
});
it("shows dash for stock details column for bottle type", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
// For bottle type, the stock details column shows "—"
const dashElements = document.querySelectorAll('[data-label="table.stockDetails"]');
const bottleDetails = Array.from(dashElements).find((el) => el.textContent === "—");
expect(bottleDetails).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ const mockMeds = [
id: 1,
name: "Aspirin",
genericName: "Acetylsalicylic acid",
packageType: "blister" as const,
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
@@ -25,6 +26,7 @@ const mockMeds = [
id: 2,
name: "Vitamin D",
genericName: null,
packageType: "blister" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 30,
@@ -1442,4 +1444,177 @@ describe("MedicationsPage form saved state", () => {
expect(screen.getByText(/common\.saved/i)).toBeInTheDocument();
});
it("shows stock overflow warning when medsLeft exceeds package size", () => {
const overflowMed = {
...mockMeds[0],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
};
mockContextValue = createMockContext({
meds: [overflowMed],
coverageByMed: {
[overflowMed.name]: {
name: overflowMed.name,
medsLeft: 25,
daysLeft: 25,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 25 * 86400000,
nextDose: null,
},
},
});
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
// packageSize = 1*1*10 + 0 = 10, medsLeft = 25 > 10 → warning shown
const warningIcon = document.querySelector(".med-total .info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).toBeInTheDocument();
});
it("does not show stock overflow warning when stock is within capacity", () => {
const normalMed = {
...mockMeds[0],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
};
mockContextValue = createMockContext({
meds: [normalMed],
coverageByMed: {
[normalMed.name]: {
name: normalMed.name,
medsLeft: 20,
daysLeft: 20,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 20 * 86400000,
nextDose: null,
},
},
});
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
// packageSize = 30, medsLeft = 20 < 30 → no warning
const warningIcon = document.querySelector(".med-total .info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
});
describe("MedicationsPage bottle package type", () => {
const bottleMed = {
id: 3,
name: "Ibuprofen",
genericName: null,
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
totalPills: 200,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
intakeRemindersEnabled: false,
notes: null,
expiryDate: null,
imageUrl: null,
updatedAt: null,
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: [bottleMed] });
mockFormHookValue = createMockFormHook();
});
it("shows bottle type and capacity instead of blister fields in med-details", () => {
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
const medDetails = document.querySelector(".med-details");
expect(medDetails).toBeInTheDocument();
// Should show type and capacity for bottle
expect(medDetails!.textContent).toContain("form.packageTypeBottle");
expect(medDetails!.textContent).toContain("medications.details.totalCapacity");
// Should NOT show blister-specific fields
expect(medDetails!.textContent).not.toContain("medications.details.blisters");
expect(medDetails!.textContent).not.toContain("medications.details.pillsPerBlister");
});
it("shows pills-only refill form for bottle type when editing", () => {
mockFormHookValue = createMockFormHook({
editingId: 3,
form: {
...createMockFormHook().form,
packageType: "bottle" as const,
totalPills: "200",
looseTablets: "150",
},
});
mockContextValue = createMockContext({ meds: [bottleMed] });
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
// Should show "pillsToAdd" label for bottle
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show "packs" label in refill
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).not.toContain("refill.packs");
});
});
describe("MedicationsPage blister refill shows packs", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: mockMeds });
mockFormHookValue = createMockFormHook({
editingId: 1,
form: {
...createMockFormHook().form,
packageType: "blister" as const,
packCount: "1",
blistersPerPack: "2",
pillsPerBlister: "10",
},
});
});
it("shows packs and loose pills refill fields for blister type", () => {
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).toContain("refill.packs");
expect(refillSection!.textContent).toContain("refill.loosePills");
});
});
+99 -1
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlannerPage } from "../../pages/PlannerPage";
@@ -481,3 +481,101 @@ describe("PlannerPage medication detail", () => {
}
});
});
describe("PlannerPage bottle package type", () => {
const bottlePlannerRows = [
{
medicationId: 3,
medicationName: "Ibuprofen",
totalPills: 60,
plannerUsage: 20,
blisterSize: 1,
blistersNeeded: 0,
fullBlisters: 0,
loosePills: 20,
enough: true,
packageType: "bottle" as const,
},
];
const blisterPlannerRows = [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 60,
plannerUsage: 20,
blisterSize: 10,
blistersNeeded: 2,
fullBlisters: 2,
loosePills: 0,
enough: true,
packageType: "blister" as const,
},
];
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: mockMeds });
});
it("shows dash for blisters column when bottle type", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(bottlePlannerRows),
});
render(
<MemoryRouter>
<PlannerPage />
</MemoryRouter>
);
// Submit the form to trigger the planner calculation
const form = document.querySelector("form.planner");
expect(form).toBeInTheDocument();
await act(async () => {
fireEvent.submit(form!);
});
// For bottle type, blisters column should show ""
await waitFor(() => {
const tableRows = document.querySelectorAll(".table-row");
expect(tableRows.length).toBeGreaterThan(0);
});
const tableRows = document.querySelectorAll(".table-row");
const bottleRow = Array.from(tableRows).find((row) => row.textContent?.includes("Ibuprofen"));
expect(bottleRow).toBeTruthy();
expect(bottleRow!.textContent).toContain("");
});
it("shows blisters calculation for blister type", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(blisterPlannerRows),
});
render(
<MemoryRouter>
<PlannerPage />
</MemoryRouter>
);
// Submit the form to trigger the planner calculation
const form = document.querySelector("form.planner");
expect(form).toBeInTheDocument();
await act(async () => {
fireEvent.submit(form!);
});
// For blister type, should show "2 × 10"
await waitFor(() => {
const tableRows = document.querySelectorAll(".table-row");
expect(tableRows.length).toBeGreaterThan(0);
});
const tableRows = document.querySelectorAll(".table-row");
const blisterRow = Array.from(tableRows).find((row) => row.textContent?.includes("Aspirin"));
expect(blisterRow).toBeTruthy();
expect(blisterRow!.textContent).toContain("2 × 10");
});
});
+291 -5
View File
@@ -30,6 +30,7 @@ const createMockContext = (overrides = {}) => ({
skipReminderIfTaken: true,
skipRemindersForTakenDoses: false,
stockCalculationMode: "automatic",
shareStockStatus: true,
stockCheckTime: "08:00",
intakeReminderTime: "09:00",
},
@@ -635,6 +636,58 @@ describe("SettingsPage stock calculation mode", () => {
});
});
describe("SettingsPage share stock status", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
shareStockStatus: true,
},
});
});
it("renders share stock status toggle", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.shareStockStatus$/)).toBeInTheDocument();
});
it("toggles share stock status setting", () => {
const setSettings = vi.fn();
mockContextValue = createMockContext({
setSettings,
settings: {
...createMockContext().settings,
shareStockStatus: true,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
// Find the toggle by its associated label text
const label = screen.getByText(/settings\.stock\.shareStockStatus$/);
const settingRow = label.closest(".setting-row");
const checkbox = settingRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).toBeTruthy();
expect(checkbox.checked).toBe(true);
// Toggle it off
fireEvent.click(checkbox);
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareStockStatus: false }));
});
});
describe("SettingsPage repeat reminders", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -898,7 +951,7 @@ describe("SettingsPage schedule overview", () => {
</MemoryRouter>
);
expect(screen.getByText(/settings\.schedule\.lastSent/i)).toBeInTheDocument();
expect(screen.getByText(/settings\.schedule\.lastIntakeSent/i)).toBeInTheDocument();
});
});
@@ -964,7 +1017,8 @@ describe("SettingsPage stock display thresholds", () => {
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.lowStockDays/i)).toBeInTheDocument();
// Low stock is now shown as a chip label, not plain text
expect(screen.getByText(/status\.lowStock/i)).toBeInTheDocument();
});
it("shows high stock days input", () => {
@@ -974,7 +1028,8 @@ describe("SettingsPage stock display thresholds", () => {
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.highStockDays/i)).toBeInTheDocument();
// High stock is now shown as a chip label, not plain text
expect(screen.getByText(/status\.highStock/i)).toBeInTheDocument();
});
it("allows changing high stock days", () => {
@@ -1011,14 +1066,14 @@ describe("SettingsPage repeat daily reminders", () => {
});
});
it("shows repeat daily reminders toggle", () => {
it("shows repeat daily reminders toggle in notifications", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.repeatDaily/i)).toBeInTheDocument();
expect(screen.getByText(/settings\.stockReminder\.repeatDaily/i)).toBeInTheDocument();
});
});
@@ -1154,6 +1209,237 @@ describe("SettingsPage importing state", () => {
});
});
describe("SettingsPage stock threshold chips", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
});
it("renders Critical stock chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
// Critical chip appears in both Stock Thresholds and Notification trigger
const criticalChips = screen.getAllByText(/status\.criticalStock/i);
expect(criticalChips.length).toBeGreaterThanOrEqual(1);
});
it("renders Low stock chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/status\.lowStock/i)).toBeInTheDocument();
});
it("renders High stock chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/status\.highStock/i)).toBeInTheDocument();
});
it("renders stock calculation mode first in stock card", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.calculationMode/i)).toBeInTheDocument();
});
it("renders thresholds section header", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.thresholds/i)).toBeInTheDocument();
});
it("renders three threshold inputs (Critical, Low, High)", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
// Should have a threshold-chips-group with 3 labels
const chipGroup = document.querySelector(".threshold-chips-group");
expect(chipGroup).toBeInTheDocument();
const inputs = chipGroup?.querySelectorAll('input[type="number"]');
expect(inputs?.length).toBe(3);
});
});
describe("SettingsPage stock threshold validation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows validation error when Critical >= Low", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 30,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.thresholdValidation/i)).toBeInTheDocument();
});
it("shows validation error when Low >= High", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 7,
lowStockDays: 200,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.thresholdValidation/i)).toBeInTheDocument();
});
it("does not show validation error when thresholds are valid", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 7,
lowStockDays: 30,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.queryByText(/settings\.stock\.thresholdValidation/i)).not.toBeInTheDocument();
});
it("disables save button when thresholds are invalid", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 30,
highStockDays: 180,
},
settingsChanged: true,
settingsSaved: false,
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
const submitBtn = document.querySelector('button[type="submit"]');
expect(submitBtn).toBeDisabled();
});
it("enables save button when thresholds are valid and changes exist", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 7,
lowStockDays: 30,
highStockDays: 180,
},
settingsChanged: true,
settingsSaved: false,
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
const submitBtn = document.querySelector('button[type="submit"]');
expect(submitBtn).not.toBeDisabled();
});
it("marks invalid threshold input with error styling", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 30,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
const invalidLabels = document.querySelectorAll(".threshold-invalid");
expect(invalidLabels.length).toBeGreaterThan(0);
});
});
describe("SettingsPage stock reminder in notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
});
it("renders stock reminder section in notifications card", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stockReminder\.title/i)).toBeInTheDocument();
});
it("renders stock reminder description with Critical chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stockReminder\.description/i)).toBeInTheDocument();
// Critical chip should appear next to the description text
const descLabel = screen.getByText(/settings\.stockReminder\.description/i);
const criticalChip = descLabel.querySelector(".status-chip.danger");
expect(criticalChip).toBeInTheDocument();
});
});
describe("SettingsPage no SMTP configured", () => {
beforeEach(() => {
vi.clearAllMocks();
+64
View File
@@ -59,6 +59,44 @@ describe("getMedTotal", () => {
expect(getMedTotal(med)).toBe(0);
});
it("calculates bottle type from looseTablets only", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
};
expect(getMedTotal(med)).toBe(150);
});
it("calculates bottle type with stock adjustment", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
stockAdjustment: -10,
};
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
packCount: 5,
blistersPerPack: 10,
pillsPerBlister: 20,
looseTablets: 80,
};
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
expect(getMedTotal(med)).toBe(80);
});
});
describe("getPackageSize", () => {
@@ -84,6 +122,32 @@ describe("getPackageSize", () => {
expect(getPackageSize(med)).toBe(10);
});
it("returns looseTablets for bottle type", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 200,
};
expect(getPackageSize(med)).toBe(200);
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
packCount: 5,
blistersPerPack: 10,
pillsPerBlister: 20,
looseTablets: 80,
stockAdjustment: 50,
};
// Should use looseTablets only, ignore stockAdjustment and blister math
expect(getPackageSize(med)).toBe(80);
});
});
describe("FIELD_LIMITS", () => {
+8
View File
@@ -181,6 +181,8 @@ export type SharedMedication = {
intakes?: Intake[]; // New intake format with per-intake takenBy
dismissedUntil?: string | null;
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations
lastStockCorrectionAt?: number | null; // Timestamp in ms for stock correction cutoff
stockAdjustment?: number; // Manual stock adjustment
};
export type SharedScheduleData = {
@@ -190,7 +192,13 @@ export type SharedScheduleData = {
medications: SharedMedication[];
stockThresholds?: {
lowStockDays: number;
normalStockDays?: number;
highStockDays?: number;
reminderDaysBefore?: number;
expiryWarningDays?: number;
};
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
export type ExpiredLinkData = {