feat: Add Medication Refill feature with mobile UI improvements (#30)
* feat: Add Medication Refill feature with UI improvements - Add refill functionality to medications (add packs/loose pills) - Add refill API endpoint with history tracking - Add refill section in edit forms (desktop & mobile) - Add refill modal in medication detail view - Add refill history display with expand/collapse - Add schedule lightbox for clicking medication images - Improve button styling with primary/info/success classes - Move '+ New entry' button to medication list header - Lightbox size: 50% desktop, 90% mobile - Update selectedMed sync after stock changes - Migrate from schema-sql.ts to Drizzle Kit migrations * fix: Improve mobile tooltips and refill modal layout - Center tooltips on screen for mobile devices (fixed position) - Close tooltips automatically when scrolling on touch devices - Use click-based tooltip activation instead of hover on mobile - Fix refill modal buttons to display in two rows on mobile
This commit is contained in:
+32
-19
@@ -1,12 +1,18 @@
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import dotenv from "dotenv";
|
||||
import { getTableCreationSQL } from "./schema-sql.js";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
@@ -44,23 +50,20 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
|
||||
export { getTableCreationSQL } from "./schema-sql.js";
|
||||
/** Run drizzle-kit migrations on the database */
|
||||
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run table creation migrations on a client */
|
||||
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const tableCreations = getTableCreationSQL();
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Run ALTER TABLE migrations for backward compatibility with older databases
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
@@ -149,9 +152,19 @@ export const db = drizzle(client);
|
||||
|
||||
// Auto-run migrations (self-healing database)
|
||||
async function runMigrations() {
|
||||
const result = await runTableMigrations(client);
|
||||
if (result.errors.length > 0) {
|
||||
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
|
||||
// Run drizzle-kit generated migrations
|
||||
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||
const migrateResult = await runDrizzleMigrations(db);
|
||||
if (!migrateResult.success) {
|
||||
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||
} else {
|
||||
console.log(`[DB] Drizzle migrations completed`);
|
||||
}
|
||||
|
||||
// Run ALTER TABLE migrations for backward compatibility
|
||||
const alterResult = await runAlterMigrations(client);
|
||||
if (alterResult.errors.length > 0) {
|
||||
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
|
||||
}
|
||||
console.log(`[DB] Tables verified/created`);
|
||||
|
||||
|
||||
+29
-25
@@ -1,39 +1,45 @@
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getTableCreationSQL } from "./schema-sql.js";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
/** Get the full migration SQL string (re-exported from schema-sql) */
|
||||
export { getTableCreationSQL };
|
||||
|
||||
/** Split SQL string into individual statements */
|
||||
/** Split SQL string into individual statements (for backwards compatibility with tests) */
|
||||
export function splitSQLStatements(sql: string): string[] {
|
||||
return sql.split(';').filter(s => s.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Execute migration statements on a client */
|
||||
/** Execute drizzle migrations on a database */
|
||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||
const statements = getTableCreationSQL();
|
||||
const errors: string[] = [];
|
||||
let executed = 0;
|
||||
const db = drizzle(client);
|
||||
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await client.execute(stmt);
|
||||
executed++;
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
// Count tables as a proxy for "executed" statements
|
||||
const tables = await client.execute(
|
||||
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
|
||||
);
|
||||
const executed = Number(tables.rows[0].count) || 0;
|
||||
|
||||
return { success: true, executed, errors };
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
return { success: false, executed: 0, errors };
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, executed, errors };
|
||||
}
|
||||
|
||||
/** Get a preview of statement (first N characters) */
|
||||
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
|
||||
async function main() {
|
||||
console.log("Starting database setup...");
|
||||
console.log("Database URL:", url);
|
||||
console.log("Migrations folder:", migrationsFolder);
|
||||
|
||||
const client = createClient({ url });
|
||||
const db = drizzle(client);
|
||||
|
||||
const statements = getTableCreationSQL();
|
||||
|
||||
for (const stmt of statements) {
|
||||
console.log("Executing:", getStatementPreview(stmt));
|
||||
await client.execute(stmt);
|
||||
}
|
||||
console.log("Running drizzle migrations...");
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
console.log("Database setup complete!");
|
||||
process.exit(0);
|
||||
|
||||
@@ -100,5 +100,15 @@ export function getTableCreationSQL(): string[] {
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
packs_added integer NOT NULL DEFAULT 0,
|
||||
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||
// UI preferences
|
||||
language: text("language", { length: 10 }).notNull().default("en"),
|
||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||
@@ -117,3 +118,15 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Refill History - Tracks when medication stock was refilled
|
||||
// =============================================================================
|
||||
export const refillHistory = sqliteTable("refill_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
packsAdded: integer("packs_added").notNull().default(0),
|
||||
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user