feat: enhance medication reminder system with improved notifications and user settings updates
- Added new translation keys for empty and low stock notifications in both English and German. - Implemented user authentication for planner routes and improved user settings loading. - Separated empty and low stock medications for clearer notifications. - Enhanced email notifications with detailed alerts for empty and low stock medications. - Updated user settings in the database when reminders are sent for both intake and stock notifications. - Improved form validation in the frontend with character limits and error messages. - Added CSS styles for form validation feedback and character count display.
This commit is contained in:
@@ -43,6 +43,11 @@ type TranslationKeys = {
|
|||||||
daysLeft: string;
|
daysLeft: string;
|
||||||
pillsAt: string;
|
pillsAt: string;
|
||||||
repeatDailyNote: string;
|
repeatDailyNote: string;
|
||||||
|
empty: string;
|
||||||
|
low: string;
|
||||||
|
reorderNow: string;
|
||||||
|
emptySection: string;
|
||||||
|
lowSection: string;
|
||||||
};
|
};
|
||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
@@ -94,6 +99,11 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
daysLeft: "{count} days left",
|
daysLeft: "{count} days left",
|
||||||
pillsAt: "{count} pills at {time}",
|
pillsAt: "{count} pills at {time}",
|
||||||
repeatDailyNote: "(Daily reminder enabled)",
|
repeatDailyNote: "(Daily reminder enabled)",
|
||||||
|
empty: "Empty",
|
||||||
|
low: "Low",
|
||||||
|
reorderNow: "Reorder Now!",
|
||||||
|
emptySection: "EMPTY (reorder immediately)",
|
||||||
|
lowSection: "RUNNING LOW (reorder soon)",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
pill: "pill",
|
pill: "pill",
|
||||||
@@ -142,6 +152,11 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
daysLeft: "{count} Tage übrig",
|
daysLeft: "{count} Tage übrig",
|
||||||
pillsAt: "{count} Tabletten um {time}",
|
pillsAt: "{count} Tabletten um {time}",
|
||||||
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
|
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
|
||||||
|
empty: "Leer",
|
||||||
|
low: "Knapp",
|
||||||
|
reorderNow: "Jetzt nachbestellen!",
|
||||||
|
emptySection: "LEER (sofort nachbestellen)",
|
||||||
|
lowSection: "WIRD KNAPP (bald nachbestellen)",
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
pill: "Tablette",
|
pill: "Tablette",
|
||||||
|
|||||||
+142
-41
@@ -1,9 +1,11 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { updateReminderSentTime } from "../services/reminder-scheduler.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
import { getDateLocale, getTranslations, t, type Language } from "../i18n/translations.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
|
||||||
type PlannerRow = {
|
type PlannerRow = {
|
||||||
medicationId: number;
|
medicationId: number;
|
||||||
@@ -38,6 +40,21 @@ type ReminderEmailBody = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function plannerRoutes(app: FastifyInstance) {
|
export async function plannerRoutes(app: FastifyInstance) {
|
||||||
|
// Add auth hook for all planner routes
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
|
||||||
|
// Helper to get user ID from request
|
||||||
|
async function getUserId(request: any): Promise<number> {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return getAnonymousUserId();
|
||||||
|
}
|
||||||
|
const authUser = request.user as AuthUser | null;
|
||||||
|
if (!authUser?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
return authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
||||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||||
|
|
||||||
@@ -191,24 +208,21 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
return reply.status(400).send({ error: "Missing low stock data" });
|
return reply.status(400).send({ error: "Missing low stock data" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user settings if authenticated, otherwise use defaults
|
// Load user settings
|
||||||
let notificationSettings = {
|
const userId = await getUserId(request);
|
||||||
emailEnabled: true,
|
const userSettings = await loadUserSettings(userId);
|
||||||
shoutrrrEnabled: false,
|
const notificationSettings = {
|
||||||
shoutrrrUrl: "",
|
|
||||||
};
|
|
||||||
const reminderAuthUser = request.user as unknown as AuthUser | null;
|
|
||||||
if (reminderAuthUser?.id) {
|
|
||||||
const userSettings = await loadUserSettings(reminderAuthUser.id);
|
|
||||||
notificationSettings = {
|
|
||||||
emailEnabled: userSettings.emailEnabled,
|
emailEnabled: userSettings.emailEnabled,
|
||||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||||
|
|
||||||
|
// Separate empty from low stock medications
|
||||||
|
const emptyMeds = lowStock.filter(r => r.medsLeft <= 0);
|
||||||
|
const lowMeds = lowStock.filter(r => r.medsLeft > 0);
|
||||||
|
|
||||||
// Send email if enabled
|
// Send email if enabled
|
||||||
if (notificationSettings.emailEnabled && email) {
|
if (notificationSettings.emailEnabled && email) {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
@@ -219,31 +233,79 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
// Build HTML table with horizontal scroll for mobile
|
// Build subject line based on what we have
|
||||||
const tableRows = lowStock
|
let subjectText: string;
|
||||||
.map(
|
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||||
(row) => `
|
subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`;
|
||||||
<tr>
|
} else if (emptyMeds.length > 0) {
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
|
subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`;
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
|
} else {
|
||||||
|
subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build alert box based on what we have
|
||||||
|
let alertHtml: string;
|
||||||
|
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||||
|
alertHtml = `
|
||||||
|
<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;">
|
||||||
|
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
|
||||||
|
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
|
||||||
|
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
} else if (emptyMeds.length > 0) {
|
||||||
|
alertHtml = `
|
||||||
|
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #dc2626;">
|
||||||
|
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
|
||||||
|
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
alertHtml = `
|
||||||
|
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
|
||||||
|
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
|
||||||
|
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build table rows with status indicator
|
||||||
|
const buildTableRow = (row: LowStockItem) => {
|
||||||
|
const isEmpty = row.medsLeft <= 0;
|
||||||
|
const statusIcon = isEmpty ? "🚨" : "⚠️";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : "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;">${row.daysLeft ?? 0}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`
|
};
|
||||||
)
|
|
||||||
.join("");
|
const tableRows = lowStock.map(buildTableRow).join("");
|
||||||
|
|
||||||
|
// Build description text
|
||||||
|
let descriptionText: string;
|
||||||
|
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||||
|
descriptionText = "The following medications need to be reordered:";
|
||||||
|
} else if (emptyMeds.length > 0) {
|
||||||
|
descriptionText = "The following medications are EMPTY and need to be reordered immediately:";
|
||||||
|
} else {
|
||||||
|
descriptionText = "The following medications are running low and need to be reordered:";
|
||||||
|
}
|
||||||
|
|
||||||
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;">⚠️ MedAssist-ng - Reorder Reminder</h2>
|
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder</h2>
|
||||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
|
<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;">
|
${alertHtml}
|
||||||
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
|
|
||||||
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
|
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
|
||||||
@@ -267,11 +329,25 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Build plain text with sections
|
||||||
|
let plainTextContent: string;
|
||||||
|
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||||
|
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||||||
|
${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}
|
||||||
|
|
||||||
|
⚠️ RUNNING LOW (reorder soon):
|
||||||
|
${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`;
|
||||||
|
} else if (emptyMeds.length > 0) {
|
||||||
|
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||||||
|
${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}`;
|
||||||
|
} else {
|
||||||
|
plainTextContent = `⚠️ Running low:
|
||||||
|
${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
const plainText = `MedAssist-ng - Reorder Reminder
|
const plainText = `MedAssist-ng - Reorder Reminder
|
||||||
|
|
||||||
The following medications are running low:
|
${plainTextContent}
|
||||||
|
|
||||||
${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}
|
|
||||||
|
|
||||||
---
|
---
|
||||||
Sent from MedAssist-ng Medication Planner`;
|
Sent from MedAssist-ng Medication Planner`;
|
||||||
@@ -290,7 +366,7 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `⚠️ MedAssist-ng - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
|
subject: `MedAssist-ng - ${subjectText}`,
|
||||||
text: plainText,
|
text: plainText,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
@@ -305,10 +381,31 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||||
const title = `${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`;
|
// Get translations based on user language (default to 'en')
|
||||||
const message = lowStock
|
const tr = getTranslations((userSettings.language as Language) || "en");
|
||||||
.map((r) => `- ${r.name}: ${r.medsLeft} pills (${r.daysLeft ?? 0} days)`)
|
|
||||||
.join("\n");
|
// Build clear title
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) {
|
||||||
|
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||||
|
}
|
||||||
|
if (lowMeds.length > 0) {
|
||||||
|
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`);
|
||||||
|
}
|
||||||
|
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||||
|
|
||||||
|
// Build clear message with sections
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||||
|
emptyMeds.forEach(r => messageParts.push(` • ${r.name}`));
|
||||||
|
}
|
||||||
|
if (lowMeds.length > 0) {
|
||||||
|
if (emptyMeds.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
|
||||||
|
lowMeds.forEach(r => messageParts.push(` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`));
|
||||||
|
}
|
||||||
|
const message = messageParts.join("\n");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
|
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
|
||||||
@@ -325,7 +422,11 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
|
|
||||||
// Update the reminder state to record this notification was sent
|
// Update the reminder state to record this notification was sent
|
||||||
if (results.email || results.push) {
|
if (results.email || results.push) {
|
||||||
updateReminderSentTime();
|
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||||||
|
updateReminderSentTime("stock", channel);
|
||||||
|
|
||||||
|
// Also update user settings in database so frontend can display the info
|
||||||
|
await updateUserReminderSentTime(userId, "stock", channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response message
|
// Build response message
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
|
|||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
||||||
import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js";
|
import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
||||||
|
|
||||||
type Blister = { usage: number; every: number; start: string };
|
type Blister = { usage: number; every: number; start: string };
|
||||||
|
|
||||||
@@ -380,6 +380,9 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
// Update global reminder state for UI display
|
// Update global reminder state for UI display
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||||
updateReminderSentTime("intake", channel);
|
updateReminderSentTime("intake", channel);
|
||||||
|
|
||||||
|
// Also update user settings in database so frontend can display the info
|
||||||
|
await updateUserReminderSentTime(settings.userId, "intake", channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications, users } from "../db/schema.js";
|
import { medications, users, userSettings } from "../db/schema.js";
|
||||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
@@ -172,6 +172,22 @@ export function updateReminderSentTime(type: "stock" | "intake" = "stock", chann
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update user settings in database when reminder is sent
|
||||||
|
export async function updateUserReminderSentTime(
|
||||||
|
userId: number,
|
||||||
|
type: "stock" | "intake" = "stock",
|
||||||
|
channel: "email" | "push" | "both" = "email"
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db.update(userSettings)
|
||||||
|
.set({
|
||||||
|
lastAutoEmailSent: now,
|
||||||
|
lastNotificationType: type,
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
})
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||||
try {
|
try {
|
||||||
const usage = JSON.parse(row.usageJson) as number[];
|
const usage = JSON.parse(row.usageJson) as number[];
|
||||||
@@ -406,17 +422,39 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
// Send Shoutrrr notification if enabled
|
// Send Shoutrrr notification if enabled
|
||||||
if (shoutrrrEnabled) {
|
if (shoutrrrEnabled) {
|
||||||
const title = allLowStock.length === 1
|
// Separate empty from low stock medications
|
||||||
? tr.push.stockTitle
|
const emptyMeds = allLowStock.filter(m => m.medsLeft <= 0);
|
||||||
: t(tr.push.stockTitleMultiple, { count: allLowStock.length });
|
const lowMeds = allLowStock.filter(m => m.medsLeft > 0);
|
||||||
let message = allLowStock
|
|
||||||
.map((m) => `• ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`)
|
// Build clear title
|
||||||
.join("\n");
|
const titleParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) {
|
||||||
|
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
|
||||||
|
}
|
||||||
|
if (lowMeds.length > 0) {
|
||||||
|
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`);
|
||||||
|
}
|
||||||
|
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 (lowMeds.length > 0) {
|
||||||
|
if (emptyMeds.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
|
||||||
|
lowMeds.forEach(m => messageParts.push(` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`));
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.repeatDailyReminders) {
|
if (settings.repeatDailyReminders) {
|
||||||
message += `\n\n${tr.push.repeatDailyNote}`;
|
messageParts.push("");
|
||||||
|
messageParts.push(tr.push.repeatDailyNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const message = messageParts.join("\n");
|
||||||
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -438,6 +476,9 @@ async function checkAndSendReminderForUser(
|
|||||||
lastNotificationType: "stock",
|
lastNotificationType: "stock",
|
||||||
lastNotificationChannel: channel,
|
lastNotificationChannel: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also update user settings in database so frontend can display the info
|
||||||
|
await updateUserReminderSentTime(settings.userId, "stock", channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+159
-46
@@ -71,6 +71,21 @@ const defaultBlister = (): FormBlister => {
|
|||||||
|
|
||||||
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
|
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
|
||||||
|
|
||||||
|
// Field validation limits (must match backend)
|
||||||
|
const FIELD_LIMITS = {
|
||||||
|
name: { min: 1, max: 100 },
|
||||||
|
genericName: { max: 100 },
|
||||||
|
takenBy: { max: 100 },
|
||||||
|
notes: { max: 2000 }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type FieldErrors = {
|
||||||
|
name?: string;
|
||||||
|
genericName?: string;
|
||||||
|
takenBy?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const todayIso = () => new Date().toISOString();
|
const todayIso = () => new Date().toISOString();
|
||||||
const plusDaysIso = (days: number) => {
|
const plusDaysIso = (days: number) => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
@@ -194,11 +209,39 @@ function AppContent() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [form, setForm] = useState<FormState>(defaultForm());
|
const [form, setForm] = useState<FormState>(defaultForm());
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
const [range, setRange] = useState<{ start: string; end: string }>({
|
const [range, setRange] = useState<{ start: string; end: string }>({
|
||||||
start: toInputValue(todayIso()),
|
start: toInputValue(todayIso()),
|
||||||
end: toInputValue(plusDaysIso(3))
|
end: toInputValue(plusDaysIso(3))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate form fields
|
||||||
|
const validateField = (field: keyof FieldErrors, value: string): string | undefined => {
|
||||||
|
const limits = FIELD_LIMITS[field];
|
||||||
|
if (field === 'name' && (!value || value.trim().length === 0)) {
|
||||||
|
return t('common.validation.required');
|
||||||
|
}
|
||||||
|
if ('max' in limits && value.length > limits.max) {
|
||||||
|
return t('common.validation.maxLength', { max: limits.max, current: value.length });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form has any errors
|
||||||
|
const hasValidationErrors = useMemo(() => {
|
||||||
|
return Object.values(fieldErrors).some(error => error !== undefined);
|
||||||
|
}, [fieldErrors]);
|
||||||
|
|
||||||
|
// Validate all fields when form changes
|
||||||
|
useEffect(() => {
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
(['name', 'genericName', 'takenBy', 'notes'] as const).forEach(field => {
|
||||||
|
const error = validateField(field, form[field]);
|
||||||
|
if (error) errors[field] = error;
|
||||||
|
});
|
||||||
|
setFieldErrors(errors);
|
||||||
|
}, [form.name, form.genericName, form.takenBy, form.notes, t]);
|
||||||
|
|
||||||
// Load user-specific planner data when user changes
|
// Load user-specific planner data when user changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && user?.id) {
|
if (typeof window !== "undefined" && user?.id) {
|
||||||
@@ -1434,17 +1477,36 @@ function AppContent() {
|
|||||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<form className="form-grid" onSubmit={saveMedication}>
|
<form className="form-grid" onSubmit={saveMedication}>
|
||||||
<label>
|
<label className={fieldErrors.name ? 'has-error' : ''}>
|
||||||
{t('form.commercialName')}
|
{t('form.commercialName')}
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder={t('form.placeholders.commercial')} required />
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...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>
|
||||||
<label>
|
<label className={fieldErrors.genericName ? 'has-error' : ''}>
|
||||||
{t('form.genericName')}
|
{t('form.genericName')}
|
||||||
<input value={form.genericName} onChange={(e) => setForm({ ...form, genericName: e.target.value })} placeholder={t('form.placeholders.generic')} />
|
<input
|
||||||
|
value={form.genericName}
|
||||||
|
onChange={(e) => setForm({ ...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>
|
||||||
<label>
|
<label className={fieldErrors.takenBy ? 'has-error' : ''}>
|
||||||
{t('form.takenBy')}
|
{t('form.takenBy')}
|
||||||
<input value={form.takenBy} onChange={(e) => setForm({ ...form, takenBy: e.target.value })} placeholder={t('form.placeholders.takenBy')} />
|
<input
|
||||||
|
value={form.takenBy}
|
||||||
|
onChange={(e) => setForm({ ...form, takenBy: e.target.value })}
|
||||||
|
placeholder={t('form.placeholders.takenBy')}
|
||||||
|
maxLength={FIELD_LIMITS.takenBy.max}
|
||||||
|
/>
|
||||||
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('form.packs')}
|
{t('form.packs')}
|
||||||
@@ -1475,17 +1537,23 @@ function AppContent() {
|
|||||||
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
|
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="full">
|
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
||||||
{t('form.notes')}
|
{t('form.notes')}
|
||||||
<textarea
|
<textarea
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => handleValueChange("notes", e.target.value)}
|
onChange={(e) => handleValueChange("notes", e.target.value)}
|
||||||
placeholder={t('form.placeholders.notes')}
|
placeholder={t('form.placeholders.notes')}
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={2000}
|
maxLength={FIELD_LIMITS.notes.max}
|
||||||
className="auto-resize"
|
className="auto-resize"
|
||||||
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; }}
|
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.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>
|
</label>
|
||||||
|
|
||||||
<div className="full blisters">
|
<div className="full blisters">
|
||||||
@@ -1586,7 +1654,7 @@ function AppContent() {
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="submit" disabled={saving}>{saving ? t('common.saving') : t('common.save')}</button>
|
<button type="submit" disabled={saving || hasValidationErrors}>{saving ? t('common.saving') : t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -2411,17 +2479,36 @@ function AppContent() {
|
|||||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); setShowEditModal(false); }}>
|
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); setShowEditModal(false); }}>
|
||||||
<label className="full">
|
<label className={`full ${fieldErrors.name ? 'has-error' : ''}`}>
|
||||||
{t('form.commercialName')}
|
{t('form.commercialName')}
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder={t('form.placeholders.commercial')} required />
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...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>
|
||||||
<label className="full">
|
<label className={`full ${fieldErrors.genericName ? 'has-error' : ''}`}>
|
||||||
{t('form.genericName')}
|
{t('form.genericName')}
|
||||||
<input value={form.genericName} onChange={(e) => setForm({ ...form, genericName: e.target.value })} placeholder={t('form.placeholders.generic')} />
|
<input
|
||||||
|
value={form.genericName}
|
||||||
|
onChange={(e) => setForm({ ...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>
|
||||||
<label className="full">
|
<label className={`full ${fieldErrors.takenBy ? 'has-error' : ''}`}>
|
||||||
{t('form.takenBy')}
|
{t('form.takenBy')}
|
||||||
<input value={form.takenBy} onChange={(e) => setForm({ ...form, takenBy: e.target.value })} placeholder={t('form.placeholders.takenBy')} />
|
<input
|
||||||
|
value={form.takenBy}
|
||||||
|
onChange={(e) => setForm({ ...form, takenBy: e.target.value })}
|
||||||
|
placeholder={t('form.placeholders.takenBy')}
|
||||||
|
maxLength={FIELD_LIMITS.takenBy.max}
|
||||||
|
/>
|
||||||
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('form.packs')}
|
{t('form.packs')}
|
||||||
@@ -2450,17 +2537,23 @@ function AppContent() {
|
|||||||
{t('form.expiryDate')}
|
{t('form.expiryDate')}
|
||||||
<input type="date" value={form.expiryDate} onChange={(e) => setForm({ ...form, expiryDate: e.target.value })} />
|
<input type="date" value={form.expiryDate} onChange={(e) => setForm({ ...form, expiryDate: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
||||||
{t('form.notes')}
|
{t('form.notes')}
|
||||||
<textarea
|
<textarea
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
placeholder={t('form.placeholders.notes')}
|
placeholder={t('form.placeholders.notes')}
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={2000}
|
maxLength={FIELD_LIMITS.notes.max}
|
||||||
className="auto-resize"
|
className="auto-resize"
|
||||||
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; }}
|
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.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>
|
</label>
|
||||||
|
|
||||||
{editingId && (() => {
|
{editingId && (() => {
|
||||||
@@ -2521,7 +2614,7 @@ function AppContent() {
|
|||||||
<button type="button" className="ghost" onClick={() => { setShowEditModal(false); resetForm(); }}>
|
<button type="button" className="ghost" onClick={() => { setShowEditModal(false); resetForm(); }}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={saving}>{saving ? t('common.saving') : t('common.save')}</button>
|
<button type="submit" disabled={saving || hasValidationErrors}>{saving ? t('common.saving') : t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -2819,11 +2912,19 @@ function getReminderStatusText(
|
|||||||
t: (key: string, options?: Record<string, unknown>) => string,
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
locale: string
|
locale: string
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
// Find the earliest medication that needs a reminder (based on reminderDaysBefore)
|
// Find empty medications (medsLeft <= 0)
|
||||||
const medsNeedingReminder = lowStock
|
const emptyMeds = allCoverage.filter((c) => c.medsLeft <= 0);
|
||||||
.filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)
|
|
||||||
|
// Find medications that need reminder (daysLeft <= reminderDaysBefore but not empty)
|
||||||
|
const medsNeedingReminder = allCoverage
|
||||||
|
.filter((c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)
|
||||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||||
|
|
||||||
|
// Find low stock medications (not yet critical but running low)
|
||||||
|
const lowStockNotYetCritical = allCoverage.filter(
|
||||||
|
(c) => c.medsLeft > 0 && c.daysLeft !== null && c.daysLeft > reminderDaysBefore && c.daysLeft < lowStockDays
|
||||||
|
);
|
||||||
|
|
||||||
const formatLastSent = (iso: string) => {
|
const formatLastSent = (iso: string) => {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||||
@@ -2844,33 +2945,45 @@ function getReminderStatusText(
|
|||||||
return dateStr;
|
return dateStr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Priority 1: Empty medications (critical - red)
|
||||||
|
if (emptyMeds.length > 0) {
|
||||||
|
const parts: React.ReactNode[] = [
|
||||||
|
<strong key="empty" className="danger-text">🚨 {t('dashboard.reminders.emptyStock', { count: emptyMeds.length })}</strong>
|
||||||
|
];
|
||||||
if (medsNeedingReminder.length > 0) {
|
if (medsNeedingReminder.length > 0) {
|
||||||
// There are medications that need reminders
|
parts.push(<span key="reorder" className="danger-text"> · ⚠ {t('dashboard.reminders.needReorder', { count: medsNeedingReminder.length })}</span>);
|
||||||
if (lastSent) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="email-status-line"><strong className="warning-text">⚠ {t('dashboard.reminders.needReorder', { count: medsNeedingReminder.length })}</strong></span>
|
|
||||||
<span className="email-status-line">{t('dashboard.reminders.lastReminder')}: {formatLastInfo(lastSent)}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="email-status-line"><strong className="warning-text">⚠ {t('dashboard.reminders.needReorder', { count: medsNeedingReminder.length })}</strong></span>
|
|
||||||
<span className="email-status-line">{t('dashboard.reminders.waitingFirstCheck')}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are low stock medications (not yet needing reminder but running low)
|
|
||||||
const lowStockNotYetCritical = allCoverage.filter(
|
|
||||||
(c) => c.daysLeft !== null && c.daysLeft > reminderDaysBefore && c.daysLeft < lowStockDays
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lowStockNotYetCritical.length > 0) {
|
if (lowStockNotYetCritical.length > 0) {
|
||||||
// There are low stock meds but not critical yet
|
parts.push(<span key="low" className="warning-text"> · {t('dashboard.reminders.lowWarning', { count: lowStockNotYetCritical.length })}</span>);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="email-status-line">{parts}</span>
|
||||||
|
<span className="email-status-line">{lastSent ? `${t('dashboard.reminders.lastReminder')}: ${formatLastInfo(lastSent)}` : ''}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Medications needing reminder soon (critical - red)
|
||||||
|
if (medsNeedingReminder.length > 0) {
|
||||||
|
const parts: React.ReactNode[] = [
|
||||||
|
<strong key="reorder" className="danger-text">⚠ {t('dashboard.reminders.needReorder', { count: medsNeedingReminder.length })}</strong>
|
||||||
|
];
|
||||||
|
if (lowStockNotYetCritical.length > 0) {
|
||||||
|
parts.push(<span key="low" className="warning-text"> · {t('dashboard.reminders.lowWarning', { count: lowStockNotYetCritical.length })}</span>);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="email-status-line">{parts}</span>
|
||||||
|
<span className="email-status-line">{lastSent ? `${t('dashboard.reminders.lastReminder')}: ${formatLastInfo(lastSent)}` : ''}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Low stock but not yet critical (warning - yellow)
|
||||||
|
if (lowStockNotYetCritical.length > 0) {
|
||||||
const nextMed = lowStockNotYetCritical.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))[0];
|
const nextMed = lowStockNotYetCritical.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))[0];
|
||||||
const daysUntilReminder = (nextMed.daysLeft ?? 0) - reminderDaysBefore;
|
const daysUntilReminder = Math.max(0, (nextMed.daysLeft ?? 0) - reminderDaysBefore);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="email-status-line"><span className="warning-text">{t('dashboard.reminders.lowWarning', { count: lowStockNotYetCritical.length })}</span></span>
|
<span className="email-status-line"><span className="warning-text">{t('dashboard.reminders.lowWarning', { count: lowStockNotYetCritical.length })}</span></span>
|
||||||
@@ -2880,8 +2993,8 @@ function getReminderStatusText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate when next reminder would be triggered
|
// Calculate when next reminder would be triggered
|
||||||
const allWithDepletion = lowStock
|
const allWithDepletion = allCoverage
|
||||||
.filter((c) => c.depletionTime !== null && c.daysLeft !== null)
|
.filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.medsLeft > 0)
|
||||||
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity));
|
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity));
|
||||||
|
|
||||||
if (allWithDepletion.length > 0) {
|
if (allWithDepletion.length > 0) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"noRemindersNeeded": "keine Erinnerungen nötig",
|
"noRemindersNeeded": "keine Erinnerungen nötig",
|
||||||
"needReorder": "{{count}} Medikament nachbestellen",
|
"needReorder": "{{count}} Medikament nachbestellen",
|
||||||
"needReorder_other": "{{count}} Medikamente nachbestellen",
|
"needReorder_other": "{{count}} Medikamente nachbestellen",
|
||||||
|
"emptyStock": "{{count}} Medikament leer",
|
||||||
|
"emptyStock_other": "{{count}} Medikamente leer",
|
||||||
"lowWarning": "{{count}} Medikament wird knapp",
|
"lowWarning": "{{count}} Medikament wird knapp",
|
||||||
"lowWarning_other": "{{count}} Medikamente werden knapp",
|
"lowWarning_other": "{{count}} Medikamente werden knapp",
|
||||||
"waitingFirstCheck": "warte auf erste Prüfung",
|
"waitingFirstCheck": "warte auf erste Prüfung",
|
||||||
@@ -275,6 +277,11 @@
|
|||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
"sending": "Wird gesendet...",
|
"sending": "Wird gesendet...",
|
||||||
"saving": "Wird gespeichert...",
|
"saving": "Wird gespeichert...",
|
||||||
|
"validation": {
|
||||||
|
"required": "Dieses Feld ist erforderlich",
|
||||||
|
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
||||||
|
"tooLong": "{{current}}/{{max}} Zeichen"
|
||||||
|
},
|
||||||
"saved": "Gespeichert ✓",
|
"saved": "Gespeichert ✓",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
"noRemindersNeeded": "no reminders needed",
|
"noRemindersNeeded": "no reminders needed",
|
||||||
"needReorder": "{{count}} med needs reorder",
|
"needReorder": "{{count}} med needs reorder",
|
||||||
"needReorder_other": "{{count}} meds need reorder",
|
"needReorder_other": "{{count}} meds need reorder",
|
||||||
|
"emptyStock": "{{count}} med is empty",
|
||||||
|
"emptyStock_other": "{{count}} meds are empty",
|
||||||
"lowWarning": "{{count}} medication running low",
|
"lowWarning": "{{count}} medication running low",
|
||||||
"lowWarning_other": "{{count}} medications running low",
|
"lowWarning_other": "{{count}} medications running low",
|
||||||
"waitingFirstCheck": "waiting for first check",
|
"waitingFirstCheck": "waiting for first check",
|
||||||
@@ -277,6 +279,11 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
||||||
|
"tooLong": "{{current}}/{{max}} characters"
|
||||||
|
},
|
||||||
"saved": "Saved ✓",
|
"saved": "Saved ✓",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
+43
-3
@@ -20,6 +20,7 @@
|
|||||||
--danger-bg: rgba(255, 94, 94, 0.12);
|
--danger-bg: rgba(255, 94, 94, 0.12);
|
||||||
--warning: #fcd34d;
|
--warning: #fcd34d;
|
||||||
--warning-bg: rgba(252, 211, 77, 0.12);
|
--warning-bg: rgba(252, 211, 77, 0.12);
|
||||||
|
--info: #93c5fd;
|
||||||
--shadow: rgba(0, 0, 0, 0.25);
|
--shadow: rgba(0, 0, 0, 0.25);
|
||||||
/* Button Design System */
|
/* Button Design System */
|
||||||
--btn-radius: 10px;
|
--btn-radius: 10px;
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
--danger-bg: rgba(239, 68, 68, 0.1);
|
--danger-bg: rgba(239, 68, 68, 0.1);
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--warning-bg: rgba(245, 158, 11, 0.1);
|
--warning-bg: rgba(245, 158, 11, 0.1);
|
||||||
|
--info: #3b82f6;
|
||||||
--shadow: rgba(0, 0, 0, 0.08);
|
--shadow: rgba(0, 0, 0, 0.08);
|
||||||
/* Button Design System */
|
/* Button Design System */
|
||||||
--btn-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
--btn-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
@@ -168,9 +170,11 @@ body.modal-open {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-status-text strong {
|
/* Allow color classes to override default strong color */
|
||||||
color: var(--accent-light);
|
.email-status-text .danger-text { color: var(--danger) !important; }
|
||||||
}
|
.email-status-text .warning-text { color: var(--warning) !important; }
|
||||||
|
.email-status-text .info-text { color: var(--info) !important; }
|
||||||
|
.email-status-text .success-text { color: var(--success) !important; }
|
||||||
|
|
||||||
.email-status-recipient {
|
.email-status-recipient {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -314,6 +318,7 @@ body.modal-open {
|
|||||||
.tag-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.25rem; }
|
.tag-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||||
.danger-text { color: var(--danger); font-weight: 700; }
|
.danger-text { color: var(--danger); font-weight: 700; }
|
||||||
.warning-text { color: var(--warning); font-weight: 700; }
|
.warning-text { color: var(--warning); font-weight: 700; }
|
||||||
|
.info-text { color: var(--info, #60a5fa); font-weight: 500; }
|
||||||
.success-text { color: var(--success); font-weight: 700; }
|
.success-text { color: var(--success); font-weight: 700; }
|
||||||
|
|
||||||
.optional-label {
|
.optional-label {
|
||||||
@@ -522,6 +527,41 @@ textarea.auto-resize {
|
|||||||
.form-grid .optional-label { text-transform: none; font-weight: 400; font-size: 0.75rem; }
|
.form-grid .optional-label { text-transform: none; font-weight: 400; font-size: 0.75rem; }
|
||||||
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
|
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
|
||||||
|
|
||||||
|
/* Form field validation */
|
||||||
|
.form-grid label.has-error input,
|
||||||
|
.form-grid label.has-error textarea {
|
||||||
|
border-color: var(--danger) !important;
|
||||||
|
background-color: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label.has-error input:focus,
|
||||||
|
.form-grid label.has-error textarea:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
/* Past days toggle button */
|
/* Past days toggle button */
|
||||||
|
|||||||
Reference in New Issue
Block a user