feat: Add Clear Missed Doses feature (#28)

- Add dismissed column to dose_tracking table schema
- Add POST /doses/dismiss endpoint for batch dismissing
- Add DELETE /doses/dismiss endpoint to un-dismiss all
- Add frontend dismissedDoses state and missedPastDoseIds useMemo
- Add Clear missed button with confirmation dialog
- Add CSS styles for .past-days-header and .clear-missed-btn
- Add i18n translations for en/de
- Add 5 tests for dismiss endpoints
- Update test schemas with dismissed column

Allows users to acknowledge missed doses without deducting stock.
Closes #28
This commit is contained in:
Daniel Volz
2026-01-16 21:56:35 +01:00
committed by GitHub
parent 318f63657b
commit 055c0dfe10
11 changed files with 410 additions and 21 deletions
+119 -18
View File
@@ -341,6 +341,10 @@ function AppContent() {
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [showPastDays, setShowPastDays] = useState(false);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
// Clear missed doses confirmation dialog
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
// Tag input state for "Taken By" field
const [takenByInput, setTakenByInput] = useState("");
// Share dialog state
@@ -384,7 +388,17 @@ function AppContent() {
const res = await fetch("/api/doses/taken", { credentials: "include" });
if (res.ok) {
const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
const taken = new Set<string>();
const dismissed = new Set<string>();
for (const d of data.doses) {
if (d.dismissed) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
}
}
setTakenDoses(taken);
setDismissedDoses(dismissed);
}
// Don't reset on error - keep current state
} catch {
@@ -467,6 +481,35 @@ function AppContent() {
}
}
// Dismiss missed doses without deducting from stock
async function dismissMissedDoses(doseIds: string[]) {
if (doseIds.length === 0) return;
setClearingMissed(true);
try {
const res = await fetch("/api/doses/dismiss", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseIds }),
});
if (res.ok) {
// Update local state - move these from neither set to dismissed set
setDismissedDoses((prev) => {
const next = new Set(prev);
for (const id of doseIds) next.add(id);
return next;
});
setShowClearMissedConfirm(false);
}
} catch {
// Error - dialog stays open
} finally {
setClearingMissed(false);
}
}
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@@ -580,6 +623,20 @@ function AppContent() {
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
// Calculate missed past dose IDs for the "Clear missed" feature
const missedPastDoseIds = useMemo(() => {
const totalPastDoses = pastDays.flatMap(d =>
d.meds.flatMap(m =>
m.doses.flatMap(dose =>
(dose.takenBy || []).length > 0
? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
: [dose.id]
)
)
);
return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id));
}, [pastDays, takenDoses, dismissedDoses]);
// Load medications and settings when user changes (or on initial mount)
useEffect(() => {
loadMeds();
@@ -1467,31 +1524,46 @@ function AppContent() {
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 && (() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
return (
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
{missedPastDoses > 0 ? (
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}> {missedPastDoses}</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}></span>
) : null}
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
{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>
{missedCount > 0 && (
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t('dashboard.schedules.clearMissed')}
>
{t('dashboard.schedules.clearMissed')}
</button>
)}
</div>
);
})()}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
@@ -1679,6 +1751,35 @@ function AppContent() {
</div>
</article>
</section>
{/* Clear Missed Doses Confirmation Modal */}
{showClearMissedConfirm && (
<div className="modal-overlay" onClick={() => setShowClearMissedConfirm(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
<button className="modal-close" onClick={() => setShowClearMissedConfirm(false)}>×</button>
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('dashboard.schedules.clearMissedConfirmTitle')}</h2>
<p style={{marginBottom: "24px"}}>{t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}</p>
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
<button
type="button"
className="ghost"
onClick={() => setShowClearMissedConfirm(false)}
disabled={clearingMissed}
>
{t('dashboard.schedules.clearMissedCancel')}
</button>
<button
type="button"
className="primary"
onClick={() => dismissMissedDoses(missedPastDoseIds)}
disabled={clearingMissed}
>
{clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
</button>
</div>
</div>
</div>
)}
</>
} />
+8 -1
View File
@@ -38,7 +38,14 @@
"pastDaysCount": "{{count}} Tag",
"pastDaysCount_other": "{{count}} Tage",
"missedDoses": "{{count}} verpasste Dosis",
"missedDoses_other": "{{count}} verpasste Dosen"
"missedDoses_other": "{{count}} verpasste Dosen",
"clearMissed": "Verpasste löschen",
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
"clearMissedConfirm": "Ja, löschen",
"clearMissedCancel": "Abbrechen",
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
},
"reminders": {
"active": "Automatische Erinnerungen aktiv",
+8 -1
View File
@@ -40,7 +40,14 @@
"pastDaysCount": "{{count}} day",
"pastDaysCount_other": "{{count}} days",
"missedDoses": "{{count}} missed dose",
"missedDoses_other": "{{count}} missed doses"
"missedDoses_other": "{{count}} missed doses",
"clearMissed": "Clear missed",
"clearMissedConfirmTitle": "Clear Missed Doses?",
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
"clearMissedConfirm": "Yes, Clear",
"clearMissedCancel": "Cancel",
"clearMissedSuccess": "Cleared {{count}} missed doses"
},
"reminders": {
"active": "Automatic reminders active",
+29
View File
@@ -690,6 +690,35 @@ textarea.auto-resize {
background: rgba(234, 179, 8, 0.08);
}
/* Past days header container - toggle + clear button */
.past-days-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.past-days-header .past-days-toggle {
flex: 1;
}
.clear-missed-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
font-weight: 500;
background: rgba(234, 179, 8, 0.15);
color: var(--warning);
border: 1px solid var(--warning);
border-radius: var(--btn-radius);
cursor: pointer;
white-space: nowrap;
transition: background 150ms ease, transform 100ms ease;
}
.clear-missed-btn:hover {
background: rgba(234, 179, 8, 0.25);
transform: translateY(-1px);
}
.clear-missed-btn:active {
transform: translateY(0);
}
/* Past day blocks styling */
.day-block.past {
opacity: 0.7;