feat(schedule): add manual collapse/expand functionality for schedule days and update translations
This commit is contained in:
+192
-50
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user