feat(schedule): add manual collapse/expand functionality for schedule days and update translations

This commit is contained in:
Daniel Volz
2025-12-26 22:14:38 +01:00
parent 551c0b4db5
commit be1e8cda18
4 changed files with 227 additions and 52 deletions
+192 -50
View File
@@ -234,6 +234,9 @@ function AppContent() {
const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false);
// Collapsed days state (manually collapsed days are persisted)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(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() {
</div>
</div>
<div className="timeline">
{groupedSchedule.map((day) => (
<div key={day.dateStr} className="day-block">
<div className="day-divider">{day.dateStr}</div>
{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 (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!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() {
);
})}
</div>
))}
);
})}
</div>
</article>
</section>
@@ -2341,6 +2411,51 @@ function SharedSchedule() {
const [error, setError] = useState<string | null>(null);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
// Collapsed days state for SharedSchedule (token-specific localStorage)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(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 ? (
<p className="shared-schedule-empty">{t('share.noSchedule')}</p>
) : (
schedule.map((day) => (
<div key={day.dateStr} className="day-block">
<div className="day-divider">{day.dateStr}</div>
{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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? 'clickable' : ''}
onClick={() => med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
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 (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!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 (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? 'clickable' : ''}
onClick={() => med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
<div className="doses-col">
{item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken;
return (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken;
return (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}></button>
) : (
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
))
);
})}
</div>
);
})
)}
</div>
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+27
View File
@@ -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); }