feat: add pill weight and taken by fields to medications schema and update related components

This commit is contained in:
Daniel Volz
2025-12-21 12:21:00 +01:00
parent a7fc360457
commit eb3bfea940
7 changed files with 114 additions and 32 deletions
@@ -0,0 +1,2 @@
-- Add pill weight column (in mg)
ALTER TABLE medications ADD COLUMN pill_weight_mg INTEGER;
@@ -0,0 +1,2 @@
-- Add taken_by column for family member tracking
ALTER TABLE medications ADD COLUMN taken_by TEXT;
+3 -1
View File
@@ -7,6 +7,8 @@
{ "idx": 4, "version": 1, "when": 1735000000, "tag": "0004_add_expiry_date", "breakpoint": false },
{ "idx": 5, "version": 1, "when": 1735100000, "tag": "0005_add_notes", "breakpoint": false },
{ "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false },
{ "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false }
{ "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false },
{ "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false },
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false }
]
}
+2
View File
@@ -14,12 +14,14 @@ export const medications = sqliteTable("medications", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name", { length: 100 }).notNull().unique(),
genericName: text("generic_name", { length: 100 }),
takenBy: text("taken_by", { length: 100 }),
count: integer("count").notNull().default(0),
strips: integer("strips").notNull().default(0),
packCount: integer("pack_count").notNull().default(1),
stripsPerPack: integer("strips_per_pack").notNull().default(1),
tabsPerStrip: integer("tabs_per_strip").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0),
pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
startJson: text("start_json").notNull().default("[]"),
+14 -2
View File
@@ -18,10 +18,12 @@ const sliceSchema = z.object({
const medicationSchema = z.object({
name: z.string().trim().min(1).max(100),
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.string().trim().max(100).nullable().optional(),
packCount: z.number().int().min(0).default(1),
stripsPerPack: z.number().int().min(1).default(1),
tabsPerStrip: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().int().min(1).nullable().optional(),
expiryDate: z.string().nullable().optional(),
notes: z.string().max(500).nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
@@ -56,6 +58,7 @@ export async function medicationRoutes(app: FastifyInstance) {
id: row.id,
name: row.name,
genericName: row.genericName,
takenBy: row.takenBy,
count: row.count,
strips: row.strips,
stripSize: row.stripSize,
@@ -63,6 +66,7 @@ export async function medicationRoutes(app: FastifyInstance) {
stripsPerPack: row.stripsPerPack ?? row.strips ?? 1,
tabsPerStrip: row.tabsPerStrip ?? row.stripSize ?? 1,
looseTablets: row.looseTablets ?? 0,
pillWeightMg: row.pillWeightMg,
slices: parseSlices(row),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
@@ -76,7 +80,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const parsed = medicationSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
@@ -88,6 +92,7 @@ export async function medicationRoutes(app: FastifyInstance) {
.values({
name,
genericName: genericName || null,
takenBy: takenBy || null,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
@@ -95,6 +100,7 @@ export async function medicationRoutes(app: FastifyInstance) {
stripsPerPack,
tabsPerStrip,
looseTablets,
pillWeightMg: pillWeightMg || null,
expiryDate: expiryDate || null,
notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
@@ -108,6 +114,7 @@ export async function medicationRoutes(app: FastifyInstance) {
id: inserted.id,
name: inserted.name,
genericName: inserted.genericName,
takenBy: inserted.takenBy,
count: inserted.count,
strips: inserted.strips,
stripSize: inserted.stripSize,
@@ -115,6 +122,7 @@ export async function medicationRoutes(app: FastifyInstance) {
stripsPerPack: inserted.stripsPerPack,
tabsPerStrip: inserted.tabsPerStrip,
looseTablets: inserted.looseTablets,
pillWeightMg: inserted.pillWeightMg,
slices,
imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate,
@@ -130,7 +138,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
@@ -142,6 +150,7 @@ export async function medicationRoutes(app: FastifyInstance) {
.set({
name,
genericName: genericName || null,
takenBy: takenBy || null,
count: derivedCount,
strips: stripsPerPack,
stripSize: tabsPerStrip,
@@ -149,6 +158,7 @@ export async function medicationRoutes(app: FastifyInstance) {
stripsPerPack,
tabsPerStrip,
looseTablets,
pillWeightMg: pillWeightMg || null,
expiryDate: expiryDate || null,
notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
@@ -166,6 +176,7 @@ export async function medicationRoutes(app: FastifyInstance) {
id: result[0].id,
name: result[0].name,
genericName: result[0].genericName,
takenBy: result[0].takenBy,
count: result[0].count,
strips: result[0].strips,
stripSize: result[0].stripSize,
@@ -173,6 +184,7 @@ export async function medicationRoutes(app: FastifyInstance) {
stripsPerPack: result[0].stripsPerPack,
tabsPerStrip: result[0].tabsPerStrip,
looseTablets: result[0].looseTablets,
pillWeightMg: result[0].pillWeightMg,
slices,
imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate,
+58 -11
View File
@@ -11,6 +11,7 @@ type Medication = {
id: number;
name: string;
genericName?: string | null;
takenBy?: string | null;
count: number;
strips: number;
stripSize: number;
@@ -18,6 +19,7 @@ type Medication = {
stripsPerPack?: number;
tabsPerStrip?: number;
looseTablets?: number;
pillWeightMg?: number | null;
slices: Slice[];
imageUrl?: string | null;
expiryDate?: string | null;
@@ -42,10 +44,12 @@ type FormSlice = { usage: string; every: string; start: string };
type FormState = {
name: string;
genericName: string;
takenBy: string;
packCount: string;
stripsPerPack: string;
tabsPerStrip: string;
looseTablets: string;
pillWeightMg: string;
expiryDate: string;
notes: string;
intakeRemindersEnabled: boolean;
@@ -54,7 +58,7 @@ type FormState = {
const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
const defaultForm = (): FormState => ({ name: "", genericName: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] });
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] });
const todayIso = () => new Date().toISOString();
const plusDaysIso = (days: number) => {
@@ -74,13 +78,29 @@ type Coverage = {
export default function App() {
const [meds, setMeds] = useState<Medication[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("plannerRows");
if (saved) {
try { return JSON.parse(saved); } catch { return []; }
}
}
return [];
});
const [plannerLoading, setPlannerLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(defaultForm());
const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
const [range, setRange] = useState<{ start: string; end: string }>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("plannerRange");
if (saved) {
try { return JSON.parse(saved); } catch { /* ignore */ }
}
}
return { start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) };
});
const navigate = useNavigate();
const location = useLocation();
@@ -435,10 +455,12 @@ export default function App() {
setForm({
name: med.name,
genericName: med.genericName ?? "",
takenBy: med.takenBy ?? "",
packCount: String(med.packCount ?? 1),
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
looseTablets: String(med.looseTablets ?? 0),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
@@ -463,10 +485,12 @@ export default function App() {
const payload = {
name: form.name.trim(),
genericName: form.genericName.trim() || null,
takenBy: form.takenBy.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),
pillWeightMg: form.pillWeightMg ? Number(form.pillWeightMg) : null,
expiryDate: form.expiryDate || null,
notes: form.notes.trim() || null,
intakeRemindersEnabled: form.intakeRemindersEnabled,
@@ -492,11 +516,16 @@ export default function App() {
.catch(() => []) as PlannerRow[];
setPlannerRows(rows);
setPlannerLoading(false);
// Save to localStorage
localStorage.setItem("plannerRange", JSON.stringify(range));
localStorage.setItem("plannerRows", JSON.stringify(rows));
}
function resetRange() {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
setPlannerRows([]);
localStorage.removeItem("plannerRange");
localStorage.removeItem("plannerRows");
}
const [theme, setTheme] = useState<"light" | "dark">(() => {
@@ -585,7 +614,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}{med?.intakeRemindersEnabled && <span className="reminder-icon" title="Intake reminders enabled">🔔</span>}{med?.notes && <span className="notes-icon" title="Has notes">📝</span>}</span>
<span data-label="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.takenBy && <span className="taken-by-badge">{med.takenBy}</span>}{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip="Intake reminders enabled">🔔</span>}{med?.notes && <span className="notes-icon info-tooltip" data-tooltip="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>
@@ -633,7 +662,7 @@ export default function App() {
const expiryClass = getExpiryClass(med?.expiryDate);
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}{med?.intakeRemindersEnabled && <span className="reminder-icon" title="Intake reminders enabled">🔔</span>}{med?.notes && <span className="notes-icon" title="Has notes">📝</span>}</span>
<span data-label="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.takenBy && <span className="taken-by-badge">{med.takenBy}</span>}{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip="Intake reminders enabled">🔔</span>}{med?.notes && <span className="notes-icon info-tooltip" data-tooltip="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>
@@ -665,7 +694,7 @@ export default function App() {
return (
<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}{med?.intakeRemindersEnabled && <span className="reminder-icon" title="Intake reminders enabled">🔔</span>}</div>
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip="Intake reminders enabled">🔔</span>}</div>
<div className="tag-row">
<span className="tag subtle">{item.total} pills total</span>
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
@@ -679,7 +708,7 @@ export default function App() {
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>
<span className="dose-usage">{dose.usage} pill{dose.usage !== 1 ? "s" : ""}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && <span className="taken-by-inline"> taken by <span className="taken-by-name">{med.takenBy}</span></span>}</span>
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title="Undo"></button>
) : (
@@ -755,6 +784,10 @@ export default function App() {
Generic Name
<input value={form.genericName} onChange={(e) => setForm({ ...form, genericName: e.target.value })} placeholder="e.g. Semaglutide (optional)" />
</label>
<label>
Taken by
<input value={form.takenBy} onChange={(e) => setForm({ ...form, takenBy: e.target.value })} placeholder="e.g. John, Sarah (optional)" />
</label>
<label>
Packs
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
@@ -771,6 +804,10 @@ export default function App() {
Loose pills
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
</label>
<label>
Pill weight (mg)
<input type="number" min="1" value={form.pillWeightMg} onChange={(e) => handleValueChange("pillWeightMg", e.target.value)} placeholder="e.g. 240" />
</label>
<label>
Total (pills)
<div className="static-value">{formatNumber(totalTablets)}</div>
@@ -906,7 +943,7 @@ export default function App() {
<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>
<span data-label="Status" className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "Enough" : "Out of Stock"}</span>
</div>
);
})}
@@ -1226,6 +1263,7 @@ export default function App() {
<div className="med-detail-titles">
<h2>{selectedMed.name}</h2>
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
{selectedMed.takenBy && <span className="med-taken-by">for {selectedMed.takenBy}</span>}
</div>
</div>
@@ -1253,6 +1291,12 @@ export default function App() {
<span className="med-detail-label">Loose Pills</span>
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
</div>
{selectedMed.pillWeightMg && (
<div className="med-detail-item">
<span className="med-detail-label">Pill Weight</span>
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</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' : ''}`}>
@@ -1264,11 +1308,11 @@ export default function App() {
{selectedMed.slices.length > 0 && (
<div className="med-detail-section">
<h3>Intake Schedule {selectedMed.intakeRemindersEnabled && <span className="reminder-icon" title="Intake reminders enabled">🔔</span>}</h3>
<h3>Intake Schedule {selectedMed.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip="Intake reminders enabled">🔔</span>}</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-usage">{slice.usage} pill{slice.usage !== 1 ? "s" : ""}{selectedMed.pillWeightMg && ` (${slice.usage * selectedMed.pillWeightMg} mg)`}</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>
@@ -1313,7 +1357,10 @@ export default function App() {
</div>
<div className="med-detail-footer">
<button className="ghost" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); setEditingId(selectedMed.id); }}>
<button className="ghost" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); }}>
Close
</button>
<button className="ghost" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); startEdit(selectedMed); }}>
Edit Medication
</button>
</div>
+33 -18
View File
@@ -543,6 +543,7 @@ textarea {
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.status-chip.success {
background: rgba(57, 217, 138, 0.15);
@@ -1548,6 +1549,16 @@ textarea {
gap: 0.5rem;
}
.taken-by-inline {
color: var(--text-secondary);
font-weight: 400;
}
.taken-by-name {
color: var(--accent-light);
font-weight: 600;
}
/* Medication list name row with avatar */
.med-name-row {
display: flex;
@@ -1692,6 +1703,21 @@ textarea {
font-weight: 400;
}
.med-taken-by {
font-size: 1rem;
color: white;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.taken-by-badge {
font-size: 0.75rem;
color: var(--accent);
font-weight: 500;
margin-left: 0.35rem;
opacity: 0.85;
}
.med-detail-header .med-avatar-lg {
width: 100px;
height: 100px;
@@ -1915,33 +1941,22 @@ textarea {
}
}
/* 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;
}
/* Reminder icon indicator */
.reminder-icon {
.reminder-icon.info-tooltip,
.notes-icon.info-tooltip {
width: auto;
height: auto;
margin-left: 0.35rem;
font-size: 0.85em;
cursor: help;
opacity: 0.75;
transition: opacity 0.15s;
}
.reminder-icon:hover {
.reminder-icon.info-tooltip:hover,
.notes-icon.info-tooltip:hover {
opacity: 1;
}
h3 .reminder-icon {
h3 .reminder-icon.info-tooltip {
font-size: 0.75em;
vertical-align: middle;
}