diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 4a270fb..f458ea9 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -19,6 +19,7 @@ export function SharedSchedule() { const [error, setError] = useState(null); const [expiredData, setExpiredData] = useState(null); const [takenDoses, setTakenDoses] = useState>(new Set()); + const [dismissedDoses, setDismissedDoses] = useState>(new Set()); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [showPastDays, setShowPastDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false); @@ -116,6 +117,7 @@ export function SharedSchedule() { }, [lightboxImage]); // Load taken doses from server with polling for real-time sync + // Separates taken and dismissed doses (like main app's useDoses hook) useEffect(() => { if (token) { async function loadTakenDoses() { @@ -123,12 +125,24 @@ export function SharedSchedule() { const res = await fetch(`/api/share/${token}/doses`); if (res.ok) { const data = await res.json(); - setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); + const taken = new Set(); + const dismissed = new Set(); + for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean }>) { + if (d.dismissed) { + dismissed.add(d.doseId); + } else { + taken.add(d.doseId); + } + } + setTakenDoses(taken); + setDismissedDoses(dismissed); } else { setTakenDoses(new Set()); + setDismissedDoses(new Set()); } } catch { setTakenDoses(new Set()); + setDismissedDoses(new Set()); } } loadTakenDoses(); @@ -538,10 +552,12 @@ export function SharedSchedule() { ) ); // Count missed doses (not taken AND not dismissed) - // Note: SharedSchedule doesn't have updatedAt info, so we only check dismissed status + // Check both: per-dose dismissed flag from API AND medication-level dismissedUntil const missedPastDoses = totalPastDoses.filter((id) => { if (takenDoses.has(id)) return false; - // Check if this dose is dismissed + // 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 timestamp = parseInt(parts[2], 10); @@ -584,9 +600,12 @@ export function SharedSchedule() { {showPastDays && pastDays.map((day) => { // Helper to check if a dose ID is "done" (taken or dismissed) + // Checks both: per-dose dismissed flag from API AND medication-level dismissedUntil const isDoseIdDone = (doseId: string) => { if (takenDoses.has(doseId)) return true; - // Check if dismissed + // 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 timestamp = parseInt(parts[2], 10); @@ -697,10 +716,11 @@ export function SharedSchedule() {
{item.doses.map((dose) => { const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; - const isDismissed = isDoseDismissed(dose.when, dose.medName); + // Check both: medication-level dismissedUntil AND per-dose dismissed flag + const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName); const allDone = people.every((person) => { const doseId = getDoseId(dose.id, person); - return takenDoses.has(doseId) || isDismissed; + return takenDoses.has(doseId) || dismissedDoses.has(doseId) || isMedLevelDismissed; }); return (
@@ -713,7 +733,8 @@ export function SharedSchedule() { {people.map((person) => { const doseId = getDoseId(dose.id, person); const isTaken = takenDoses.has(doseId); - const isDone = isTaken || isDismissed; + const isPerDoseDismissed = dismissedDoses.has(doseId); + const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed; return (
{person && {person}} diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index ef8ba95..a509753 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -17,11 +17,16 @@ }, "dashboard": { "reorder": { - "title": "Nachbestell-Erinnerung", + "title": "Nachfüll-Erinnerung", "badge": "Bestandsüberwachung", "noMeds": "Noch keine Medikamente konfiguriert.", - "allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.", - "lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden" + "allGood": "Alles in Ordnung, genug Vorrat.", + "lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.", + "lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.", + "lowWarningPrefix": "Genug Vorrat, aber", + "lowWarningSuffix": "wird knapp.", + "lowWarningSuffix_other": "werden knapp.", + "sendReminder": "🔔 Erinnerung jetzt senden" }, "overview": { "title": "Medikamentenübersicht", @@ -64,8 +69,8 @@ "inDays_one": "in {{days}} Tag", "inDays_other": "in {{days}} Tagen", "noRemindersNeeded": "Keine Erinnerungen nötig", - "needReorder": "{{count}} Medikament nachbestellen", - "needReorder_other": "{{count}} Medikamente nachbestellen", + "needRefill": "{{count}} Medikament nachfüllen", + "needRefill_other": "{{count}} Medikamente nachfüllen", "emptyStock": "{{count}} Medikament leer", "emptyStock_other": "{{count}} Medikamente leer", "lowWarning": "{{count}} Medikament wird knapp", @@ -81,7 +86,9 @@ "criticalMeds": "{{count}} Medikament kritisch", "criticalMeds_other": "{{count}} Medikamente kritisch", "lowMeds": "{{count}} Medikament knapp", - "lowMeds_other": "{{count}} Medikamente knapp" + "lowMeds_other": "{{count}} Medikamente knapp", + "daysLeft": "{{days}} Tag übrig", + "daysLeft_other": "{{days}} Tage übrig" } }, "table": { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 544b410..f04e948 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -17,12 +17,15 @@ }, "dashboard": { "reorder": { - "title": "Reorder Reminder", + "title": "Refill Reminder", "badge": "Stock watch", "noMeds": "No medications configured yet.", "allGood": "All good, enough stock.", - "lowWarning": "Enough stock for now, but {{count}} medication is running low.", - "lowWarning_other": "Enough stock for now, but {{count}} medications are running low.", + "lowWarning": "Enough stock for now, but {{meds}} is running low.", + "lowWarning_other": "Enough stock for now, but {{meds}} are running low.", + "lowWarningPrefix": "Enough stock for now, but", + "lowWarningSuffix": "is running low.", + "lowWarningSuffix_other": "are running low.", "sendReminder": "🔔 Send Reminder Now" }, "overview": { @@ -66,8 +69,8 @@ "inDays_one": "in {{days}} day", "inDays_other": "in {{days}} days", "noRemindersNeeded": "No reminders needed", - "needReorder": "{{count}} med needs reorder", - "needReorder_other": "{{count}} meds need reorder", + "needRefill": "{{count}} med needs refill", + "needRefill_other": "{{count}} meds need refill", "emptyStock": "{{count}} med is empty", "emptyStock_other": "{{count}} meds are empty", "lowWarning": "{{count}} medication running low", @@ -83,7 +86,9 @@ "criticalMeds": "{{count}} medication critical", "criticalMeds_other": "{{count}} medications critical", "lowMeds": "{{count}} medication low", - "lowMeds_other": "{{count}} medications low" + "lowMeds_other": "{{count}} medications low", + "daysLeft": "{{days}} day left", + "daysLeft_other": "{{days}} days left" } }, "table": { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a5afe92..3b0468c 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -105,7 +105,7 @@ function getReminderStatusData( locale: string ): { status: { text: string; className: string }; - next: { name: string; days: number } | null; + lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[]; lastSent: { date: string; medName: string | null; takenBy: string | null } | null; } { const criticalCount = lowCoverage.length; @@ -134,17 +134,28 @@ function getReminderStatusData( }; } - // Find next medication to hit reminder threshold - const nextToRunOut = allCoverage - .filter((c) => c.daysLeft !== null && c.daysLeft > reminderDaysBefore) - .sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0]; + // Collect all low stock medications (critical + low) + const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = []; - let next: { name: string; days: number } | null = null; - if (nextToRunOut && nextToRunOut.daysLeft !== null) { - const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore); - next = { name: nextToRunOut.name, days: daysUntilReminder }; + // Add critical meds (from lowCoverage - these are ≤3 days) + for (const c of lowCoverage) { + if (c.daysLeft !== null) { + lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true }); + } } + // Add low but not critical meds + for (const c of allCoverage) { + if (c.medsLeft <= 0) continue; + if (c.daysLeft === null) continue; + if (c.daysLeft < lowStockDays && c.daysLeft > 3) { + lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false }); + } + } + + // Sort by days left (most urgent first) + lowStockMeds.sort((a, b) => a.daysLeft - b.daysLeft); + // Parse last sent info let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null; if (lastAutoEmailSent) { @@ -163,7 +174,7 @@ function getReminderStatusData( }; } - return { status, next, lastSent }; + return { status, lowStockMeds, lastSent }; } export function DashboardPage() { @@ -273,167 +284,198 @@ export function DashboardPage() { {reminderData.status.text}
-
- {stockRemindersEnabled && reminderData.next && ( -
- {t("dashboard.reminders.next")}: - - {reminderData.next.name}{" "} - {t("dashboard.reminders.inDays", { count: reminderData.next.days, days: reminderData.next.days })} - -
- )} - {intakeRemindersEnabled && reminderData.lastSent && ( -
- {t("dashboard.reminders.lastSent")}: - - {reminderData.lastSent.medName && ( - {reminderData.lastSent.medName} - )} - {reminderData.lastSent.takenBy && ( - ({reminderData.lastSent.takenBy}) - )} - {reminderData.lastSent.date} - -
- )} -
+ {(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && ( +
+ {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && ( +
+ {reminderData.lowStockMeds.map((med) => ( +
+ {med.name} + + {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })} + +
+ ))} +
+ )} + {intakeRemindersEnabled && reminderData.lastSent && ( +
+ {t("dashboard.reminders.lastSent")}: + + {reminderData.lastSent.medName && ( + {reminderData.lastSent.medName} + )} + {reminderData.lastSent.takenBy && ( + ({reminderData.lastSent.takenBy}) + )} + {reminderData.lastSent.date} + +
+ )} +
+ )} )} -
-
-
-

{t("dashboard.reorder.title")}

-
- {(() => { - if (meds.length === 0) { - return

{t("dashboard.reorder.noMeds")}

; - } - - // Count medications with "Low" stock status (based on lowStockDays setting) - const lowStockCount = coverage.all.filter((c) => { - if (c.medsLeft <= 0) return true; // out of stock - if (c.daysLeft === null) return false; // no schedule - return c.daysLeft < settings.lowStockDays; - }).length; - - if (coverage.low.length === 0) { - // No critical meds (≤3 days) - if (lowStockCount === 0) { - // All good - everything is Normal or High - return

{t("dashboard.reorder.allGood")}

; - } else { - // Some meds are Low but not critical - return

{t("dashboard.reorder.lowWarning", { count: lowStockCount })}

; + {/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */} + {!anyRemindersEnabled && ( +
+
+
+

{t("dashboard.reorder.title")}

+
+ {(() => { + if (meds.length === 0) { + return

{t("dashboard.reorder.noMeds")}

; } - } - return ( - <> -
-
- {t("table.name")} - {t("table.fullBlisters")} - {t("table.openBlister")} - {t("table.daysLeft")} - {t("table.status")} - {t("table.runsOut")} - {t("table.autoRemind")} -
- {coverage.low.map((row) => { - const status = getStockStatus(row.daysLeft, row.medsLeft, settings); - const med = meds.find((m) => m.name === row.name); - const textClass = - status.className === "danger" - ? "danger-text" - : status.className === "warning" - ? "warning-text" - : "success-text"; - const stock = getBlisterStock( - Math.round(row.medsLeft), - med?.pillsPerBlister ?? 1, - med?.looseTablets ?? 0, - med ? getMedTotal(med) : Math.round(row.medsLeft) - ); - return ( -
med && openMedDetail(med)}> - - - {row.name} - {med?.takenBy && - med.takenBy.length > 0 && - med.takenBy.map((person) => ( - { - e.stopPropagation(); - openUserFilter(person); - }} - > - {person} - - ))} - {(med?.intakeRemindersEnabled || med?.notes) && ( - - {med?.intakeRemindersEnabled && ( - - 🔔 - - )} - {med?.notes && ( - - 📝 - - )} + // Count medications with "Low" stock status (based on lowStockDays setting) + const lowStockMeds = coverage.all.filter((c) => { + if (c.medsLeft <= 0) return true; // out of stock + if (c.daysLeft === null) return false; // no schedule + return c.daysLeft < settings.lowStockDays; + }); + const lowStockCount = lowStockMeds.length; + const lowStockNames = lowStockMeds.map((c) => c.name).join(", "); + + if (coverage.low.length === 0) { + // No critical meds (≤3 days) + if (lowStockCount === 0) { + // All good - everything is Normal or High + return

{t("dashboard.reorder.allGood")}

; + } else { + // Some meds are Low but not critical - render with clickable med names + return ( +

+ {t("dashboard.reorder.lowWarningPrefix")}{" "} + {lowStockMeds.map((c, idx) => { + const med = meds.find((m) => m.name === c.name); + return ( + + {idx > 0 && ", "} + med && openMedDetail(med)}> + {c.name} - )} - - - {formatFullBlisters(stock.fullBlisters, t)} - - - {formatOpenBlisterAndLoose( - stock.openBlisterPills, - stock.loosePills, - med?.pillsPerBlister ?? 1, - t - )} - - - {formatNumber(row.daysLeft)} - - - {t(status.label)} - - {row.depletionDate ?? "-"} - - {getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))} - -

- ); - })} -
- {(settings.emailEnabled || settings.shoutrrrEnabled) && ( -
- - {reminderEmailResult && ( - - {reminderEmailResult.message} - - )} + + ); + })}{" "} + {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })} +

+ ); + } + } + + return ( + <> +
+
+ {t("table.name")} + {t("table.fullBlisters")} + {t("table.openBlister")} + {t("table.daysLeft")} + {t("table.status")} + {t("table.runsOut")} + {t("table.autoRemind")} +
+ {coverage.low.map((row) => { + const status = getStockStatus(row.daysLeft, row.medsLeft, settings); + const med = meds.find((m) => m.name === row.name); + const textClass = + status.className === "danger" + ? "danger-text" + : status.className === "warning" + ? "warning-text" + : "success-text"; + const stock = getBlisterStock( + Math.round(row.medsLeft), + med?.pillsPerBlister ?? 1, + med?.looseTablets ?? 0, + med ? getMedTotal(med) : Math.round(row.medsLeft) + ); + return ( +
med && openMedDetail(med)}> + + + {row.name} + {med?.takenBy && + med.takenBy.length > 0 && + med.takenBy.map((person) => ( + { + e.stopPropagation(); + openUserFilter(person); + }} + > + {person} + + ))} + {(med?.intakeRemindersEnabled || med?.notes) && ( + + {med?.intakeRemindersEnabled && ( + + 🔔 + + )} + {med?.notes && ( + + 📝 + + )} + + )} + + + {formatFullBlisters(stock.fullBlisters, t)} + + + {formatOpenBlisterAndLoose( + stock.openBlisterPills, + stock.loosePills, + med?.pillsPerBlister ?? 1, + t + )} + + + {formatNumber(row.daysLeft)} + + + {t(status.label)} + + {row.depletionDate ?? "-"} + + {getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))} + +
+ ); + })}
- )} - - ); - })()} -
-
+ {(settings.emailEnabled || settings.shoutrrrEnabled) && ( +
+ + {reminderEmailResult && ( + + {reminderEmailResult.message} + + )} +
+ )} + + ); + })()} +
+
+ )}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ecf3426..ca5eae7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -195,7 +195,7 @@ body.modal-open { .reminder-status-header { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; flex-wrap: wrap; } @@ -212,11 +212,10 @@ body.modal-open { } .reminder-status-badge { - padding: 0.2rem 0.5rem; - border-radius: 4px; + padding: 0.25rem 0.6rem; + border-radius: 6px; font-size: 0.75rem; font-weight: 500; - margin-left: auto; } .reminder-status-badge.success { @@ -282,6 +281,52 @@ body.modal-open { color: var(--text-secondary); } +.reminder-low-stock-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding-left: 1.75rem; +} + +.reminder-low-stock-item { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.reminder-low-stock-item .reminder-med-name { + font-weight: 500; + color: var(--text-primary); +} + +.reminder-low-stock-item .reminder-days-left { + color: var(--warning); + font-size: 0.75rem; +} + +.reminder-low-stock-item.critical .reminder-days-left { + color: var(--danger); + font-weight: 500; +} + +.med-link { + font-weight: 600; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} + +.med-link.clickable { + cursor: pointer; +} + +.med-link.clickable:hover { + color: var(--accent); + text-decoration-style: solid; +} + @media (max-width: 600px) { .reminder-status-bar { padding: 0.75rem; @@ -300,6 +345,9 @@ body.modal-open { flex-direction: column; gap: 0.15rem; } + .reminder-low-stock-list { + padding-left: 0; + } } .tabs { diff --git a/frontend/src/test/pages/DashboardPage.test.tsx b/frontend/src/test/pages/DashboardPage.test.tsx index e08c9a3..2c529ff 100644 --- a/frontend/src/test/pages/DashboardPage.test.tsx +++ b/frontend/src/test/pages/DashboardPage.test.tsx @@ -577,15 +577,15 @@ describe("DashboardPage with email notifications", () => { expect(statusBar).toBeInTheDocument(); }); - it("shows reminder email button when there are low stock meds", () => { + it("hides reorder reminder card when reminders are enabled (to avoid redundancy)", () => { render( ); - // Should show send reminder button - expect(screen.getByText(/dashboard\.reorder\.sendReminder/i)).toBeInTheDocument(); + // 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(); }); }); diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index 70272b4..23e237e 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -466,7 +466,7 @@ describe("getReminderStatusText", () => { }; const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en"); - expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needReorder"))).toBe(true); + expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needRefill"))).toBe(true); }); it("handles intake reminder type with push channel", () => { @@ -497,7 +497,7 @@ describe("getReminderStatusText", () => { expect(result.lines[0].className).toBe("danger-text"); }); - it("shows needReorder when below critical threshold", () => { + it("shows needRefill when below critical threshold", () => { const criticalMed: Coverage = { name: "Critical", medsLeft: 5, @@ -508,7 +508,7 @@ describe("getReminderStatusText", () => { }; const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en"); - expect(result.lines.some((l) => l.text.includes("needReorder"))).toBe(true); + expect(result.lines.some((l) => l.text.includes("needRefill"))).toBe(true); }); it("shows low warning when below low threshold but above critical", () => { diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index 569385b..6152f46 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -237,7 +237,7 @@ export function getReminderStatusText( }); if (medsNeedingReminder.length > 0) { lines.push({ - text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, + text: `⚠ ${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`, className: "danger-text", }); } @@ -255,7 +255,7 @@ export function getReminderStatusText( if (medsNeedingReminder.length > 0) { lines.push({ - text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, + text: `⚠ ${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`, className: "danger-text", strong: true, });