diff --git a/backend/src/db/migrations/0008_add_pill_weight.sql b/backend/src/db/migrations/0008_add_pill_weight.sql new file mode 100644 index 0000000..02c45e8 --- /dev/null +++ b/backend/src/db/migrations/0008_add_pill_weight.sql @@ -0,0 +1,2 @@ +-- Add pill weight column (in mg) +ALTER TABLE medications ADD COLUMN pill_weight_mg INTEGER; diff --git a/backend/src/db/migrations/0009_add_taken_by.sql b/backend/src/db/migrations/0009_add_taken_by.sql new file mode 100644 index 0000000..9f8877a --- /dev/null +++ b/backend/src/db/migrations/0009_add_taken_by.sql @@ -0,0 +1,2 @@ +-- Add taken_by column for family member tracking +ALTER TABLE medications ADD COLUMN taken_by TEXT; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index 05dc333..63e4ea1 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4fb90a0..e43339d 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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("[]"), diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index eb95100..85ad5c9 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -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, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5402f39..8f90b6f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); - const [plannerRows, setPlannerRows] = useState([]); + const [plannerRows, setPlannerRows] = useState(() => { + 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(null); const [form, setForm] = useState(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 (
med && setSelectedMed(med)}> - {row.name}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} + {row.name}{med?.takenBy && {med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} {formatNumber(row.medsLeft)} {formatNumber(row.daysLeft)} {status.label} @@ -633,7 +662,7 @@ export default function App() { const expiryClass = getExpiryClass(med?.expiryDate); return (
med && setSelectedMed(med)}> - {row.name}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} + {row.name}{med?.takenBy && {med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} {formatNumber(row.medsLeft)} {formatNumber(row.daysLeft)} {row.depletionDate ?? "-"} @@ -665,7 +694,7 @@ export default function App() { return (
-
{item.medName}{med?.intakeRemindersEnabled && 🔔}
+
{item.medName}{med?.intakeRemindersEnabled && 🔔}
{item.total} pills total @@ -679,7 +708,7 @@ export default function App() { return (
{dose.timeStr} - {dose.usage} pill{dose.usage !== 1 ? "s" : ""} + {dose.usage} pill{dose.usage !== 1 ? "s" : ""}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && taken by {med.takenBy}} {isTaken ? ( ) : ( @@ -755,6 +784,10 @@ export default function App() { Generic Name setForm({ ...form, genericName: e.target.value })} placeholder="e.g. Semaglutide (optional)" /> + +
); })} @@ -1226,6 +1263,7 @@ export default function App() {

{selectedMed.name}

{selectedMed.genericName && {selectedMed.genericName}} + {selectedMed.takenBy && for {selectedMed.takenBy}}
@@ -1253,6 +1291,12 @@ export default function App() { Loose Pills {selectedMed.looseTablets ?? 0}
+ {selectedMed.pillWeightMg && ( +
+ Pill Weight + {selectedMed.pillWeightMg} mg +
+ )}
Expiry Date @@ -1264,11 +1308,11 @@ export default function App() { {selectedMed.slices.length > 0 && (
-

Intake Schedule {selectedMed.intakeRemindersEnabled && 🔔}

+

Intake Schedule {selectedMed.intakeRemindersEnabled && 🔔}

{selectedMed.slices.map((slice, idx) => (
- {slice.usage} pill{slice.usage !== 1 ? "s" : ""} + {slice.usage} pill{slice.usage !== 1 ? "s" : ""}{selectedMed.pillWeightMg && ` (${slice.usage * selectedMed.pillWeightMg} mg)`} every {slice.every} day{slice.every !== 1 ? "s" : ""} at {new Date(slice.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
@@ -1313,7 +1357,10 @@ export default function App() {
- +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1c14dad..d2ebb64 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }