From be1e8cda18efa4a1973d2569336e25bbc5ac96f4 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 26 Dec 2025 22:14:38 +0100 Subject: [PATCH] feat(schedule): add manual collapse/expand functionality for schedule days and update translations --- frontend/src/App.tsx | 242 ++++++++++++++++++++++++++++++-------- frontend/src/i18n/de.json | 5 +- frontend/src/i18n/en.json | 5 +- frontend/src/styles.css | 27 +++++ 4 files changed, 227 insertions(+), 52 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9c2b96..2395b7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -234,6 +234,9 @@ function AppContent() { const [shareGenerating, setShareGenerating] = useState(false); const [shareLink, setShareLink] = useState(null); const [shareCopied, setShareCopied] = useState(false); + // Collapsed days state (manually collapsed days are persisted) + const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); + const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); // Load user-specific scheduleDays and takenDoses when user changes useEffect(() => { @@ -241,6 +244,17 @@ function AppContent() { const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays")); setScheduleDays(storedDays ? Number(storedDays) : 30); + // Load manually collapsed/expanded days from localStorage + const storedCollapsed = localStorage.getItem(userStorageKey(user.id, "collapsedDays")); + const storedExpanded = localStorage.getItem(userStorageKey(user.id, "expandedDays")); + try { + setManuallyCollapsedDays(storedCollapsed ? new Set(JSON.parse(storedCollapsed)) : new Set()); + setManuallyExpandedDays(storedExpanded ? new Set(JSON.parse(storedExpanded)) : new Set()); + } catch { + setManuallyCollapsedDays(new Set()); + setManuallyExpandedDays(new Set()); + } + // Load taken doses from server async function loadTakenDoses() { try { @@ -731,6 +745,35 @@ function AppContent() { setShareCopied(false); } + // Toggle day collapse/expand + function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) { + if (isAutoCollapsed) { + // Day is auto-collapsed (all taken) - toggle the expanded override + setManuallyExpandedDays((prev) => { + const next = new Set(prev); + if (next.has(dateStr)) { + next.delete(dateStr); + } else { + next.add(dateStr); + } + if (user?.id) localStorage.setItem(userStorageKey(user.id, "expandedDays"), JSON.stringify([...next])); + return next; + }); + } else { + // Day is not auto-collapsed - toggle manual collapse + setManuallyCollapsedDays((prev) => { + const next = new Set(prev); + if (next.has(dateStr)) { + next.delete(dateStr); + } else { + next.add(dateStr); + } + if (user?.id) localStorage.setItem(userStorageKey(user.id, "collapsedDays"), JSON.stringify([...next])); + return next; + }); + } + } + const [theme, setTheme] = useState<"light" | "dark">(() => { if (typeof window !== "undefined") { return (localStorage.getItem("theme") as "light" | "dark") || "dark"; @@ -959,10 +1002,36 @@ function AppContent() {
- {groupedSchedule.map((day) => ( -
-
{day.dateStr}
- {day.meds.map((item) => { + {groupedSchedule.map((day) => { + // Check if all doses in this day are taken (auto-collapse) + const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); + const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; + + // Determine if day should be collapsed + const isAutoCollapsed = allDayTaken; + const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); + const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr); + const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; + + return ( +
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + title={isCollapsed ? t('common.expand') : t('common.collapse')} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t('dashboard.schedules.allTaken')} + ) : ( + {takenCount}/{allDoseIds.length} + )} + +
+ {!isCollapsed && day.meds.map((item) => { const medCoverage = coverageByMed[item.medName]; const med = meds.find(m => m.name === item.medName); const depletionTime = depletionByMed[item.medName]; @@ -1005,7 +1074,8 @@ function AppContent() { ); })}
- ))} + ); + })}
@@ -2341,6 +2411,51 @@ function SharedSchedule() { const [error, setError] = useState(null); const [takenDoses, setTakenDoses] = useState>(new Set()); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); + // Collapsed days state for SharedSchedule (token-specific localStorage) + const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); + const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); + + // Load collapsed/expanded state from localStorage + useEffect(() => { + if (token && typeof window !== "undefined") { + const storedCollapsed = localStorage.getItem(`share_${token}_collapsedDays`); + const storedExpanded = localStorage.getItem(`share_${token}_expandedDays`); + try { + setManuallyCollapsedDays(storedCollapsed ? new Set(JSON.parse(storedCollapsed)) : new Set()); + setManuallyExpandedDays(storedExpanded ? new Set(JSON.parse(storedExpanded)) : new Set()); + } catch { + setManuallyCollapsedDays(new Set()); + setManuallyExpandedDays(new Set()); + } + } + }, [token]); + + // Toggle day collapse/expand for SharedSchedule + function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) { + if (isAutoCollapsed) { + setManuallyExpandedDays((prev) => { + const next = new Set(prev); + if (next.has(dateStr)) { + next.delete(dateStr); + } else { + next.add(dateStr); + } + if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next])); + return next; + }); + } else { + setManuallyCollapsedDays((prev) => { + const next = new Set(prev); + if (next.has(dateStr)) { + next.delete(dateStr); + } else { + next.add(dateStr); + } + if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next])); + return next; + }); + } + } // Close lightbox on Escape key useEffect(() => { @@ -2560,54 +2675,81 @@ function SharedSchedule() { {schedule.length === 0 ? (

