feat: add expiry_date, notes, and generic_name columns to medications table with corresponding migrations
This commit is contained in:
@@ -13,6 +13,9 @@ export const db = drizzle(client);
|
|||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ name: "image_url", sql: "ALTER TABLE medications ADD COLUMN image_url TEXT" },
|
{ 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) {
|
for (const migration of migrations) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Migration 0004: Add expiry_date column for medication expiration tracking
|
||||||
|
ALTER TABLE medications ADD COLUMN expiry_date TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add notes column for medication instructions
|
||||||
|
ALTER TABLE medications ADD COLUMN notes TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add generic_name column for medication active ingredient
|
||||||
|
ALTER TABLE medications ADD COLUMN generic_name TEXT;
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false },
|
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false },
|
||||||
{ "idx": 1, "version": 1, "when": 1734700000, "tag": "0001_add_strips", "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": 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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const users = sqliteTable("users", {
|
|||||||
export const medications = sqliteTable("medications", {
|
export const medications = sqliteTable("medications", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
name: text("name", { length: 100 }).notNull().unique(),
|
name: text("name", { length: 100 }).notNull().unique(),
|
||||||
|
genericName: text("generic_name", { length: 100 }),
|
||||||
count: integer("count").notNull().default(0),
|
count: integer("count").notNull().default(0),
|
||||||
strips: integer("strips").notNull().default(0),
|
strips: integer("strips").notNull().default(0),
|
||||||
packCount: integer("pack_count").notNull().default(1),
|
packCount: integer("pack_count").notNull().default(1),
|
||||||
@@ -24,6 +25,8 @@ export const medications = sqliteTable("medications", {
|
|||||||
startJson: text("start_json").notNull().default("[]"),
|
startJson: text("start_json").notNull().default("[]"),
|
||||||
stripSize: integer("strip_size").notNull().default(1),
|
stripSize: integer("strip_size").notNull().default(1),
|
||||||
imageUrl: text("image_url"),
|
imageUrl: text("image_url"),
|
||||||
|
expiryDate: text("expiry_date"),
|
||||||
|
notes: text("notes"),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ const sliceSchema = z.object({
|
|||||||
|
|
||||||
const medicationSchema = z.object({
|
const medicationSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
|
genericName: z.string().trim().max(100).nullable().optional(),
|
||||||
packCount: z.number().int().min(0).default(1),
|
packCount: z.number().int().min(0).default(1),
|
||||||
stripsPerPack: z.number().int().min(1).default(1),
|
stripsPerPack: z.number().int().min(1).default(1),
|
||||||
tabsPerStrip: z.number().int().min(1).default(1),
|
tabsPerStrip: z.number().int().min(1).default(1),
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
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
|
// count will be derived on the backend
|
||||||
slices: z.array(sliceSchema).min(1).max(12),
|
slices: z.array(sliceSchema).min(1).max(12),
|
||||||
});
|
});
|
||||||
@@ -51,6 +54,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
genericName: row.genericName,
|
||||||
count: row.count,
|
count: row.count,
|
||||||
strips: row.strips,
|
strips: row.strips,
|
||||||
stripSize: row.stripSize,
|
stripSize: row.stripSize,
|
||||||
@@ -60,6 +64,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
looseTablets: row.looseTablets ?? 0,
|
looseTablets: row.looseTablets ?? 0,
|
||||||
slices: parseSlices(row),
|
slices: parseSlices(row),
|
||||||
imageUrl: row.imageUrl,
|
imageUrl: row.imageUrl,
|
||||||
|
expiryDate: row.expiryDate,
|
||||||
|
notes: row.notes,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -68,7 +74,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const parsed = medicationSchema.safeParse(req.body);
|
const parsed = medicationSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
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 usageJson = JSON.stringify(slices.map((s) => s.usage));
|
||||||
const everyJson = JSON.stringify(slices.map((s) => s.every));
|
const everyJson = JSON.stringify(slices.map((s) => s.every));
|
||||||
const startJson = JSON.stringify(slices.map((s) => s.start));
|
const startJson = JSON.stringify(slices.map((s) => s.start));
|
||||||
@@ -79,6 +85,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.insert(medications)
|
.insert(medications)
|
||||||
.values({
|
.values({
|
||||||
name,
|
name,
|
||||||
|
genericName: genericName || null,
|
||||||
count: derivedCount,
|
count: derivedCount,
|
||||||
strips: stripsPerPack,
|
strips: stripsPerPack,
|
||||||
stripSize: tabsPerStrip,
|
stripSize: tabsPerStrip,
|
||||||
@@ -86,6 +93,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
stripsPerPack,
|
stripsPerPack,
|
||||||
tabsPerStrip,
|
tabsPerStrip,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
|
expiryDate: expiryDate || null,
|
||||||
|
notes: notes || null,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
startJson,
|
||||||
@@ -95,6 +104,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
return {
|
return {
|
||||||
id: inserted.id,
|
id: inserted.id,
|
||||||
name: inserted.name,
|
name: inserted.name,
|
||||||
|
genericName: inserted.genericName,
|
||||||
count: inserted.count,
|
count: inserted.count,
|
||||||
strips: inserted.strips,
|
strips: inserted.strips,
|
||||||
stripSize: inserted.stripSize,
|
stripSize: inserted.stripSize,
|
||||||
@@ -104,6 +114,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
slices,
|
slices,
|
||||||
imageUrl: inserted.imageUrl,
|
imageUrl: inserted.imageUrl,
|
||||||
|
expiryDate: inserted.expiryDate,
|
||||||
|
notes: inserted.notes,
|
||||||
updatedAt: inserted.updatedAt,
|
updatedAt: inserted.updatedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -114,7 +126,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid 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 usageJson = JSON.stringify(slices.map((s) => s.usage));
|
||||||
const everyJson = JSON.stringify(slices.map((s) => s.every));
|
const everyJson = JSON.stringify(slices.map((s) => s.every));
|
||||||
const startJson = JSON.stringify(slices.map((s) => s.start));
|
const startJson = JSON.stringify(slices.map((s) => s.start));
|
||||||
@@ -125,6 +137,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.update(medications)
|
.update(medications)
|
||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
|
genericName: genericName || null,
|
||||||
count: derivedCount,
|
count: derivedCount,
|
||||||
strips: stripsPerPack,
|
strips: stripsPerPack,
|
||||||
stripSize: tabsPerStrip,
|
stripSize: tabsPerStrip,
|
||||||
@@ -132,6 +145,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
stripsPerPack,
|
stripsPerPack,
|
||||||
tabsPerStrip,
|
tabsPerStrip,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
|
expiryDate: expiryDate || null,
|
||||||
|
notes: notes || null,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
startJson,
|
||||||
@@ -145,6 +160,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
return {
|
return {
|
||||||
id: result[0].id,
|
id: result[0].id,
|
||||||
name: result[0].name,
|
name: result[0].name,
|
||||||
|
genericName: result[0].genericName,
|
||||||
count: result[0].count,
|
count: result[0].count,
|
||||||
strips: result[0].strips,
|
strips: result[0].strips,
|
||||||
stripSize: result[0].stripSize,
|
stripSize: result[0].stripSize,
|
||||||
@@ -154,6 +170,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
slices,
|
slices,
|
||||||
imageUrl: result[0].imageUrl,
|
imageUrl: result[0].imageUrl,
|
||||||
|
expiryDate: result[0].expiryDate,
|
||||||
|
notes: result[0].notes,
|
||||||
updatedAt: result[0].updatedAt,
|
updatedAt: result[0].updatedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+116
-15
@@ -10,6 +10,7 @@ type Slice = {
|
|||||||
type Medication = {
|
type Medication = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
genericName?: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
strips: number;
|
strips: number;
|
||||||
stripSize: number;
|
stripSize: number;
|
||||||
@@ -19,6 +20,8 @@ type Medication = {
|
|||||||
looseTablets?: number;
|
looseTablets?: number;
|
||||||
slices: Slice[];
|
slices: Slice[];
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
expiryDate?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
updatedAt: string | number | null;
|
updatedAt: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,16 +39,19 @@ type FormSlice = { usage: string; every: string; start: string };
|
|||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
name: string;
|
name: string;
|
||||||
|
genericName: string;
|
||||||
packCount: string;
|
packCount: string;
|
||||||
stripsPerPack: string;
|
stripsPerPack: string;
|
||||||
tabsPerStrip: string;
|
tabsPerStrip: string;
|
||||||
looseTablets: string;
|
looseTablets: string;
|
||||||
|
expiryDate: string;
|
||||||
|
notes: string;
|
||||||
slices: FormSlice[];
|
slices: FormSlice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
|
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 todayIso = () => new Date().toISOString();
|
||||||
const plusDaysIso = (days: number) => {
|
const plusDaysIso = (days: number) => {
|
||||||
@@ -110,6 +116,42 @@ export default function App() {
|
|||||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
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
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
@@ -139,12 +181,13 @@ export default function App() {
|
|||||||
const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]);
|
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 depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
|
||||||
const groupedSchedule = useMemo(() => {
|
const groupedSchedule = useMemo(() => {
|
||||||
const days = new Map<string, { dateStr: string; meds: Map<string, { medName: string; total: number; times: string[]; lastWhen: number }> }>();
|
type DoseInfo = { id: string; timeStr: string; when: number; usage: number };
|
||||||
schedule.events.slice(0, 30).forEach((event) => {
|
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 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.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);
|
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||||
day.meds.set(event.medName, medEntry);
|
day.meds.set(event.medName, medEntry);
|
||||||
days.set(event.dateStr, day);
|
days.set(event.dateStr, day);
|
||||||
@@ -341,10 +384,13 @@ export default function App() {
|
|||||||
setEditingId(med.id);
|
setEditingId(med.id);
|
||||||
setForm({
|
setForm({
|
||||||
name: med.name,
|
name: med.name,
|
||||||
|
genericName: med.genericName ?? "",
|
||||||
packCount: String(med.packCount ?? 1),
|
packCount: String(med.packCount ?? 1),
|
||||||
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
|
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
|
||||||
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
|
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
|
||||||
looseTablets: String(med.looseTablets ?? 0),
|
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) })),
|
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 = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
genericName: form.genericName.trim() || null,
|
||||||
packCount: Number(form.packCount) || 0,
|
packCount: Number(form.packCount) || 0,
|
||||||
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
|
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
|
||||||
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
|
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
|
||||||
looseTablets: Math.max(0, Number(form.looseTablets) || 0),
|
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) })),
|
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);
|
const med = meds.find(m => m.name === row.name);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
<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="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="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>
|
<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);
|
const med = meds.find(m => m.name === row.name);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
<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="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="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>
|
<span data-label="Runs out">{row.depletionDate ?? "-"}</span>
|
||||||
@@ -535,7 +584,7 @@ export default function App() {
|
|||||||
<article className="card">
|
<article className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>Upcoming Schedules</h2>
|
<h2>Upcoming Schedules</h2>
|
||||||
<span className="pill neutral">Next 10</span>
|
<span className="pill neutral">Next 10 days</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{groupedSchedule.map((day) => (
|
{groupedSchedule.map((day) => (
|
||||||
@@ -545,8 +594,10 @@ export default function App() {
|
|||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
const med = meds.find(m => m.name === item.medName);
|
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 (
|
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="time-main">
|
||||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />{item.medName}</div>
|
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />{item.medName}</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
@@ -556,8 +607,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="time-col">
|
<div className="doses-col">
|
||||||
<div className="time-chip times-chip">{item.times.join(" · ")}</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -618,8 +682,12 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<form className="form-grid" onSubmit={saveMedication}>
|
<form className="form-grid" onSubmit={saveMedication}>
|
||||||
<label>
|
<label>
|
||||||
Name
|
Commercial Name
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
|
<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>
|
||||||
<label>
|
<label>
|
||||||
Packs
|
Packs
|
||||||
@@ -641,6 +709,21 @@ export default function App() {
|
|||||||
Total (pills)
|
Total (pills)
|
||||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||||
</label>
|
</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="full slices">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
@@ -946,7 +1029,10 @@ export default function App() {
|
|||||||
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
||||||
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="med-detail-body">
|
<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-label">Loose Pills</span>
|
||||||
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
|
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1015,6 +1107,15 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{selectedMed.notes && (
|
||||||
|
<div className="med-detail-section">
|
||||||
|
<h3>📝 Notes</h3>
|
||||||
|
<div className="med-notes-content">
|
||||||
|
{selectedMed.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="med-detail-footer">
|
<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 events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setDate(end.getDate() + 3);
|
end.setDate(end.getDate() + 10);
|
||||||
|
|
||||||
meds.forEach((med) => {
|
meds.forEach((med) => {
|
||||||
med.slices.forEach((slice, idx) => {
|
med.slices.forEach((slice, idx) => {
|
||||||
|
|||||||
+134
-1
@@ -225,6 +225,12 @@ body {
|
|||||||
.warning-text { color: var(--warning); font-weight: 700; }
|
.warning-text { color: var(--warning); font-weight: 700; }
|
||||||
.success-text { color: var(--success); 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 { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
.med-actions button { padding: 0.5rem 0.9rem; }
|
.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 { 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 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 .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; }
|
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
|
||||||
|
|
||||||
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
@@ -304,7 +311,7 @@ input:focus, select:focus {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: 0.02em;
|
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); }
|
[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-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
.time-main { display: flex; flex-direction: column; gap: 0.4rem; }
|
.time-main { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
@@ -325,6 +332,96 @@ input:focus, select:focus {
|
|||||||
}
|
}
|
||||||
.times-chip { white-space: nowrap; }
|
.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; }
|
.highlights { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||||
|
|
||||||
.card p { margin: 0; }
|
.card p { margin: 0; }
|
||||||
@@ -512,6 +609,16 @@ input:focus, select:focus {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doses-col {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-item {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
.time-chip {
|
.time-chip {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
@@ -1017,6 +1124,19 @@ input:focus, select:focus {
|
|||||||
text-align: center;
|
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 {
|
.med-detail-header .med-avatar-lg {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -1239,3 +1359,16 @@ input:focus, select:focus {
|
|||||||
gap: 0.5rem;
|
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