Refactor code structure for improved readability and maintainability

This commit is contained in:
Daniel Volz
2025-12-20 20:48:23 +01:00
parent 4c351aae2d
commit a0e879e8d2
9 changed files with 1982 additions and 15 deletions
+209 -15
View File
@@ -18,6 +18,7 @@ type Medication = {
tabsPerStrip?: number;
looseTablets?: number;
slices: Slice[];
imageUrl?: string | null;
updatedAt: string | number | null;
};
@@ -105,6 +106,24 @@ export default function App() {
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
const [showImageLightbox, setShowImageLightbox] = useState(false);
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (showImageLightbox) {
setShowImageLightbox(false);
} else if (selectedMed) {
setSelectedMed(null);
}
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [selectedMed, showImageLightbox]);
// Check if settings have changed
const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
@@ -278,6 +297,30 @@ export default function App() {
loadMeds();
}
async function uploadMedImage(medId: number, file: File) {
setUploadingImage(true);
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch(`/api/medications/${medId}/image`, {
method: "POST",
body: formData,
});
if (res.ok) {
loadMeds();
}
} catch {
// ignore
}
setUploadingImage(false);
}
async function deleteMedImage(medId: number) {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
loadMeds();
}
function setSliceValue(idx: number, field: keyof FormSlice, value: string) {
setForm((prev) => {
const next = [...prev.slices];
@@ -427,9 +470,10 @@ export default function App() {
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name);
return (
<div key={row.name} className="table-row">
<span data-label="Name">{row.name}</span>
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<span data-label="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}</span>
<span data-label="Pills" className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span data-label="Days" className={status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""}>{formatNumber(row.daysLeft)}</span>
<span data-label="Status" className={`status-chip ${status.className}`}>{status.label}</span>
@@ -472,9 +516,10 @@ export default function App() {
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name);
return (
<div key={row.name} className="table-row">
<span data-label="Name">{row.name}</span>
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<span data-label="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}</span>
<span data-label="Pills" className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span data-label="Days left" className={status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""}>{formatNumber(row.daysLeft)}</span>
<span data-label="Runs out">{row.depletionDate ?? "-"}</span>
@@ -499,10 +544,11 @@ export default function App() {
{day.meds.map((item) => {
const depletionTime = depletionByMed[item.medName];
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const med = meds.find(m => m.name === item.medName);
return (
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">{item.medName}</div>
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />{item.medName}</div>
<div className="tag-row">
<span className="tag subtle">{item.total} pills total</span>
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
@@ -536,7 +582,10 @@ export default function App() {
<div key={med.id} className="med-row">
<div className="med-header">
<div className="med-info">
<div className="med-name">{med.name}</div>
<div className="med-name-row">
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<div className="med-name">{med.name}</div>
</div>
<div className="med-details">
<span>Packs: <strong>{med.packCount ?? 1}</strong></span>
<span>Blisters per pack: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
@@ -621,6 +670,31 @@ export default function App() {
))}
</div>
{editingId && (
<div className="full image-upload-section">
<label className="setting-label">Medication Image</label>
{(() => {
const currentMed = meds.find(m => m.id === editingId);
if (currentMed?.imageUrl) {
return (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="ghost danger" onClick={() => deleteMedImage(editingId)}>Remove Image</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
disabled={uploadingImage}
/>
);
})()}
</div>
)}
<div className="full align-end gap">
{editingId && (
<button type="button" className="ghost" onClick={resetForm}>
@@ -665,15 +739,18 @@ export default function App() {
<span>Available</span>
<span>Status</span>
</div>
{plannerRows.map((row) => (
<div key={row.medicationId} className="table-row">
<span data-label="Medication">{row.medicationName}</span>
<span data-label="Usage"><strong>{row.plannerUsage}</strong> pills</span>
<span data-label="Blisters">{row.stripsNeeded} × {row.stripSize}</span>
<span data-label="Available">{row.stripsAvailable} blisters</span>
<span data-label="Status" className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "✓ Enough" : "⚠ Out of Stock"}</span>
</div>
))}
{plannerRows.map((row) => {
const med = meds.find(m => m.name === row.medicationName);
return (
<div key={row.medicationId} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<span data-label="Medication" className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
<span data-label="Usage"><strong>{row.plannerUsage}</strong> pills</span>
<span data-label="Blisters">{row.stripsNeeded} × {row.stripSize}</span>
<span data-label="Available">{row.stripsAvailable} blisters</span>
<span data-label="Status" className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "✓ Enough" : "⚠ Out of Stock"}</span>
</div>
);
})}
</div>
{settings.emailEnabled && settings.notificationEmail && (
<div className="planner-email-action">
@@ -854,6 +931,113 @@ export default function App() {
</section>
} />
</Routes>
{/* Medication Detail Modal */}
{selectedMed && (
<div className="modal-overlay" onClick={() => setSelectedMed(null)}>
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setSelectedMed(null)}>×</button>
<div className="med-detail-header">
<div
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? 'clickable' : ''}`}
onClick={() => selectedMed.imageUrl && setShowImageLightbox(true)}
>
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
</div>
<h2>{selectedMed.name}</h2>
</div>
<div className="med-detail-body">
<div className="med-detail-section">
<h3>Stock Information</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">Total Pills</span>
<span className="med-detail-value">{formatNumber(selectedMed.count)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">Packs</span>
<span className="med-detail-value">{selectedMed.packCount ?? 0}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">Blisters/Pack</span>
<span className="med-detail-value">{selectedMed.stripsPerPack ?? 0}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">Pills/Blister</span>
<span className="med-detail-value">{selectedMed.tabsPerStrip ?? 1}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">Loose Pills</span>
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
</div>
</div>
</div>
{selectedMed.slices.length > 0 && (
<div className="med-detail-section">
<h3>Intake Schedule</h3>
<div className="med-detail-schedules">
{selectedMed.slices.map((slice, idx) => (
<div key={idx} className="med-schedule-item">
<span className="med-schedule-usage">{slice.usage} pill{slice.usage !== 1 ? "s" : ""}</span>
<span className="med-schedule-freq">every {slice.every} day{slice.every !== 1 ? "s" : ""}</span>
<span className="med-schedule-time">at {new Date(slice.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
</div>
))}
</div>
</div>
)}
{(() => {
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
if (!medCoverage) return null;
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
return (
<div className="med-detail-section">
<h3>Coverage Status</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">Days Left</span>
<span className="med-detail-value">{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">Runs Out</span>
<span className="med-detail-value">{medCoverage.depletionDate ?? "—"}</span>
</div>
<div className="med-detail-item full-width">
<span className="med-detail-label">Status</span>
<span className={`status-chip ${status.className}`}>{status.label}</span>
</div>
</div>
</div>
);
})()}
</div>
<div className="med-detail-footer">
<button className="ghost" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); setEditingId(selectedMed.id); }}>
Edit Medication
</button>
</div>
</div>
{/* Image Lightbox */}
{showImageLightbox && selectedMed.imageUrl && (
<div className="lightbox-overlay" onClick={() => setShowImageLightbox(false)}>
<button className="lightbox-close" onClick={() => setShowImageLightbox(false)}>×</button>
<img
src={`/api/images/${selectedMed.imageUrl}`}
alt={selectedMed.name}
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
)}
</main>
);
}
@@ -1070,3 +1254,13 @@ function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: S
// Low stock: < lowStockDays (e.g. < 30 days)
return { level: "low", className: "warning", label: "Low Stock" };
}
function MedicationAvatar({ name, imageUrl, size = "sm" }: { name: string; imageUrl?: string | null; size?: "sm" | "md" | "lg" }) {
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
const sizeClass = `med-avatar med-avatar-${size}`;
if (imageUrl) {
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
}
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
}
+389
View File
@@ -850,3 +850,392 @@ input:focus, select:focus {
@media (max-width: 600px) {
.setting-group { grid-template-columns: 1fr; }
}
/* Medication Avatar */
.med-avatar {
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.med-avatar-sm { width: 28px; height: 28px; }
.med-avatar-md { width: 40px; height: 40px; }
.med-avatar-lg { width: 56px; height: 56px; }
.med-avatar-initials {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.7em;
}
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
/* Table/Timeline cells with avatar */
.cell-with-avatar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-main .med-name {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Medication list name row with avatar */
.med-name-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
/* Image upload section */
.image-upload-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
}
.image-preview {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
.image-preview img {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border-primary);
}
.image-upload-section input[type="file"] {
margin-top: 0.5rem;
font-size: 0.9rem;
}
.image-upload-section input[type="file"]::file-selector-button {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-right: 0.75rem;
font-weight: 500;
}
.image-upload-section input[type="file"]::file-selector-button:hover {
background: var(--accent-light);
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--bg-secondary);
border-radius: 12px;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s ease;
border: 1px solid var(--border-primary);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s, color 0.2s;
}
.modal-close:hover {
background: var(--border-primary);
color: var(--text-primary);
}
/* Medication Detail Modal */
.med-detail-modal {
padding: 0;
}
.med-detail-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem 2rem 1.5rem;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
color: white;
border-radius: 12px 12px 0 0;
}
.med-detail-header h2 {
margin: 0;
font-size: 1.5rem;
text-align: center;
}
.med-detail-header .med-avatar-lg {
width: 100px;
height: 100px;
font-size: 2.5rem;
border: 3px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.med-detail-body {
padding: 1.5rem 2rem;
background: var(--bg-primary);
}
.med-detail-section {
margin-bottom: 1.5rem;
}
.med-detail-section:last-child {
margin-bottom: 0;
}
.med-detail-section h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin: 0 0 0.75rem;
}
.med-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.med-detail-item {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: 8px;
}
.med-detail-item.full-width {
grid-column: 1 / -1;
}
.med-detail-label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.med-detail-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.med-detail-schedules {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.med-schedule-item {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--bg-secondary);
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
}
.med-schedule-usage {
font-weight: 600;
color: var(--accent);
}
.med-schedule-freq {
color: var(--text-secondary);
}
.med-schedule-time {
margin-left: auto;
font-weight: 500;
}
.med-detail-footer {
padding: 1rem 2rem 1.5rem;
display: flex;
justify-content: center;
border-top: 1px solid var(--border-primary);
background: var(--bg-primary);
border-radius: 0 0 12px 12px;
}
/* Clickable avatar wrapper */
.med-detail-avatar-wrapper {
position: relative;
display: inline-block;
}
.med-detail-avatar-wrapper.clickable {
cursor: pointer;
}
.med-detail-avatar-wrapper.clickable:hover .med-avatar-lg {
transform: scale(1.05);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.med-detail-avatar-wrapper .expand-icon {
position: absolute;
bottom: -4px;
right: -4px;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 0.9rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.med-detail-avatar-wrapper.clickable:hover .expand-icon {
opacity: 1;
}
/* Image Lightbox */
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 2rem;
animation: fadeIn 0.2s ease;
}
.lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.1);
border: none;
font-size: 2rem;
color: white;
cursor: pointer;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.lightbox-image {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: zoomIn 0.3s ease;
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* Clickable rows */
.table-row.clickable {
cursor: pointer;
transition: background 0.15s;
}
.table-row.clickable:hover {
background: var(--bg-secondary);
}
/* Mobile adjustments for modal */
@media (max-width: 500px) {
.modal-content {
max-height: 85vh;
border-radius: 12px 12px 0 0;
margin-top: auto;
}
.med-detail-header {
padding: 1.5rem 1.5rem 1rem;
}
.med-detail-header .med-avatar-lg {
width: 80px;
height: 80px;
font-size: 2rem;
}
.med-detail-body {
padding: 1rem 1.5rem;
}
.med-detail-footer {
padding: 1rem 1.5rem;
}
.med-detail-grid {
gap: 0.5rem;
}
}