{t('share.noSchedule')}

) : ( - schedule.map((day) => ( -
-
{day.dateStr}
- {day.meds.map((item) => { - const med = data.medications.find(m => m.name === item.medName); - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); - return ( -
-
-
- med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })} - > - - - {item.medName} - {med?.genericName && ({med.genericName})} + schedule.map((day) => { + // Check if all doses in this day are taken (auto-collapse) + const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); + const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); + const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; + + // Determine if day should be collapsed + const isAutoCollapsed = allDayTaken; + const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); + const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr); + const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; + + return ( +
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + title={isCollapsed ? t('common.expand') : t('common.collapse')} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t('dashboard.schedules.allTaken')} + ) : ( + {takenCount}/{allDoseIds.length} + )} + +
+ {!isCollapsed && day.meds.map((item) => { + const med = data.medications.find(m => m.name === item.medName); + const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + return ( +
+
+
+ med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })} + > + + + {item.medName} + {med?.genericName && ({med.genericName})} +
+
+ {item.total} {t('common.pills')} {t('common.total')} +
-
- {item.total} {t('common.pills')} {t('common.total')} +
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + const isOverdue = dose.when < Date.now() && !isTaken; + return ( +
+ {dose.timeStr} + + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} + {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + + {isTaken ? ( + + ) : ( + + )} +
+ ); + })}
-
- {item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); - const isOverdue = dose.when < Date.now() && !isTaken; - return ( -
- {dose.timeStr} - - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} - - {isTaken ? ( - - ) : ( - - )} -
- ); - })} -
-
- ); - })} -
- )) + ); + })} +
+ ); + }) )}
diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index e98f55e..98c08c2 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -31,7 +31,8 @@ "title": "Kommende Einnahmen", "1month": "1 Monat", "3months": "3 Monate", - "6months": "6 Monate" + "6months": "6 Monate", + "allTaken": "Alle eingenommen" }, "reminders": { "active": "Automatische Erinnerungen aktiv", @@ -263,6 +264,8 @@ "reset": "Zurücksetzen", "test": "Test", "undo": "Rückgängig", + "expand": "Klicken zum Aufklappen", + "collapse": "Klicken zum Einklappen", "optional": "optional", "pill": "Tablette", "pills": "Tabletten", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 100be8f..38e1f28 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -33,7 +33,8 @@ "title": "Upcoming Schedules", "1month": "1 month", "3months": "3 months", - "6months": "6 months" + "6months": "6 months", + "allTaken": "All taken" }, "reminders": { "active": "Automatic reminders active", @@ -265,6 +266,8 @@ "reset": "Reset", "test": "Test", "undo": "Undo", + "expand": "Click to expand", + "collapse": "Click to collapse", "optional": "optional", "pill": "pill", "pills": "pills", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ba0a1e2..4b5dcb6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -388,6 +388,8 @@ textarea { .timeline { display: flex; flex-direction: column; gap: 1rem; } .day-block { border: 1px solid var(--border-primary); border-radius: 16px; padding: 1rem 1.25rem; background: var(--bg-secondary); box-shadow: 0 8px 32px var(--shadow); transition: background 200ms ease, border-color 200ms ease; } +.day-block.collapsed { padding-bottom: 0.75rem; } +.day-block.all-taken { border-color: rgba(57, 217, 138, 0.3); } .day-divider { margin: 0 0 0.75rem; padding-bottom: 0.5rem; @@ -396,6 +398,31 @@ textarea { font-weight: 700; font-size: 0.95rem; letter-spacing: 0.02em; + display: flex; + align-items: center; + gap: 0.5rem; +} +.day-divider.clickable { cursor: pointer; user-select: none; } +.day-divider.clickable:hover { color: var(--accent); } +.day-collapse-icon { + font-size: 0.7rem; + opacity: 0.6; + transition: transform 0.2s ease; + width: 1rem; + text-align: center; +} +.day-date { flex: 1; } +.day-summary { + font-size: 0.8rem; + font-weight: 500; + opacity: 0.7; +} +.day-complete { color: var(--success); } +.day-progress { color: var(--text-secondary); } +.day-block.collapsed .day-divider { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; } .time-row { display: grid; grid-template-columns: minmax(200px, 280px) 1fr; align-items: start; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); transition: opacity 0.2s ease; } [data-theme=\"light\"] .time-row { border-bottom-color: rgba(0,0,0,0.06); }