feat: add expiry_date, notes, and generic_name columns to medications table with corresponding migrations
This commit is contained in:
+116
-15
@@ -10,6 +10,7 @@ type Slice = {
|
||||
type Medication = {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
count: number;
|
||||
strips: number;
|
||||
stripSize: number;
|
||||
@@ -19,6 +20,8 @@ type Medication = {
|
||||
looseTablets?: number;
|
||||
slices: Slice[];
|
||||
imageUrl?: string | null;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
|
||||
@@ -36,16 +39,19 @@ type FormSlice = { usage: string; every: string; start: string };
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
genericName: string;
|
||||
packCount: string;
|
||||
stripsPerPack: string;
|
||||
tabsPerStrip: string;
|
||||
looseTablets: string;
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
slices: FormSlice[];
|
||||
};
|
||||
|
||||
const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
|
||||
|
||||
const defaultForm = (): FormState => ({ name: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", slices: [defaultSlice()] });
|
||||
const defaultForm = (): FormState => ({ name: "", genericName: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", expiryDate: "", notes: "", slices: [defaultSlice()] });
|
||||
|
||||
const todayIso = () => new Date().toISOString();
|
||||
const plusDaysIso = (days: number) => {
|
||||
@@ -110,6 +116,42 @@ export default function App() {
|
||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||
|
||||
// Track taken doses (stored in localStorage)
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("takenDoses");
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Clean up old entries (older than 7 days)
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo);
|
||||
return new Set(filtered.map((item: { id: string }) => item.id));
|
||||
}
|
||||
} catch {}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
function markDoseTaken(doseId: string) {
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
// Persist with timestamp for cleanup
|
||||
const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() }));
|
||||
localStorage.setItem("takenDoses", JSON.stringify(items));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function undoDoseTaken(doseId: string) {
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() }));
|
||||
localStorage.setItem("takenDoses", JSON.stringify(items));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
@@ -139,12 +181,13 @@ export default function App() {
|
||||
const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]);
|
||||
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
|
||||
const groupedSchedule = useMemo(() => {
|
||||
const days = new Map<string, { dateStr: string; meds: Map<string, { medName: string; total: number; times: string[]; lastWhen: number }> }>();
|
||||
schedule.events.slice(0, 30).forEach((event) => {
|
||||
type DoseInfo = { id: string; timeStr: string; when: number; usage: number };
|
||||
const days = new Map<string, { dateStr: string; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
|
||||
schedule.events.slice(0, 200).forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, meds: new Map() };
|
||||
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, times: [], lastWhen: event.when };
|
||||
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when };
|
||||
medEntry.total += event.usage;
|
||||
medEntry.times.push(event.timeStr);
|
||||
medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage });
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
@@ -341,10 +384,13 @@ export default function App() {
|
||||
setEditingId(med.id);
|
||||
setForm({
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
packCount: String(med.packCount ?? 1),
|
||||
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
|
||||
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
|
||||
looseTablets: String(med.looseTablets ?? 0),
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })),
|
||||
});
|
||||
}
|
||||
@@ -365,10 +411,13 @@ export default function App() {
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
genericName: form.genericName.trim() || null,
|
||||
packCount: Number(form.packCount) || 0,
|
||||
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
|
||||
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
|
||||
looseTablets: Math.max(0, Number(form.looseTablets) || 0),
|
||||
expiryDate: form.expiryDate || null,
|
||||
notes: form.notes.trim() || null,
|
||||
slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })),
|
||||
};
|
||||
|
||||
@@ -473,7 +522,7 @@ export default function App() {
|
||||
const med = meds.find(m => m.name === row.name);
|
||||
return (
|
||||
<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="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.notes && <span className="notes-icon" title="Has notes">📝</span>}</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>
|
||||
@@ -519,7 +568,7 @@ export default function App() {
|
||||
const med = meds.find(m => m.name === row.name);
|
||||
return (
|
||||
<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="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.notes && <span className="notes-icon" title="Has notes">📝</span>}</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>
|
||||
@@ -535,7 +584,7 @@ export default function App() {
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Upcoming Schedules</h2>
|
||||
<span className="pill neutral">Next 10</span>
|
||||
<span className="pill neutral">Next 10 days</span>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{groupedSchedule.map((day) => (
|
||||
@@ -545,8 +594,10 @@ export default function App() {
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const allTaken = item.doses.every((d) => takenDoses.has(d.id));
|
||||
const takenCount = item.doses.filter((d) => takenDoses.has(d.id)).length;
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />{item.medName}</div>
|
||||
<div className="tag-row">
|
||||
@@ -556,8 +607,21 @@ export default function App() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time-col">
|
||||
<div className="time-chip times-chip">{item.times.join(" · ")}</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} pill{dose.usage !== 1 ? "s" : ""}</span>
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title="Undo">↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title="Mark as taken">✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -618,8 +682,12 @@ export default function App() {
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
|
||||
Commercial Name
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Ozempic" required />
|
||||
</label>
|
||||
<label>
|
||||
Generic Name
|
||||
<input value={form.genericName} onChange={(e) => setForm({ ...form, genericName: e.target.value })} placeholder="e.g. Semaglutide (optional)" />
|
||||
</label>
|
||||
<label>
|
||||
Packs
|
||||
@@ -641,6 +709,21 @@ export default function App() {
|
||||
Total (pills)
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
<label>
|
||||
Expiry Date <span className="optional-label">(optional)</span>
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="full">
|
||||
Notes <span className="optional-label">(optional)</span>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => handleValueChange("notes", e.target.value)}
|
||||
placeholder="e.g. Take with food, avoid alcohol..."
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="full slices">
|
||||
<div className="card-head">
|
||||
@@ -946,7 +1029,10 @@ export default function App() {
|
||||
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
||||
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
||||
</div>
|
||||
<h2>{selectedMed.name}</h2>
|
||||
<div className="med-detail-titles">
|
||||
<h2>{selectedMed.name}</h2>
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="med-detail-body">
|
||||
@@ -973,6 +1059,12 @@ export default function App() {
|
||||
<span className="med-detail-label">Loose Pills</span>
|
||||
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">Expiry Date</span>
|
||||
<span className={`med-detail-value ${selectedMed.expiryDate && new Date(selectedMed.expiryDate) < new Date() ? 'danger-text' : ''}`}>
|
||||
{selectedMed.expiryDate ? new Date(selectedMed.expiryDate).toLocaleDateString([], { day: "2-digit", month: "short", year: "numeric" }) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1015,6 +1107,15 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selectedMed.notes && (
|
||||
<div className="med-detail-section">
|
||||
<h3>📝 Notes</h3>
|
||||
<div className="med-notes-content">
|
||||
{selectedMed.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="med-detail-footer">
|
||||
@@ -1073,7 +1174,7 @@ function buildSchedulePreview(meds: Medication[]) {
|
||||
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
|
||||
const now = new Date();
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 3);
|
||||
end.setDate(end.getDate() + 10);
|
||||
|
||||
meds.forEach((med) => {
|
||||
med.slices.forEach((slice, idx) => {
|
||||
|
||||
+134
-1
@@ -225,6 +225,12 @@ body {
|
||||
.warning-text { color: var(--warning); font-weight: 700; }
|
||||
.success-text { color: var(--success); font-weight: 700; }
|
||||
|
||||
.optional-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.med-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.med-actions button { padding: 0.5rem 0.9rem; }
|
||||
|
||||
@@ -291,6 +297,7 @@ input:focus, select:focus {
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem 1.25rem; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 0.4rem; color: var(--text-secondary); font-size: 0.85rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.form-grid .optional-label { text-transform: none; font-weight: 400; font-size: 0.75rem; }
|
||||
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
|
||||
|
||||
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
||||
@@ -304,7 +311,7 @@ input:focus, select:focus {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.time-row { display: grid; grid-template-columns: minmax(200px, 280px) 1fr; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.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); }
|
||||
.time-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.time-main { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
@@ -325,6 +332,96 @@ input:focus, select:focus {
|
||||
}
|
||||
.times-chip { white-space: nowrap; }
|
||||
|
||||
/* Dose tracking */
|
||||
.doses-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.dose-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid rgba(47, 134, 246, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dose-item.taken {
|
||||
background: var(--success-bg);
|
||||
border-color: rgba(57, 217, 138, 0.3);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dose-item.taken .dose-time,
|
||||
.dose-item.taken .dose-usage {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dose-time {
|
||||
font-weight: 600;
|
||||
color: var(--accent-light);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.dose-usage {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dose-btn {
|
||||
margin-left: auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dose-btn.take {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dose-btn.take:hover {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dose-btn.undo {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dose-btn.undo:hover {
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning);
|
||||
color: var(--warning);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.time-row.taken {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.time-row.taken .med-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.highlights { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.card p { margin: 0; }
|
||||
@@ -512,6 +609,16 @@ input:focus, select:focus {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.doses-col {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dose-item {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
@@ -1017,6 +1124,19 @@ input:focus, select:focus {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.med-detail-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.med-generic-name {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.med-detail-header .med-avatar-lg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
@@ -1239,3 +1359,16 @@ input:focus, select:focus {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notes icon indicator */
|
||||
.notes-icon {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.85em;
|
||||
cursor: help;
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.notes-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user