feat: track number of prescription repeats (#193)

* feat: track prescription repeats and refill reminders

* test: align backend and frontend suites with current prescription and UI behavior

* test: update frontend and backend expectations for latest reminders and refill flow
This commit is contained in:
Daniel Volz
2026-02-14 19:07:36 +01:00
committed by GitHub
parent edf42bb068
commit 8273b07231
37 changed files with 3331 additions and 4673 deletions
@@ -0,0 +1,8 @@
ALTER TABLE `medications` ADD `prescription_enabled` integer NOT NULL DEFAULT 0;
ALTER TABLE `medications` ADD `prescription_authorized_refills` integer;
ALTER TABLE `medications` ADD `prescription_remaining_refills` integer;
ALTER TABLE `medications` ADD `prescription_low_refill_threshold` integer NOT NULL DEFAULT 1;
ALTER TABLE `medications` ADD `prescription_expiry_date` text;
ALTER TABLE `user_settings` ADD `email_prescription_reminders` integer NOT NULL DEFAULT 1;
ALTER TABLE `user_settings` ADD `shoutrrr_prescription_reminders` integer NOT NULL DEFAULT 1;
+30
View File
@@ -135,6 +135,19 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Added for share stock visibility toggle
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
// Added for prescription refill tracking and reminders
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
// Added for refill history prescription tracking
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -172,6 +185,23 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
}
}
// Create indexes that might be missing (silently fail if already exists)
const createIndexMigrations = [
// Added in v1.6.x - case-insensitive unique usernames
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
];
for (const sql of createIndexMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
}
}
}
return { success: errors.length === 0, errors };
}
+14
View File
@@ -47,6 +47,11 @@ export const medications = sqliteTable("medications", {
expiryDate: text("expiry_date"),
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
prescriptionLowRefillThreshold: integer("prescription_low_refill_threshold").notNull().default(1),
prescriptionExpiryDate: text("prescription_expiry_date"),
dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -65,11 +70,15 @@ export const userSettings = sqliteTable("user_settings", {
notificationEmail: text("notification_email"),
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
emailPrescriptionReminders: integer("email_prescription_reminders", { mode: "boolean" }).notNull().default(true),
// Push notifications (shoutrrr/ntfy)
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
shoutrrrUrl: text("shoutrrr_url"),
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
shoutrrrPrescriptionReminders: integer("shoutrrr_prescription_reminders", { mode: "boolean" })
.notNull()
.default(true),
// Reminder settings
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
@@ -98,6 +107,10 @@ export const userSettings = sqliteTable("user_settings", {
lastStockReminderSent: text("last_stock_reminder_sent"),
lastStockReminderChannel: text("last_stock_reminder_channel"),
lastStockReminderMedNames: text("last_stock_reminder_med_names"),
// Last prescription reminder tracking (separate from stock/intake)
lastPrescriptionReminderSent: text("last_prescription_reminder_sent"),
lastPrescriptionReminderChannel: text("last_prescription_reminder_channel"),
lastPrescriptionReminderMedNames: text("last_prescription_reminder_med_names"),
// Timestamps
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -159,5 +172,6 @@ export const refillHistory = sqliteTable("refill_history", {
.references(() => users.id, { onDelete: "cascade" }),
packsAdded: integer("packs_added").notNull().default(0),
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
usedPrescription: integer("used_prescription", { mode: "boolean" }).notNull().default(false),
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
});
+107 -8
View File
@@ -123,6 +123,39 @@ type TranslationKeys = {
criticalSection: string;
lowStockSection: string;
};
// Prescription reminder (shared across email + push)
prescriptionReminder: {
subjectSingle: string;
subjectMultiple: string;
pushTitleLow: string;
pushTitleEmpty: string;
pushEmpty: string;
pushEmptySingle: string;
pushLow: string;
pushLowSingle: string;
pushRenewNow: string;
pushEmptySection: string;
pushLowSection: string;
pushRefillsLeft: string;
title: string;
titleEmpty: string;
descriptionLow: string;
descriptionEmpty: string;
alertLowSingle: string;
alertLowMultiple: string;
alertEmptySingle: string;
alertEmptyMultiple: string;
line: string;
lineEmpty: string;
expiresSuffix: string;
repeatDailyNote: string;
tableHeaders: {
medication: string;
refillsLeft: string;
reminderThreshold: string;
prescriptionExpires: string;
};
};
// Demand calculator email
demandCalculator: {
subject: string;
@@ -156,8 +189,8 @@ type TranslationKeys = {
const translations: Record<Language, TranslationKeys> = {
en: {
stockReminder: {
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Critically Low",
title: "⚠️ MedAssist-ng - Automatic Reorder Reminder",
subject: "MedAssist-ng: ⚠️ {count} Medication{s} Running Critically Low",
title: "⚠️ MedAssist-ng: Automatic Reorder Reminder",
description: "The following medications are running critically low and need to be reordered:",
descriptionEmpty: "The following medications are empty and need to be reordered immediately:",
descriptionMixed: "The following medications need to be reordered:",
@@ -211,9 +244,41 @@ const translations: Record<Language, TranslationKeys> = {
criticalSection: "Running critically low",
lowStockSection: "Running low",
},
prescriptionReminder: {
subjectSingle: "MedAssist-ng: 🚨 Prescription Refill Reminder",
subjectMultiple: "MedAssist-ng: 🚨 {count} Prescriptions Need Renewal Soon",
pushTitleLow: "💊 MedAssist-ng: {count} prescriptions are running low",
pushTitleEmpty: "💊 MedAssist-ng: {count} prescriptions need renewal now",
pushEmpty: "prescriptions out of refills",
pushEmptySingle: "prescription out of refills",
pushLow: "prescriptions low on refills",
pushLowSingle: "prescription low on refills",
pushRenewNow: "Renew Now!",
pushEmptySection: "Prescriptions with no refills left",
pushLowSection: "Prescriptions running low on refills",
pushRefillsLeft: "{count} refill(s) remaining on this prescription",
title: "⚠️ MedAssist-ng - Prescription Reminder",
titleEmpty: "🚨 MedAssist-ng - Prescription Reminder",
descriptionLow: "Some prescriptions are low on remaining refills.",
descriptionEmpty: "Some prescriptions have no refills left. Contact your doctor for renewal.",
alertLowSingle: "⚠️ 1 prescription is low on refills",
alertLowMultiple: "⚠️ {count} prescriptions are low on refills",
alertEmptySingle: "🚨 1 prescription needs renewal now",
alertEmptyMultiple: "🚨 {count} prescriptions need renewal now",
line: "{name}: {refills} refill(s) remaining on this prescription{expirySuffix}",
lineEmpty: "{name}: no refills remaining on this prescription{expirySuffix}",
expiresSuffix: ", expires {date}",
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
tableHeaders: {
medication: "Medication",
refillsLeft: "Prescription refills left",
reminderThreshold: "Reminder threshold",
prescriptionExpires: "Prescription expires",
},
},
demandCalculator: {
subject: "MedAssist-ng - Supply Overview ({from} - {until})",
title: "MedAssist-ng - Demand Calculator",
subject: "MedAssist-ng: Supply Overview ({from} - {until})",
title: "MedAssist-ng: Demand Calculator",
description: "Supply overview from {from} to {until}",
summaryOutOfStock: "⚠️ {count} medication{s} will be out of stock during this period.",
summaryAllOk: "✓ All medications have sufficient supply for this period.",
@@ -240,8 +305,8 @@ const translations: Record<Language, TranslationKeys> = {
},
de: {
stockReminder: {
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} kritisch niedrig",
title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung",
subject: "MedAssist-ng: ⚠️ {count} Medikament{e} kritisch niedrig",
title: "⚠️ MedAssist-ng: Automatische Nachbestell-Erinnerung",
description: "Die folgenden Medikamente sind kritisch niedrig und sollten nachbestellt werden:",
descriptionEmpty: "Die folgenden Medikamente sind leer und müssen sofort nachbestellt werden:",
descriptionMixed: "Die folgenden Medikamente müssen nachbestellt werden:",
@@ -296,9 +361,43 @@ const translations: Record<Language, TranslationKeys> = {
criticalSection: "Kritisch niedrig",
lowStockSection: "Niedrig",
},
prescriptionReminder: {
subjectSingle: "MedAssist-ng: 🚨 Rezept-Nachfüll-Erinnerung",
subjectMultiple: "MedAssist-ng: 🚨 {count} Rezepte müssen bald erneuert werden",
pushTitleLow: "💊 MedAssist-ng: {count} Rezept(e) haben nur noch wenige Nachfüllungen",
pushTitleEmpty: "💊 MedAssist-ng: {count} Rezept(e) müssen jetzt erneuert werden",
pushEmpty: "Rezepte ohne verbleibende Nachfüllung",
pushEmptySingle: "Rezept ohne verbleibende Nachfüllung",
pushLow: "Rezepte mit wenigen verbleibenden Nachfüllungen",
pushLowSingle: "Rezept mit wenigen verbleibenden Nachfüllungen",
pushRenewNow: "Jetzt erneuern!",
pushEmptySection: "Rezepte ohne Nachfüllungen",
pushLowSection: "Rezepte mit bald aufgebrauchten Nachfüllungen",
pushRefillsLeft: "{count} Nachfüllung(en) für dieses Rezept übrig",
title: "⚠️ MedAssist-ng - Rezept-Erinnerung",
titleEmpty: "🚨 MedAssist-ng - Rezept-Erinnerung",
descriptionLow: "Einige Rezepte haben nur noch wenige Nachfüllungen.",
descriptionEmpty:
"Einige Rezepte haben keine Nachfüllungen mehr. Bitte kontaktieren Sie Ihren Arzt für eine Erneuerung.",
alertLowSingle: "⚠️ 1 Rezept ist bei den Nachfüllungen niedrig",
alertLowMultiple: "⚠️ {count} Rezepte sind bei den Nachfüllungen niedrig",
alertEmptySingle: "🚨 1 Rezept muss jetzt erneuert werden",
alertEmptyMultiple: "🚨 {count} Rezepte müssen jetzt erneuert werden",
line: "{name}: {refills} Nachfüllung(en) für dieses Rezept übrig{expirySuffix}",
lineEmpty: "{name}: keine Nachfüllung mehr für dieses Rezept{expirySuffix}",
expiresSuffix: ", läuft ab {date}",
repeatDailyNote:
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
tableHeaders: {
medication: "Medikament",
refillsLeft: "Rezept-Nachfüllungen übrig",
reminderThreshold: "Erinnerungsschwelle",
prescriptionExpires: "Rezeptablauf",
},
},
demandCalculator: {
subject: "MedAssist-ng - Bestandsübersicht ({from} - {until})",
title: "MedAssist-ng - Bedarfsrechner",
subject: "MedAssist-ng: Bestandsübersicht ({from} - {until})",
title: "MedAssist-ng: Bedarfsrechner",
description: "Bestandsübersicht von {from} bis {until}",
summaryOutOfStock: "⚠️ {count} Medikament{e} wird im Zeitraum nicht ausreichen.",
summaryAllOk: "✓ Alle Medikamente reichen für diesen Zeitraum.",
+21
View File
@@ -52,6 +52,11 @@ const medicationExportSchema = z.object({
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
prescriptionEnabled: z.boolean().default(false),
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
prescriptionExpiryDate: z.string().nullable().optional(),
image: z.string().nullable().optional(), // base64 data URL or null
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
});
@@ -80,11 +85,13 @@ const settingsExportSchema = z
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
emailPrescriptionReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
shoutrrrPrescriptionReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
@@ -285,6 +292,11 @@ export async function exportRoutes(app: FastifyInstance) {
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
@@ -346,11 +358,13 @@ export async function exportRoutes(app: FastifyInstance) {
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
@@ -508,6 +522,11 @@ export async function exportRoutes(app: FastifyInstance) {
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
imageUrl: null, // Will be set after image is saved
})
.returning();
@@ -551,10 +570,12 @@ export async function exportRoutes(app: FastifyInstance) {
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
+63 -1
View File
@@ -48,12 +48,39 @@ const medicationSchema = z
doseUnit: doseUnitSchema,
expiryDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
prescriptionEnabled: z.boolean().default(false),
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
prescriptionExpiryDate: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat)
// Accept either new intakes format or legacy blisters format
intakes: z.array(intakeSchema).min(1).max(12).optional(),
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
})
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" });
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
.refine(
(data) => {
if (!data.prescriptionEnabled) return true;
if (data.prescriptionAuthorizedRefills == null || data.prescriptionRemainingRefills == null) return false;
return data.prescriptionRemainingRefills <= data.prescriptionAuthorizedRefills;
},
{
message: "When prescription is enabled, remaining refills must be <= authorized refills",
path: ["prescriptionRemainingRefills"],
}
)
.refine(
(data) => {
if (!data.prescriptionEnabled) return true;
if (data.prescriptionAuthorizedRefills == null) return false;
return data.prescriptionLowRefillThreshold <= data.prescriptionAuthorizedRefills;
},
{
message: "When prescription is enabled, low refill threshold must be <= authorized refills",
path: ["prescriptionLowRefillThreshold"],
}
);
export async function medicationRoutes(app: FastifyInstance) {
// All medication routes require auth
@@ -109,6 +136,11 @@ export async function medicationRoutes(app: FastifyInstance) {
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
prescriptionEnabled: row.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: row.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: row.prescriptionExpiryDate ?? null,
dismissedUntil: row.dismissedUntil ?? null,
updatedAt: row.updatedAt,
};
@@ -134,6 +166,11 @@ export async function medicationRoutes(app: FastifyInstance) {
doseUnit,
expiryDate,
notes,
prescriptionEnabled,
prescriptionAuthorizedRefills,
prescriptionRemainingRefills,
prescriptionLowRefillThreshold,
prescriptionExpiryDate,
intakeRemindersEnabled,
intakes: inputIntakes,
blisters: inputBlisters,
@@ -187,6 +224,11 @@ export async function medicationRoutes(app: FastifyInstance) {
doseUnit: doseUnit ?? "mg",
expiryDate: expiryDate || null,
notes: notes || null,
prescriptionEnabled: prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: prescriptionExpiryDate || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
intakesJson,
usageJson,
@@ -216,6 +258,11 @@ export async function medicationRoutes(app: FastifyInstance) {
expiryDate: inserted.expiryDate,
notes: inserted.notes,
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: inserted.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: inserted.prescriptionExpiryDate ?? null,
updatedAt: inserted.updatedAt,
};
});
@@ -249,6 +296,11 @@ export async function medicationRoutes(app: FastifyInstance) {
doseUnit,
expiryDate,
notes,
prescriptionEnabled,
prescriptionAuthorizedRefills,
prescriptionRemainingRefills,
prescriptionLowRefillThreshold,
prescriptionExpiryDate,
intakeRemindersEnabled,
intakes: inputIntakes,
blisters: inputBlisters,
@@ -312,6 +364,11 @@ export async function medicationRoutes(app: FastifyInstance) {
doseUnit: doseUnit ?? "mg",
expiryDate: expiryDate || null,
notes: notes || null,
prescriptionEnabled: prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: prescriptionExpiryDate || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
intakesJson,
usageJson,
@@ -465,6 +522,11 @@ export async function medicationRoutes(app: FastifyInstance) {
expiryDate: result[0].expiryDate,
notes: result[0].notes,
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: result[0].prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: result[0].prescriptionExpiryDate ?? null,
updatedAt: result[0].updatedAt,
};
});
+225 -5
View File
@@ -61,6 +61,19 @@ type ReminderEmailBody = {
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
type PrescriptionReminderItem = {
name: string;
remainingRefills: number;
threshold: number;
expiryDate?: string | null;
};
type PrescriptionReminderBody = {
email: string;
prescriptionLow: PrescriptionReminderItem[];
language?: Language;
};
export async function plannerRoutes(app: FastifyInstance) {
// Add auth hook for all planner routes
app.addHook("preHandler", requireAuth);
@@ -344,7 +357,7 @@ ${getFooterPlain(language)}`;
if (lowStockMeds.length > 0) {
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
}
const notificationTitle = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
// Build description text
let descriptionText: string;
@@ -485,8 +498,7 @@ ${getFooterPlain(language)}`;
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">${getFooterHtml(language)}</p>
<p style="color: #9ca3af; font-size: 11px; margin: 16px 0 0 0;">${getFooterHtml(language)}</p>
</div>
</div>
`;
@@ -507,7 +519,7 @@ ${getFooterPlain(language)}`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `MedAssist-ng - ${subjectText}`,
subject: `MedAssist-ng: ${subjectText}`,
text: plainText,
html,
});
@@ -544,7 +556,7 @@ ${getFooterPlain(language)}`;
// Also update user settings in database so frontend can display the info
const firstMed = lowStock[0];
const medNames = lowStock.length > 1 ? `${firstMed.name} (+${lowStock.length - 1})` : firstMed?.name;
const medNames = lowStock.map((m: { name: string }) => m.name).join(", ");
await updateUserReminderSentTime(userId, "stock", channel, medNames);
}
@@ -564,4 +576,212 @@ ${getFooterPlain(language)}`;
return reply.status(400).send({ error: "No notification channels configured" });
}
});
// Manual prescription reminder (supports email and push)
app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => {
const { email, prescriptionLow } = request.body;
if (!prescriptionLow || prescriptionLow.length === 0) {
return reply.status(400).send({ error: "Missing prescription reminder data" });
}
const userId = await getUserId(request);
const userSettings = await loadUserSettings(userId);
const language = (userSettings.language as Language) || "en";
const tr = getTranslations(language);
const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0);
const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0);
const lines = prescriptionLow.map((item) => {
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
if (item.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
name: item.name,
expirySuffix,
})}`;
}
return `- ${t(tr.prescriptionReminder.line, {
name: item.name,
refills: item.remainingRefills,
expirySuffix,
})}`;
});
const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", ");
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
const subject =
prescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length });
const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
const alertText =
emptyRx.length > 0
? emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
: lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const tableRows = prescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
const safeRefills = Number(item.remainingRefills) || 0;
const safeThreshold = Number(item.threshold) || 0;
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
</tr>`;
})
.join("");
const emailTitle = emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title;
const text = `${emailTitle}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emailTitle}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${bodyText}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${emptyRx.length > 0 ? "background: #fef2f2; border: 1px solid #dc2626;" : "background: #fffbeb; border: 1px solid #f59e0b;"}">
<p style="margin: 0; ${emptyRx.length > 0 ? "color: #dc2626; font-weight: 600;" : "color: #b45309; font-weight: 500;"} font-size: 13px;">
${alertText}
</p>
</div>
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 460px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.refillsLeft}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.reminderThreshold}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.prescriptionExpires}</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">${getFooterHtml(language)}</p>
</div>
</div>
`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject,
text,
html,
});
results.email = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Email: ${errorMessage}`);
}
}
}
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
);
}
}
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
try {
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
if (pushResult.success) {
results.push = true;
} else {
results.errors.push(`Push: ${pushResult.error}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Push: ${errorMessage}`);
}
}
if (results.email || results.push) {
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
updateReminderSentTime("prescription", channel);
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
}
const sentChannels: string[] = [];
if (results.email) sentChannels.push("email");
if (results.push) sentChannels.push("push");
if (sentChannels.length > 0) {
return reply.send({
success: true,
message: `Prescription reminder sent via ${sentChannels.join(" and ")}`,
});
}
if (results.errors.length > 0) {
return reply.status(500).send({ error: results.errors.join("; ") });
}
return reply.status(400).send({ error: "No notification channels configured" });
});
}
+26 -1
View File
@@ -11,6 +11,7 @@ const refillSchema = z
.object({
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
usePrescription: z.boolean().default(false),
})
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
message: "Must add at least one pack or some loose pills",
@@ -50,17 +51,32 @@ export async function refillRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded } = parsed.data;
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
}
const remaining = med.prescriptionRemainingRefills ?? 0;
if (remaining <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
}
// Update medication stock
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
const newRemainingRefills = usePrescription
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
: (med.prescriptionRemainingRefills ?? null);
await db
.update(medications)
.set({
packCount: newPackCount,
looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills,
stockAdjustment: 0, // Reset offset since we're adding to base stock
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
updatedAt: new Date(),
@@ -75,6 +91,7 @@ export async function refillRoutes(app: FastifyInstance) {
userId,
packsAdded,
loosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
@@ -100,6 +117,13 @@ export async function refillRoutes(app: FastifyInstance) {
looseTablets: newLooseTablets,
totalPills: newTotalPills,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
});
@@ -132,6 +156,7 @@ export async function refillRoutes(app: FastifyInstance) {
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
});
+58 -1
View File
@@ -15,10 +15,12 @@ export type UserSettings = {
notificationEmail: string | null;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
emailPrescriptionReminders: boolean;
shoutrrrEnabled: boolean;
shoutrrrUrl: string | null;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
@@ -39,6 +41,9 @@ export type UserSettings = {
lastStockReminderSent: string | null;
lastStockReminderChannel: string | null;
lastStockReminderMedNames: string | null;
lastPrescriptionReminderSent: string | null;
lastPrescriptionReminderChannel: string | null;
lastPrescriptionReminderMedNames: string | null;
};
type SettingsBody = {
@@ -53,8 +58,10 @@ type SettingsBody = {
shoutrrrUrl: string;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
emailPrescriptionReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
@@ -94,10 +101,12 @@ function getDefaultSettings() {
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true),
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true),
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
@@ -118,6 +127,9 @@ function getDefaultSettings() {
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
lastPrescriptionReminderSent: null,
lastPrescriptionReminderChannel: null,
lastPrescriptionReminderMedNames: null,
};
}
@@ -148,10 +160,12 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
@@ -172,6 +186,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
};
}
@@ -184,10 +201,12 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
@@ -208,6 +227,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
}));
}
@@ -250,8 +272,10 @@ export async function settingsRoutes(app: FastifyInstance) {
shoutrrrUrl: settings.shoutrrrUrl ?? "",
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
@@ -276,6 +300,10 @@ export async function settingsRoutes(app: FastifyInstance) {
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
// Prescription reminder tracking (separate from stock/intake)
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
// Server settings (from .env, read-only)
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
});
@@ -303,10 +331,12 @@ export async function settingsRoutes(app: FastifyInstance) {
notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true,
emailIntakeReminders: body.emailIntakeReminders ?? true,
emailPrescriptionReminders: body.emailPrescriptionReminders ?? true,
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
shoutrrrUrl: body.shoutrrrUrl || null,
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: body.reminderDaysBefore,
repeatDailyReminders,
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
@@ -334,6 +364,30 @@ export async function settingsRoutes(app: FastifyInstance) {
return reply.send({ success: true });
});
// Update only the language setting (lightweight, called on dropdown change)
app.put<{ Body: { language: string } }>("/settings/language", async (request, reply) => {
const userId = await getUserId(request, reply);
const { language } = request.body;
if (!language || !["en", "de"].includes(language)) {
return reply.status(400).send({ error: "Invalid language" });
}
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (existingSettings.length > 0) {
await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId,
...getDefaultSettings(),
language,
});
}
return reply.send({ success: true });
});
// Test email - use SMTP settings from process.env
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
const { email } = request.body;
@@ -533,7 +587,10 @@ export async function sendShoutrrrNotification(
// This works for ntfy, Apprise, and most simple push services
if (!isJsonWebhook) {
targetUrl = sanitizedUrl;
headers = { Title: cleanTitle, Tags: "pill" };
// Use RFC 2047 Base64 encoding for Title header to safely pass non-ASCII
// characters (umlauts, accents, etc.) through HTTP headers
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
headers = { Title: encodedTitle, Tags: "pill" };
body = message;
// Add auth if present (extracted during sanitization)
+406 -124
View File
@@ -24,6 +24,17 @@ import {
type ReminderState,
} from "../utils/scheduler-utils.js";
function escapeHtml(text: string): string {
const htmlEscapes: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
}
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
@@ -48,7 +59,7 @@ export function getReminderState(): ReminderState {
}
export function updateReminderSentTime(
type: "stock" | "intake" = "stock",
type: "stock" | "intake" | "prescription" = "stock",
channel: "email" | "push" | "both" = "email"
): void {
const state = loadReminderState();
@@ -66,7 +77,7 @@ export function updateReminderSentTime(
// Stock and intake reminders are tracked separately so neither overwrites the other
export async function updateUserReminderSentTime(
userId: number,
type: "stock" | "intake" = "stock",
type: "stock" | "intake" | "prescription" = "stock",
channel: "email" | "push" | "both" = "email",
medName?: string,
takenBy?: string
@@ -83,6 +94,16 @@ export async function updateUserReminderSentTime(
lastStockReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
} else if (type === "prescription") {
// Write to dedicated prescription reminder columns only
await db
.update(userSettings)
.set({
lastPrescriptionReminderSent: now,
lastPrescriptionReminderChannel: channel,
lastPrescriptionReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
} else {
// Write to intake reminder columns
await db
@@ -107,11 +128,20 @@ type LowStockItem = {
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
isCritical: boolean;
};
type PrescriptionReminderItem = {
name: string;
remainingRefills: number;
lowThreshold: number;
expiryDate: string | null;
};
async function getMedicationsNeedingReminder(
userId: number,
reminderDaysBefore: number,
lowStockDays: number,
language: Language
): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
@@ -126,13 +156,18 @@ async function getMedicationsNeedingReminder(
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
// Check if medication runs out within reminderDaysBefore days
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
if (daysLeft === null) continue;
const isCritical = daysLeft <= reminderDaysBefore;
const isLow = daysLeft < lowStockDays;
if (isCritical || isLow) {
lowStock.push({
name: row.name,
medsLeft: totalPills,
daysLeft,
depletionDate,
isCritical,
});
}
}
@@ -140,6 +175,23 @@ async function getMedicationsNeedingReminder(
return lowStock;
}
async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise<PrescriptionReminderItem[]> {
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
return rows
.filter(
(row) =>
(row.prescriptionEnabled ?? false) &&
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
)
.map((row) => ({
name: row.name,
remainingRefills: row.prescriptionRemainingRefills ?? 0,
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
expiryDate: row.prescriptionExpiryDate ?? null,
}));
}
async function sendReminderEmail(
email: string,
lowStock: LowStockItem[],
@@ -158,35 +210,82 @@ async function sendReminderEmail(
}
const tr = getTranslations(language);
const tableRows = lowStock
.map(
(row) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
</tr>
`
)
.join("");
const alertText =
lowStock.length === 1
? tr.stockReminder.alertSingle
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
// Separate into 3 categories: empty, critical, and low stock
const emptyMeds = lowStock.filter((item) => item.medsLeft <= 0);
const criticalMeds = lowStock.filter((item) => item.medsLeft > 0 && item.isCritical);
const lowStockMeds = lowStock.filter((item) => item.medsLeft > 0 && !item.isCritical);
// Build per-category alert boxes
const alertParts: string[] = [];
if (emptyMeds.length > 0) {
const emptyAlert =
emptyMeds.length === 1
? tr.stockReminder.alertEmptySingle
: t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length });
alertParts.push(`
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">${emptyAlert}</p>
</div>`);
}
if (criticalMeds.length > 0) {
const criticalAlert =
criticalMeds.length === 1
? tr.stockReminder.alertLowSingle
: t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length });
alertParts.push(`
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fff7ed; border: 1px solid #ea580c;">
<p style="margin: 0; color: #c2410c; font-weight: 600; font-size: 13px;">${criticalAlert}</p>
</div>`);
}
if (lowStockMeds.length > 0) {
const lowAlert =
lowStockMeds.length === 1
? tr.stockReminder.alertLowStockSingle
: t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length });
alertParts.push(`
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">${lowAlert}</p>
</div>`);
}
const alertHtml = alertParts.join("");
// Build description text
let descriptionText: string;
if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) {
descriptionText = tr.stockReminder.descriptionMixed;
} else if (emptyMeds.length > 0) {
descriptionText = tr.stockReminder.descriptionEmpty;
} else if (criticalMeds.length > 0) {
descriptionText = tr.stockReminder.description;
} else {
descriptionText = tr.stockReminder.descriptionLow;
}
// Build table rows with status indicator
const tableRows = lowStock
.map((row) => {
const isEmpty = row.medsLeft <= 0;
const isCritical = row.isCritical;
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
</tr>`;
})
.join("");
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.stockReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${tr.stockReminder.description}</p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - ${tr.push.reorderNow}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${descriptionText}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
${alertText}
</p>
</div>
${alertHtml}
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
@@ -239,7 +338,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `⚠️ ${subject}`,
subject,
text: plainText,
html,
});
@@ -272,118 +371,301 @@ async function checkAndSendReminderForUser(
const language = settings.language;
const tr = getTranslations(language);
// Check if any stock reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders;
const stockEmailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders;
const stockPushEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders;
const prescriptionEmailEnabled =
settings.emailEnabled && settings.notificationEmail && settings.emailPrescriptionReminders;
const prescriptionPushEnabled =
settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrPrescriptionReminders;
if (!emailEnabled && !shoutrrrEnabled) {
return; // No stock reminder notifications enabled for this user
if (!stockEmailEnabled && !stockPushEnabled && !prescriptionEmailEnabled && !prescriptionPushEnabled) {
return;
}
const state = loadReminderState();
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
const userStateKey = `user_${settings.userId}`;
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
// Get all medications that need a reminder for this user
const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language);
const allLowStock = await getMedicationsNeedingReminder(
settings.userId,
settings.reminderDaysBefore,
settings.lowStockDays,
language
);
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
if (allLowStock.length === 0) {
return; // No low stock for this user
}
// Simple per-user tracking - check if we already sent today
const userNotifiedKey = `${userStateKey}_${today}`;
if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) {
return; // Already notified this user today
}
logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
// Send email if enabled
if (emailEnabled) {
const result = await sendReminderEmail(
settings.notificationEmail!,
allLowStock,
language,
settings.repeatDailyReminders
);
emailSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`);
} else {
logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`);
}
}
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
// Separate empty from critical stock medications (all auto-reminder meds are critical by definition)
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0);
// Build clear title
const titleParts: string[] = [];
if (emptyMeds.length > 0) {
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
}
if (criticalMeds.length > 0) {
titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical || "Critical"}`);
}
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`;
// Build clear message with sections
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection || "Empty (reorder immediately)"}:`);
emptyMeds.forEach((m) => messageParts.push(`${m.name}`));
}
if (criticalMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.push.criticalSection || "Running critically low"}:`);
criticalMeds.forEach((m) =>
messageParts.push(
`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
)
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
logger.info(
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
);
}
if (settings.repeatDailyReminders) {
messageParts.push("");
messageParts.push(tr.push.repeatDailyNote);
}
let emailSuccess = false;
let shoutrrrSuccess = false;
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
if (stockEmailEnabled) {
const result = await sendReminderEmail(
settings.notificationEmail!,
allLowStock,
language,
settings.repeatDailyReminders
);
emailSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
}
}
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`);
} else {
logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`);
if (stockPushEnabled) {
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
const titleParts: string[] = [];
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyMeds.forEach((m) => messageParts.push(`${m.name}`));
}
if (criticalMeds.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalMeds.forEach((m) =>
messageParts.push(
`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
)
);
}
if (lowStockMeds.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowStockMeds.forEach((m) =>
messageParts.push(
`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
)
);
}
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
}
}
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "stock",
lastNotificationChannel: channel,
});
const firstMed = allLowStock[0];
const medNames = allLowStock.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
}
}
}
// Update state if any notification was sent successfully
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "stock",
lastNotificationChannel: channel,
});
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
);
// Also update user settings in database so frontend can display the info
// For stock reminders, show the first medication name
const firstMed = allLowStock[0];
const medNames = allLowStock.length > 1 ? `${firstMed.name} (+${allLowStock.length - 1})` : firstMed?.name;
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
const lines = allPrescriptionLow.map((m) => {
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
if (m.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
name: m.name,
expirySuffix,
})}`;
}
return `- ${t(tr.prescriptionReminder.line, {
name: m.name,
refills: m.remainingRefills,
expirySuffix,
})}`;
});
let emailSuccess = false;
let shoutrrrSuccess = false;
if (prescriptionEmailEnabled) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: { user: smtpUser, pass: smtpPass ?? "" },
});
const subject =
allPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
const alertText =
emptyRx.length > 0
? emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
: lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const tableRows = allPrescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
const safeRefills = Number(item.remainingRefills) || 0;
const safeThreshold = Number(item.lowThreshold) || 0;
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
</tr>`;
})
.join("");
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${bodyText}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${
emptyRx.length > 0
? "background: #fef2f2; border: 1px solid #dc2626;"
: "background: #fffbeb; border: 1px solid #f59e0b;"
}">
<p style="margin: 0; ${emptyRx.length > 0 ? "color: #dc2626; font-weight: 600;" : "color: #b45309; font-weight: 500;"} font-size: 13px;">
${alertText}
</p>
</div>
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 460px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.refillsLeft}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.reminderThreshold}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.prescriptionExpires}</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
${getFooterHtml(language)}
</p>
${settings.repeatDailyReminders ? `<p style="color: #9ca3af; font-size: 11px; margin: 8px 0 0 0; font-style: italic;">${tr.prescriptionReminder.repeatDailyNote}</p>` : ""}
</div>
</div>
`;
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
await transporter.sendMail({
from: smtpFrom,
to: settings.notificationEmail!,
subject,
text,
html,
});
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
}
}
}
if (prescriptionPushEnabled) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
);
}
}
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
}
}
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "prescription",
lastNotificationChannel: channel,
});
const firstMed = allPrescriptionLow[0];
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
}
}
}
}
+88
View File
@@ -99,6 +99,11 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
prescription_enabled integer NOT NULL DEFAULT 0,
prescription_authorized_refills integer,
prescription_remaining_refills integer,
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
prescription_expiry_date text,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -110,10 +115,12 @@ async function createSchema(client: Client) {
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
email_prescription_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
@@ -135,6 +142,9 @@ async function createSchema(client: Client) {
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
last_prescription_reminder_sent text,
last_prescription_reminder_channel text,
last_prescription_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -163,6 +173,7 @@ async function createSchema(client: Client) {
user_id integer NOT NULL,
packs_added integer NOT NULL DEFAULT 0,
loose_pills_added integer NOT NULL DEFAULT 0,
used_prescription 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
@@ -1621,6 +1632,83 @@ describe("E2E Tests with Real Routes", () => {
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
});
it("should decrement remaining refills and mark history when using prescription refill", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Prescription Refill Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 3,
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(1);
const medsResponse = await app.inject({
method: "GET",
url: "/medications",
});
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: any) => m.id === medId);
expect(med.prescriptionRemainingRefills).toBe(1);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0].usedPrescription).toBe(true);
});
it("should reject prescription refill when no remaining prescription refills are available", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Prescription Empty Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 2,
prescriptionRemainingRefills: 0,
prescriptionLowRefillThreshold: 1,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true },
});
expect(refillResponse.statusCode).toBe(409);
expect(refillResponse.json().error).toContain("No remaining prescription refills");
});
it("should return 400 when no packs or pills added", async () => {
const createResponse = await app.inject({
method: "POST",
+10
View File
@@ -94,6 +94,11 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
prescription_enabled integer NOT NULL DEFAULT 0,
prescription_authorized_refills integer,
prescription_remaining_refills integer,
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
prescription_expiry_date text,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -105,10 +110,12 @@ async function createSchema(client: Client) {
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
email_prescription_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
@@ -130,6 +137,9 @@ async function createSchema(client: Client) {
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
last_prescription_reminder_sent text,
last_prescription_reminder_channel text,
last_prescription_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
+107
View File
@@ -94,10 +94,12 @@ async function createSchema(client: Client) {
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
email_prescription_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
@@ -119,6 +121,9 @@ async function createSchema(client: Client) {
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
last_prescription_reminder_sent text,
last_prescription_reminder_channel text,
last_prescription_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -980,4 +985,106 @@ describe("Planner Routes", () => {
expect(message).toContain("Running critically low");
});
});
describe("POST /reminder/send-prescription", () => {
it("should reject request with missing prescription data", async () => {
const response = await app.inject({
method: "POST",
url: "/reminder/send-prescription",
payload: {
email: "test@example.com",
prescriptionLow: [],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing prescription reminder data" });
});
it("should return error when no notification channels configured", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`,
args: [999999999],
});
const response = await app.inject({
method: "POST",
url: "/reminder/send-prescription",
payload: {
email: "test@example.com",
prescriptionLow: [{ name: "Aspirin", remainingRefills: 0, threshold: 1, expiryDate: "2026-01-01" }],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "No notification channels configured" });
});
it("should send prescription email reminder when email is enabled", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
method: "POST",
url: "/reminder/send-prescription",
payload: {
email: "test@example.com",
prescriptionLow: [
{ name: "Aspirin", remainingRefills: 0, threshold: 1, expiryDate: "2026-01-01" },
{ name: "Ibuprofen", remainingRefills: 1, threshold: 2, expiryDate: null },
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Prescription reminder sent via email" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
expect(mockUpdateReminderSentTime).toHaveBeenCalledWith("prescription", "email");
expect(mockUpdateUserReminderSentTime).toHaveBeenCalledWith(
999999999,
"prescription",
"email",
"Aspirin, Ibuprofen"
);
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send prescription push reminder when shoutrrr is enabled", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/reminder/send-prescription",
payload: {
email: "test@example.com",
prescriptionLow: [{ name: "Aspirin", remainingRefills: 1, threshold: 2, expiryDate: "2026-01-01" }],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Prescription reminder sent via push" });
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
expect(title).toContain("Renew Now");
expect(message).toContain("Aspirin");
expect(mockUpdateReminderSentTime).toHaveBeenCalledWith("prescription", "push");
expect(mockUpdateUserReminderSentTime).toHaveBeenCalledWith(999999999, "prescription", "push", "Aspirin");
});
});
});
+2 -2
View File
@@ -98,7 +98,7 @@ describe("Translations Module", () => {
// Stock reminder subject
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Critically Low");
expect(subject).toBe("MedAssist-ng: ⚠️ 3 Medications Running Critically Low");
// Intake reminder description
const description = t(translations.intakeReminder.description, { minutes: 30 });
@@ -113,7 +113,7 @@ describe("Translations Module", () => {
const translations = getTranslations("de");
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente kritisch niedrig");
expect(subject).toBe("MedAssist-ng: ⚠️ 2 Medikamente kritisch niedrig");
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
expect(takenBy).toBe("für Daniel");
+1 -1
View File
@@ -485,7 +485,7 @@ export type ReminderState = {
lastAutoEmailDate: string | null;
notifiedMedications: string[];
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | null;
lastNotificationType: "stock" | "intake" | "prescription" | null;
lastNotificationChannel: "email" | "push" | "both" | null;
};
+16 -9
View File
@@ -1,4 +1,4 @@
import { expect } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { authFile, navigateTo, test } from "./fixtures";
/**
@@ -10,6 +10,14 @@ import { authFile, navigateTo, test } from "./fixtures";
test.describe("Medications Page", () => {
test.use({ storageState: authFile });
async function openMedicationForm(page: Page) {
await navigateTo(page, "/medications");
const newMedicationButton = page.getByRole("button", { name: /New medication/i });
if (await newMedicationButton.isVisible().catch(() => false)) {
await newMedicationButton.click();
}
}
test("should display medications page", async ({ page }) => {
await navigateTo(page, "/medications");
@@ -31,9 +39,8 @@ test.describe("Medications Page", () => {
});
test("should display the medication form with required fields", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
// The form should always be visible on the medications page
const commercialName = page.getByLabel(/Commercial Name/i);
await expect(commercialName).toBeVisible();
@@ -45,7 +52,7 @@ test.describe("Medications Page", () => {
});
test("should fill in medication details", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
const nameField = page.getByLabel(/Commercial Name/i);
await nameField.fill("Test Aspirin");
@@ -57,7 +64,7 @@ test.describe("Medications Page", () => {
});
test("should have stock inventory fields", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
// Stock fields should be visible
await expect(page.getByLabel(/^Packs$/i)).toBeVisible();
@@ -74,7 +81,7 @@ test.describe("Medications Page", () => {
});
test("should toggle package type between blister and bottle", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
// Find the package type radio buttons or selector
const blisterOption = page.getByText(/Blister Pack/i);
@@ -93,7 +100,7 @@ test.describe("Medications Page", () => {
});
test("should have intake schedule with add button", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
// Intake schedule section
const scheduleSection = page.getByText(/Intake schedule/i);
@@ -108,7 +115,7 @@ test.describe("Medications Page", () => {
});
test("should have save and cancel buttons", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
// Fill in a name to make the form dirty
await page.getByLabel(/Commercial Name/i).fill("Test");
@@ -119,7 +126,7 @@ test.describe("Medications Page", () => {
});
test("should prevent navigation with unsaved changes", async ({ page }) => {
await navigateTo(page, "/medications");
await openMedicationForm(page);
// Fill in the form to create unsaved changes
await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication");
+7 -7
View File
@@ -13,7 +13,7 @@ test.describe("Settings Page", () => {
test("should display settings form", async ({ page }) => {
await navigateTo(page, "/settings");
await expect(page.locator("form.settings-form")).toBeVisible();
await expect(page.locator("div.settings-form")).toBeVisible();
});
test("should show language section with select", async ({ page }) => {
@@ -60,7 +60,7 @@ test.describe("Settings Page", () => {
await expect(thresholdGroup).toBeVisible();
// Should have three threshold number inputs
const thresholdInputs = thresholdGroup.locator('input[type="number"]');
const thresholdInputs = thresholdGroup.locator('input[type="text"]');
await expect(thresholdInputs).toHaveCount(3);
});
@@ -97,11 +97,11 @@ test.describe("Settings Page", () => {
await expect(otherCard).toHaveClass(/selected/);
});
test("should have save button in form footer", async ({ page }) => {
test("should have export action button", async ({ page }) => {
await navigateTo(page, "/settings");
const saveButton = page.locator('div.form-footer > button[type="submit"]');
await expect(saveButton).toBeVisible();
const exportButton = page.getByRole("button", { name: /Export Data|Daten exportieren/i });
await expect(exportButton).toBeVisible();
});
test("should show export/import section", async ({ page }) => {
@@ -156,7 +156,7 @@ test.describe("Settings Page", () => {
await navigateTo(page, "/settings");
const thresholdGroup = page.locator("div.threshold-chips-group");
const inputs = thresholdGroup.locator('input[type="number"]');
const inputs = thresholdGroup.locator('input[type="text"]');
// Set an invalid value (critical > low)
const criticalInput = inputs.first();
@@ -182,6 +182,6 @@ test.describe("Settings Page", () => {
await settingsOption.click();
await expect(page).toHaveURL(/\/settings/);
await expect(page.locator("form.settings-form")).toBeVisible();
await expect(page.locator("div.settings-form")).toBeVisible();
});
});
+6 -2
View File
@@ -127,6 +127,8 @@ function AppContent() {
setRefillPacks,
refillLoose,
setRefillLoose,
usePrescriptionRefill,
setUsePrescriptionRefill,
refillSaving,
refillHistory,
refillHistoryExpanded,
@@ -355,8 +357,8 @@ function AppContent() {
};
// For MedDetailModal: refill without form update (not editing)
const handleSubmitRefill = async (medId: number) => {
await ctx.submitRefill(medId, null, () => {}, loadMeds);
const handleSubmitRefill = async (medId: number, usePrescription: boolean = false) => {
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
};
// Wrapper for openEditStockModal (provides selectedMed and coverage)
@@ -430,6 +432,8 @@ function AppContent() {
onRefillPacksChange={setRefillPacks}
refillLoose={refillLoose}
onRefillLooseChange={setRefillLoose}
usePrescriptionRefill={usePrescriptionRefill}
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
refillSaving={refillSaving}
refillHistory={refillHistory}
refillHistoryExpanded={refillHistoryExpanded}
+120 -10
View File
@@ -6,6 +6,7 @@
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
* 2. Props mode: Accepts all required data as props (for gradual adoption)
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Lightbox, MedicationAvatar } from "../components";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
@@ -83,11 +84,13 @@ export interface MedDetailModalProps {
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
usePrescriptionRefill: boolean;
onUsePrescriptionRefillChange: (value: boolean) => void;
refillSaving: boolean;
refillHistory: RefillEntry[];
refillHistoryExpanded: boolean;
onRefillHistoryExpandedChange: (value: boolean) => void;
onSubmitRefill: (medId: number) => Promise<void>;
onSubmitRefill: (medId: number, usePrescription?: boolean) => Promise<void>;
// Edit stock state
editStockFullBlisters: number;
onEditStockFullBlistersChange: (value: number) => void;
@@ -115,6 +118,8 @@ export function MedDetailModal({
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
usePrescriptionRefill,
onUsePrescriptionRefillChange,
refillSaving,
refillHistory,
refillHistoryExpanded,
@@ -128,6 +133,20 @@ export function MedDetailModal({
onSubmitStockCorrection,
}: MedDetailModalProps) {
const { t, i18n } = useTranslation();
const [editStockFullInput, setEditStockFullInput] = useState("0");
const [editStockPartialInput, setEditStockPartialInput] = useState("0");
const parseStockInput = (value: string): number => {
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? 0 : parsed;
};
useEffect(() => {
if (showEditStockModal) {
setEditStockFullInput(String(editStockFullBlisters));
setEditStockPartialInput(String(editStockPartialBlisterPills));
}
}, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills]);
if (!selectedMed) return null;
@@ -138,6 +157,7 @@ export function MedDetailModal({
const textClass =
status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
const fullForBounds = Math.max(0, parseStockInput(editStockFullInput));
return (
<div className="modal-overlay" onClick={onClose}>
@@ -263,6 +283,42 @@ export function MedDetailModal({
</div>
</div>
{/* Prescription Details Section */}
{selectedMed.prescriptionEnabled && (
<div className="med-detail-section">
<h3>{t("form.sections.prescription")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("prescription.authorizedRefills")}</span>
<span className="med-detail-value">{selectedMed.prescriptionAuthorizedRefills ?? "—"}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("prescription.remainingRefills")}</span>
<span className="med-detail-value">{selectedMed.prescriptionRemainingRefills ?? "—"}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("prescription.lowThreshold")}</span>
<span className="med-detail-value">{selectedMed.prescriptionLowRefillThreshold ?? "—"}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("prescription.expiryDate")}</span>
<span className="med-detail-value">
{selectedMed.prescriptionExpiryDate
? new Date(selectedMed.prescriptionExpiryDate).toLocaleDateString(
getSystemLocale(i18n.language),
{
day: "2-digit",
month: "short",
year: "numeric",
}
)
: "—"}
</span>
</div>
</div>
</div>
)}
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
<div className="med-detail-section">
@@ -373,6 +429,12 @@ export function MedDetailModal({
entry.loosePillsAdded;
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
})()}
{entry.usedPrescription && (
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
{" "}
📋
</span>
)}
</span>
</div>
))}
@@ -459,6 +521,23 @@ export function MedDetailModal({
/>
</label>
)}
{selectedMed.prescriptionEnabled && (
<div className="refill-prescription-row full">
<label className="refill-prescription-toggle">
<input
type="checkbox"
checked={usePrescriptionRefill}
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
disabled={(Number(selectedMed.prescriptionRemainingRefills) || 0) <= 0}
/>
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
</label>
<span className="refill-remaining-badge">
{t("prescription.remainingRefills")}: {Number(selectedMed.prescriptionRemainingRefills) || 0}
</span>
</div>
)}
</div>
<div className="modal-footer">
@@ -468,7 +547,7 @@ export function MedDetailModal({
<div className="refill-footer-right">
<button
className="success"
onClick={() => onSubmitRefill(selectedMed.id)}
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
@@ -525,8 +604,17 @@ export function MedDetailModal({
<input
type="number"
min="0"
value={editStockPartialBlisterPills}
onChange={(e) => onEditStockPartialBlisterPillsChange(parseInt(e.target.value, 10) || 0)}
value={editStockPartialInput}
onChange={(e) => {
const raw = e.target.value;
setEditStockPartialInput(raw);
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.max(0, parseStockInput(raw)));
}}
onBlur={() => {
const normalized = Math.max(0, parseStockInput(editStockPartialInput));
onEditStockPartialBlisterPillsChange(normalized);
setEditStockPartialInput(String(normalized));
}}
/>
</label>
) : (
@@ -537,23 +625,45 @@ export function MedDetailModal({
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
value={editStockFullInput}
onChange={(e) => {
const raw = e.target.value;
setEditStockFullInput(raw);
onEditStockFullBlistersChange(raw === "" ? 0 : Math.max(0, parseStockInput(raw)));
}}
onBlur={() => {
const normalized = Math.max(0, parseStockInput(editStockFullInput));
onEditStockFullBlistersChange(normalized);
setEditStockFullInput(String(normalized));
}}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
min={fullForBounds > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
value={editStockPartialInput}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const raw = e.target.value;
setEditStockPartialInput(raw);
if (raw === "") {
onEditStockPartialBlisterPillsChange(0);
return;
}
const val = parseStockInput(raw);
const min = fullForBounds > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
onBlur={() => {
const min = fullForBounds > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
const normalized = Math.max(min, Math.min(parseStockInput(editStockPartialInput), max));
onEditStockPartialBlisterPillsChange(normalized);
setEditStockPartialInput(String(normalized));
}}
/>
</label>
</>
+347 -250
View File
@@ -41,12 +41,14 @@ export interface MobileEditModalProps {
onAddIntake: (takenBy?: string) => void;
onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
// Refill state (for edit mode)
refillPacks: number;
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
usePrescriptionRefill: boolean;
onUsePrescriptionRefillChange: (value: boolean) => void;
refillSaving: boolean;
onSubmitRefill: (medId: number) => Promise<void>;
// Image handling
@@ -99,6 +101,8 @@ export function MobileEditModal({
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
usePrescriptionRefill,
onUsePrescriptionRefillChange,
refillSaving,
onSubmitRefill,
meds,
@@ -137,184 +141,276 @@ export function MobileEditModal({
onSaveMedication(e);
}}
>
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required
/>
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.general")}</h4>
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required
/>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="0"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="number"
min="1"
value={form.totalPills}
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
type="number"
min="0"
step="0.1"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
/>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</div>
</label>
<label className="full">
{t("form.expiryDate")}
<input
type="date"
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
</label>
</div>
{/* Refill section - only shown when editing (mobile) */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.totalPills}
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</label>
<label className="full">
{t("form.expiryDate")}
<input
type="date"
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
</div>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
<label className="full">
{t("prescription.enabled")}
<label className="toggle-switch small">
<input
type="checkbox"
checked={form.prescriptionEnabled}
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</label>
{form.prescriptionEnabled && (
<>
<label className="prescription-field">
{t("prescription.authorizedRefills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionAuthorizedRefills}
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.remainingRefills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionRemainingRefills}
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.lowThreshold")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionLowRefillThreshold}
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.expiryDate")}
<input
type="date"
value={form.prescriptionExpiryDate}
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
/>
</label>
</>
)}
</div>
<div className="full form-category refill-section">
<h4 className="form-category-title">{t("refill.title")}</h4>
{editingId ? (
<>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
@@ -322,102 +418,109 @@ export function MobileEditModal({
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
<label className="full">
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
<div className="refill-submit-row full">
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
{form.prescriptionEnabled && (
<div className="refill-prescription-row full">
<label className="refill-prescription-toggle">
<input
type="checkbox"
checked={usePrescriptionRefill}
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
/>
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
</label>
<span className="refill-remaining-badge">
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
</span>
) : null;
})()}
</div>
</div>
)}
</>
) : (
<p className="refill-unavailable">{t("refill.saveFirst", "Save medication first to enable refill")}</p>
)}
</div>
{editingId && (
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{currentMed?.imageUrl ? (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
) : (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
)}
</div>
)}
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
{editingId && currentMed?.imageUrl ? (
<div className="full image-field">
<span className="field-label">{t("form.medicationImage")}</span>
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
<div className="full form-category intake-section">
<div className="form-category-header">
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
<button
type="button"
className="ghost add-blister"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
</div>
) : editingId ? (
<label className="full">
{t("form.medicationImage")}
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
</label>
) : null}
<fieldset className="full blister-section">
<legend>{t("form.blisters.title")}</legend>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input
type="number"
min="0"
step="0.1"
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={intake.usage}
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
/>
@@ -425,8 +528,9 @@ export function MobileEditModal({
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input
type="number"
min="1"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={intake.every}
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
/>
@@ -477,14 +581,7 @@ export function MobileEditModal({
)}
</div>
))}
<button
type="button"
className="ghost add-blister"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
</fieldset>
</div>
<div className="modal-footer">
<button type="button" className="ghost" onClick={onClose}>
+11 -2
View File
@@ -17,6 +17,7 @@ export type DoseInfo = {
when: number;
usage: number;
takenBy: string[];
intakeRemindersEnabled: boolean;
};
export type DayMedEntry = {
@@ -58,7 +59,7 @@ export interface AppContextValue {
testingShoutrrr: boolean;
testShoutrrrResult: { success: boolean; message: string } | null;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
saveSettings: (e?: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
@@ -105,6 +106,8 @@ export interface AppContextValue {
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
refillLoose: number;
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
usePrescriptionRefill: boolean;
setUsePrescriptionRefill: React.Dispatch<React.SetStateAction<boolean>>;
refillSaving: boolean;
refillHistory: ReturnType<typeof useRefill>["refillHistory"];
refillHistoryExpanded: boolean;
@@ -121,7 +124,8 @@ export interface AppContextValue {
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<any>>,
loadMeds: () => void
loadMeds: () => void,
usePrescription?: boolean
) => Promise<void>;
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
openRefillModal: () => void;
@@ -382,6 +386,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
when: event.when,
usage: event.usage,
takenBy: event.takenBy ? [event.takenBy] : [],
intakeRemindersEnabled: event.intakeRemindersEnabled,
});
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
@@ -602,6 +607,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.notificationEmail !== savedSettings.notificationEmail ||
settings.emailStockReminders !== savedSettings.emailStockReminders ||
settings.emailIntakeReminders !== savedSettings.emailIntakeReminders ||
settings.emailPrescriptionReminders !== savedSettings.emailPrescriptionReminders ||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
settings.lowStockDays !== savedSettings.lowStockDays ||
@@ -611,6 +617,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl ||
settings.shoutrrrStockReminders !== savedSettings.shoutrrrStockReminders ||
settings.shoutrrrIntakeReminders !== savedSettings.shoutrrrIntakeReminders ||
settings.shoutrrrPrescriptionReminders !== savedSettings.shoutrrrPrescriptionReminders ||
settings.skipRemindersForTakenDoses !== savedSettings.skipRemindersForTakenDoses ||
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
@@ -735,6 +742,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setRefillPacks: refill.setRefillPacks,
refillLoose: refill.refillLoose,
setRefillLoose: refill.setRefillLoose,
usePrescriptionRefill: refill.usePrescriptionRefill,
setUsePrescriptionRefill: refill.setUsePrescriptionRefill,
refillSaving: refill.refillSaving,
refillHistory: refill.refillHistory,
refillHistoryExpanded: refill.refillHistoryExpanded,
+69 -4
View File
@@ -43,6 +43,11 @@ export const defaultForm = (): FormState => ({
doseUnit: "mg",
expiryDate: "",
notes: "",
prescriptionEnabled: false,
prescriptionAuthorizedRefills: "",
prescriptionRemainingRefills: "",
prescriptionLowRefillThreshold: "1",
prescriptionExpiryDate: "",
intakeRemindersEnabled: false,
blisters: [defaultBlister()],
intakes: [defaultIntake()],
@@ -78,7 +83,7 @@ export interface UseMedicationFormReturn {
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
resetForm: () => void;
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
handleValueChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
addTakenByPerson: (name: string) => void;
removeTakenByPerson: (name: string) => void;
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
@@ -96,6 +101,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
const [takenByInput, setTakenByInput] = useState("");
const parseNonNegativeInt = useCallback((value: string): number => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) return 0;
return parsed;
}, []);
// Validate form fields
const validateField = useCallback(
(field: keyof FieldErrors, value: string | string[]): string | undefined => {
@@ -199,6 +210,10 @@ export function useMedicationForm(): UseMedicationFormReturn {
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
const editForm: FormState = {
name: med.name,
genericName: med.genericName ?? "",
@@ -217,6 +232,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
doseUnit: med.doseUnit ?? "mg",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills != null ? String(authorizedRefills) : "",
prescriptionRemainingRefills: med.prescriptionRemainingRefills != null ? String(remainingRefills) : "",
prescriptionLowRefillThreshold: String(lowRefillThreshold),
prescriptionExpiryDate: med.prescriptionExpiryDate ? med.prescriptionExpiryDate.slice(0, 10) : "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
blisters: med.blisters.map((s) => ({
usage: String(s.usage),
@@ -246,9 +266,54 @@ export function useMedicationForm(): UseMedicationFormReturn {
setOriginalForm(newForm);
}, []);
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
}, []);
const handleValueChange = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => {
const next = { ...prev, [key]: value } as FormState;
if (key === "prescriptionAuthorizedRefills") {
const raw = String(value);
next.prescriptionAuthorizedRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (key === "prescriptionRemainingRefills") {
const raw = String(value);
next.prescriptionRemainingRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (key === "prescriptionLowRefillThreshold") {
const raw = String(value);
next.prescriptionLowRefillThreshold = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (!next.prescriptionEnabled) {
return next;
}
const authorizedRefills = parseNonNegativeInt(next.prescriptionAuthorizedRefills);
if (key === "prescriptionAuthorizedRefills") {
next.prescriptionRemainingRefills = String(
Math.min(parseNonNegativeInt(next.prescriptionRemainingRefills), authorizedRefills)
);
next.prescriptionLowRefillThreshold = String(
Math.min(parseNonNegativeInt(next.prescriptionLowRefillThreshold), authorizedRefills)
);
}
if (key === "prescriptionRemainingRefills") {
next.prescriptionRemainingRefills = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
}
if (key === "prescriptionLowRefillThreshold") {
next.prescriptionLowRefillThreshold = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
}
return next;
});
},
[parseNonNegativeInt]
);
// Tag input helpers for "Taken By" field
const addTakenByPerson = useCallback(
+15 -3
View File
@@ -10,6 +10,8 @@ export interface UseRefillReturn {
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
refillLoose: number;
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
usePrescriptionRefill: boolean;
setUsePrescriptionRefill: React.Dispatch<React.SetStateAction<boolean>>;
refillSaving: boolean;
refillHistory: RefillEntry[];
refillHistoryExpanded: boolean;
@@ -30,7 +32,8 @@ export interface UseRefillReturn {
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void
loadMeds: () => void,
usePrescription?: boolean
) => Promise<void>;
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
openRefillModal: () => void;
@@ -44,6 +47,7 @@ export function useRefill(): UseRefillReturn {
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
const [refillLoose, setRefillLoose] = useState(0);
const [usePrescriptionRefill, setUsePrescriptionRefill] = useState(false);
const [refillSaving, setRefillSaving] = useState(false);
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
@@ -75,7 +79,8 @@ export function useRefill(): UseRefillReturn {
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void
loadMeds: () => void,
usePrescription: boolean = false
) => {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
@@ -84,7 +89,7 @@ export function useRefill(): UseRefillReturn {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }),
});
if (res.ok) {
const data = await res.json();
@@ -94,11 +99,16 @@ export function useRefill(): UseRefillReturn {
...f,
packCount: String(data.newStock.packCount),
looseTablets: String(data.newStock.looseTablets),
prescriptionRemainingRefills:
data.prescription?.remainingRefills != null
? String(data.prescription.remainingRefills)
: f.prescriptionRemainingRefills,
}));
}
// Reset refill form
setRefillPacks(1);
setRefillLoose(0);
setUsePrescriptionRefill(false);
// Close refill modal via history back for proper back-button support
if (showRefillModal) {
window.history.back();
@@ -217,6 +227,8 @@ export function useRefill(): UseRefillReturn {
setRefillPacks,
refillLoose,
setRefillLoose,
usePrescriptionRefill,
setUsePrescriptionRefill,
refillSaving,
refillHistory,
refillHistoryExpanded,
+110 -44
View File
@@ -2,7 +2,7 @@
// useSettings Hook - Settings state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface Settings {
@@ -26,19 +26,24 @@ export interface Settings {
hasSmtpPassword: boolean;
lastAutoEmailSent: string | null;
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | null;
lastNotificationType: "stock" | "intake" | "prescription" | null;
lastNotificationChannel: "email" | "push" | "both" | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: "email" | "push" | "both" | null;
lastStockReminderMedNames: string | null;
lastPrescriptionReminderSent: string | null;
lastPrescriptionReminderChannel: "email" | "push" | "both" | null;
lastPrescriptionReminderMedNames: string | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
emailPrescriptionReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
expiryWarningDays: number;
@@ -72,12 +77,17 @@ const defaultSettings: Settings = {
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
lastPrescriptionReminderSent: null,
lastPrescriptionReminderChannel: null,
lastPrescriptionReminderMedNames: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30,
@@ -97,7 +107,7 @@ export interface UseSettingsReturn {
testShoutrrrResult: { success: boolean; message: string } | null;
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
saveSettings: (e?: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
hasUnsavedChanges: boolean;
@@ -152,6 +162,11 @@ export function useSettings(): UseSettingsReturn {
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
lastPrescriptionReminderChannel:
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
lastPrescriptionReminderMedNames:
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
}));
setSavedSettings((prev) => ({
...prev,
@@ -163,6 +178,11 @@ export function useSettings(): UseSettingsReturn {
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
lastPrescriptionReminderChannel:
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
lastPrescriptionReminderMedNames:
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
}));
})
.catch(() => {});
@@ -172,54 +192,45 @@ export function useSettings(): UseSettingsReturn {
return () => clearInterval(interval);
}, []);
const saveSettings = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Internal save function (no event needed)
const performSave = useCallback(
async (settingsToSave: Settings) => {
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
// Validate email if email notifications are enabled
if (effectiveEmailEnabled && settings.notificationEmail) {
const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
if (!emailRegex.test(settings.notificationEmail)) {
setTestEmailResult({ success: false, message: "Invalid email address" });
return;
}
}
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
setSettingsSaving(true);
setTestEmailResult(null);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
repeatDailyReminders: settingsToSave.repeatDailyReminders,
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
lowStockDays: settingsToSave.lowStockDays,
normalStockDays: settingsToSave.normalStockDays,
highStockDays: settingsToSave.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shoutrrrUrl: settingsToSave.shoutrrrUrl,
emailStockReminders: settingsToSave.emailStockReminders,
emailIntakeReminders: settingsToSave.emailIntakeReminders,
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
smtpUser: settings.smtpUser,
smtpPass: settings.smtpPass || undefined,
smtpFrom: settings.smtpFrom,
smtpSecure: settings.smtpSecure,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
smtpUser: settingsToSave.smtpUser,
smtpPass: settingsToSave.smtpPass || undefined,
smtpFrom: settingsToSave.smtpFrom,
smtpSecure: settingsToSave.smtpSecure,
};
await fetch("/api/settings", {
@@ -230,7 +241,7 @@ export function useSettings(): UseSettingsReturn {
}).catch(() => null);
const updatedSettings = {
...settings,
...settingsToSave,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled,
};
@@ -239,7 +250,62 @@ export function useSettings(): UseSettingsReturn {
setSavedSettings(updatedSettings);
setSettingsSaved(true);
},
[settings, i18n.language]
[i18n.language]
);
// Debounced auto-save: fires whenever settings change
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadDone = useRef(false);
useEffect(() => {
// Skip auto-save during initial load
if (!initialLoadDone.current) {
return;
}
// Don't save if nothing changed
if (JSON.stringify(settings) === JSON.stringify(savedSettings)) {
return;
}
// Don't save if thresholds are invalid
if (settings.reminderDaysBefore >= settings.lowStockDays || settings.lowStockDays >= settings.highStockDays) {
return;
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
performSave(settings);
}, 600);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [settings, savedSettings, performSave]);
// Mark initial load as done after first settings load completes
useEffect(() => {
if (!settingsLoading && !initialLoadDone.current) {
// Use a small delay to ensure savedSettings is set
const t = setTimeout(() => {
initialLoadDone.current = true;
}, 100);
return () => clearTimeout(t);
}
}, [settingsLoading]);
// Legacy saveSettings wrapper (kept for compatibility)
const saveSettings = useCallback(
async (e?: React.FormEvent) => {
if (e) e.preventDefault();
await performSave(settings);
},
[settings, performSave]
);
const testEmail = useCallback(async () => {
+31 -3
View File
@@ -88,6 +88,15 @@
"criticalMeds_other": "{{count}} Medikamente kritisch",
"lowMeds": "{{count}} Medikament knapp",
"lowMeds_other": "{{count}} Medikamente knapp",
"prescriptionNeeds": "Rezept knapp",
"prescriptionLowMeds": "{{count}} Rezept knapp",
"prescriptionLowMeds_other": "{{count}} Rezepte knapp",
"prescriptionCriticalMeds": "{{count}} Rezept aufgebraucht",
"prescriptionCriticalMeds_other": "{{count}} Rezepte aufgebraucht",
"needsPrescriptionRefill": "Rezept nachfüllen nötig",
"usedBy": "Verwendet von",
"refillsLeft": "{{count}} Nachfüllung übrig",
"refillsLeft_other": "{{count}} Nachfüllungen übrig",
"daysLeft": "{{days}} Tag übrig",
"daysLeft_other": "{{days}} Tage übrig",
"needsRefill": "Nachfüllen nötig"
@@ -133,6 +142,11 @@
"editEntry": "Medikament bearbeiten",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
"sections": {
"general": "Allgemein",
"stock": "Bestand & Dosis",
"prescription": "Rezept"
},
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
"takenBy": "Eingenommen von",
@@ -206,6 +220,7 @@
"push": "Push",
"stockReminders": "Bestands-Erinnerungen",
"intakeReminders": "Einnahme-Erinnerungen",
"prescriptionReminders": "Rezept-Erinnerungen",
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
@@ -228,7 +243,7 @@
},
"schedule": {
"title": "Erinnerungsplan",
"stockCheck": "Bestandsprüfung",
"stockCheck": "Bestands- & Rezeptprüfung",
"dailyAt6": "Täglich um 6:00 Uhr",
"intakeCheck": "Einnahmeprüfung",
"15minBefore": "15 Min. vor geplanter Zeit",
@@ -236,6 +251,7 @@
"lastSent": "Letzte Benachrichtigung",
"lastStockSent": "Letzte Bestands-Erinnerung",
"lastIntakeSent": "Letzte Einnahme-Erinnerung",
"lastPrescriptionSent": "Letzte Rezept-Erinnerung",
"envHint": "Diese Werte können über REMINDER_HOUR und REMINDER_MINUTES_BEFORE in .env konfiguriert werden"
},
"stock": {
@@ -258,7 +274,8 @@
},
"stockReminder": {
"title": "Bestands-Erinnerung",
"description": "Benachrichtigung wenn Medikamentenbestand erreicht",
"description": "Bestands-Erinnerungen aktivieren",
"infoTooltip": "Benachrichtigungen umfassen alle Medikamente mit Niedrig- oder Kritisch-Status. Niedrig: Bestand wird knapp. Kritisch: Bestand ist kritisch niedrig — bald nachbestellen.",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
},
@@ -364,6 +381,7 @@
"sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
"saveFailed": "Speichern fehlgeschlagen",
"networkError": "Netzwerkfehler",
"saving": "Wird gespeichert...",
"unsavedChanges": {
@@ -468,6 +486,7 @@
"downloadFilename": "medassist-export"
},
"refill": {
"saveFirst": "Speichere das Medikament zuerst, um Nachfüllen zu aktivieren",
"title": "Nachfüllen",
"packs": "Packungen hinzufügen",
"pillsToAdd": "Tabletten hinzufügen",
@@ -482,7 +501,16 @@
"packsAdded_other": "{{count}} Packungen",
"pillsAdded": "{{count}} Tablette",
"pillsAdded_other": "{{count}} Tabletten",
"button": "Nachfüllen"
"button": "Nachfüllen",
"viaPrescription": "Rezept"
},
"prescription": {
"enabled": "Rezept verfolgen",
"authorizedRefills": "Genehmigte Nachfüllungen",
"remainingRefills": "Verbleibende Nachfüllungen",
"lowThreshold": "Schwelle für Rezept-Erinnerung",
"expiryDate": "Rezeptablauf",
"useForRefill": "Rezept-Nachfüllung verwenden"
},
"editStock": {
"title": "Bestand korrigieren",
+31 -3
View File
@@ -88,6 +88,15 @@
"criticalMeds_other": "{{count}} medications critical",
"lowMeds": "{{count}} medication low",
"lowMeds_other": "{{count}} medications low",
"prescriptionNeeds": "Prescription low",
"prescriptionLowMeds": "{{count}} prescription low",
"prescriptionLowMeds_other": "{{count}} prescriptions low",
"prescriptionCriticalMeds": "{{count}} prescription empty",
"prescriptionCriticalMeds_other": "{{count}} prescriptions empty",
"needsPrescriptionRefill": "Needs prescription refill",
"usedBy": "Used by",
"refillsLeft": "{{count}} refill left",
"refillsLeft_other": "{{count}} refills left",
"daysLeft": "{{days}} day left",
"daysLeft_other": "{{days}} days left",
"needsRefill": "Needs refill"
@@ -133,6 +142,11 @@
"editEntry": "Edit medication",
"newEntry": "New medication",
"badge": "Packs + loose pills",
"sections": {
"general": "General",
"stock": "Stock & Dose",
"prescription": "Prescription"
},
"commercialName": "Commercial Name",
"genericName": "Generic Name",
"takenBy": "Taken by",
@@ -206,6 +220,7 @@
"push": "Push",
"stockReminders": "Stock Reminders",
"intakeReminders": "Intake Reminders",
"prescriptionReminders": "Prescription Reminders",
"enableHint": "Enable at least one channel below to receive notifications.",
"skipTakenDoses": "Skip reminders for taken doses",
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
@@ -228,7 +243,7 @@
},
"schedule": {
"title": "Reminder Schedule",
"stockCheck": "Stock check",
"stockCheck": "Stock & prescription check",
"dailyAt6": "Daily at 6:00 AM",
"intakeCheck": "Intake check",
"15minBefore": "15 min before scheduled time",
@@ -236,6 +251,7 @@
"lastSent": "Last notification sent",
"lastStockSent": "Last stock reminder",
"lastIntakeSent": "Last intake reminder",
"lastPrescriptionSent": "Last prescription reminder",
"envHint": "These values can be configured via REMINDER_HOUR and REMINDER_MINUTES_BEFORE in .env"
},
"stock": {
@@ -258,7 +274,8 @@
},
"stockReminder": {
"title": "Stock Reminder",
"description": "Sends notification when medication stock reaches",
"description": "Enable stock reminders",
"infoTooltip": "Notifications include all medications with Low or Critical stock status. Low: stock is running low. Critical: stock is critically low — reorder soon.",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
},
@@ -364,6 +381,7 @@
"sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
"saveFailed": "Failed to save",
"networkError": "Network error",
"saving": "Saving...",
"unsavedChanges": {
@@ -468,6 +486,7 @@
"downloadFilename": "medassist-export"
},
"refill": {
"saveFirst": "Save medication first to enable refill",
"title": "Refill",
"packs": "Packs to add",
"pillsToAdd": "Pills to add",
@@ -482,7 +501,16 @@
"packsAdded_other": "{{count}} packs",
"pillsAdded": "{{count}} pill",
"pillsAdded_other": "{{count}} pills",
"button": "Refill"
"button": "Refill",
"viaPrescription": "Prescription"
},
"prescription": {
"enabled": "Track prescription",
"authorizedRefills": "Authorized refills",
"remainingRefills": "Remaining refills",
"lowThreshold": "Low-refill reminder threshold",
"expiryDate": "Prescription expiry",
"useForRefill": "Use prescription refill"
},
"editStock": {
"title": "Correct Stock",
+216 -114
View File
@@ -79,7 +79,7 @@ function NotificationBellIcon() {
export function getReminderStatusData(
reminderDaysBefore: number,
lowStockDays: number,
lowCoverage: Coverage[],
_allLowCoverage: Coverage[],
allCoverage: Coverage[],
lastAutoEmailSent: string | null,
lastNotificationType: string | null,
@@ -97,12 +97,30 @@ export function getReminderStatusData(
lastStockSent: { date: string; medNames: string | null } | null;
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
} {
const criticalCount = lowCoverage.length;
const lowCount = allCoverage.filter((c) => {
if (c.medsLeft <= 0) return false;
if (c.daysLeft === null) return false;
return c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore;
}).length;
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
for (const c of allCoverage) {
if (c.medsLeft <= 0) {
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
continue;
}
if (c.daysLeft === null) continue;
const roundedDaysLeft = Math.round(c.daysLeft);
const isCritical = c.daysLeft <= reminderDaysBefore;
const isLow = c.daysLeft < lowStockDays;
if (!isCritical && !isLow) continue;
const existing = lowStockMap.get(c.name);
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
}
}
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
// Determine status
let status: { text: string; className: string };
@@ -123,34 +141,6 @@ export function getReminderStatusData(
};
}
// Collect all low stock medications (critical + low), deduplicated by name
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
// Add critical meds (from lowCoverage - these are ≤3 days)
for (const c of lowCoverage) {
if (c.daysLeft !== null) {
const existing = lowStockMap.get(c.name);
if (!existing || c.daysLeft < existing.daysLeft) {
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
}
}
}
// Add low but not critical meds
for (const c of allCoverage) {
if (c.medsLeft <= 0) continue;
if (c.daysLeft === null) continue;
if (c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore) {
const existing = lowStockMap.get(c.name);
if (!existing || c.daysLeft < existing.daysLeft) {
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
}
}
}
// Convert to array and sort by days left (most urgent first)
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
// Parse last stock reminder sent info (from dedicated stock tracking columns)
let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastStockReminderSent) {
@@ -252,45 +242,117 @@ export function DashboardPage() {
const intakeRemindersEnabled =
(settings.emailEnabled && settings.emailIntakeReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled;
const prescriptionRemindersEnabled =
(settings.emailEnabled && settings.emailPrescriptionReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrPrescriptionReminders);
const prescriptionLowMeds = meds
.filter((med) => {
if (!med.prescriptionEnabled) return false;
const remaining = med.prescriptionRemainingRefills ?? 0;
const threshold = med.prescriptionLowRefillThreshold ?? 1;
return remaining <= threshold;
})
.map((med) => ({
id: med.id,
name: med.name,
remainingRefills: med.prescriptionRemainingRefills ?? 0,
threshold: med.prescriptionLowRefillThreshold ?? 1,
}))
.sort((a, b) => a.remainingRefills - b.remainingRefills);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled;
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
const prescriptionStatus =
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
? {
text:
prescriptionEmptyCount > 0
? t("dashboard.reminders.prescriptionCriticalMeds", { count: prescriptionEmptyCount })
: t("dashboard.reminders.prescriptionLowMeds", { count: prescriptionLowMeds.length }),
className: prescriptionEmptyCount > 0 ? "danger" : "warning",
}
: null;
// Manual reminder send state
const [sendingReminder, setSendingReminder] = useState(false);
const [reminderResult, setReminderResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendManualReminder() {
if (!stockRemindersEnabled || reminderData.lowStockMeds.length === 0) return;
const sendableStock = stockRemindersEnabled && reminderData.lowStockMeds.length > 0;
const sendablePrescription = prescriptionRemindersEnabled && prescriptionLowMeds.length > 0;
if (!sendableStock && !sendablePrescription) return;
setSendingReminder(true);
setReminderResult(null);
try {
const lowStock = reminderData.lowStockMeds.map((m) => {
const cov = coverage.all.find((c) => c.name === m.name);
return {
name: m.name,
medsLeft: cov?.medsLeft ?? 0,
daysLeft: m.daysLeft,
depletionDate: cov?.depletionDate ?? null,
isCritical: m.isCritical,
};
});
const messages: string[] = [];
const errors: string[] = [];
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
}),
});
const data = await res.json();
if (res.ok) {
setReminderResult({ success: true, message: data.message || t("common.sent") });
// Refresh settings so "Last stock reminder" row appears immediately
if (sendableStock) {
const lowStock = reminderData.lowStockMeds.map((m) => {
const cov = coverage.all.find((c) => c.name === m.name);
return {
name: m.name,
medsLeft: cov?.medsLeft ?? 0,
daysLeft: m.daysLeft,
depletionDate: cov?.depletionDate ?? null,
isCritical: m.isCritical,
};
});
const stockRes = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
}),
});
const stockData = await stockRes.json();
if (stockRes.ok) {
messages.push(stockData.message || t("common.sent"));
} else {
errors.push(stockData.error || t("common.sendFailed"));
}
}
if (sendablePrescription) {
const prescriptionLow = prescriptionLowMeds.map((med) => {
const fullMed = meds.find((m) => m.id === med.id);
return {
name: med.name,
remainingRefills: med.remainingRefills,
threshold: med.threshold,
expiryDate: fullMed?.prescriptionExpiryDate ?? null,
};
});
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
prescriptionLow,
}),
});
const prescriptionData = await prescriptionRes.json();
if (prescriptionRes.ok) {
messages.push(prescriptionData.message || t("common.sent"));
} else {
errors.push(prescriptionData.error || t("common.sendFailed"));
}
}
if (messages.length > 0) {
setReminderResult({ success: true, message: messages.join(" • ") });
loadSettings();
} else {
setReminderResult({ success: false, message: data.error || t("common.sendFailed") });
setReminderResult({ success: false, message: errors.join(" • ") || t("common.sendFailed") });
}
} catch {
setReminderResult({ success: false, message: t("common.networkError") });
@@ -307,9 +369,15 @@ export function DashboardPage() {
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
{stockRemindersEnabled && (
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
)}
{prescriptionStatus && (
<span className={`status-chip small ${prescriptionStatus.className}`}>{prescriptionStatus.text}</span>
)}
</div>
{(reminderData.lowStockMeds.length > 0 ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details">
@@ -346,27 +414,54 @@ export function DashboardPage() {
</span>
</div>
)}
{prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsPrescriptionRefill")}:</span>
<span className="reminder-status-value">
{prescriptionLowMeds.map((med, idx) => {
const medication = meds.find((m) => m.id === med.id);
const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
return (
<span key={med.id}>
{idx > 0 && ", "}
<span className={`reminder-days-left ${textClass}`}>
{t("prescription.remainingRefills")}: {med.remainingRefills} ·{" "}
{t("dashboard.reminders.usedBy")}:{" "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)}
>
{med.name}
</span>
</span>
</span>
);
})}
</span>
</div>
)}
{stockRemindersEnabled && reminderData.lastStockSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastStockSent.medNames &&
(() => {
// Extract first med name (medNames may be "Name (+N)")
const rawName = reminderData.lastStockSent!.medNames!;
const firstName = rawName.replace(/\s*\(\+\d+\)$/, "");
const suffix = rawName.includes("(+") ? rawName.slice(firstName.length) : "";
const medication = meds.find((m) => m.name === firstName);
return medication ? (
<>
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{firstName}
const names = reminderData.lastStockSent!.medNames!.split(", ");
return names.map((name, idx) => {
const medication = meds.find((m) => m.name === name);
return (
<span key={name}>
{idx > 0 && ", "}
{medication ? (
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{name}
</span>
) : (
<span className="reminder-med-name">{name}</span>
)}
</span>
{suffix && <span className="reminder-med-name">{suffix}</span>}
</>
) : (
<span className="reminder-med-name">{rawName}</span>
);
);
});
})()}
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
</span>
@@ -396,7 +491,8 @@ export function DashboardPage() {
)}
</div>
)}
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
{((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
<div className="reminder-send-row">
<button type="button" className="ghost" onClick={sendManualReminder} disabled={sendingReminder}>
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
@@ -527,20 +623,26 @@ export function DashboardPage() {
</span>
))}
</span>
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
🔔
{(() => {
const hasIntakeReminders =
med?.intakes?.some((i) => i.intakeRemindersEnabled) ?? med?.intakeRemindersEnabled;
return (
(hasIntakeReminders || med?.notes) && (
<span className="med-icons">
{hasIntakeReminders && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
🔔
</span>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
)}
</span>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
)}
</span>
)}
)
);
})()}
</span>
<span data-label={t("table.stock")} className={textClass}>
{med?.packageType === "bottle"
@@ -698,14 +800,6 @@ export function DashboardPage() {
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -726,6 +820,14 @@ export function DashboardPage() {
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
@@ -905,14 +1007,6 @@ export function DashboardPage() {
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -937,6 +1031,14 @@ export function DashboardPage() {
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
@@ -1092,14 +1194,6 @@ export function DashboardPage() {
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1120,6 +1214,14 @@ export function DashboardPage() {
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
File diff suppressed because it is too large Load Diff
+78 -43
View File
@@ -9,10 +9,6 @@ export function SettingsPage() {
settings,
setSettings,
settingsLoading,
settingsSaving,
settingsSaved,
saveSettings,
settingsChanged,
// Email testing
testEmail,
testingEmail,
@@ -41,7 +37,7 @@ export function SettingsPage() {
{settingsLoading ? (
<p>{t("settings.loading")}</p>
) : (
<form className="settings-form" onSubmit={saveSettings}>
<div className="settings-form">
{/* Language */}
<article className="card">
<div className="card-head">
@@ -52,7 +48,16 @@ export function SettingsPage() {
<span className="setting-label">{t("settings.language.select")}</span>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
className="language-select"
>
<option value="en">🇬🇧 English</option>
@@ -132,6 +137,37 @@ export function SettingsPage() {
</label>
</div>
</div>
<div className="matrix-row">
<div className="matrix-label">{t("settings.notifications.prescriptionReminders")}</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
}
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="matrix-cell">
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled
? settings.shoutrrrPrescriptionReminders
: false
}
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</div>
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
<p className="hint-text">{t("settings.notifications.enableHint")}</p>
@@ -196,10 +232,9 @@ export function SettingsPage() {
</span>
</label>
<input
type="number"
min="5"
max="480"
step="5"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.reminderRepeatIntervalMinutes}
onChange={(e) =>
setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value, 10) || 30 })
@@ -218,10 +253,9 @@ export function SettingsPage() {
</span>
</label>
<input
type="number"
min="1"
max="20"
step="1"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.maxNaggingReminders ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
@@ -243,7 +277,9 @@ export function SettingsPage() {
<div className="setting-row compact">
<label className="setting-label">
{t("settings.stockReminder.description")}{" "}
<span className="status-chip small danger">{t("status.criticalStock")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.stockReminder.infoTooltip")}>
</span>{" "}
</label>
<label
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
@@ -276,6 +312,7 @@ export function SettingsPage() {
<span className="toggle-slider"></span>
</label>
</div>
<div className="setting-row compact" style={{ marginTop: "4px" }}>
<label className="setting-label">
{t("settings.stockReminder.repeatDaily")}
@@ -320,6 +357,7 @@ export function SettingsPage() {
emailEnabled: false,
emailStockReminders: false,
emailIntakeReminders: false,
emailPrescriptionReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
});
@@ -389,6 +427,7 @@ export function SettingsPage() {
shoutrrrEnabled: false,
shoutrrrStockReminders: false,
shoutrrrIntakeReminders: false,
shoutrrrPrescriptionReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
});
@@ -497,6 +536,20 @@ export function SettingsPage() {
</span>
</div>
)}
{settings.lastPrescriptionReminderSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
<span className="schedule-value">
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
</div>
</article>
@@ -565,9 +618,9 @@ export function SettingsPage() {
</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="364"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
@@ -592,9 +645,9 @@ export function SettingsPage() {
</span>
<div className="input-with-tooltip">
<input
type="number"
min="2"
max="365"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
@@ -612,9 +665,9 @@ export function SettingsPage() {
</span>
<div className="input-with-tooltip">
<input
type="number"
min="3"
max="730"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
@@ -747,25 +800,7 @@ export function SettingsPage() {
</div>
</div>
</article>
<div className="form-footer">
<button
type="submit"
disabled={
settingsSaving ||
(!settingsChanged && settingsSaved) ||
settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays
}
>
{settingsSaving
? t("common.saving")
: settingsSaved && !settingsChanged
? t("common.saved")
: t("settings.saveSettings")}
</button>
</div>
</form>
</div>
)}
{/* Import Confirmation Modal */}
+195 -86
View File
@@ -319,7 +319,7 @@ body.modal-open {
.reminder-status-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.5rem;
padding-left: 1.75rem;
}
@@ -375,8 +375,9 @@ body.modal-open {
.reminder-send-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding-left: 1.75rem;
padding-top: 0.25rem;
}
@@ -435,6 +436,9 @@ body.modal-open {
.reminder-low-stock-list {
padding-left: 0;
}
.reminder-send-row {
padding-left: 0;
}
}
.tabs {
@@ -602,6 +606,26 @@ body.modal-open {
flex-direction: column;
gap: 0.75rem;
}
/* Grid view for medications page */
.med-grid-wrapper {
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.med-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (max-width: 768px) {
.med-grid {
grid-template-columns: 1fr;
}
}
.med-row {
display: flex;
flex-direction: column;
@@ -823,20 +847,10 @@ body.modal-open {
}
}
.blisters h3 {
margin: 0;
}
.gap {
gap: 0.6rem;
}
/* Blisters header actions */
.blisters-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Inline checkbox for compact layout */
.inline-checkbox {
display: flex !important;
@@ -1069,6 +1083,82 @@ textarea.auto-resize {
.form-grid .full {
grid-column: 1 / -1;
}
.form-category {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem 1.25rem;
padding: 0.95rem;
border: 1px solid var(--border-primary);
border-radius: 12px;
background: var(--bg-tertiary);
}
.form-category-title {
grid-column: 1 / -1;
margin: 0;
color: var(--accent-light);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.form-category .full {
grid-column: 1 / -1;
}
.form-category > label:not(.full) {
align-self: end;
}
.form-category-header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.form-category-header .form-category-title {
margin: 0;
}
/* Intake section */
.intake-section .blister-row {
grid-column: 1 / -1;
}
/* Image section */
.image-section {
grid-template-columns: 1fr;
}
.image-section .image-preview {
grid-column: 1 / -1;
}
.image-section input[type="file"] {
grid-column: 1 / -1;
font-size: 0.9rem;
}
.image-section input[type="file"]::file-selector-button {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-right: 0.75rem;
font-weight: 500;
}
.image-section input[type="file"]::file-selector-button:hover {
background: var(--accent-light);
}
.form-grid .optional-label {
text-transform: none;
font-weight: 400;
@@ -1926,6 +2016,7 @@ textarea.auto-resize {
.status-chip.small {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
word-spacing: 0.1rem;
}
@media (max-width: 760px) {
@@ -3354,13 +3445,6 @@ textarea.auto-resize {
margin-bottom: 0.75rem;
}
/* Image upload section */
.image-upload-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
}
.image-preview {
display: flex;
align-items: center;
@@ -3376,26 +3460,6 @@ textarea.auto-resize {
border: 1px solid var(--border-primary);
}
.image-upload-section input[type="file"] {
margin-top: 0.5rem;
font-size: 0.9rem;
}
.image-upload-section input[type="file"]::file-selector-button {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-right: 0.75rem;
font-weight: 500;
}
.image-upload-section input[type="file"]::file-selector-button:hover {
background: var(--accent-light);
}
/* Modal styles */
.modal-overlay {
position: fixed;
@@ -5168,30 +5232,68 @@ a.about-version-link:hover {
}
}
/* Inline refill form in edit modal */
.refill-form-inline {
/* Refill: submit row (button + pill preview) */
.refill-submit-row {
display: flex;
align-items: flex-end;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.refill-form-inline label {
flex: 1;
min-width: 100px;
.refill-submit-row button {
height: 42px;
padding: 0 2rem;
min-width: 120px;
flex-shrink: 0;
}
.refill-form-inline label input {
/* Refill: prescription toggle row */
.refill-prescription-row {
display: flex;
align-items: center;
gap: 1rem;
padding-top: 0.25rem;
border-top: 1px solid var(--border-primary);
}
.refill-prescription-row .refill-prescription-toggle {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: start;
gap: 0.6rem;
margin: 0;
text-transform: none;
letter-spacing: normal;
line-height: 1.2;
font-size: 0.95rem;
color: var(--text-primary);
cursor: pointer;
white-space: normal;
width: 100%;
}
.refill-form-inline button {
.refill-prescription-row .refill-prescription-toggle input {
width: 18px;
height: 18px;
min-width: 18px;
margin: 0;
flex-shrink: 0;
margin-bottom: 0;
align-self: flex-end;
height: 42px;
padding: 0 1rem;
min-width: 110px;
}
.refill-prescription-label-text {
min-width: 0;
overflow-wrap: anywhere;
}
.refill-prescription-row .refill-prescription-toggle input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.refill-remaining-badge {
font-size: 0.85rem;
font-weight: 600;
color: var(--success);
margin-left: auto;
}
.refill-preview {
@@ -5212,11 +5314,7 @@ a.about-version-link:hover {
}
.refill-section {
border-left: 3px solid var(--success);
background: var(--bg-secondary);
margin-top: 1.5rem;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 8px 8px 0;
margin-top: 0;
}
.refill-section .refill-title {
@@ -5226,23 +5324,33 @@ a.about-version-link:hover {
margin: 0 0 0.75rem 0;
}
.refill-section .refill-form-inline button {
.refill-section .refill-submit-row button {
background: var(--success);
color: white;
border: none;
}
.refill-section .refill-form-inline button:hover:not(:disabled) {
.refill-section .refill-submit-row button:hover:not(:disabled) {
background: var(--success-hover, #3aa865);
filter: brightness(1.1);
}
.refill-section .refill-form-inline button:disabled {
.refill-section .refill-submit-row button:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
.refill-unavailable {
grid-column: 1 / -1;
margin: 0;
padding: 0.6rem 0.75rem;
border-radius: 8px;
border: 1px dashed var(--border-secondary);
color: var(--text-secondary);
font-size: 0.88rem;
}
/* =============================================================================
Edit Stock Modal (Correction)
============================================================================= */
@@ -5573,6 +5681,31 @@ a.about-version-link:hover {
width: 100%;
}
.mobile-edit-form .form-category {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem 1rem;
padding: 0.8rem;
}
.mobile-edit-form .refill-prescription-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.6rem;
align-items: start;
width: 100%;
}
.mobile-edit-form .refill-prescription-row .refill-prescription-toggle {
grid-template-columns: 18px minmax(0, 1fr);
line-height: 1.3;
max-width: 100%;
}
.mobile-edit-form .refill-remaining-badge {
margin-left: 0;
justify-self: start;
}
.mobile-edit-form.form-grid input,
.mobile-edit-form.form-grid textarea,
.mobile-edit-form.form-grid select {
@@ -5580,30 +5713,6 @@ a.about-version-link:hover {
width: 100%;
}
/* Mobile Blister/Intake Schedule Section */
.mobile-edit-form .blister-section {
padding: 1rem;
margin-top: 0.5rem;
}
.mobile-edit-form .blister-section legend {
font-size: 0.95rem;
padding: 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
width: 100%;
}
.mobile-edit-form .blister-section legend .toggle-switch {
margin-left: auto;
}
.mobile-edit-form .blister-section legend .legend-hint {
margin-left: 0;
}
.mobile-edit-form .blister-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -144,6 +144,36 @@ describe("MedDetailModal", () => {
expect(screen.getByText("Test notes")).toBeInTheDocument();
});
it("shows prescription details section when prescription is enabled", () => {
const med: Medication = {
...mockMedication,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 5,
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
prescriptionExpiryDate: "2026-12-31",
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText(/form\.sections\.prescription/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.authorizedRefills/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.remainingRefills/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.lowThreshold/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.expiryDate/i)).toBeInTheDocument();
});
it("does not show prescription details section when prescription is disabled", () => {
const med: Medication = {
...mockMedication,
prescriptionEnabled: false,
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.queryByText(/form\.sections\.prescription/i)).not.toBeInTheDocument();
});
it("displays schedule information", () => {
render(<MedDetailModal {...defaultProps} />);
@@ -247,7 +277,7 @@ describe("MedDetailModal with refill modal", () => {
const submitBtn = document.querySelector(".refill-modal .modal-footer .success") as HTMLButtonElement;
fireEvent.click(submitBtn);
expect(onSubmitRefill).toHaveBeenCalledWith(mockMedication.id);
expect(onSubmitRefill).toHaveBeenCalledWith(mockMedication.id, undefined);
});
it("disables refill submit button when no pills are entered", () => {
@@ -460,7 +460,7 @@ describe("MobileEditModal field callbacks", () => {
const onHandleValueChange = vi.fn();
render(<MobileEditModal {...defaultProps} onHandleValueChange={onHandleValueChange} />);
const packCountInput = document.querySelector('input[type="number"][min="0"]') as HTMLInputElement;
const packCountInput = document.querySelector('input[type="text"][inputmode="numeric"]') as HTMLInputElement;
fireEvent.change(packCountInput, { target: { value: "4" } });
expect(onHandleValueChange).toHaveBeenCalledWith("packCount", "4");
+8 -2
View File
@@ -138,13 +138,19 @@ describe("useRefill", () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
expect(fetch).toHaveBeenNthCalledWith(
1,
"/api/medications/1/refill",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0 }),
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, usePrescription: false }),
})
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/medications/1/refills",
expect.objectContaining({ credentials: "include" })
);
expect(mockSetForm).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
+2 -3
View File
@@ -84,7 +84,7 @@ describe("useSettings", () => {
expect(result.current.settingsSaved).toBe(true);
});
it("validates email before saving", async () => {
it("keeps email channel enabled when recipient is non-empty", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
@@ -111,8 +111,7 @@ describe("useSettings", () => {
await result.current.saveSettings(mockEvent);
});
expect(result.current.testEmailResult?.success).toBe(false);
expect(result.current.testEmailResult?.message).toContain("Invalid email");
expect(result.current.settings.emailEnabled).toBe(true);
});
it("tests email notification", async () => {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -52,6 +52,11 @@ export type Medication = {
imageUrl?: string | null;
expiryDate?: string | null;
notes?: string | null;
prescriptionEnabled?: boolean;
prescriptionAuthorizedRefills?: number | null;
prescriptionRemainingRefills?: number | null;
prescriptionLowRefillThreshold?: number;
prescriptionExpiryDate?: string | null;
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
updatedAt: string | number | null;
@@ -74,6 +79,7 @@ export type RefillEntry = {
id: number;
packsAdded: number;
loosePillsAdded: number;
usedPrescription?: boolean;
refillDate: string;
};
@@ -110,6 +116,11 @@ export type FormState = {
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
expiryDate: string;
notes: string;
prescriptionEnabled: boolean;
prescriptionAuthorizedRefills: string;
prescriptionRemainingRefills: string;
prescriptionLowRefillThreshold: string;
prescriptionExpiryDate: string;
intakeRemindersEnabled: boolean; // Deprecated, kept for backward compat
blisters: FormBlister[]; // Legacy form format
intakes: FormIntake[]; // New form format with per-intake takenBy
@@ -154,6 +165,7 @@ export type ScheduleEvent = {
when: number;
isPast: boolean;
takenBy: string | null; // Per-intake takenBy (single person or null)
intakeRemindersEnabled: boolean; // Per-intake reminder flag
};
export type BlisterStock = {