Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d657558f7 | |||
| 0c28999c89 | |||
| 2296303236 | |||
| 9a2d42b8b9 | |||
| 088a6c1a05 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.14.2",
|
"version": "1.14.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -817,11 +817,12 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
const rawTakenAt = Number(dose.takenAt);
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
let takenAtMs: number;
|
||||||
? rawTakenAt < 1_000_000_000_000
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
? rawTakenAt * 1000
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
: rawTakenAt
|
} else {
|
||||||
: new Date(dose.takenAt).getTime();
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -876,11 +877,14 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
const peopleForThisIntake = intakePerson
|
let peopleForThisIntake: Array<string | null>;
|
||||||
? [intakePerson]
|
if (intakePerson) {
|
||||||
: fallbackPeople.length > 0
|
peopleForThisIntake = [intakePerson];
|
||||||
? fallbackPeople
|
} else if (fallbackPeople.length > 0) {
|
||||||
: [null];
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
let timeBasedConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
let lastAutoConsumedDateMs = 0;
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
const newPackCount = med.packCount + effectivePacksAdded;
|
const newPackCount = med.packCount + effectivePacksAdded;
|
||||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0;
|
let consumedRefills = 0;
|
||||||
|
if (usePrescription) {
|
||||||
|
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||||
|
}
|
||||||
const newRemainingRefills = usePrescription
|
const newRemainingRefills = usePrescription
|
||||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
@@ -40,6 +40,56 @@ function escapeHtml(text: string): string {
|
|||||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||||
|
|
||||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||||
|
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
||||||
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function ensureReminderLocksDir(): void {
|
||||||
|
if (!existsSync(reminderLocksDir)) {
|
||||||
|
mkdirSync(reminderLocksDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireReminderSendLock(lockKey: string): string | null {
|
||||||
|
ensureReminderLocksDir();
|
||||||
|
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
|
||||||
|
|
||||||
|
const tryCreateLock = (): boolean => {
|
||||||
|
try {
|
||||||
|
const fd = openSync(lockFilePath, "wx");
|
||||||
|
closeSync(fd);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(lockFilePath);
|
||||||
|
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; lock acquisition fails safely
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseReminderSendLock(lockFilePath: string | null): void {
|
||||||
|
if (!lockFilePath) return;
|
||||||
|
try {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
} catch {
|
||||||
|
// ignore release errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadReminderState(): ReminderState {
|
function loadReminderState(): ReminderState {
|
||||||
try {
|
try {
|
||||||
@@ -167,11 +217,12 @@ async function getMedicationsNeedingReminder(
|
|||||||
}
|
}
|
||||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
const rawTakenAt = Number(dose.takenAt);
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
let takenAtMs: number;
|
||||||
? rawTakenAt < 1_000_000_000_000
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
? rawTakenAt * 1000
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
: rawTakenAt
|
} else {
|
||||||
: new Date(dose.takenAt).getTime();
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +267,14 @@ async function getMedicationsNeedingReminder(
|
|||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [null];
|
let peopleForThisIntake: Array<string | null>;
|
||||||
|
if (intakePerson) {
|
||||||
|
peopleForThisIntake = [intakePerson];
|
||||||
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
let timeBasedConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
let lastAutoConsumedDateMs = 0;
|
let lastAutoConsumedDateMs = 0;
|
||||||
@@ -557,166 +615,184 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
logger.info(
|
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
if (!stockSendLock) {
|
||||||
);
|
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
|
||||||
|
} else {
|
||||||
let emailSuccess = false;
|
try {
|
||||||
let shoutrrrSuccess = false;
|
logger.info(
|
||||||
|
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
let emailSuccess = false;
|
||||||
const currentState = loadReminderState();
|
let shoutrrrSuccess = false;
|
||||||
const singleChannel = emailSuccess ? "email" : "push";
|
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
|
||||||
saveReminderState({
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
|
||||||
lastNotificationType: "stock",
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
if (stockEmailEnabled) {
|
||||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: "stock",
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||||
|
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(stockSendLock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
logger.info(
|
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
if (!prescriptionSendLock) {
|
||||||
);
|
logger.debug(
|
||||||
|
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||||
|
);
|
||||||
|
|
||||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||||
const lines = allPrescriptionLow.map((m) => {
|
const lines = allPrescriptionLow.map((m) => {
|
||||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||||
if (m.remainingRefills <= 0) {
|
if (m.remainingRefills <= 0) {
|
||||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||||
name: m.name,
|
name: m.name,
|
||||||
expirySuffix,
|
expirySuffix,
|
||||||
})}`;
|
})}`;
|
||||||
}
|
}
|
||||||
return `- ${t(tr.prescriptionReminder.line, {
|
return `- ${t(tr.prescriptionReminder.line, {
|
||||||
name: m.name,
|
name: m.name,
|
||||||
refills: m.remainingRefills,
|
refills: m.remainingRefills,
|
||||||
expirySuffix,
|
expirySuffix,
|
||||||
})}`;
|
})}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
let emailSuccess = false;
|
let emailSuccess = false;
|
||||||
let shoutrrrSuccess = false;
|
let shoutrrrSuccess = false;
|
||||||
|
|
||||||
if (prescriptionEmailEnabled) {
|
if (prescriptionEmailEnabled) {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const smtpUser = process.env.SMTP_USER;
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: smtpHost,
|
host: smtpHost,
|
||||||
port: smtpPort,
|
port: smtpPort,
|
||||||
secure: smtpSecure,
|
secure: smtpSecure,
|
||||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const subject =
|
const subject =
|
||||||
allPrescriptionLow.length === 1
|
allPrescriptionLow.length === 1
|
||||||
? tr.prescriptionReminder.subjectSingle
|
? tr.prescriptionReminder.subjectSingle
|
||||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||||
|
|
||||||
const bodyText =
|
const bodyText =
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
emptyRx.length > 0
|
||||||
const emptyAlert =
|
? tr.prescriptionReminder.descriptionEmpty
|
||||||
emptyRx.length === 1
|
: tr.prescriptionReminder.descriptionLow;
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
const emptyAlert =
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
emptyRx.length === 1
|
||||||
const lowAlert =
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
lowRx.length === 1
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
const lowAlert =
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
lowRx.length === 1
|
||||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
const tableRows = allPrescriptionLow
|
const tableRows = allPrescriptionLow
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const isEmpty = item.remainingRefills <= 0;
|
const isEmpty = item.remainingRefills <= 0;
|
||||||
const safeName = escapeHtml(item.name);
|
const safeName = escapeHtml(item.name);
|
||||||
const safeRefills = Number(item.remainingRefills) || 0;
|
const safeRefills = Number(item.remainingRefills) || 0;
|
||||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||||
return `
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<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; 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; ${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;">${safeThreshold}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
<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);">
|
<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>
|
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
||||||
@@ -756,80 +832,85 @@ async function checkAndSendReminderForUser(
|
|||||||
</div>
|
</div>
|
||||||
</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}` : ""}`;
|
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({
|
await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: settings.notificationEmail!,
|
to: settings.notificationEmail!,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
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 singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: "prescription",
|
||||||
|
lastNotificationChannel: channel,
|
||||||
});
|
});
|
||||||
emailSuccess = true;
|
|
||||||
} catch (error) {
|
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(prescriptionSendLock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 singleChannel = emailSuccess ? "email" : "push";
|
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
|
||||||
saveReminderState({
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
|
||||||
lastNotificationType: "prescription",
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
|
||||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||||
|
let schedulerStarted = false;
|
||||||
|
|
||||||
function scheduleNextCheck(logger: ServiceLogger): void {
|
function scheduleNextCheck(logger: ServiceLogger): void {
|
||||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||||
@@ -854,6 +935,11 @@ function scheduleNextCheck(logger: ServiceLogger): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startReminderScheduler(logger: ServiceLogger): void {
|
export function startReminderScheduler(logger: ServiceLogger): void {
|
||||||
|
if (schedulerStarted) {
|
||||||
|
logger.info(`[Reminder] Scheduler already started, skipping duplicate start call`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulerStarted = true;
|
||||||
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||||
|
|
||||||
// Check if we need to run immediately (missed today's check)
|
// Check if we need to run immediately (missed today's check)
|
||||||
@@ -873,9 +959,15 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
|||||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runReminderSchedulerNow(logger: ServiceLogger): Promise<void> {
|
||||||
|
logger.info(`[Reminder] Manual trigger: running reminder check now (${getTimezone()})`);
|
||||||
|
await checkAndSendReminder(logger);
|
||||||
|
}
|
||||||
|
|
||||||
export function stopReminderScheduler(): void {
|
export function stopReminderScheduler(): void {
|
||||||
if (schedulerTimeout) {
|
if (schedulerTimeout) {
|
||||||
clearTimeout(schedulerTimeout);
|
clearTimeout(schedulerTimeout);
|
||||||
schedulerTimeout = null;
|
schedulerTimeout = null;
|
||||||
}
|
}
|
||||||
|
schedulerStarted = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /import
|
// POST /import
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
|
||||||
app.post("/import", async (request, reply) => {
|
app.post("/import", async (request, reply) => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||||
const importData = request.body as any;
|
const importData = request.body as any;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify from "fastify";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type OidcMocks = {
|
type OidcMocks = {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
|||||||
* auth.spec.ts should keep importing from `@playwright/test` directly
|
* auth.spec.ts should keep importing from `@playwright/test` directly
|
||||||
* since it tests the unauthenticated flow.
|
* since it tests the unauthenticated flow.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{}>({
|
export const test = base.extend<object>({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
await setupAuthMeMock(page);
|
await setupAuthMeMock(page);
|
||||||
await use(page);
|
await use(page);
|
||||||
|
|||||||
Generated
+20
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.12.0",
|
"version": "1.14.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.12.0",
|
"version": "1.14.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.10",
|
"i18next": "^25.8.10",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
@@ -1735,6 +1736,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||||
|
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -3302,6 +3313,13 @@
|
|||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.14.2",
|
"version": "1.14.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
|||||||
+16
-17
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AboutModal,
|
AboutModal,
|
||||||
@@ -119,8 +119,6 @@ function AppContent() {
|
|||||||
// Medications
|
// Medications
|
||||||
meds,
|
meds,
|
||||||
loadMeds,
|
loadMeds,
|
||||||
// Settings
|
|
||||||
settings,
|
|
||||||
// Refill
|
// Refill
|
||||||
showRefillModal,
|
showRefillModal,
|
||||||
setShowRefillModal,
|
setShowRefillModal,
|
||||||
@@ -190,6 +188,17 @@ function AppContent() {
|
|||||||
// Local-only state (not shared across components)
|
// Local-only state (not shared across components)
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const closeProfile = useCallback(() => {
|
||||||
|
if (showProfile) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}, [showProfile]);
|
||||||
|
|
||||||
|
const closeAbout = useCallback(() => {
|
||||||
|
if (showAbout) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}, [showAbout]);
|
||||||
|
|
||||||
// Get centralized stockThresholds from context
|
// Get centralized stockThresholds from context
|
||||||
const { stockThresholds } = ctx;
|
const { stockThresholds } = ctx;
|
||||||
@@ -389,25 +398,15 @@ function AppContent() {
|
|||||||
openEditStockModal(selectedMed, coverage);
|
openEditStockModal(selectedMed, coverage);
|
||||||
};
|
};
|
||||||
|
|
||||||
function openProfile() {
|
const openProfile = useCallback(() => {
|
||||||
setShowProfile(true);
|
setShowProfile(true);
|
||||||
window.history.pushState({ modal: "profile" }, "");
|
window.history.pushState({ modal: "profile" }, "");
|
||||||
}
|
}, []);
|
||||||
function closeProfile() {
|
|
||||||
if (showProfile) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAbout() {
|
const openAbout = useCallback(() => {
|
||||||
setShowAbout(true);
|
setShowAbout(true);
|
||||||
window.history.pushState({ modal: "about" }, "");
|
window.history.pushState({ modal: "about" }, "");
|
||||||
}
|
}, []);
|
||||||
function closeAbout() {
|
|
||||||
if (showAbout) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: auth refresh callbacks intentionally coordinate via refs/guards */
|
||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
@@ -70,7 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
initialFetchDone.current = true;
|
initialFetchDone.current = true;
|
||||||
fetchAuthState();
|
fetchAuthState();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [fetchAuthState]);
|
||||||
|
|
||||||
// Proactively refresh token every 10 minutes to prevent expiration
|
// Proactively refresh token every 10 minutes to prevent expiration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +90,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => clearInterval(refreshInterval);
|
return () => clearInterval(refreshInterval);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, authState?.authEnabled]);
|
}, [user, authState?.authEnabled, refreshUser, tryRefreshToken]);
|
||||||
|
|
||||||
async function fetchAuthState(retryCount = 0) {
|
async function fetchAuthState(retryCount = 0) {
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export interface LightboxProps {
|
export interface LightboxProps {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -12,17 +11,6 @@ export interface LightboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
function handleOverlayClick(e: MouseEvent) {
|
function handleOverlayClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
@@ -31,7 +19,16 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
<div
|
||||||
|
className="lightbox-overlay"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="lightbox-container">
|
<div className="lightbox-container">
|
||||||
<button className="lightbox-close" onClick={onClose}>
|
<button className="lightbox-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
||||||
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
||||||
*/
|
*/
|
||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses label-styled wrappers with custom interactive rows */
|
||||||
|
/* biome-ignore-all lint/style/noNestedTernary: stock/preview rendering keeps explicit branch mapping */
|
||||||
|
|
||||||
import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil, Plus, X } from "lucide-react";
|
import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -474,7 +476,7 @@ export function MedDetailModal({
|
|||||||
const rawFull = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
const rawFull = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
||||||
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
||||||
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||||
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||||
setEditStockFullInput(raw);
|
setEditStockFullInput(raw);
|
||||||
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
||||||
onEditStockFullBlistersChange(normalized.full);
|
onEditStockFullBlistersChange(normalized.full);
|
||||||
@@ -503,7 +505,7 @@ export function MedDetailModal({
|
|||||||
const rawFull = Math.max(0, parseStockInput(editStockFullInput) + delta);
|
const rawFull = Math.max(0, parseStockInput(editStockFullInput) + delta);
|
||||||
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
||||||
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||||
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||||
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
||||||
onEditStockFullBlistersChange(normalized.full);
|
onEditStockFullBlistersChange(normalized.full);
|
||||||
onEditStockPartialBlisterPillsChange(normalized.partial);
|
onEditStockPartialBlisterPillsChange(normalized.partial);
|
||||||
@@ -560,7 +562,7 @@ export function MedDetailModal({
|
|||||||
const nextPartial = Math.max(0, parseStockInput(editStockPartialInput) + delta);
|
const nextPartial = Math.max(0, parseStockInput(editStockPartialInput) + delta);
|
||||||
const nextFull = Math.max(0, parseStockInput(editStockFullInput));
|
const nextFull = Math.max(0, parseStockInput(editStockFullInput));
|
||||||
const nextLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
const nextLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||||
const rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
|
const _rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
|
||||||
const normalized = normalizeBlisterStock(nextFull, nextPartial, nextLoose);
|
const normalized = normalizeBlisterStock(nextFull, nextPartial, nextLoose);
|
||||||
onEditStockFullBlistersChange(normalized.full);
|
onEditStockFullBlistersChange(normalized.full);
|
||||||
onEditStockPartialBlisterPillsChange(normalized.partial);
|
onEditStockPartialBlisterPillsChange(normalized.partial);
|
||||||
@@ -646,8 +648,11 @@ export function MedDetailModal({
|
|||||||
className="modal-overlay med-detail-overlay"
|
className="modal-overlay med-detail-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (showEditStockModal) return;
|
if (showEditStockModal || showImageLightbox || showRefillModal) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -815,35 +820,49 @@ export function MedDetailModal({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-schedules">
|
<div className="med-detail-schedules">
|
||||||
{selectedMed.blisters.map((blister, idx) => {
|
{(selectedMed.intakes && selectedMed.intakes.length > 0
|
||||||
// When using new intakes format with per-intake takenBy,
|
? selectedMed.intakes
|
||||||
// each intake already represents one person's dose — don't multiply.
|
: selectedMed.blisters.map((blister) => ({
|
||||||
// For legacy intakes (no per-intake takenBy), multiply by personCount.
|
usage: blister.usage,
|
||||||
const intake = selectedMed.intakes?.[idx];
|
every: blister.every,
|
||||||
const hasPerIntakeTakenBy = !!intake?.takenBy;
|
start: blister.start,
|
||||||
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
|
takenBy: null,
|
||||||
const totalUsage = blister.usage * personCount;
|
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
||||||
|
}))
|
||||||
|
).map((intake, idx) => {
|
||||||
|
const hasPerIntakeTakenBy = !!intake.takenBy;
|
||||||
|
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
||||||
|
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
||||||
|
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="med-schedule-item">
|
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
|
||||||
<span className="med-schedule-usage">
|
<span className="med-schedule-usage">
|
||||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{selectedMed.pillWeightMg &&
|
{selectedMed.pillWeightMg &&
|
||||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
</span>
|
</span>
|
||||||
<span className="med-schedule-freq">
|
<span className="med-schedule-freq">
|
||||||
{blister.every === 1 ? t("common.daily") : t("common.everyNDays", { count: blister.every })}
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||||
</span>
|
</span>
|
||||||
{hasPerIntakeTakenBy && intake.takenBy && (
|
{hasPerIntakeTakenBy && (
|
||||||
<span className="med-schedule-person">{intake.takenBy}</span>
|
<span className="med-schedule-person">
|
||||||
|
{intake.takenBy}
|
||||||
|
{showIntakeBell && (
|
||||||
|
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||||
|
<Bell size={13} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{intake?.intakeRemindersEnabled && (
|
{!hasPerIntakeTakenBy && showIntakeBell && (
|
||||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||||
<Bell size={13} aria-hidden="true" />
|
<Bell size={13} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="med-schedule-time">
|
<span className="med-schedule-time">
|
||||||
{t("modal.at")}{" "}
|
{t("modal.at")}{" "}
|
||||||
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Handles new medication creation and editing existing medications
|
* Handles new medication creation and editing existing medications
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||||
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -91,9 +92,9 @@ export function MobileEditModal({
|
|||||||
onAddTakenByPerson,
|
onAddTakenByPerson,
|
||||||
onRemoveTakenByPerson,
|
onRemoveTakenByPerson,
|
||||||
onTakenByKeyDown,
|
onTakenByKeyDown,
|
||||||
onSetBlisterValue,
|
_onSetBlisterValue,
|
||||||
onAddBlister,
|
_onAddBlister,
|
||||||
onRemoveBlister,
|
_onRemoveBlister,
|
||||||
onSetIntakeValue,
|
onSetIntakeValue,
|
||||||
onAddIntake,
|
onAddIntake,
|
||||||
onRemoveIntake,
|
onRemoveIntake,
|
||||||
@@ -639,7 +640,10 @@ export function MobileEditModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{form.intakes.map((intake, idx) => (
|
{form.intakes.map((intake, idx) => (
|
||||||
<div key={idx} className="blister-row">
|
<div
|
||||||
|
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
|
||||||
|
className="blister-row"
|
||||||
|
>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.usage")}</span>
|
<span>{t("form.blisters.usage")}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -124,8 +124,12 @@ export function ShareDialog({
|
|||||||
return (
|
return (
|
||||||
<div className="share-dialog-form">
|
<div className="share-dialog-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("share.selectPerson")}</label>
|
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
|
||||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
<select
|
||||||
|
id="share-person-select"
|
||||||
|
value={shareSelectedPerson}
|
||||||
|
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
|
||||||
|
>
|
||||||
{sharePeople.map((person) => (
|
{sharePeople.map((person) => (
|
||||||
<option key={person} value={person}>
|
<option key={person} value={person}>
|
||||||
{person}
|
{person}
|
||||||
@@ -135,8 +139,12 @@ export function ShareDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("share.selectPeriod")}</label>
|
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
|
||||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
<select
|
||||||
|
id="share-period-select"
|
||||||
|
value={shareSelectedDays}
|
||||||
|
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SharedSchedule Component - Public view for shared schedules
|
// SharedSchedule Component - Public view for shared schedules
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
|
||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -98,13 +98,14 @@ export function UserFilterModal({
|
|||||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||||
{personIntakes.length > 0 && (
|
{personIntakes.length > 0 && (
|
||||||
<div className="user-med-intakes">
|
<div className="user-med-intakes">
|
||||||
{personIntakes.map((intake, idx) => {
|
{personIntakes.map((intake) => {
|
||||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="user-med-intake-item">
|
<span key={intakeKey} className="user-med-intake-item">
|
||||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med.pillWeightMg != null &&
|
{med.pillWeightMg != null &&
|
||||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
||||||
@@ -253,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||||
|
const selectedMedIdRef = useRef<number | null>(null);
|
||||||
|
const medDetailOpenedAtRef = useRef(0);
|
||||||
|
const medDetailCloseInFlightRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
selectedMedIdRef.current = selectedMed?.id ?? null;
|
||||||
|
if (!selectedMed) {
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [selectedMed]);
|
||||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||||
|
const imageLightboxOpenedAtRef = useRef(0);
|
||||||
|
const imageLightboxCloseInFlightRef = useRef(false);
|
||||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||||
|
const scheduleLightboxOpenedAtRef = useRef(0);
|
||||||
|
const scheduleLightboxCloseInFlightRef = useRef(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showImageLightbox) {
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduleLightboxImage) {
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
// Export/Import state
|
// Export/Import state
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -467,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Modal helpers with browser history support
|
// Modal helpers with browser history support
|
||||||
const openMedDetail = useCallback(
|
const openMedDetail = useCallback(
|
||||||
(med: Medication) => {
|
(med: Medication) => {
|
||||||
|
if (selectedMedIdRef.current === med.id) return;
|
||||||
|
selectedMedIdRef.current = med.id;
|
||||||
|
medDetailOpenedAtRef.current = Date.now();
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
setSelectedMed(med);
|
setSelectedMed(med);
|
||||||
refill.setRefillHistoryExpanded(false);
|
refill.setRefillHistoryExpanded(false);
|
||||||
refill.loadRefillHistory(med.id);
|
refill.loadRefillHistory(med.id);
|
||||||
@@ -476,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const closeMedDetail = useCallback(() => {
|
const closeMedDetail = useCallback(() => {
|
||||||
if (selectedMed) {
|
if (!selectedMed || medDetailCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
|
||||||
|
// Ignore ultra-fast close requests caused by rapid double-click races
|
||||||
|
if (Date.now() - medDetailOpenedAtRef.current < 320) return;
|
||||||
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "medDetail") {
|
||||||
|
// State already popped by another event: close locally without another back step.
|
||||||
|
selectedMedIdRef.current = null;
|
||||||
|
setSelectedMed(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
medDetailCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
}, [selectedMed]);
|
}, [selectedMed]);
|
||||||
|
|
||||||
const openImageLightbox = useCallback(() => {
|
const openImageLightbox = useCallback(() => {
|
||||||
|
if (showImageLightbox) return;
|
||||||
|
imageLightboxOpenedAtRef.current = Date.now();
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
setShowImageLightbox(true);
|
setShowImageLightbox(true);
|
||||||
window.history.pushState({ modal: "imageLightbox" }, "");
|
window.history.pushState({ modal: "imageLightbox" }, "");
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeImageLightbox = useCallback(() => {
|
|
||||||
if (showImageLightbox) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}, [showImageLightbox]);
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
const closeImageLightbox = useCallback(() => {
|
||||||
setScheduleLightboxImage(imageUrl);
|
if (!showImageLightbox || imageLightboxCloseInFlightRef.current) return;
|
||||||
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
if (Date.now() - imageLightboxOpenedAtRef.current < 320) return;
|
||||||
}, []);
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "imageLightbox") {
|
||||||
|
setShowImageLightbox(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageLightboxCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
|
const openScheduleLightbox = useCallback(
|
||||||
|
(imageUrl: string) => {
|
||||||
|
if (scheduleLightboxImage) return;
|
||||||
|
scheduleLightboxOpenedAtRef.current = Date.now();
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
|
setScheduleLightboxImage(imageUrl);
|
||||||
|
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
||||||
|
},
|
||||||
|
[scheduleLightboxImage]
|
||||||
|
);
|
||||||
|
|
||||||
const closeScheduleLightbox = useCallback(() => {
|
const closeScheduleLightbox = useCallback(() => {
|
||||||
if (scheduleLightboxImage) {
|
if (!scheduleLightboxImage || scheduleLightboxCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
if (Date.now() - scheduleLightboxOpenedAtRef.current < 320) return;
|
||||||
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "scheduleLightbox") {
|
||||||
|
setScheduleLightboxImage(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleLightboxCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
}, [scheduleLightboxImage]);
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
const openUserFilter = useCallback((person: string) => {
|
const openUserFilter = useCallback(
|
||||||
setSelectedUser(person);
|
(person: string) => {
|
||||||
window.history.pushState({ modal: "userFilter", person }, "");
|
if (selectedUser === person) return;
|
||||||
}, []);
|
setSelectedUser(person);
|
||||||
|
window.history.pushState({ modal: "userFilter", person }, "");
|
||||||
|
},
|
||||||
|
[selectedUser]
|
||||||
|
);
|
||||||
|
|
||||||
const closeUserFilter = useCallback(() => {
|
const closeUserFilter = useCallback(() => {
|
||||||
if (selectedUser) {
|
if (selectedUser) {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
if (error) errors[f] = error;
|
if (error) errors[f] = error;
|
||||||
});
|
});
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
}, [form.name, form.genericName, form.notes, validateField]);
|
}, [form.name, form.genericName, form.notes, validateField, form]);
|
||||||
|
|
||||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||||
import { Bell, NotebookPen, Share2 } from "lucide-react";
|
import { Bell, NotebookPen, Share2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -511,7 +512,21 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||||
<span className="med-name-line">
|
<span className="med-name-line">
|
||||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
<span
|
||||||
|
className={med?.imageUrl ? "med-avatar-clickable" : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||||
|
</span>
|
||||||
<span className="med-name-block-dash">
|
<span className="med-name-block-dash">
|
||||||
<span className="med-name-text">
|
<span className="med-name-text">
|
||||||
{row.name}
|
{row.name}
|
||||||
@@ -729,7 +744,15 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="med-name-stack">
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -981,7 +1004,15 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="med-name-stack">
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -1204,7 +1235,15 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="med-name-stack">
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
|
||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
|
||||||
|
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
|
||||||
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -7,7 +10,7 @@ import { useAuth } from "../components/Auth";
|
|||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, Medication } from "../types";
|
import type { DoseUnit, Medication } from "../types";
|
||||||
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
@@ -31,7 +34,6 @@ export function MedicationsPage() {
|
|||||||
deleteMedImage,
|
deleteMedImage,
|
||||||
uploadingImage,
|
uploadingImage,
|
||||||
existingPeople,
|
existingPeople,
|
||||||
coverage,
|
|
||||||
coverageByMed,
|
coverageByMed,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Mobile modal state (declared early because it's used in useEffect below)
|
// Mobile modal state (declared early because it's used in useEffect below)
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const showEditModalRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
showEditModalRef.current = showEditModal;
|
||||||
|
}, [showEditModal]);
|
||||||
const processedEditMedIdRef = useRef<string | null>(null);
|
const processedEditMedIdRef = useRef<string | null>(null);
|
||||||
const hasDesktopFormHistoryState = useRef(false);
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
@@ -197,6 +203,8 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Open mobile edit modal
|
// Open mobile edit modal
|
||||||
function openEditModal() {
|
function openEditModal() {
|
||||||
|
if (showEditModalRef.current) return;
|
||||||
|
showEditModalRef.current = true;
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
window.history.pushState({ modal: "edit" }, "");
|
window.history.pushState({ modal: "edit" }, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: planner uses custom DateTimeInput control wrappers */
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MedicationAvatar } from "../components";
|
import { MedicationAvatar } from "../components";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, ExportModal } from "../components";
|
import { ConfirmModal, ExportModal } from "../components";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
|
|||||||
@@ -2212,6 +2212,9 @@ button.has-validation-error {
|
|||||||
.time-main .med-name span.clickable {
|
.time-main .med-name span.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.time-main .med-name .med-name-stack.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.time-main .med-name span.clickable:hover .med-avatar {
|
.time-main .med-name span.clickable:hover .med-avatar {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
@@ -4605,9 +4608,6 @@ button.has-validation-error {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prescription-detail-grid .med-detail-value {
|
|
||||||
}
|
|
||||||
|
|
||||||
.med-detail-item {
|
.med-detail-item {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -4650,8 +4650,8 @@ button.has-validation-error {
|
|||||||
.med-schedule-item {
|
.med-schedule-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 0.35rem 0.75rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -4665,22 +4665,26 @@ button.has-validation-error {
|
|||||||
|
|
||||||
.med-schedule-freq {
|
.med-schedule-freq {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-time {
|
.med-schedule-time {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-person {
|
.med-schedule-person {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-bell {
|
.med-schedule-bell {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .med-schedule-bell {
|
[data-theme="light"] .med-schedule-bell {
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ describe("Lightbox", () => {
|
|||||||
|
|
||||||
it("calls onClose when Escape key is pressed", () => {
|
it("calls onClose when Escape key is pressed", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
render(<Lightbox {...defaultProps} onClose={onClose} />);
|
const { container } = render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||||
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
const overlay = container.querySelector(".lightbox-overlay");
|
||||||
|
fireEvent.keyDown(overlay!, { key: "Escape" });
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useMedications } from "../../hooks/useMedications";
|
import { useMedications } from "../../hooks/useMedications";
|
||||||
|
import type { Medication } from "../../types";
|
||||||
|
|
||||||
describe("useMedications", () => {
|
describe("useMedications", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -193,7 +194,7 @@ describe("useMedications", () => {
|
|||||||
it("allows setting meds directly", () => {
|
it("allows setting meds directly", () => {
|
||||||
const { result } = renderHook(() => useMedications());
|
const { result } = renderHook(() => useMedications());
|
||||||
|
|
||||||
const newMeds = [{ id: 1, name: "NewMed" }] as any;
|
const newMeds: Array<Pick<Medication, "id" | "name">> = [{ id: 1, name: "NewMed" }];
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setMeds(newMeds);
|
result.current.setMeds(newMeds);
|
||||||
|
|||||||
Reference in New Issue
Block a user