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
+10 -1
View File
@@ -120,7 +120,7 @@ Share your medication schedule with others via a public link.
</details> </details>
### Smart Inventory ### Smart Inventory
- Track exact stock: packs, blisters, and loose pills - Track exact stock: packs, blisters, bottles, and loose pills
- Display remaining days of supply - Display remaining days of supply
- Automatic calculation based on intake schedule - Automatic calculation based on intake schedule
@@ -141,6 +141,7 @@ Share your medication schedule with others via a public link.
### Trip Planner ### Trip Planner
- Calculate how many pills you need for a trip or date range - Calculate how many pills you need for a trip or date range
- Plan ahead for vacations, business trips, or hospital stays - Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification
### Multi-Person Support ### Multi-Person Support
- Manage medications for multiple people - Manage medications for multiple people
@@ -254,6 +255,14 @@ Configure push notifications in Settings → Push, or set defaults via environme
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push | | `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push | | `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
### Default User Settings
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
#### URL Examples #### URL Examples
**ntfy** (free, self-hostable): **ntfy** (free, self-hostable):
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.8.8", "version": "1.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.8.8", "version": "1.9.0",
"dependencies": { "dependencies": {
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
+113 -58
View File
@@ -195,6 +195,16 @@ export function MedDetailModal({
<span className={`med-detail-value ${textClass}`}> <span className={`med-detail-value ${textClass}`}>
{currentStock} /{" "} {currentStock} /{" "}
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize} {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> </span>
</div> </div>
</div> </div>
@@ -266,7 +276,12 @@ export function MedDetailModal({
</h3> </h3>
<div className="med-detail-schedules"> <div className="med-detail-schedules">
{selectedMed.blisters.map((blister, idx) => { {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; const totalUsage = blister.usage * personCount;
return ( return (
<div key={idx} className="med-schedule-item"> <div key={idx} className="med-schedule-item">
@@ -350,10 +365,14 @@ export function MedDetailModal({
})} })}
</span> </span>
<span className="refill-amount"> <span className="refill-amount">
+ {(() => {
{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + const total =
entry.loosePillsAdded}{" "} selectedMed.packageType === "bottle"
{t("common.pills")} ? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
})()}
</span> </span>
</div> </div>
))} ))}
@@ -408,24 +427,38 @@ export function MedDetailModal({
<p className="refill-med-name">{selectedMed.name}</p> <p className="refill-med-name">{selectedMed.name}</p>
<div className="refill-form"> <div className="refill-form">
<label> {selectedMed.packageType === "blister" ? (
{t("refill.packs")} <>
<input <label>
type="number" {t("refill.packs")}
min="0" <input
value={refillPacks} type="number"
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)} min="0"
/> value={refillPacks}
</label> onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
<label> />
{t("refill.loosePills")} </label>
<input <label>
type="number" {t("refill.loosePills")}
min="0" <input
value={refillLoose} type="number"
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)} min="0"
/> value={refillLoose}
</label> 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>
<div className="modal-footer"> <div className="modal-footer">
@@ -440,12 +473,17 @@ export function MedDetailModal({
> >
{refillSaving ? t("common.saving") : t("refill.button")} {refillSaving ? t("common.saving") : t("refill.button")}
</button> </button>
{(refillPacks > 0 || refillLoose > 0) && ( {(() => {
<span className="refill-preview"> const totalRefill =
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose}{" "} selectedMed.packageType === "blister"
{t("common.pills")} ? refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
</span> : refillLoose;
)} return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -472,50 +510,67 @@ export function MedDetailModal({
{(() => { {(() => {
const dbTotal = getMedTotal(selectedMed); const dbTotal = getMedTotal(selectedMed);
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal; 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; const difference = newTotal - currentTotal;
return ( return (
<> <>
<div className="edit-stock-form"> <div className="edit-stock-form">
<label> {isBottle ? (
{t("editStock.fullBlisters")}{" "} <label>
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })} {t("editStock.totalPills")}
<input <input
type="number" type="number"
min="0" min="0"
value={editStockFullBlisters} value={editStockPartialBlisterPills}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => onEditStockPartialBlisterPillsChange(parseInt(e.target.value, 10) || 0)}
/> />
</label> </label>
<label> ) : (
{t("editStock.partialBlisterPills")} <>
<input <label>
type="number" {t("editStock.fullBlisters")}{" "}
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0} {t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
max={selectedMed.pillsPerBlister} <input
value={editStockPartialBlisterPills} type="number"
onChange={(e) => { min="0"
const val = parseInt(e.target.value, 10) || 0; value={editStockFullBlisters}
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0; onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
const max = selectedMed.pillsPerBlister; />
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max))); </label>
}} <label>
/> {t("editStock.partialBlisterPills")}
</label> <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>
<div className="edit-stock-summary"> <div className="edit-stock-summary">
<div className="summary-row"> <div className="summary-row">
<span>{t("editStock.currentTotal")}:</span> <span>{t("editStock.currentTotal")}:</span>
<span> <span>
{currentTotal} {t("common.pills")} {currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")}
</span> </span>
</div> </div>
<div className="summary-row"> <div className="summary-row">
<span>{t("editStock.newTotal")}:</span> <span>{t("editStock.newTotal")}:</span>
<span> <span>
{newTotal} {t("common.pills")} {newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
</span> </span>
</div> </div>
<div <div
@@ -524,7 +579,7 @@ export function MedDetailModal({
<span>{t("editStock.difference")}:</span> <span>{t("editStock.difference")}:</span>
<span> <span>
{difference > 0 ? "+" : ""} {difference > 0 ? "+" : ""}
{difference} {t("common.pills")} {difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}
</span> </span>
</div> </div>
</div> </div>
+46 -25
View File
@@ -266,7 +266,8 @@ export function MobileEditModal({
)} )}
<div className="full"> <div className="full">
<p className="sub"> <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> </p>
</div> </div>
<label className="full"> <label className="full">
@@ -307,24 +308,38 @@ export function MobileEditModal({
<div className="full refill-section"> <div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4> <h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline"> <div className="refill-form-inline">
<label> {form.packageType === "blister" ? (
{t("refill.packs")} <>
<input <label>
type="number" {t("refill.packs")}
min="0" <input
value={refillPacks} type="number"
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)} min="0"
/> value={refillPacks}
</label> onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
<label> />
{t("refill.loosePills")} </label>
<input <label>
type="number" {t("refill.loosePills")}
min="0" <input
value={refillLoose} type="number"
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)} min="0"
/> value={refillLoose}
</label> 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 <button
type="button" type="button"
className="success" className="success"
@@ -333,12 +348,18 @@ export function MobileEditModal({
> >
{refillSaving ? t("common.saving") : t("refill.button")} {refillSaving ? t("common.saving") : t("refill.button")}
</button> </button>
{(refillPacks > 0 || refillLoose > 0) && ( {(() => {
<span className="refill-preview"> const totalRefill =
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "} form.packageType === "blister"
{t("common.pills")} ? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
</span> refillLoose
)} : refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div> </div>
</div> </div>
)} )}
+313 -291
View File
@@ -12,6 +12,22 @@ import { isDoseDismissed } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage"; import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar"; 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() { export function SharedSchedule() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -198,17 +214,6 @@ export function SharedSchedule() {
return doseId; 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) { async function markDoseTaken(doseId: string) {
// Optimistic update // Optimistic update
setTakenDoses((prev) => { setTakenDoses((prev) => {
@@ -419,96 +424,189 @@ export function SharedSchedule() {
return { todayDay: todayEntry || null, futureDays: future }; return { todayDay: todayEntry || null, futureDays: future };
}, [schedule, data?.scheduleDays, i18n.language]); }, [schedule, data?.scheduleDays, i18n.language]);
// Build a map of medication name -> dismissedUntil date string // Calculate coverage for stock status colors — matches main app's calculateCoverage logic
// This is robust against timestamp changes from schedule updates or timezone fixes // Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
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
const { coverageByMed, depletionByMed } = useMemo(() => { const { coverageByMed, depletionByMed } = useMemo(() => {
if (!data) return { coverageByMed: {}, depletionByMed: {} }; 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 coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
const depletion: Record<string, number | null> = {}; 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) { for (const med of data.medications) {
const totalCount = getMedTotal(med); const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
const taken = takenByMed[med.name] || 0; const blisters = med.blisters;
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 };
// Calculate depletion time (when medication will run out) // Count unique people from all intakes (for per-intake takenBy)
if (dailyUsage > 0 && currentCount > 0) { const uniquePeople = new Set<string>();
const daysUntilEmpty = currentCount / dailyUsage; intakes.forEach((intake) => {
depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000; if (intake.takenBy) uniquePeople.add(intake.takenBy);
} else if (currentCount <= 0) { });
depletion[med.name] = Date.now(); // Already empty 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 { } 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 }; return { coverageByMed: coverage, depletionByMed: depletion };
}, [data, schedule, takenDoses]); }, [data, takenDoses]);
// Stock thresholds from user settings (provided by API) or defaults // Stock thresholds from API — matches DashboardPage's StockThresholds type exactly
const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30; 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) // Get worst stock status for a day's medications — identical to DashboardPage
const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => { function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
const statuses = meds.map((item) => { const statuses = meds.map((item) => {
const coverage = coverageByMed[item.medName]; const coverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName]; const depletionTime = depletionByMed[item.medName];
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
// Will be out of stock by this day?
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
return "danger";
}
if (!coverage) return "success"; if (!coverage) return "success";
const { daysLeft, medsLeft } = coverage; const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
return status.className;
// 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";
}); });
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; 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) { if (loading) {
return ( return (
@@ -631,94 +729,54 @@ export function SharedSchedule() {
<p className="shared-schedule-empty">{t("share.noSchedule")}</p> <p className="shared-schedule-empty">{t("share.noSchedule")}</p>
) : ( ) : (
<> <>
{/* Past days toggle */} {/* Past days toggle — identical to DashboardPage */}
{pastDays.length > 0 && {pastDays.length > 0 &&
(() => { (() => {
// Count all past doses (for display) const missedCount = missedPastDoseIds.length;
// With per-intake takenBy, each dose.id is unique
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id))); 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 ( return (
<div <div className="past-days-header">
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`} <div
onClick={() => setShowPastDays(!showPastDays)} 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"> <span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")} <span className="past-days-label">
</span> {showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
<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}
</span> </span>
) : totalPastDoses.length > 0 ? ( <span className="past-days-count">
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}> ({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span> </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> </div>
); );
})()} })()}
{/* Past days (when expanded) */} {/* Past days (when expanded) — identical to DashboardPage */}
{showPastDays && {showPastDays &&
pastDays.map((day) => { 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 allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone); const allDayTaken =
const doneCount = allDoseIds.filter(isDoseIdDone).length; 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 isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -728,18 +786,18 @@ export function SharedSchedule() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span> <span className="day-date">{day.dateStr}</span>
<span className="day-summary"> <span className="day-summary">
{allDayDone ? ( {allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span> <span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : ( ) : (
<> <>
<span <span
className="day-warning" className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - doneCount })} title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
> >
</span> </span>
<span className="day-progress"> <span className="day-progress">
{doneCount}/{allDoseIds.length} {takenCount}/{allDoseIds.length}
</span> </span>
</> </>
)} )}
@@ -749,61 +807,48 @@ export function SharedSchedule() {
day.meds.map((item) => { day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName); const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[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 depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = showStock
// Calculate status for this medication on this day ? willBeOutOfStock
let status: { className: string; label: string } | null = null; ? { className: "danger", label: "status.outOfStock" }
if (willBeOutOfStock) { : medCoverage
status = { className: "danger", label: "status.outOfStock" }; ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
} else if (medCoverage) { : null
const { daysLeft, medsLeft } = medCoverage; : null;
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 itemDoseIds = item.doses.map((d) => d.id); const itemDoseIds = item.doses.map((d) => d.id);
// A dose is "done" if taken OR dismissed const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
const allDone = itemDoseIds.every(isDoseIdDone);
return ( return (
<div <div
key={`${day.dateStr}-${item.medName}`} key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allDone ? "taken" : ""}`} className={`time-row ${allTaken ? "taken" : ""}`}
> >
<div className="time-main"> <div className="time-main">
<div className="med-name"> <div className="med-name">
<span <div
className={med?.imageUrl ? "clickable" : ""} className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)} onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
> >
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /> <MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span> </div>
<span className="med-name-text">{item.medName}</span> <span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>} {med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")} {status && (
</span> <span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>} )}
</div> </div>
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {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 isTaken = takenDoses.has(dose.id);
const isPerDoseDismissed = dismissedDoses.has(dose.id);
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
return ( 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-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {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"})`} ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <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>} {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isDone ? ( {isTaken ? (
isTaken ? ( <button
<button className="dose-btn undo"
className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)}
onClick={() => undoDoseTaken(dose.id)} title={t("common.undo")}
title={t("common.undo")} >
>
</button>
</button>
) : (
// Dismissed - show checkmark but no undo
<span
className="dose-btn dismissed"
title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
>
</span>
)
) : ( ) : (
<button <button
className="dose-btn take" className="dose-btn take"
@@ -871,7 +906,7 @@ export function SharedSchedule() {
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -894,23 +929,16 @@ export function SharedSchedule() {
day.meds.map((item) => { day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName); const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[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 depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = showStock
let status: { className: string; label: string } | null = null; ? willBeOutOfStock
if (willBeOutOfStock) { ? { className: "danger", label: "status.outOfStock" }
status = { className: "danger", label: "status.outOfStock" }; : medCoverage
} else if (medCoverage) { ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
const { daysLeft, medsLeft } = medCoverage; : null
if (medsLeft <= 0 || daysLeft === 0) { : null;
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 itemDoseIds = item.doses.map((d) => d.id); const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -921,20 +949,20 @@ export function SharedSchedule() {
> >
<div className="time-main"> <div className="time-main">
<div className="med-name"> <div className="med-name">
<span <div
className={med?.imageUrl ? "clickable" : ""} className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)} onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
> >
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /> <MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span> </div>
<span className="med-name-text">{item.medName}</span> <span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>} {med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")} {status && (
</span> <span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>} )}
</div> </div>
</div> </div>
<div className="doses-col"> <div className="doses-col">
@@ -942,7 +970,10 @@ export function SharedSchedule() {
const isTaken = takenDoses.has(dose.id); const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken; const isOverdue = dose.when < Date.now() && !isTaken;
return ( 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-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -985,43 +1016,55 @@ export function SharedSchedule() {
); );
})()} })()}
{/* Future days toggle */} {/* Future days toggle — identical to DashboardPage */}
{futureDays.length > 0 && ( {futureDays.length > 0 &&
<div (() => {
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`} const totalFutureDoses = futureDays.flatMap((d) =>
onClick={() => setShowFutureDays(!showFutureDays)} d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
> );
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span> const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
<span className="future-days-label"> return (
{showFutureDays ? t("dashboard.schedules.hideFutureDays") : t("dashboard.schedules.showFutureDays")} <div className="future-days-header">
</span> <div
<span className="future-days-count"> className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })}) onClick={() => setShowFutureDays(!showFutureDays)}
</span> >
</div> <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 && {showFutureDays &&
futureDays.map((day) => { 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 allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds); const worstStatus = getDayStockStatus(day.meds);
// Determine if day should be collapsed (auto-collapsed by default, manual override) // Future days: collapsed by default, manual override to expand
const isAutoCollapsed = allDayTaken; const isAutoCollapsed = true;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded;
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -1044,24 +1087,15 @@ export function SharedSchedule() {
day.meds.map((item) => { day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName); const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName]; const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName]; const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = showStock
// Calculate status for this medication on this day ? willBeOutOfStock
let status: { className: string; label: string } | null = null; ? { className: "danger", label: "status.outOfStock" }
if (willBeOutOfStock) { : medCoverage
status = { className: "danger", label: "status.outOfStock" }; ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
} else if (medCoverage) { : null
const { daysLeft, medsLeft } = medCoverage; : null;
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 itemDoseIds = item.doses.map((d) => d.id); const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -1072,37 +1106,27 @@ export function SharedSchedule() {
> >
<div className="time-main"> <div className="time-main">
<div className="med-name"> <div className="med-name">
<span <div
className={med?.imageUrl ? "clickable" : ""} className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)} onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
> >
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /> <MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span> </div>
<span className="med-name-text">{item.medName}</span> <span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>} {med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")} {status && (
</span> <span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>} )}
</div> </div>
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id); 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 ( return (
<div <div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
key={dose.id}
className={`dose-item ${isFutureDose ? "future" : ""} ${isTaken ? "all-taken" : ""}`}
>
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {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"})`} ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div <div className={`dose-person ${isTaken ? "taken" : ""}`}>
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>} {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? ( {isTaken ? (
<button <button
@@ -1127,7 +1149,7 @@ export function SharedSchedule() {
className="dose-btn take" className="dose-btn take"
onClick={() => markDoseTaken(dose.id)} onClick={() => markDoseTaken(dose.id)}
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty} disabled={true}
> >
</button> </button>
+2 -1
View File
@@ -66,7 +66,8 @@ export function UserFilterModal({
</div> </div>
<div className="user-med-stats"> <div className="user-med-stats">
<span className="user-med-pills"> <span className="user-med-pills">
{currentStock}/{formatNumber(packageSize)} {t("common.pills")} {currentStock}/{formatNumber(packageSize)}{" "}
{packageSize === 1 ? t("common.pill") : t("common.pills")}
</span> </span>
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>} {status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
</div> </div>
+3 -1
View File
@@ -615,7 +615,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled || settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes || settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders || settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
); );
}, [settingsHook.settings, settingsHook.savedSettings]); }, [settingsHook.settings, settingsHook.savedSettings]);
+15
View File
@@ -30,6 +30,9 @@ export interface Settings {
lastNotificationChannel: "email" | "push" | "both" | null; lastNotificationChannel: "email" | "push" | "both" | null;
lastReminderMedName: string | null; lastReminderMedName: string | null;
lastReminderTakenBy: string | null; lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: "email" | "push" | "both" | null;
lastStockReminderMedNames: string | null;
shoutrrrEnabled: boolean; shoutrrrEnabled: boolean;
shoutrrrUrl: string; shoutrrrUrl: string;
emailStockReminders: boolean; emailStockReminders: boolean;
@@ -37,6 +40,7 @@ export interface Settings {
shoutrrrStockReminders: boolean; shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean; shoutrrrIntakeReminders: boolean;
stockCalculationMode: "automatic" | "manual"; stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
expiryWarningDays: number; expiryWarningDays: number;
} }
@@ -65,6 +69,9 @@ const defaultSettings: Settings = {
lastNotificationChannel: null, lastNotificationChannel: null,
lastReminderMedName: null, lastReminderMedName: null,
lastReminderTakenBy: null, lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
shoutrrrEnabled: false, shoutrrrEnabled: false,
shoutrrrUrl: "", shoutrrrUrl: "",
emailStockReminders: true, emailStockReminders: true,
@@ -72,6 +79,7 @@ const defaultSettings: Settings = {
shoutrrrStockReminders: true, shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true, shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic", stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30, expiryWarningDays: 30,
}; };
@@ -141,6 +149,9 @@ export function useSettings(): UseSettingsReturn {
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel, lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName, lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy, lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
})); }));
setSavedSettings((prev) => ({ setSavedSettings((prev) => ({
...prev, ...prev,
@@ -149,6 +160,9 @@ export function useSettings(): UseSettingsReturn {
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel, lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName, lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy, lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
})); }));
}) })
.catch(() => {}); .catch(() => {});
@@ -198,6 +212,7 @@ export function useSettings(): UseSettingsReturn {
shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode, stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
language: i18n.language, language: i18n.language,
smtpHost: settings.smtpHost, smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort, smtpPort: settings.smtpPort,
+41 -19
View File
@@ -21,11 +21,11 @@
"badge": "Bestandsüberwachung", "badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.", "noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.", "allGood": "Alles in Ordnung, genug Vorrat.",
"lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.", "lowWarning": "Genug Vorrat, aber {{meds}} ist kritisch niedrig.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.", "lowWarning_other": "Genug Vorrat, aber {{meds}} sind kritisch niedrig.",
"lowWarningPrefix": "Genug Vorrat, aber", "lowWarningPrefix": "Genug Vorrat, aber",
"lowWarningSuffix": "wird knapp.", "lowWarningSuffix": "ist kritisch niedrig.",
"lowWarningSuffix_other": "werden knapp.", "lowWarningSuffix_other": "sind kritisch niedrig.",
"sendReminder": "🔔 Erinnerung jetzt senden" "sendReminder": "🔔 Erinnerung jetzt senden"
}, },
"overview": { "overview": {
@@ -59,10 +59,11 @@
"reminders": { "reminders": {
"active": "Automatische Erinnerungen aktiv", "active": "Automatische Erinnerungen aktiv",
"status": "Status", "status": "Status",
"allStockOk": "Bestand OK", "allStockOk": "Bestand gut",
"allOk": "Alles OK", "allOk": "Alles gut",
"lastReminder": "Letzte Einnahme-Erinnerung", "lastReminder": "Letzte Einnahme-Erinnerung",
"lastSent": "Letzte Einnahme-Erinnerung", "lastSent": "Letzte Einnahme-Erinnerung",
"lastStockSent": "Letzte Bestands-Erinnerung",
"next": "Nachbestell-Erinnerung", "next": "Nachbestell-Erinnerung",
"nextIn": "Nachbestell-Erinnerung", "nextIn": "Nachbestell-Erinnerung",
"inDays": "in {{days}} Tagen", "inDays": "in {{days}} Tagen",
@@ -73,8 +74,8 @@
"needRefill_other": "{{count}} Medikamente nachfüllen", "needRefill_other": "{{count}} Medikamente nachfüllen",
"emptyStock": "{{count}} Medikament leer", "emptyStock": "{{count}} Medikament leer",
"emptyStock_other": "{{count}} Medikamente leer", "emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament wird knapp", "lowWarning": "{{count}} Medikament kritisch niedrig",
"lowWarning_other": "{{count}} Medikamente werden knapp", "lowWarning_other": "{{count}} Medikamente kritisch niedrig",
"waitingFirstCheck": "Warte auf erste Prüfung", "waitingFirstCheck": "Warte auf erste Prüfung",
"type": "Typ", "type": "Typ",
"typeStock": "Bestand", "typeStock": "Bestand",
@@ -123,7 +124,9 @@
"pillsPerBlister": "Tabletten pro Blister", "pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose", "loose": "Lose",
"total": "Gesamt", "total": "Gesamt",
"stock": "Bestand" "stock": "Bestand",
"totalCapacity": "Kapazität",
"type": "Typ"
} }
}, },
"form": { "form": {
@@ -181,6 +184,7 @@
"calculate": "Berechnen", "calculate": "Berechnen",
"calculating": "Wird berechnet...", "calculating": "Wird berechnet...",
"sendEmail": "📧 Per E-Mail senden", "sendEmail": "📧 Per E-Mail senden",
"sendNotification": "🔔 Bedarf senden",
"table": { "table": {
"medication": "Medikament", "medication": "Medikament",
"usage": "Verbrauch", "usage": "Verbrauch",
@@ -229,25 +233,34 @@
"intakeCheck": "Einnahmeprüfung", "intakeCheck": "Einnahmeprüfung",
"15minBefore": "15 Min. vor geplanter Zeit", "15minBefore": "15 Min. vor geplanter Zeit",
"nextCheck": "Nächste Bestandsprüfung", "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" "envHint": "Diese Werte können über REMINDER_HOUR und REMINDER_MINUTES_BEFORE in .env konfiguriert werden"
}, },
"stock": { "stock": {
"title": "Bestand", "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", "calculationMode": "Bestandsberechnung",
"automatic": "Automatisch", "automatic": "Automatisch",
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert", "automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
"manual": "Manuell", "manual": "Manuell",
"manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden", "manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden",
"display": "Anzeige", "thresholds": "Schwellenwerte",
"lowStockDays": "Niedriger Bestand (Tage)", "criticalStockDays": "Kritisch (Tage)",
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert", "criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
"highStockDays": "Hoher Bestand (Tage)", "lowStockDays": "Niedrig (Tage)",
"highStockTooltip": "Grün mit Stern ab diesem Schwellenwert" "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" "saveSettings": "Einstellungen speichern"
}, },
@@ -288,6 +301,7 @@
"tooltips": { "tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert", "intakeReminders": "Einnahme-Erinnerungen aktiviert",
"hasNotes": "Hat Notizen", "hasNotes": "Hat Notizen",
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
"lightMode": "Zum hellen Modus wechseln", "lightMode": "Zum hellen Modus wechseln",
"darkMode": "Zum dunklen Modus wechseln" "darkMode": "Zum dunklen Modus wechseln"
}, },
@@ -348,6 +362,9 @@
"common": { "common": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
"sending": "Wird gesendet...", "sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
"networkError": "Netzwerkfehler",
"saving": "Wird gespeichert...", "saving": "Wird gespeichert...",
"unsavedChanges": { "unsavedChanges": {
"title": "Ungespeicherte Änderungen", "title": "Ungespeicherte Änderungen",
@@ -386,6 +403,9 @@
"fullBlisters": "volle Blister", "fullBlisters": "volle Blister",
"inBlister": "in 1 Blister", "inBlister": "in 1 Blister",
"total": "gesamt", "total": "gesamt",
"pillsTotal": "{{count}} Tabletten gesamt",
"pillsTotal_one": "{{count}} Tablette gesamt",
"pillsTotal_other": "{{count}} Tabletten gesamt",
"max": "max" "max": "max"
}, },
"share": { "share": {
@@ -450,6 +470,7 @@
"refill": { "refill": {
"title": "Nachfüllen", "title": "Nachfüllen",
"packs": "Packungen hinzufügen", "packs": "Packungen hinzufügen",
"pillsToAdd": "Tabletten hinzufügen",
"loosePills": "Lose Tabletten hinzufügen", "loosePills": "Lose Tabletten hinzufügen",
"pillsPerPack": "1 Packung = {{count}} Tabletten", "pillsPerPack": "1 Packung = {{count}} Tabletten",
"addToStock": "Zum Bestand hinzufügen", "addToStock": "Zum Bestand hinzufügen",
@@ -466,6 +487,7 @@
"editStock": { "editStock": {
"title": "Bestand korrigieren", "title": "Bestand korrigieren",
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.", "hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
"totalPills": "Gesamte Tabletten",
"fullBlisters": "Volle Blister", "fullBlisters": "Volle Blister",
"partialBlisterPills": "Angebrochener Blister", "partialBlisterPills": "Angebrochener Blister",
"pillsPerBlister": "(je {{count}} Tabletten)", "pillsPerBlister": "(je {{count}} Tabletten)",
+41 -19
View File
@@ -21,11 +21,11 @@
"badge": "Stock watch", "badge": "Stock watch",
"noMeds": "No medications configured yet.", "noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.", "allGood": "All good, enough stock.",
"lowWarning": "Enough stock for now, but {{meds}} is running low.", "lowWarning": "Enough stock for now, but {{meds}} is running critically low.",
"lowWarning_other": "Enough stock for now, but {{meds}} are running low.", "lowWarning_other": "Enough stock for now, but {{meds}} are running critically low.",
"lowWarningPrefix": "Enough stock for now, but", "lowWarningPrefix": "Enough stock for now, but",
"lowWarningSuffix": "is running low.", "lowWarningSuffix": "is running critically low.",
"lowWarningSuffix_other": "are running low.", "lowWarningSuffix_other": "are running critically low.",
"sendReminder": "🔔 Send Reminder Now" "sendReminder": "🔔 Send Reminder Now"
}, },
"overview": { "overview": {
@@ -59,10 +59,11 @@
"reminders": { "reminders": {
"active": "Automatic reminders active", "active": "Automatic reminders active",
"status": "Status", "status": "Status",
"allStockOk": "All stock OK", "allStockOk": "All stock good",
"allOk": "All OK", "allOk": "All good",
"lastReminder": "Last intake reminder", "lastReminder": "Last intake reminder",
"lastSent": "Last intake reminder", "lastSent": "Last intake reminder",
"lastStockSent": "Last stock reminder",
"next": "Refill reminder", "next": "Refill reminder",
"nextIn": "Refill reminder", "nextIn": "Refill reminder",
"inDays": "in {{days}} days", "inDays": "in {{days}} days",
@@ -73,8 +74,8 @@
"needRefill_other": "{{count}} meds need refill", "needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty", "emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty", "emptyStock_other": "{{count}} meds are empty",
"lowWarning": "{{count}} medication running low", "lowWarning": "{{count}} medication running critically low",
"lowWarning_other": "{{count}} medications running low", "lowWarning_other": "{{count}} medications running critically low",
"waitingFirstCheck": "Waiting for first check", "waitingFirstCheck": "Waiting for first check",
"type": "Type", "type": "Type",
"typeStock": "Stock", "typeStock": "Stock",
@@ -123,7 +124,9 @@
"pillsPerBlister": "Pills per blister", "pillsPerBlister": "Pills per blister",
"loose": "Loose", "loose": "Loose",
"total": "Total", "total": "Total",
"stock": "Stock" "stock": "Stock",
"totalCapacity": "Capacity",
"type": "Type"
} }
}, },
"form": { "form": {
@@ -181,6 +184,7 @@
"calculate": "Calculate", "calculate": "Calculate",
"calculating": "Calculating...", "calculating": "Calculating...",
"sendEmail": "📧 Send via Email", "sendEmail": "📧 Send via Email",
"sendNotification": "🔔 Send Demand",
"table": { "table": {
"medication": "Medication", "medication": "Medication",
"usage": "Usage", "usage": "Usage",
@@ -229,25 +233,34 @@
"intakeCheck": "Intake check", "intakeCheck": "Intake check",
"15minBefore": "15 min before scheduled time", "15minBefore": "15 min before scheduled time",
"nextCheck": "Next stock check", "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" "envHint": "These values can be configured via REMINDER_HOUR and REMINDER_MINUTES_BEFORE in .env"
}, },
"stock": { "stock": {
"title": "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", "calculationMode": "Stock Calculation",
"automatic": "Automatic", "automatic": "Automatic",
"automaticDesc": "Stock automatically decreases based on schedule", "automaticDesc": "Stock automatically decreases based on schedule",
"manual": "Manual", "manual": "Manual",
"manualDesc": "Stock only decreases when doses are marked as taken", "manualDesc": "Stock only decreases when doses are marked as taken",
"display": "Display", "thresholds": "Thresholds",
"lowStockDays": "Low Stock (days)", "criticalStockDays": "Critical (days)",
"lowStockTooltip": "Yellow warning color threshold", "criticalStockTooltip": "Stock below this value is critical and needs immediate attention",
"highStockDays": "High Stock (days)", "lowStockDays": "Low (days)",
"highStockTooltip": "Green with star threshold" "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" "saveSettings": "Save Settings"
}, },
@@ -288,6 +301,7 @@
"tooltips": { "tooltips": {
"intakeReminders": "Intake reminders enabled", "intakeReminders": "Intake reminders enabled",
"hasNotes": "Has notes", "hasNotes": "Has notes",
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
"lightMode": "Switch to light mode", "lightMode": "Switch to light mode",
"darkMode": "Switch to dark mode" "darkMode": "Switch to dark mode"
}, },
@@ -348,6 +362,9 @@
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
"sending": "Sending...", "sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
"networkError": "Network error",
"saving": "Saving...", "saving": "Saving...",
"unsavedChanges": { "unsavedChanges": {
"title": "Unsaved Changes", "title": "Unsaved Changes",
@@ -386,6 +403,9 @@
"fullBlisters": "full blisters", "fullBlisters": "full blisters",
"inBlister": "in 1 blister", "inBlister": "in 1 blister",
"total": "total", "total": "total",
"pillsTotal": "{{count}} pills total",
"pillsTotal_one": "{{count}} pill total",
"pillsTotal_other": "{{count}} pills total",
"max": "max" "max": "max"
}, },
"share": { "share": {
@@ -450,6 +470,7 @@
"refill": { "refill": {
"title": "Refill", "title": "Refill",
"packs": "Packs to add", "packs": "Packs to add",
"pillsToAdd": "Pills to add",
"loosePills": "Loose pills to add", "loosePills": "Loose pills to add",
"pillsPerPack": "1 pack = {{count}} pills", "pillsPerPack": "1 pack = {{count}} pills",
"addToStock": "Add to Stock", "addToStock": "Add to Stock",
@@ -466,6 +487,7 @@
"editStock": { "editStock": {
"title": "Correct Stock", "title": "Correct Stock",
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.", "hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
"totalPills": "Total pills",
"fullBlisters": "Full blisters", "fullBlisters": "Full blisters",
"partialBlisterPills": "Partial blister", "partialBlisterPills": "Partial blister",
"pillsPerBlister": "({{count}} pills each)", "pillsPerBlister": "({{count}} pills each)",
+130 -33
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components"; import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
@@ -80,12 +81,16 @@ function getReminderStatusData(
_lastNotificationChannel: string | null, _lastNotificationChannel: string | null,
lastReminderMedName: string | null, lastReminderMedName: string | null,
lastReminderTakenBy: string | null, lastReminderTakenBy: string | null,
lastStockReminderSent: string | null,
_lastStockReminderChannel: string | null,
lastStockReminderMedNames: string | null,
t: (key: string, options?: Record<string, unknown>) => string, t: (key: string, options?: Record<string, unknown>) => string,
locale: string locale: string
): { ): {
status: { text: string; className: string }; status: { text: string; className: string };
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[]; 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 criticalCount = lowCoverage.length;
const lowCount = allCoverage.filter((c) => { const lowCount = allCoverage.filter((c) => {
@@ -141,25 +146,40 @@ function getReminderStatusData(
// Convert to array and sort by days left (most urgent first) // Convert to array and sort by days left (most urgent first)
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft); const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
// Parse last sent info // Parse last stock reminder sent info (from dedicated stock tracking columns)
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null; let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastAutoEmailSent) { if (lastStockReminderSent) {
const lastSentDate = new Date(lastAutoEmailSent); const sentDate = new Date(lastStockReminderSent);
const formattedDate = lastSentDate.toLocaleDateString(locale, { const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
hour: "2-digit", hour: "2-digit",
minute: "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, date: formattedDate,
medName: lastReminderMedName, medName: lastReminderMedName,
takenBy: lastReminderTakenBy, takenBy: lastReminderTakenBy,
}; };
} }
return { status, lowStockMeds, lastSent }; return { status, lowStockMeds, lastStockSent, lastIntakeSent };
} }
export function DashboardPage() { export function DashboardPage() {
@@ -199,6 +219,7 @@ export function DashboardPage() {
openShareDialog, openShareDialog,
openScheduleLightbox, openScheduleLightbox,
stockThresholds, stockThresholds,
loadSettings,
} = useAppContext(); } = useAppContext();
// Get structured reminder data // Get structured reminder data
@@ -212,6 +233,9 @@ export function DashboardPage() {
settings.lastNotificationChannel, settings.lastNotificationChannel,
settings.lastReminderMedName, settings.lastReminderMedName,
settings.lastReminderTakenBy, settings.lastReminderTakenBy,
settings.lastStockReminderSent,
settings.lastStockReminderChannel,
settings.lastStockReminderMedNames,
t, t,
getSystemLocale(i18n.language) getSystemLocale(i18n.language)
); );
@@ -225,6 +249,50 @@ export function DashboardPage() {
(settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders); (settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled; 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 ( return (
<> <>
{anyRemindersEnabled && ( {anyRemindersEnabled && (
@@ -234,14 +302,11 @@ export function DashboardPage() {
<NotificationBellIcon /> <NotificationBellIcon />
</span> </span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span> <span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
{reminderData.lowStockMeds.length === 0 && ( <span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
<span className={`reminder-status-badge ${reminderData.status.className}`}>
{reminderData.status.className === "success" && "✓ "}
{reminderData.status.text}
</span>
)}
</div> </div>
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && ( {(reminderData.lowStockMeds.length > 0 ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details"> <div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && ( {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row"> <div className="reminder-status-row">
@@ -276,30 +341,68 @@ export function DashboardPage() {
</span> </span>
</div> </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"> <div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span> <span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value"> <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 ? ( return medication ? (
<span className="med-link clickable" onClick={() => openMedDetail(medication)}> <span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{reminderData.lastSent!.medName} {reminderData.lastIntakeSent!.medName}
</span> </span>
) : ( ) : (
<span className="reminder-med-name">{reminderData.lastSent!.medName}</span> <span className="reminder-med-name">{reminderData.lastIntakeSent!.medName}</span>
); );
})()} })()}
{reminderData.lastSent.takenBy && ( {reminderData.lastIntakeSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastSent.takenBy})</span> <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> </span>
</div> </div>
)} )}
</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> </section>
)} )}
{/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */} {/* 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 ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -626,9 +729,7 @@ export function DashboardPage() {
)} )}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && ( {status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span> <span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)} )}
@@ -778,9 +879,7 @@ export function DashboardPage() {
)} )}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && ( {status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span> <span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)} )}
@@ -967,9 +1066,7 @@ export function DashboardPage() {
)} )}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && ( {status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span> <span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)} )}
+79 -35
View File
@@ -340,22 +340,46 @@ export function MedicationsPage() {
</div> </div>
<div className="med-details"> <div className="med-details">
<span> <span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong> {t("medications.details.type")}:{" "}
</span> <strong>
<span> {med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong> </strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
</span> </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>
<div className="med-total"> <div className="med-total">
{t("medications.details.stock")}:{" "} {t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "} {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> </div>
<div className="med-actions"> <div className="med-actions">
@@ -569,24 +593,38 @@ export function MedicationsPage() {
<div className="full refill-section"> <div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4> <h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline"> <div className="refill-form-inline">
<label> {form.packageType === "blister" ? (
{t("refill.packs")} <>
<input <label>
type="number" {t("refill.packs")}
min="0" <input
value={refillPacks} type="number"
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)} min="0"
/> value={refillPacks}
</label> onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
<label> />
{t("refill.loosePills")} </label>
<input <label>
type="number" {t("refill.loosePills")}
min="0" <input
value={refillLoose} type="number"
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)} min="0"
/> value={refillLoose}
</label> 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 <button
type="button" type="button"
className="success" className="success"
@@ -595,12 +633,18 @@ export function MedicationsPage() {
> >
{refillSaving ? t("refill.adding") : t("refill.button")} {refillSaving ? t("refill.adding") : t("refill.button")}
</button> </button>
{(refillPacks > 0 || refillLoose > 0) && ( {(() => {
<span className="refill-preview"> const totalRefill =
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "} form.packageType === "blister"
{t("common.pills")} ? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
</span> refillLoose
)} : refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div> </div>
</div> </div>
)} )}
+21 -11
View File
@@ -117,8 +117,11 @@ export function PlannerPage() {
} }
} }
async function sendPlannerEmail() { const canSendNotification =
if (!settings.notificationEmail || plannerRows.length === 0) return; (settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
async function sendPlannerNotification() {
if (!canSendNotification || plannerRows.length === 0) return;
setSendingPlannerEmail(true); setSendingPlannerEmail(true);
setPlannerEmailResult(null); setPlannerEmailResult(null);
@@ -136,12 +139,12 @@ export function PlannerPage() {
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setPlannerEmailResult({ success: true, message: data.message || "Email sent!" }); setPlannerEmailResult({ success: true, message: data.message || t("common.sent") });
} else { } else {
setPlannerEmailResult({ success: false, message: data.error || "Failed to send" }); setPlannerEmailResult({ success: false, message: data.error || t("common.sendFailed") });
} }
} catch { } catch {
setPlannerEmailResult({ success: false, message: "Network error" }); setPlannerEmailResult({ success: false, message: t("common.networkError") });
} }
setSendingPlannerEmail(false); setSendingPlannerEmail(false);
} }
@@ -210,18 +213,20 @@ export function PlannerPage() {
{row.medicationName} {row.medicationName}
</span> </span>
<span data-label={t("planner.table.usage")}> <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>
<span data-label={t("planner.table.blisters")}> <span data-label={t("planner.table.blisters")}>
{row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`} {row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`}
</span> </span>
<span data-label={t("planner.table.available")}> <span data-label={t("planner.table.available")}>
{row.packageType === "bottle" ? ( {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.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> </span>
@@ -235,10 +240,15 @@ export function PlannerPage() {
); );
})} })}
</div> </div>
{settings.emailEnabled && settings.notificationEmail && ( {canSendNotification && (
<div className="planner-email-action"> <div className="planner-email-action">
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}> <button
{sendingPlannerEmail ? t("common.sending") : t("planner.sendEmail")} type="button"
className="ghost"
onClick={sendPlannerNotification}
disabled={sendingPlannerEmail}
>
{sendingPlannerEmail ? t("common.sending") : t("planner.sendNotification")}
</button> </button>
{plannerEmailResult && ( {plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}> <span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
+3 -7
View File
@@ -136,7 +136,7 @@ export function SchedulePage() {
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -186,9 +186,7 @@ export function SchedulePage() {
)} )}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")}
</span>
</div> </div>
</div> </div>
<div className="doses-col"> <div className="doses-col">
@@ -285,9 +283,7 @@ export function SchedulePage() {
)} )}
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle"> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>} {status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div> </div>
</div> </div>
+166 -60
View File
@@ -236,6 +236,75 @@ export function SettingsPage() {
)} )}
</div> </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="setting-section">
<div className="section-header"> <div className="section-header">
<h3>{t("settings.notifications.email")}</h3> <h3>{t("settings.notifications.email")}</h3>
@@ -400,9 +469,23 @@ export function SettingsPage() {
</span> </span>
</div> </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 && ( {settings.lastAutoEmailSent && (
<div className="schedule-row"> <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"> <span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), { {new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit", day: "2-digit",
@@ -423,51 +506,6 @@ export function SettingsPage() {
<h2>{t("settings.stock.title")}</h2> <h2>{t("settings.stock.title")}</h2>
</div> </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="setting-section">
<div className="section-header"> <div className="section-header">
<h3>{t("settings.stock.calculationMode")}</h3> <h3>{t("settings.stock.calculationMode")}</h3>
@@ -512,40 +550,100 @@ export function SettingsPage() {
<div className="setting-section"> <div className="setting-section">
<div className="section-header"> <div className="section-header">
<h3>{t("settings.stock.display")}</h3> <h3>{t("settings.stock.thresholds")}</h3>
</div> </div>
<div className="setting-group"> <div className="setting-group threshold-chips-group">
<label> <label className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label">{t("settings.stock.lowStockDays")}</span> <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"> <div className="input-with-tooltip">
<input <input
type="number" type="number"
min="1" 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" max="365"
value={settings.lowStockDays} value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })} onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/> />
<span className="info-tooltip" data-tooltip={t("settings.stock.lowStockTooltip")}>
</span>
</div> </div>
</label> </label>
<label> <label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label">{t("settings.stock.highStockDays")}</span> <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"> <div className="input-with-tooltip">
<input <input
type="number" type="number"
min="1" min="3"
max="730" max="730"
value={settings.highStockDays} value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })} onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/> />
<span className="info-tooltip" data-tooltip={t("settings.stock.highStockTooltip")}>
</span>
</div> </div>
</label> </label>
</div> </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> </div>
</article> </article>
@@ -651,7 +749,15 @@ export function SettingsPage() {
</article> </article>
<div className="form-footer"> <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 {settingsSaving
? t("common.saving") ? t("common.saving")
: settingsSaved && !settingsChanged : settingsSaved && !settingsChanged
+108 -1
View File
@@ -374,6 +374,31 @@ body.modal-open {
color: var(--danger); 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 { .med-link {
font-weight: 600; font-weight: 600;
text-decoration: underline; text-decoration: underline;
@@ -473,7 +498,6 @@ body.modal-open {
border-radius: 14px; border-radius: 14px;
padding: 1.25rem; padding: 1.25rem;
box-shadow: 0 14px 36px var(--shadow); box-shadow: 0 14px 36px var(--shadow);
overflow: hidden;
transition: transition:
background 200ms ease, background 200ms ease,
border-color 200ms ease; border-color 200ms ease;
@@ -1416,6 +1440,20 @@ textarea.auto-resize {
} }
.day-block.all-taken { .day-block.all-taken {
border-color: rgba(57, 217, 138, 0.3); 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 { .day-block.today.all-taken {
border-color: var(--success); border-color: var(--success);
@@ -2493,6 +2531,16 @@ textarea.auto-resize {
z-index: 101; 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::after,
.info-tooltip:hover::before, .info-tooltip:hover::before,
.info-tooltip:focus::after, .info-tooltip:focus::after,
@@ -2969,6 +3017,62 @@ textarea.auto-resize {
font-size: 0.9rem; 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 */ /* Compact Setting Row - for inline toggles without card styling */
.setting-row.compact { .setting-row.compact {
padding: 0.75rem 0; padding: 0.75rem 0;
@@ -3144,6 +3248,9 @@ textarea.auto-resize {
.setting-group { .setting-group {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.threshold-chips-group {
grid-template-columns: 1fr 1fr 1fr;
}
} }
/* Medication Avatar */ /* Medication Avatar */
@@ -15,6 +15,7 @@ const mockMedication: Medication = {
id: 1, id: 1,
name: "Test Med", name: "Test Med",
genericName: "Generic Name", genericName: "Generic Name",
packageType: "blister",
packCount: 1, packCount: 1,
blistersPerPack: 1, blistersPerPack: 1,
pillsPerBlister: 30, 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(); 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 { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "../../pages/DashboardPage"; import { DashboardPage } from "../../pages/DashboardPage";
@@ -160,6 +160,7 @@ const createMockAppContext = (overrides = {}) => ({
setShowClearMissedConfirm: vi.fn(), setShowClearMissedConfirm: vi.fn(),
clearingMissed: false, clearingMissed: false,
dismissMissedDoses: vi.fn(), dismissMissedDoses: vi.fn(),
loadSettings: vi.fn(),
...overrides, ...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) // 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"); const statusBar = document.querySelector(".reminder-status-bar");
expect(statusBar).toBeInTheDocument(); 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", () => { describe("DashboardPage with past days", () => {
@@ -819,3 +892,69 @@ describe("DashboardPage good stock state", () => {
expect(screen.getByText(/dashboard\.reorder\.allGood/i)).toBeInTheDocument(); 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, id: 1,
name: "Aspirin", name: "Aspirin",
genericName: "Acetylsalicylic acid", genericName: "Acetylsalicylic acid",
packageType: "blister" as const,
packCount: 1, packCount: 1,
blistersPerPack: 2, blistersPerPack: 2,
pillsPerBlister: 10, pillsPerBlister: 10,
@@ -25,6 +26,7 @@ const mockMeds = [
id: 2, id: 2,
name: "Vitamin D", name: "Vitamin D",
genericName: null, genericName: null,
packageType: "blister" as const,
packCount: 0, packCount: 0,
blistersPerPack: 1, blistersPerPack: 1,
pillsPerBlister: 30, pillsPerBlister: 30,
@@ -1442,4 +1444,177 @@ describe("MedicationsPage form saved state", () => {
expect(screen.getByText(/common\.saved/i)).toBeInTheDocument(); 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 { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlannerPage } from "../../pages/PlannerPage"; 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, skipReminderIfTaken: true,
skipRemindersForTakenDoses: false, skipRemindersForTakenDoses: false,
stockCalculationMode: "automatic", stockCalculationMode: "automatic",
shareStockStatus: true,
stockCheckTime: "08:00", stockCheckTime: "08:00",
intakeReminderTime: "09: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", () => { describe("SettingsPage repeat reminders", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -898,7 +951,7 @@ describe("SettingsPage schedule overview", () => {
</MemoryRouter> </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> </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", () => { it("shows high stock days input", () => {
@@ -974,7 +1028,8 @@ describe("SettingsPage stock display thresholds", () => {
</MemoryRouter> </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", () => { 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( render(
<MemoryRouter> <MemoryRouter>
<SettingsPage /> <SettingsPage />
</MemoryRouter> </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", () => { describe("SettingsPage no SMTP configured", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
+64
View File
@@ -59,6 +59,44 @@ describe("getMedTotal", () => {
expect(getMedTotal(med)).toBe(0); 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", () => { describe("getPackageSize", () => {
@@ -84,6 +122,32 @@ describe("getPackageSize", () => {
expect(getPackageSize(med)).toBe(10); 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", () => { describe("FIELD_LIMITS", () => {
+8
View File
@@ -181,6 +181,8 @@ export type SharedMedication = {
intakes?: Intake[]; // New intake format with per-intake takenBy intakes?: Intake[]; // New intake format with per-intake takenBy
dismissedUntil?: string | null; dismissedUntil?: string | null;
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations 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 = { export type SharedScheduleData = {
@@ -190,7 +192,13 @@ export type SharedScheduleData = {
medications: SharedMedication[]; medications: SharedMedication[];
stockThresholds?: { stockThresholds?: {
lowStockDays: number; lowStockDays: number;
normalStockDays?: number;
highStockDays?: number;
reminderDaysBefore?: number;
expiryWarningDays?: number;
}; };
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
}; };
export type ExpiredLinkData = { export type ExpiredLinkData = {