diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 56f6707..b83941e 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -13,6 +13,9 @@ export const db = drizzle(client); async function runMigrations() { const migrations = [ { name: "image_url", sql: "ALTER TABLE medications ADD COLUMN image_url TEXT" }, + { name: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" }, + { name: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" }, + { name: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" }, ]; for (const migration of migrations) { diff --git a/backend/src/db/migrations/0004_add_expiry_date.sql b/backend/src/db/migrations/0004_add_expiry_date.sql new file mode 100644 index 0000000..434dac6 --- /dev/null +++ b/backend/src/db/migrations/0004_add_expiry_date.sql @@ -0,0 +1,2 @@ +-- Migration 0004: Add expiry_date column for medication expiration tracking +ALTER TABLE medications ADD COLUMN expiry_date TEXT; diff --git a/backend/src/db/migrations/0005_add_notes.sql b/backend/src/db/migrations/0005_add_notes.sql new file mode 100644 index 0000000..1c4fd96 --- /dev/null +++ b/backend/src/db/migrations/0005_add_notes.sql @@ -0,0 +1,2 @@ +-- Add notes column for medication instructions +ALTER TABLE medications ADD COLUMN notes TEXT; diff --git a/backend/src/db/migrations/0006_add_generic_name.sql b/backend/src/db/migrations/0006_add_generic_name.sql new file mode 100644 index 0000000..0fb906d --- /dev/null +++ b/backend/src/db/migrations/0006_add_generic_name.sql @@ -0,0 +1,2 @@ +-- Add generic_name column for medication active ingredient +ALTER TABLE medications ADD COLUMN generic_name TEXT; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index f52788e..e4c7bac 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -3,6 +3,9 @@ { "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false }, { "idx": 1, "version": 1, "when": 1734700000, "tag": "0001_add_strips", "breakpoint": false }, { "idx": 2, "version": 1, "when": 1734800000, "tag": "0002_pack_inventory", "breakpoint": false }, - { "idx": 3, "version": 1, "when": 1734900000, "tag": "0003_add_image_url", "breakpoint": false } + { "idx": 3, "version": 1, "when": 1734900000, "tag": "0003_add_image_url", "breakpoint": false }, + { "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 } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 5a4bed7..660d730 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -13,6 +13,7 @@ export const users = sqliteTable("users", { export const medications = sqliteTable("medications", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name", { length: 100 }).notNull().unique(), + genericName: text("generic_name", { length: 100 }), count: integer("count").notNull().default(0), strips: integer("strips").notNull().default(0), packCount: integer("pack_count").notNull().default(1), @@ -24,6 +25,8 @@ export const medications = sqliteTable("medications", { startJson: text("start_json").notNull().default("[]"), stripSize: integer("strip_size").notNull().default(1), imageUrl: text("image_url"), + expiryDate: text("expiry_date"), + notes: text("notes"), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 72af695..ebb6392 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -17,10 +17,13 @@ const sliceSchema = z.object({ const medicationSchema = z.object({ name: z.string().trim().min(1).max(100), + genericName: 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), + expiryDate: z.string().nullable().optional(), + notes: z.string().max(500).nullable().optional(), // count will be derived on the backend slices: z.array(sliceSchema).min(1).max(12), }); @@ -51,6 +54,7 @@ export async function medicationRoutes(app: FastifyInstance) { return rows.map((row) => ({ id: row.id, name: row.name, + genericName: row.genericName, count: row.count, strips: row.strips, stripSize: row.stripSize, @@ -60,6 +64,8 @@ export async function medicationRoutes(app: FastifyInstance) { looseTablets: row.looseTablets ?? 0, slices: parseSlices(row), imageUrl: row.imageUrl, + expiryDate: row.expiryDate, + notes: row.notes, updatedAt: row.updatedAt, })); }); @@ -68,7 +74,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, packCount, stripsPerPack, tabsPerStrip, looseTablets, slices } = parsed.data; + const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, 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)); @@ -79,6 +85,7 @@ export async function medicationRoutes(app: FastifyInstance) { .insert(medications) .values({ name, + genericName: genericName || null, count: derivedCount, strips: stripsPerPack, stripSize: tabsPerStrip, @@ -86,6 +93,8 @@ export async function medicationRoutes(app: FastifyInstance) { stripsPerPack, tabsPerStrip, looseTablets, + expiryDate: expiryDate || null, + notes: notes || null, usageJson, everyJson, startJson, @@ -95,6 +104,7 @@ export async function medicationRoutes(app: FastifyInstance) { return { id: inserted.id, name: inserted.name, + genericName: inserted.genericName, count: inserted.count, strips: inserted.strips, stripSize: inserted.stripSize, @@ -104,6 +114,8 @@ export async function medicationRoutes(app: FastifyInstance) { looseTablets: inserted.looseTablets, slices, imageUrl: inserted.imageUrl, + expiryDate: inserted.expiryDate, + notes: inserted.notes, updatedAt: inserted.updatedAt, }; }); @@ -114,7 +126,7 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const { name, packCount, stripsPerPack, tabsPerStrip, looseTablets, slices } = parsed.data; + const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, 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)); @@ -125,6 +137,7 @@ export async function medicationRoutes(app: FastifyInstance) { .update(medications) .set({ name, + genericName: genericName || null, count: derivedCount, strips: stripsPerPack, stripSize: tabsPerStrip, @@ -132,6 +145,8 @@ export async function medicationRoutes(app: FastifyInstance) { stripsPerPack, tabsPerStrip, looseTablets, + expiryDate: expiryDate || null, + notes: notes || null, usageJson, everyJson, startJson, @@ -145,6 +160,7 @@ export async function medicationRoutes(app: FastifyInstance) { return { id: result[0].id, name: result[0].name, + genericName: result[0].genericName, count: result[0].count, strips: result[0].strips, stripSize: result[0].stripSize, @@ -154,6 +170,8 @@ export async function medicationRoutes(app: FastifyInstance) { looseTablets: result[0].looseTablets, slices, imageUrl: result[0].imageUrl, + expiryDate: result[0].expiryDate, + notes: result[0].notes, updatedAt: result[0].updatedAt, }; }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d10377..cb71dc5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [showImageLightbox, setShowImageLightbox] = useState(false); + // Track taken doses (stored in localStorage) + const [takenDoses, setTakenDoses] = useState>(() => { + 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 }>(); - schedule.events.slice(0, 30).forEach((event) => { + type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; + const days = new Map }>(); + 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 (
med && setSelectedMed(med)}> - {row.name} + {row.name}{med?.notes && ๐Ÿ“} {formatNumber(row.medsLeft)} {formatNumber(row.daysLeft)} {status.label} @@ -519,7 +568,7 @@ export default function App() { const med = meds.find(m => m.name === row.name); return (
med && setSelectedMed(med)}> - {row.name} + {row.name}{med?.notes && ๐Ÿ“} {formatNumber(row.medsLeft)} {formatNumber(row.daysLeft)} {row.depletionDate ?? "-"} @@ -535,7 +584,7 @@ export default function App() {

Upcoming Schedules

- Next 10 + Next 10 days
{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 ( -
+
{item.medName}
@@ -556,8 +607,21 @@ export default function App() {
-
-
{item.times.join(" ยท ")}
+
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + return ( +
+ {dose.timeStr} + {dose.usage} pill{dose.usage !== 1 ? "s" : ""} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })}
); @@ -618,8 +682,12 @@ export default function App() {
+ + + +