feat: obsolete medication archiving, start date, and UI improvements (#215)
* feat: obsolete medication archiving, start date, and UI improvements - Add soft-archive (obsolete) for medications with dedicated section and toggle - Add medication start date field with date picker and validation - Add obsolete/reactivate API endpoints with proper auth - Filter obsolete meds from schedule, coverage, planner, and notifications - Improve UserFilterModal with intake schedules, stock badges, and click-to-open - Improve dashboard taken-by badges with per-intake bell icons - Add Escape key support to ConfirmModal and MobileEditModal - Fix Lightbox close button positioning near image - Add read-only mode support for MobileEditModal - DB migrations: 0008 (is_obsolete, obsolete_at), 0009 (medication_start_date) - All user-facing text uses i18n keys (en + de) * test: fix tests for obsolete medications and UI changes - Backend: add is_obsolete, obsolete_at, medication_start_date columns to test schemas - Backend: add test medication inserts in planner tests for active-med filtering - Frontend: update useMedications URL to include includeObsolete param - Frontend: fix MobileEditModal selectors and validation assertions - Frontend: add onClearUser prop to UserFilterModal test renders - Frontend: fix MedicationsPage and DashboardPage test assertions
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
|
||||
ALTER TABLE `medications` ADD `obsolete_at` integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
|
||||
@@ -57,6 +57,20 @@
|
||||
"when": 1770659669121,
|
||||
"tag": "0007_add_share_stock_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1771160400000,
|
||||
"tag": "0008_add_obsolete_medications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1771164000000,
|
||||
"tag": "0009_add_medication_start_date",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,6 +119,11 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for soft-archiving medications (without deleting history)
|
||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||
// Added for explicit medication lifecycle start date
|
||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
|
||||
@@ -47,6 +47,9 @@ export const medications = sqliteTable("medications", {
|
||||
expiryDate: text("expiry_date"),
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
|
||||
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
|
||||
|
||||
@@ -49,9 +49,12 @@ const medicationExportSchema = z.object({
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
isObsolete: z.boolean().default(false),
|
||||
obsoleteAt: z.string().nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||
@@ -289,9 +292,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
@@ -515,6 +521,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
@@ -522,6 +529,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
|
||||
@@ -32,6 +32,9 @@ const blisterSchema = z.object({
|
||||
|
||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||
const medicationStartDateSchema = z
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
@@ -46,6 +49,7 @@ const medicationSchema = z
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||
doseUnit: doseUnitSchema,
|
||||
medicationStartDate: medicationStartDateSchema,
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
@@ -59,6 +63,19 @@ const medicationSchema = z
|
||||
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
||||
})
|
||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDate = data.medicationStartDate ?? "";
|
||||
if (!startDate) return true;
|
||||
|
||||
const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? [];
|
||||
return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate);
|
||||
},
|
||||
{
|
||||
message: "Medication start date must be on or before all intake dates",
|
||||
path: ["medicationStartDate"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.prescriptionEnabled) return true;
|
||||
@@ -103,9 +120,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const includeObsolete = request.query.includeObsolete === "true";
|
||||
const whereClause = includeObsolete
|
||||
? eq(medications.userId, userId)
|
||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
||||
return rows.map((row) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -129,6 +150,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: row.pillWeightMg,
|
||||
doseUnit: row.doseUnit ?? "mg",
|
||||
medicationStartDate: row.medicationStartDate || null,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
// Legacy blisters format (for backward compat with frontend during transition)
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
@@ -136,6 +158,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
expiryDate: row.expiryDate,
|
||||
notes: row.notes,
|
||||
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
||||
isObsolete: row.isObsolete ?? false,
|
||||
obsoleteAt: row.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: row.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
|
||||
@@ -164,6 +188,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -222,6 +247,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -252,12 +278,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: inserted.pillWeightMg,
|
||||
doseUnit: inserted.doseUnit ?? "mg",
|
||||
medicationStartDate: inserted.medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: inserted.imageUrl,
|
||||
expiryDate: inserted.expiryDate,
|
||||
notes: inserted.notes,
|
||||
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
|
||||
isObsolete: inserted.isObsolete ?? false,
|
||||
obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
|
||||
@@ -294,6 +323,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -362,6 +392,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -516,12 +547,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: result[0].pillWeightMg,
|
||||
doseUnit: result[0].doseUnit ?? "mg",
|
||||
medicationStartDate: result[0].medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: result[0].imageUrl,
|
||||
expiryDate: result[0].expiryDate,
|
||||
notes: result[0].notes,
|
||||
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
|
||||
isObsolete: result[0].isObsolete ?? false,
|
||||
obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
|
||||
@@ -531,6 +565,64 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/obsolete", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: true,
|
||||
obsoleteAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/reactivate", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
||||
@@ -678,7 +770,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -103,6 +103,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ id: medications.id })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||
if (activeRows.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -132,11 +142,11 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
})
|
||||
);
|
||||
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const outOfStockCount = activeRows.filter((r) => !r.enough).length;
|
||||
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
|
||||
|
||||
// Load prescription data for medications referenced in planner rows
|
||||
const medIds = rows.map((r) => r.medicationId).filter(Boolean);
|
||||
const medIds = activeRows.map((r) => r.medicationId).filter(Boolean);
|
||||
const allMeds =
|
||||
medIds.length > 0
|
||||
? await db
|
||||
@@ -156,7 +166,7 @@ ${t(dc.description, { from: fromDate, until: untilDate })}
|
||||
|
||||
${summaryText}
|
||||
|
||||
${rows
|
||||
${activeRows
|
||||
.map((r) => {
|
||||
const isBottle = r.packageType === "bottle";
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
@@ -191,7 +201,7 @@ ${getFooterPlain(language)}`;
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
// Escape/coerce all user-provided values to prevent XSS
|
||||
const tableRows = rows
|
||||
const tableRows = activeRows
|
||||
.map((row) => {
|
||||
const safeName = escapeHtml(row.medicationName);
|
||||
const safePlannerUsage = Number(row.plannerUsage) || 0;
|
||||
@@ -312,7 +322,7 @@ ${getFooterPlain(language)}`;
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||
const pushMessage = `${summaryText}\n\n${rows
|
||||
const pushMessage = `${summaryText}\n\n${activeRows
|
||||
.map((r) => {
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
@@ -360,6 +370,16 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredLowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -374,9 +394,9 @@ ${getFooterPlain(language)}`;
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
// Separate into 3 categories: empty, critical, and low stock
|
||||
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
|
||||
// Build shared notification content (method-agnostic)
|
||||
const titleParts: string[] = [];
|
||||
@@ -504,7 +524,7 @@ ${getFooterPlain(language)}`;
|
||||
</tr>`;
|
||||
};
|
||||
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
const tableRows = filteredLowStock.map(buildTableRow).join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
@@ -587,8 +607,7 @@ ${getFooterPlain(language)}`;
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
const firstMed = lowStock[0];
|
||||
const medNames = lowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(userId, "stock", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -618,14 +637,24 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredPrescriptionLow.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const language = (userSettings.language as Language) || "en";
|
||||
const tr = getTranslations(language);
|
||||
|
||||
const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
|
||||
const lines = prescriptionLow.map((item) => {
|
||||
const lines = filteredPrescriptionLow.map((item) => {
|
||||
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
|
||||
if (item.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
@@ -640,7 +669,7 @@ ${getFooterPlain(language)}`;
|
||||
})}`;
|
||||
});
|
||||
|
||||
const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
@@ -665,9 +694,9 @@ ${getFooterPlain(language)}`;
|
||||
});
|
||||
|
||||
const subject =
|
||||
prescriptionLow.length === 1
|
||||
filteredPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length });
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
@@ -680,7 +709,7 @@ ${getFooterPlain(language)}`;
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
|
||||
const tableRows = prescriptionLow
|
||||
const tableRows = filteredPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
@@ -144,7 +144,11 @@ async function getMedicationsNeedingReminder(
|
||||
lowStockDays: number,
|
||||
language: Language
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
@@ -176,7 +180,11 @@ async function getMedicationsNeedingReminder(
|
||||
}
|
||||
|
||||
async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise<PrescriptionReminderItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
|
||||
@@ -99,6 +99,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
|
||||
@@ -94,6 +94,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
|
||||
@@ -111,6 +111,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
@@ -168,6 +171,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
@@ -188,6 +192,18 @@ describe("Planner Routes", () => {
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
|
||||
// Insert test medications so active-medication filters pass
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (1, 999999999, 'Aspirin', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (2, 999999999, 'Ibuprofen', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
|
||||
+8
-1
@@ -2,7 +2,14 @@
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"files": {
|
||||
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css", "frontend/e2e/**/*.ts", "frontend/playwright.config.ts"]
|
||||
"includes": [
|
||||
"backend/src/**/*.ts",
|
||||
"frontend/src/**/*.ts",
|
||||
"frontend/src/**/*.tsx",
|
||||
"frontend/src/**/*.css",
|
||||
"frontend/e2e/**/*.ts",
|
||||
"frontend/playwright.config.ts"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -454,6 +454,11 @@ function AppContent() {
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
onClose={closeUserFilter}
|
||||
onClearUser={() => {
|
||||
setSelectedUser(null);
|
||||
// Replace the userFilter history entry so it doesn't remain on the stack
|
||||
window.history.replaceState(null, "");
|
||||
}}
|
||||
onOpenMedDetail={openMedDetail}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -27,6 +27,17 @@ export function ConfirmModal({
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* DateInput - Custom date input that displays dates in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted date string on top of a native <input type="date">,
|
||||
* so the browser calendar popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDate, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateInput({ value, placeholder, className, ...rest }: DateInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
const displayValue = value ? formatDate(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="date" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* DateTimeInput - Custom datetime input that displays date+time in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted datetime string on top of a native <input type="datetime-local">,
|
||||
* so the browser datetime popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026, 20:30 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDateTime, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateTimeInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, placeholder, className, ...rest }: DateTimeInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
// datetime-local value is "YYYY-MM-DDTHH:MM" — formatDateTime handles this format
|
||||
const displayValue = value ? formatDateTime(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="datetime-local" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface LightboxProps {
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
@@ -19,10 +20,12 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<div className="lightbox-container">
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,16 @@ export function MedDetailModal({
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")} {selectedMed.takenBy.join(", ")}
|
||||
{t("modal.for")}{" "}
|
||||
{selectedMed.takenBy.map((person, index) => (
|
||||
<span key={person}>
|
||||
{index > 0 && ", "}
|
||||
{person}
|
||||
{selectedMed.intakes?.some(
|
||||
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
||||
) && <span className="taken-by-badge">🔔</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -287,7 +296,7 @@ export function MedDetailModal({
|
||||
{selectedMed.prescriptionEnabled && (
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("form.sections.prescription")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-grid prescription-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.authorizedRefills")}</span>
|
||||
<span className="med-detail-value">{selectedMed.prescriptionAuthorizedRefills ?? "—"}</span>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||
* Handles new medication creation and editing existing medications
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import { DOSE_UNITS } from "../types";
|
||||
import { deriveTotal } from "../utils";
|
||||
import { DateInput } from "./DateInput";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -25,6 +27,8 @@ export interface MobileEditModalProps {
|
||||
formSaved: boolean;
|
||||
formChanged: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
dateConsistencyError: string | null;
|
||||
readOnlyMode: boolean;
|
||||
// TakenBy tag input
|
||||
takenByInput: string;
|
||||
onTakenByInputChange: (value: string) => void;
|
||||
@@ -84,6 +88,8 @@ export function MobileEditModal({
|
||||
formSaved,
|
||||
formChanged,
|
||||
hasValidationErrors,
|
||||
dateConsistencyError,
|
||||
readOnlyMode,
|
||||
takenByInput,
|
||||
onTakenByInputChange,
|
||||
existingPeople,
|
||||
@@ -114,6 +120,18 @@ export function MobileEditModal({
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [show, onClose]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||
@@ -121,14 +139,11 @@ export function MobileEditModal({
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="edit-modal-header">
|
||||
<button type="button" className="ghost small" onClick={onClose}>
|
||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||
← {t("common.back")}
|
||||
</button>
|
||||
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
|
||||
<h2>{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}</h2>
|
||||
</div>
|
||||
<form
|
||||
className="form-grid mobile-edit-form"
|
||||
@@ -144,18 +159,19 @@ export function MobileEditModal({
|
||||
onSaveMedication(e);
|
||||
}}
|
||||
>
|
||||
<fieldset className="readonly-fieldset" disabled={readOnlyMode}>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
|
||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
required={!readOnlyMode}
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
@@ -167,6 +183,14 @@ export function MobileEditModal({
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.medicationStartDate")}
|
||||
<DateInput
|
||||
value={form.medicationStartDate}
|
||||
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
||||
/>
|
||||
{!readOnlyMode && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
@@ -315,8 +339,7 @@ export function MobileEditModal({
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
<DateInput
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
@@ -392,8 +415,7 @@ export function MobileEditModal({
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
<DateInput
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
@@ -402,6 +424,7 @@ export function MobileEditModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div className="full form-category refill-section">
|
||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
||||
{editingId ? (
|
||||
@@ -481,9 +504,12 @@ export function MobileEditModal({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="refill-unavailable">{t("refill.saveFirst", "Save medication first to enable refill")}</p>
|
||||
<p className="refill-unavailable">
|
||||
{t("refill.saveFirst", "Save medication first to enable refill")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
<div className="full form-category image-section">
|
||||
@@ -508,6 +534,7 @@ export function MobileEditModal({
|
||||
<div className="full form-category intake-section">
|
||||
<div className="form-category-header">
|
||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost add-blister"
|
||||
@@ -515,6 +542,7 @@ export function MobileEditModal({
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
@@ -540,8 +568,7 @@ export function MobileEditModal({
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<input
|
||||
type="date"
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
@@ -577,7 +604,7 @@ export function MobileEditModal({
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{form.intakes.length > 1 && (
|
||||
{!readOnlyMode && form.intakes.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
@@ -585,17 +612,20 @@ export function MobileEditModal({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
{readOnlyMode ? t("common.close") : t("common.cancel")}
|
||||
</button>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
disabled={saving || (!formChanged && (formSaved || !!editingId))}
|
||||
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
@@ -15,6 +16,7 @@ export interface UserFilterModalProps {
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
onClose: () => void;
|
||||
onClearUser: () => void;
|
||||
onOpenMedDetail: (med: Medication) => void;
|
||||
}
|
||||
|
||||
@@ -24,13 +26,14 @@ export function UserFilterModal({
|
||||
coverage,
|
||||
settings,
|
||||
onClose,
|
||||
onClearUser,
|
||||
onOpenMedDetail,
|
||||
}: UserFilterModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
|
||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
@@ -47,15 +50,29 @@ export function UserFilterModal({
|
||||
<div className="user-meds-list">
|
||||
{userMeds.map((med) => {
|
||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||
const status = medCoverage
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
||||
: getStockStatus(null, getMedTotal(med), settings);
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={med.id}
|
||||
className="user-med-item clickable"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onClearUser();
|
||||
onOpenMedDetail(med);
|
||||
}}
|
||||
>
|
||||
@@ -63,6 +80,25 @@ export function UserFilterModal({
|
||||
<div className="user-med-info">
|
||||
<span className="user-med-name">{med.name}</span>
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
{personIntakes.length > 0 && (
|
||||
<div className="user-med-intakes">
|
||||
{personIntakes.map((intake, idx) => {
|
||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<span key={idx} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{t("form.blisters.every")} {intake.every}{" "}
|
||||
{intake.every !== 1 ? t("common.days") : t("common.day")} {t("modal.at")} {timeStr}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
|
||||
@@ -270,15 +270,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Computed values - combine app language with timezone region for locale
|
||||
const systemLocale = getSystemLocale(i18n.language);
|
||||
const schedule = useMemo(
|
||||
() => buildSchedulePreview(medications.meds, systemLocale, true),
|
||||
[medications.meds, systemLocale]
|
||||
);
|
||||
const activeMeds = useMemo(() => medications.meds.filter((m) => !m.isObsolete), [medications.meds]);
|
||||
const schedule = useMemo(() => buildSchedulePreview(activeMeds, systemLocale, true), [activeMeds, systemLocale]);
|
||||
|
||||
const coverage = useMemo(
|
||||
() =>
|
||||
calculateCoverage(
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -287,7 +285,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
doses.takenDoseTimestamps
|
||||
),
|
||||
[
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -430,8 +428,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [groupedSchedule, scheduleDays]);
|
||||
|
||||
const missedPastDoseIds = useMemo(
|
||||
() => computeMissedPastDoseIds(pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses]
|
||||
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
|
||||
);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
@@ -486,8 +484,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = useCallback(() => {
|
||||
share.openShareDialog(medications.meds);
|
||||
}, [share, medications.meds]);
|
||||
share.openShareDialog(activeMeds);
|
||||
}, [share, activeMeds]);
|
||||
|
||||
// Get t function for translations
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -41,6 +41,7 @@ export const defaultForm = (): FormState => ({
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
prescriptionEnabled: false,
|
||||
@@ -230,6 +231,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications", { credentials: "include" })
|
||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medikamentenliste",
|
||||
"entries": "{{count}} Einträge",
|
||||
"entries_one": "{{count}} Eintrag",
|
||||
"entries_other": "{{count}} Einträge"
|
||||
"entries_other": "{{count}} Einträge",
|
||||
"markObsolete": "Als obsolet markieren",
|
||||
"reactivate": "Reaktivieren",
|
||||
"obsoleteTitle": "Obsolet ({{count}})",
|
||||
"obsoleteSince": "Beendet",
|
||||
"started": "Gestartet"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packungen",
|
||||
@@ -140,10 +145,15 @@
|
||||
"deleteModal": {
|
||||
"title": "Medikament löschen",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Medikament als obsolet markieren",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich als obsolet markieren?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Medikament bearbeiten",
|
||||
"viewEntry": "Medikament ansehen",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
"sections": {
|
||||
@@ -165,6 +175,7 @@
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillWeight": "Dosis pro Tablette",
|
||||
"total": "Gesamt (Tabletten)",
|
||||
"medicationStartDate": "Medikations-Startdatum",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
@@ -177,6 +188,9 @@
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Einnahmeplan",
|
||||
"remind": "Erinnern",
|
||||
@@ -406,6 +420,7 @@
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansehen",
|
||||
"delete": "Löschen",
|
||||
"remove": "Entfernen",
|
||||
"reset": "Zurücksetzen",
|
||||
@@ -513,7 +528,7 @@
|
||||
"prescription": {
|
||||
"enabled": "Rezept verfolgen",
|
||||
"authorizedRefills": "Genehmigte Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Rezept-Nachfüllungen",
|
||||
"lowThreshold": "Schwelle für Rezept-Erinnerung",
|
||||
"expiryDate": "Rezeptablauf",
|
||||
"useForRefill": "Rezept-Nachfüllung verwenden"
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medication list",
|
||||
"entries": "{{count}} entries",
|
||||
"entries_one": "{{count}} entry",
|
||||
"entries_other": "{{count}} entries"
|
||||
"entries_other": "{{count}} entries",
|
||||
"markObsolete": "Mark obsolete",
|
||||
"reactivate": "Reactivate",
|
||||
"obsoleteTitle": "Obsolete ({{count}})",
|
||||
"obsoleteSince": "Stopped",
|
||||
"started": "Started"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packs",
|
||||
@@ -140,10 +145,15 @@
|
||||
"deleteModal": {
|
||||
"title": "Delete medication",
|
||||
"message": "Do you really want to delete \"{{name}}\"?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Mark medication as obsolete",
|
||||
"message": "Do you really want to mark \"{{name}}\" as obsolete?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Edit medication",
|
||||
"viewEntry": "View medication",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
"sections": {
|
||||
@@ -165,6 +175,7 @@
|
||||
"loosePills": "Loose pills",
|
||||
"pillWeight": "Dose per pill",
|
||||
"total": "Total (pills)",
|
||||
"medicationStartDate": "Medication Start Date",
|
||||
"expiryDate": "Expiry Date",
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
@@ -177,6 +188,9 @@
|
||||
"weight": "e.g. 240",
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Intake schedule",
|
||||
"remind": "Remind",
|
||||
@@ -406,6 +420,7 @@
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"reset": "Reset",
|
||||
@@ -513,7 +528,7 @@
|
||||
"prescription": {
|
||||
"enabled": "Track prescription",
|
||||
"authorizedRefills": "Authorized refills",
|
||||
"remainingRefills": "Remaining refills",
|
||||
"remainingRefills": "Remaining prescription refills",
|
||||
"lowThreshold": "Low-refill reminder threshold",
|
||||
"expiryDate": "Prescription expiry",
|
||||
"useForRefill": "Use prescription refill"
|
||||
|
||||
@@ -607,10 +607,11 @@ export function DashboardPage() {
|
||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||
<span className="med-name-line">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-block-dash">
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy &&
|
||||
med.takenBy.length > 0 &&
|
||||
med.takenBy.map((person) => (
|
||||
{med?.takenBy && med.takenBy.length > 0 && (
|
||||
<span className="med-taken-by-line">
|
||||
{med.takenBy.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="taken-by-badge clickable"
|
||||
@@ -620,29 +621,20 @@ export function DashboardPage() {
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{(() => {
|
||||
const hasIntakeReminders =
|
||||
med?.intakes?.some((i) => i.intakeRemindersEnabled) ?? med?.intakeRemindersEnabled;
|
||||
return (
|
||||
(hasIntakeReminders || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{hasIntakeReminders && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{med?.notes && (
|
||||
<span className="med-icons">
|
||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.stock")} className={textClass}>
|
||||
{med?.packageType === "bottle"
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
||||
|
||||
export function MedicationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
meds,
|
||||
saving,
|
||||
@@ -63,6 +71,7 @@ export function MedicationsPage() {
|
||||
|
||||
// View mode: grid (default) or form (edit/new)
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">("grid");
|
||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||
|
||||
// Mobile modal state (declared early because it's used in useEffect below)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
@@ -92,11 +101,47 @@ export function MedicationsPage() {
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
// Track if close was confirmed programmatically (to avoid double confirmation)
|
||||
const closeConfirmedRef = useRef(false);
|
||||
// Pending action to execute after user confirms "Leave" in unsaved changes modal
|
||||
const pendingActionRef = useRef<(() => void) | null>(null);
|
||||
// Confirmation modal for unsaved changes
|
||||
const [showUnsavedConfirm, setShowUnsavedConfirm] = useState(false);
|
||||
const [unsavedConfirmSource, setUnsavedConfirmSource] = useState<"mobile-edit" | "desktop-form" | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<Medication | null>(null);
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||
const [showObsolete, setShowObsolete] = useState(true);
|
||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY));
|
||||
if (saved !== null) {
|
||||
setShowObsolete(saved === "true");
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const toggleObsoleteSection = useCallback(() => {
|
||||
setShowObsolete((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY), String(next));
|
||||
return next;
|
||||
});
|
||||
}, [user?.id]);
|
||||
|
||||
const loadAllMeds = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
const data = (await res.json()) as unknown;
|
||||
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
|
||||
} catch {
|
||||
setAllMeds([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAllMeds();
|
||||
}, [loadAllMeds]);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
@@ -112,6 +157,19 @@ export function MedicationsPage() {
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||
|
||||
const dateConsistencyError = useMemo(() => {
|
||||
const medicationStartDate = form.medicationStartDate;
|
||||
if (!medicationStartDate) return null;
|
||||
|
||||
const conflictingIntake = form.intakes.find((intake) => intake.startDate && intake.startDate < medicationStartDate);
|
||||
if (!conflictingIntake?.startDate) return null;
|
||||
|
||||
return t("form.validation.startDateAfterIntake", {
|
||||
medicationStartDate,
|
||||
intakeDate: conflictingIntake.startDate,
|
||||
});
|
||||
}, [form.medicationStartDate, form.intakes, t]);
|
||||
|
||||
// Open mobile edit modal
|
||||
function openEditModal() {
|
||||
setShowEditModal(true);
|
||||
@@ -135,20 +193,34 @@ export function MedicationsPage() {
|
||||
|
||||
// Handle confirmed close (user clicked "Leave" in confirmation modal)
|
||||
function handleConfirmClose() {
|
||||
const source = unsavedConfirmSource;
|
||||
const pendingAction = pendingActionRef.current;
|
||||
setShowUnsavedConfirm(false);
|
||||
setUnsavedConfirmSource(null);
|
||||
pendingActionRef.current = null;
|
||||
closeConfirmedRef.current = true;
|
||||
hasUnsavedHistoryState.current = false;
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
|
||||
if (pendingAction) {
|
||||
// There's a pending action (e.g. switching to another medication) — reset and run it
|
||||
resetForm();
|
||||
setReadOnlyView(false);
|
||||
pendingAction();
|
||||
} else if (source === "mobile-edit" && showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
setReadOnlyView(false);
|
||||
window.history.back();
|
||||
} else {
|
||||
// Desktop form — reset and go back to grid
|
||||
handleResetForm();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cancelled close (user clicked "Stay" in confirmation modal)
|
||||
function handleCancelClose() {
|
||||
setShowUnsavedConfirm(false);
|
||||
pendingActionRef.current = null;
|
||||
if (unsavedConfirmSource === "mobile-edit") {
|
||||
setShowEditModal(true);
|
||||
}
|
||||
@@ -163,9 +235,24 @@ export function MedicationsPage() {
|
||||
window.history.back();
|
||||
}
|
||||
resetForm();
|
||||
setReadOnlyView(false);
|
||||
setViewMode("grid");
|
||||
}
|
||||
|
||||
// Guard for desktop form Back/Cancel — shows unsaved changes modal if needed
|
||||
function handleDesktopFormLeave() {
|
||||
if (readOnlyView) {
|
||||
handleResetForm();
|
||||
return;
|
||||
}
|
||||
if (formChanged) {
|
||||
setUnsavedConfirmSource("desktop-form");
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
handleResetForm();
|
||||
}
|
||||
|
||||
function requestDeleteMed(med: Medication) {
|
||||
setDeleteCandidate(med);
|
||||
setShowDeleteConfirm(true);
|
||||
@@ -175,6 +262,7 @@ export function MedicationsPage() {
|
||||
async function handleConfirmDelete() {
|
||||
if (!deleteCandidate) return;
|
||||
await deleteMed(deleteCandidate.id, editingId, resetForm);
|
||||
await loadAllMeds();
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteCandidate(null);
|
||||
// Pop the delete-confirm history entry
|
||||
@@ -188,14 +276,70 @@ export function MedicationsPage() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function requestMarkObsolete(med: Medication) {
|
||||
setObsoleteCandidate(med);
|
||||
setShowObsoleteConfirm(true);
|
||||
window.history.pushState({ modal: "obsolete-confirm" }, "");
|
||||
}
|
||||
|
||||
async function handleConfirmMarkObsolete() {
|
||||
if (!obsoleteCandidate) return;
|
||||
await markMedicationObsolete(obsoleteCandidate.id);
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function handleCancelMarkObsolete() {
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
// Handle submit refill
|
||||
async function handleSubmitRefill(medId: number) {
|
||||
await submitRefill(medId, editingId, setForm, loadMeds, usePrescriptionRefill);
|
||||
await loadAllMeds();
|
||||
}
|
||||
|
||||
async function markMedicationObsolete(id: number) {
|
||||
try {
|
||||
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
|
||||
if (editingId === id) {
|
||||
handleResetForm();
|
||||
}
|
||||
loadMeds();
|
||||
await loadAllMeds();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function reactivateMedication(id: number) {
|
||||
try {
|
||||
await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" });
|
||||
loadMeds();
|
||||
await loadAllMeds();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Save medication
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (readOnlyView) return;
|
||||
if (hasValidationErrors || dateConsistencyError) {
|
||||
// Scroll to first visible error so the user sees what's wrong
|
||||
const firstError = document.querySelector(".field-error");
|
||||
if (firstError) {
|
||||
firstError.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Brief highlight pulse
|
||||
firstError.classList.add("error-pulse");
|
||||
setTimeout(() => firstError.classList.remove("error-pulse"), 1500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
|
||||
@@ -231,6 +375,7 @@ export function MedicationsPage() {
|
||||
looseTablets: Number(form.looseTablets) || 0,
|
||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||
doseUnit: form.doseUnit,
|
||||
medicationStartDate: form.medicationStartDate || null,
|
||||
expiryDate: form.expiryDate || null,
|
||||
notes: form.notes.trim() || null,
|
||||
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
||||
@@ -284,6 +429,7 @@ export function MedicationsPage() {
|
||||
|
||||
setFormSaved(true);
|
||||
loadMeds();
|
||||
void loadAllMeds();
|
||||
|
||||
// Clean up history state if we had unsaved changes
|
||||
if (hasUnsavedHistoryState.current) {
|
||||
@@ -310,6 +456,13 @@ export function MedicationsPage() {
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||
if (showObsoleteConfirm) {
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete confirmation is open — dismiss it and stay where we are
|
||||
if (showDeleteConfirm) {
|
||||
setShowDeleteConfirm(false);
|
||||
@@ -354,7 +507,7 @@ export function MedicationsPage() {
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [showDeleteConfirm, showEditModal, formChanged, resetForm]);
|
||||
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, formChanged, resetForm]);
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
@@ -369,167 +522,125 @@ export function MedicationsPage() {
|
||||
|
||||
// Handle edit button click - open modal on mobile, switch to form on desktop
|
||||
function handleEditClick(med: Medication) {
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
setReadOnlyView(false);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
setReadOnlyView(false);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
}
|
||||
|
||||
const orderedMeds = useMemo(() => {
|
||||
if (!editingId) {
|
||||
return meds;
|
||||
}
|
||||
|
||||
const selectedMedication = meds.find((med) => med.id === editingId);
|
||||
if (!selectedMedication) {
|
||||
return meds;
|
||||
}
|
||||
|
||||
return [selectedMedication, ...meds.filter((med) => med.id !== editingId)];
|
||||
}, [meds, editingId]);
|
||||
|
||||
const medListRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (viewMode !== "form" || !editingId) {
|
||||
function handleViewClick(med: Medication) {
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
setReadOnlyView(true);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (medListRef.current) {
|
||||
medListRef.current.scrollTop = 0;
|
||||
setReadOnlyView(true);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
}
|
||||
}, [viewMode, editingId]);
|
||||
|
||||
return (
|
||||
<section className={viewMode === "grid" ? "med-grid-wrapper" : "grid"}>
|
||||
{viewMode === "grid" ? (
|
||||
/* ── Grid View: compact medication cards ── */
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t("medications.list.title")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => {
|
||||
function handleNewEntryClick() {
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
resetForm();
|
||||
setReadOnlyView(false);
|
||||
if (window.innerWidth <= 768) {
|
||||
openEditModal();
|
||||
} else {
|
||||
setViewMode("form");
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {t("form.newEntry")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-grid">
|
||||
{orderedMeds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name-row">
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||||
<div className="med-name">{med.name}</div>
|
||||
</div>
|
||||
<div className="med-details">
|
||||
<span>
|
||||
{t("medications.details.type")}:{" "}
|
||||
<strong>
|
||||
{med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}
|
||||
</strong>
|
||||
</span>
|
||||
{med.packageType === "blister" ? (
|
||||
<>
|
||||
<span>
|
||||
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{t("medications.details.totalCapacity")}:{" "}
|
||||
<strong>{med.totalPills ?? med.looseTablets}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="med-total">
|
||||
{t("medications.details.stock")}:{" "}
|
||||
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
|
||||
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
||||
{(coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)) >
|
||||
getPackageSize(med) && (
|
||||
<span
|
||||
className="info-tooltip tooltip-align-left warning-text"
|
||||
data-tooltip={t("tooltips.stockExceedsCapacity")}
|
||||
>
|
||||
{" "}
|
||||
⚠️
|
||||
</span>
|
||||
)}
|
||||
{med.prescriptionEnabled && (
|
||||
<div className="med-total">
|
||||
{t("prescription.remainingRefills")}: <strong>{med.prescriptionRemainingRefills ?? 0}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
{editingId !== med.id && (
|
||||
<button className="info" onClick={() => handleEditClick(med)}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
)}
|
||||
<button className="danger" onClick={() => requestDeleteMed(med)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="blister-list">
|
||||
{med.blisters.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} · {t("form.blisters.every")}{" "}
|
||||
{s.every} {s.every === 1 ? t("common.day") : t("common.days")} · {t("form.blisters.from")}{" "}
|
||||
{formatDateTime(s.start)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
/* ── Form View: list panel + form panel (existing layout) ── */
|
||||
<>
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>{t("medications.list.title")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => {
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
// On mobile, open the edit modal
|
||||
setReadOnlyView(false);
|
||||
if (window.innerWidth <= 768) {
|
||||
openEditModal();
|
||||
} else {
|
||||
setViewMode("form");
|
||||
}
|
||||
}}
|
||||
>
|
||||
}
|
||||
|
||||
const activeMeds = useMemo(() => allMeds.filter((med) => !med.isObsolete), [allMeds]);
|
||||
const obsoleteMeds = useMemo(() => allMeds.filter((med) => med.isObsolete), [allMeds]);
|
||||
|
||||
const orderedMeds = useMemo(() => {
|
||||
if (!editingId) {
|
||||
return activeMeds;
|
||||
}
|
||||
|
||||
const selectedMedication = activeMeds.find((med) => med.id === editingId);
|
||||
if (!selectedMedication) {
|
||||
return activeMeds;
|
||||
}
|
||||
|
||||
return [selectedMedication, ...activeMeds.filter((med) => med.id !== editingId)];
|
||||
}, [activeMeds, editingId]);
|
||||
|
||||
const selectedMedication = useMemo(() => {
|
||||
if (!editingId) return null;
|
||||
return allMeds.find((med) => med.id === editingId) ?? null;
|
||||
}, [allMeds, editingId]);
|
||||
|
||||
return (
|
||||
<section className={`med-grid-wrapper${viewMode === "form" ? " desktop-edit-open" : ""}`}>
|
||||
{/* ── Grid View: always visible medication cards ── */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t("medications.list.title")}</h2>
|
||||
<button type="button" className="btn primary small" onClick={handleNewEntryClick}>
|
||||
+ {t("form.newEntry")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-list" ref={medListRef}>
|
||||
<div className="med-groups">
|
||||
<div className="med-group med-group-active">
|
||||
<div className="med-grid">
|
||||
{orderedMeds.map((med) => (
|
||||
<div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name-row">
|
||||
<span
|
||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||
onClick={() =>
|
||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
||||
}
|
||||
>
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||||
</span>
|
||||
<div className="med-name-block">
|
||||
<div className="med-name">{med.name}</div>
|
||||
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="danger" onClick={() => requestDeleteMed(med)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
|
||||
{t("medications.list.markObsolete")}
|
||||
</button>
|
||||
{editingId !== med.id && (
|
||||
<button className="info" onClick={() => handleEditClick(med)}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="med-details">
|
||||
<span>
|
||||
@@ -560,6 +671,11 @@ export function MedicationsPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{med.prescriptionEnabled && (
|
||||
<div className="med-total">
|
||||
{t("prescription.remainingRefills")}: <strong>{med.prescriptionRemainingRefills ?? 0}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="med-total">
|
||||
{t("medications.details.stock")}:{" "}
|
||||
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
|
||||
@@ -575,54 +691,110 @@ export function MedicationsPage() {
|
||||
⚠️
|
||||
</span>
|
||||
)}
|
||||
{med.prescriptionEnabled && (
|
||||
<div className="med-total">
|
||||
{t("prescription.remainingRefills")}:{" "}
|
||||
<strong>{med.prescriptionRemainingRefills ?? 0}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
{editingId !== med.id && (
|
||||
<button className="info" onClick={() => handleEditClick(med)}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
)}
|
||||
<button className="danger" onClick={() => requestDeleteMed(med)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="blister-list">
|
||||
{med.blisters.map((s, idx) => (
|
||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} · {t("form.blisters.every")}{" "}
|
||||
{s.every} {s.every === 1 ? t("common.day") : t("common.days")} · {t("form.blisters.from")}{" "}
|
||||
{formatDateTime(s.start)}
|
||||
{"takenBy" in s && s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
||||
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && (
|
||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||
{" "}
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{obsoleteMeds.length > 0 && (
|
||||
<div className="med-group med-group-obsolete">
|
||||
<button
|
||||
type="button"
|
||||
className="med-group-head med-group-head-toggle"
|
||||
onClick={toggleObsoleteSection}
|
||||
aria-expanded={showObsolete}
|
||||
>
|
||||
<h3 className="med-group-title">
|
||||
{showObsolete ? "▼" : "▶"} {t("medications.list.obsoleteTitle", { count: obsoleteMeds.length })}
|
||||
</h3>
|
||||
</button>
|
||||
{showObsolete && (
|
||||
<div className="med-grid med-grid-obsolete">
|
||||
{obsoleteMeds.map((med) => (
|
||||
<div key={med.id} className="med-row obsolete-row">
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name-row">
|
||||
<span
|
||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||
onClick={() =>
|
||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
||||
}
|
||||
>
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||||
</span>
|
||||
<div className="med-name-block">
|
||||
<div className="med-name">{med.name}</div>
|
||||
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="danger" onClick={() => requestDeleteMed(med)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
<button className="success" onClick={() => reactivateMedication(med.id)}>
|
||||
{t("medications.list.reactivate")}
|
||||
</button>
|
||||
<button className="info" onClick={() => handleViewClick(med)}>
|
||||
{t("common.view")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-details">
|
||||
{med.medicationStartDate && (
|
||||
<span style={{ gridColumn: "1 / -1" }}>
|
||||
{t("medications.list.started")}: <strong>{formatDate(med.medicationStartDate)}</strong>
|
||||
</span>
|
||||
)}
|
||||
<span style={{ gridColumn: "1 / -1" }}>
|
||||
{t("medications.list.obsoleteSince")}: <strong>{formatDate(med.obsoleteAt)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card form desktop-only">
|
||||
{/* ── Desktop Edit Panel: inline below medication list ── */}
|
||||
<aside className={`edit-sidebar desktop-only${viewMode === "form" ? " open" : ""}`}>
|
||||
<article className="card form">
|
||||
<div className="card-head">
|
||||
<div className="edit-header">
|
||||
<button type="button" className="ghost small" onClick={handleResetForm}>
|
||||
<button type="button" className="ghost small btn-nav" onClick={handleDesktopFormLeave}>
|
||||
← {t("common.back")}
|
||||
</button>
|
||||
{editingId ? (
|
||||
<>
|
||||
<MedicationAvatar
|
||||
name={meds.find((m) => m.id === editingId)?.name || ""}
|
||||
imageUrl={meds.find((m) => m.id === editingId)?.imageUrl}
|
||||
name={selectedMedication?.name || ""}
|
||||
imageUrl={selectedMedication?.imageUrl}
|
||||
size="md"
|
||||
/>
|
||||
<h2>
|
||||
{t("form.editEntry")}: {meds.find((m) => m.id === editingId)?.name}
|
||||
{readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedication?.name}
|
||||
</h2>
|
||||
</>
|
||||
) : (
|
||||
@@ -631,18 +803,19 @@ export function MedicationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={fieldErrors.name ? "has-error" : ""}>
|
||||
<label className={!readOnlyView && fieldErrors.name ? "has-error" : ""}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
required={!readOnlyView}
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
{!readOnlyView && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={fieldErrors.genericName ? "has-error" : ""}>
|
||||
{t("form.genericName")}
|
||||
@@ -654,17 +827,29 @@ export function MedicationsPage() {
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label>
|
||||
{t("form.medicationStartDate")}
|
||||
<DateInput
|
||||
value={form.medicationStartDate}
|
||||
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
|
||||
/>
|
||||
{!readOnlyView && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>}
|
||||
</label>
|
||||
<label className={fieldErrors.takenBy ? "has-error" : ""}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
{!readOnlyView && (
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!readOnlyView && (
|
||||
<>
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
@@ -673,7 +858,9 @@ export function MedicationsPage() {
|
||||
if (takenByInput.trim()) addTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={
|
||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
||||
form.takenBy.length === 0
|
||||
? t("form.placeholders.takenBy")
|
||||
: t("form.placeholders.addPerson")
|
||||
}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions"
|
||||
@@ -685,6 +872,8 @@ export function MedicationsPage() {
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
@@ -800,8 +989,7 @@ export function MedicationsPage() {
|
||||
</label>
|
||||
<label>
|
||||
{t("form.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
<DateInput
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => handleValueChange("expiryDate", e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
@@ -878,8 +1066,7 @@ export function MedicationsPage() {
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
<DateInput
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => handleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
@@ -888,6 +1075,7 @@ export function MedicationsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnlyView && (
|
||||
<div className="full form-category refill-section">
|
||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
||||
{editingId ? (
|
||||
@@ -972,10 +1160,12 @@ export function MedicationsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="full form-category intake-section">
|
||||
<div className="form-category-header">
|
||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||
{!readOnlyView && (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
@@ -983,6 +1173,7 @@ export function MedicationsPage() {
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
@@ -1009,8 +1200,7 @@ export function MedicationsPage() {
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startDate")}
|
||||
<input
|
||||
type="date"
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
@@ -1050,7 +1240,7 @@ export function MedicationsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{form.intakes.length > 1 && (
|
||||
{!readOnlyView && form.intakes.length > 1 && (
|
||||
<button type="button" className="danger" onClick={() => removeIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
@@ -1119,22 +1309,24 @@ export function MedicationsPage() {
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
<div className="full align-end gap">
|
||||
<button type="button" className="ghost" onClick={handleResetForm}>
|
||||
{t("common.cancel")}
|
||||
<button type="button" className="ghost" onClick={handleDesktopFormLeave}>
|
||||
{readOnlyView ? t("common.close") : t("common.cancel")}
|
||||
</button>
|
||||
{!readOnlyView && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
disabled={saving || (!formChanged && (formSaved || !!editingId))}
|
||||
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Mobile Edit Modal */}
|
||||
<MobileEditModal
|
||||
@@ -1147,6 +1339,8 @@ export function MedicationsPage() {
|
||||
formSaved={formSaved}
|
||||
formChanged={formChanged}
|
||||
hasValidationErrors={hasValidationErrors}
|
||||
dateConsistencyError={dateConsistencyError}
|
||||
readOnlyMode={readOnlyView}
|
||||
takenByInput={takenByInput}
|
||||
onTakenByInputChange={setTakenByInput}
|
||||
existingPeople={existingPeople}
|
||||
@@ -1168,7 +1362,7 @@ export function MedicationsPage() {
|
||||
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
|
||||
refillSaving={refillSaving}
|
||||
onSubmitRefill={handleSubmitRefill}
|
||||
meds={meds}
|
||||
meds={allMeds}
|
||||
onUploadMedImage={uploadMedImage}
|
||||
onDeleteMedImage={deleteMedImage}
|
||||
onClose={() => {
|
||||
@@ -1194,6 +1388,20 @@ export function MedicationsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Medication Confirmation Modal */}
|
||||
{showObsoleteConfirm && obsoleteCandidate && (
|
||||
<ConfirmModal
|
||||
title={t("medications.obsoleteModal.title")}
|
||||
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
|
||||
confirmLabel={t("medications.list.markObsolete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={handleConfirmMarkObsolete}
|
||||
onCancel={handleCancelMarkObsolete}
|
||||
confirmVariant="danger"
|
||||
overlayClassName={showEditModal ? "nested-confirm" : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Medication Confirmation Modal */}
|
||||
{showDeleteConfirm && deleteCandidate && (
|
||||
<ConfirmModal
|
||||
@@ -1207,6 +1415,11 @@ export function MedicationsPage() {
|
||||
overlayClassName={showEditModal ? "nested-confirm" : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{lightboxImage && (
|
||||
<Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={() => setLightboxImage(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { PlannerRow } from "../types";
|
||||
@@ -158,8 +158,7 @@ export function PlannerPage() {
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
{t("planner.from")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
<DateTimeInput
|
||||
step="60"
|
||||
value={range.start}
|
||||
onChange={(e) => setRange({ ...range, start: e.target.value })}
|
||||
@@ -167,12 +166,7 @@ export function PlannerPage() {
|
||||
</label>
|
||||
<label>
|
||||
{t("planner.until")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={range.end}
|
||||
onChange={(e) => setRange({ ...range, end: e.target.value })}
|
||||
/>
|
||||
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<label className="planner-checkbox">
|
||||
<input
|
||||
|
||||
+472
-43
@@ -30,6 +30,13 @@
|
||||
--btn-primary-bg: var(--accent);
|
||||
--btn-primary-hover: #3d94ff;
|
||||
--btn-ghost-hover: rgba(255, 255, 255, 0.08);
|
||||
--btn-danger-text: #2f0a0a;
|
||||
--btn-success-text: #0a2b1f;
|
||||
--btn-obsolete-bg: linear-gradient(135deg, #f7d14a 0%, #f2b91a 100%);
|
||||
--btn-obsolete-hover: linear-gradient(135deg, #f9db72 0%, #f5c73c 100%);
|
||||
--btn-obsolete-text: #2b2205;
|
||||
--btn-obsolete-border: #f8e38a;
|
||||
--btn-obsolete-shadow: 0 6px 14px rgba(252, 211, 77, 0.28);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -60,6 +67,13 @@
|
||||
--btn-primary-bg: var(--accent);
|
||||
--btn-primary-hover: #1d4ed8;
|
||||
--btn-ghost-hover: rgba(0, 0, 0, 0.06);
|
||||
--btn-danger-text: #ffffff;
|
||||
--btn-success-text: #ffffff;
|
||||
--btn-obsolete-bg: linear-gradient(135deg, #f5b52c 0%, #f59e0b 100%);
|
||||
--btn-obsolete-hover: linear-gradient(135deg, #f8c85b 0%, #f7ad2d 100%);
|
||||
--btn-obsolete-text: #ffffff;
|
||||
--btn-obsolete-border: #d48806;
|
||||
--btn-obsolete-shadow: 0 6px 14px rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -90,6 +104,7 @@ body.modal-open {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -491,8 +506,10 @@ body.modal-open {
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
margin-bottom: 1rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -617,19 +634,128 @@ body.modal-open {
|
||||
.med-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.med-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.med-group {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.med-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.med-group-head-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.1rem 0.25rem;
|
||||
margin: -0.1rem -0.25rem 0.8rem;
|
||||
}
|
||||
|
||||
.med-group-head-toggle:hover .med-group-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.med-group-title {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.med-group-obsolete {
|
||||
border-color: var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.med-grid-obsolete {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.obsolete-row {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button {
|
||||
opacity: 0.72;
|
||||
filter: saturate(0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.med-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Flatten nested boxes on mobile to reclaim horizontal space */
|
||||
.med-grid-wrapper > .card {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.med-grid-wrapper .card-head {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.med-group,
|
||||
.med-groups {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.med-group-head {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.med-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.blister-row-simple {
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.med-details {
|
||||
gap: 0.2rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
}
|
||||
|
||||
.med-row {
|
||||
display: grid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-row: span 2;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
@@ -646,9 +772,8 @@ body.modal-open {
|
||||
}
|
||||
.med-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.med-info {
|
||||
flex: 1;
|
||||
@@ -661,7 +786,7 @@ body.modal-open {
|
||||
}
|
||||
.med-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 0.25rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
@@ -690,8 +815,9 @@ body.modal-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
width: 100%;
|
||||
align-self: start;
|
||||
}
|
||||
.blister-row-simple {
|
||||
color: var(--text-muted);
|
||||
@@ -788,6 +914,8 @@ body.modal-open {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.med-actions button {
|
||||
padding: 0.5rem 0.9rem;
|
||||
@@ -800,6 +928,9 @@ body.modal-open {
|
||||
.med-actions {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.med-details {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
.blister-list {
|
||||
display: flex;
|
||||
@@ -931,7 +1062,7 @@ button.secondary:hover {
|
||||
/* Success button (Refill, etc.) */
|
||||
button.success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
color: var(--btn-success-text);
|
||||
border: none;
|
||||
}
|
||||
button.success:hover {
|
||||
@@ -982,10 +1113,39 @@ button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
|
||||
/* Navigation button (Back): neutral and low visual urgency */
|
||||
button.btn-nav {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-nav:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Reversible status-change button (Mark obsolete): warning, not destructive */
|
||||
button.btn-obsolete {
|
||||
background: var(--btn-obsolete-bg);
|
||||
border: 1px solid var(--btn-obsolete-border);
|
||||
color: var(--btn-obsolete-text);
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
button.btn-obsolete:hover {
|
||||
background: var(--btn-obsolete-hover);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-obsolete:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Danger button (Delete, etc.) */
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
color: var(--btn-danger-text);
|
||||
border: none;
|
||||
}
|
||||
button.danger:hover {
|
||||
@@ -1212,6 +1372,70 @@ textarea.auto-resize {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.field-error.error-pulse {
|
||||
animation: error-pulse-anim 1.5s ease;
|
||||
}
|
||||
|
||||
@keyframes error-pulse-anim {
|
||||
0%,
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
15% {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
85% {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
button.has-validation-error {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.readonly-fieldset {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Subtle read-only styling for disabled fieldset inputs */
|
||||
.readonly-fieldset:disabled input,
|
||||
.readonly-fieldset:disabled select,
|
||||
.readonly-fieldset:disabled textarea,
|
||||
.readonly-fieldset:disabled .date-input-wrapper {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
border-color: transparent;
|
||||
background: var(--bg-input);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .date-input-display {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .tag {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .static-value {
|
||||
opacity: 0.55;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .tag-input-container {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Dose input with unit selector */
|
||||
.dose-input-group {
|
||||
display: flex;
|
||||
@@ -2038,13 +2262,13 @@ textarea.auto-resize {
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.table-row span {
|
||||
.table-row > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
.table-row span::before {
|
||||
.table-row > span::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--accent-light);
|
||||
@@ -2055,44 +2279,44 @@ textarea.auto-resize {
|
||||
text-align: left;
|
||||
}
|
||||
/* First span (name cell) - centered horizontal layout */
|
||||
.table-row span:first-child {
|
||||
justify-content: center;
|
||||
.table-row > span:first-child {
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 0.15rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.table-row span:first-child::before {
|
||||
.table-row > span:first-child::before {
|
||||
display: none; /* Hide "NAME" label on mobile */
|
||||
}
|
||||
/* Status chip in table row - left aligned */
|
||||
.table-row span.status-chip {
|
||||
.table-row > span.status-chip {
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.table-row span.status-chip::before {
|
||||
.table-row > span.status-chip::before {
|
||||
margin-right: 0;
|
||||
}
|
||||
/* Avatar + name layout - centered */
|
||||
/* Avatar + name layout - left aligned */
|
||||
.table-row .cell-with-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.table-row .cell-with-avatar .med-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.table-row .cell-with-avatar .med-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Icons on separate line on mobile - centered under med name */
|
||||
/* Icons on separate line on mobile - left aligned */
|
||||
.table-row .cell-with-avatar .med-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2102,6 +2326,30 @@ textarea.auto-resize {
|
||||
.table-row .notes-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Prevent data-label ::before on nested spans inside name cell */
|
||||
.table-row .cell-with-avatar span::before {
|
||||
display: none;
|
||||
}
|
||||
.table-row .cell-with-avatar .taken-by-badge,
|
||||
.table-row .cell-with-avatar .med-name-text {
|
||||
display: inline !important;
|
||||
justify-content: initial;
|
||||
}
|
||||
.table-row .med-taken-by-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.table-row .cell-with-avatar .taken-by-badge {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
margin-left: 0;
|
||||
}
|
||||
.table-4 .table-head,
|
||||
.table-4 .table-row,
|
||||
.table-5 .table-head,
|
||||
@@ -2116,7 +2364,17 @@ textarea.auto-resize {
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page {
|
||||
padding: 1rem 0.75rem 2rem;
|
||||
padding: 0.75rem 0.4rem 2rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.65rem;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -3389,6 +3647,20 @@ textarea.auto-resize {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.med-avatar-clickable {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
.med-avatar-clickable .med-avatar {
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.med-avatar-clickable:hover .med-avatar {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Table/Timeline cells with avatar */
|
||||
.cell-with-avatar {
|
||||
display: flex;
|
||||
@@ -3403,6 +3675,21 @@ textarea.auto-resize {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.med-taken-by-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.med-name-block-dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cell-with-avatar .med-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3444,12 +3731,26 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.med-name-block {
|
||||
min-width: 0;
|
||||
}
|
||||
.med-generic-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-preview button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
@@ -3509,19 +3810,24 @@ textarea.auto-resize {
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close,
|
||||
.lightbox-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--btn-ghost-hover);
|
||||
border: 1px solid var(--border-secondary);
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3738,6 +4044,19 @@ textarea.auto-resize {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-med-intakes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.user-med-intake-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-meds-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -3828,6 +4147,16 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-item {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(2.6em, auto) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-value {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.med-detail-item {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
@@ -3961,19 +4290,19 @@ textarea.auto-resize {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.lightbox-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
min-height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -3981,11 +4310,11 @@ textarea.auto-resize {
|
||||
transition: background 150ms ease;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
@@ -5645,6 +5974,37 @@ a.about-version-link:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Desktop Edit Panel (two-column layout) ── */
|
||||
.edit-sidebar {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.med-grid-wrapper.desktop-edit-open {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(380px, 46%);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.med-grid-wrapper.desktop-edit-open .med-grid,
|
||||
.med-grid-wrapper.desktop-edit-open .med-grid-obsolete {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.edit-sidebar.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-sidebar .card {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Desktop only - hide on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
@@ -5657,7 +6017,7 @@ a.about-version-link:hover {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
@@ -5687,8 +6047,9 @@ a.about-version-link:hover {
|
||||
|
||||
.mobile-edit-form .form-category {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.8rem;
|
||||
gap: 0.75rem 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-color: color-mix(in srgb, var(--border-primary) 50%, transparent);
|
||||
}
|
||||
|
||||
.mobile-edit-form .refill-prescription-row {
|
||||
@@ -5848,6 +6209,74 @@ a.about-version-link:hover {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Custom DateInput / DateTimeInput
|
||||
========================================== */
|
||||
.date-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input-display {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
right: 2.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-input-native {
|
||||
color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure native text stays invisible on focus/selection */
|
||||
.date-input-native:focus,
|
||||
.date-input-native:active {
|
||||
color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
|
||||
.date-input-native::selection {
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.date-input-native::-webkit-datetime-edit {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Keep the calendar/clock picker icon visible and clickable */
|
||||
.date-input-native::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
filter: invert(0.8);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.date-input-native::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Light theme: don't invert icon */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.date-input-native::-webkit-calendar-picker-indicator {
|
||||
filter: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.action-card {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,9 +14,16 @@ const defaultForm: FormState = {
|
||||
looseTablets: "0",
|
||||
totalPills: "",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionAuthorizedRefills: "",
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
blisters: [
|
||||
{
|
||||
usage: "1",
|
||||
@@ -47,6 +54,8 @@ const defaultProps = {
|
||||
formSaved: false,
|
||||
formChanged: false,
|
||||
hasValidationErrors: false,
|
||||
dateConsistencyError: null,
|
||||
readOnlyMode: false,
|
||||
takenByInput: "",
|
||||
onTakenByInputChange: vi.fn(),
|
||||
existingPeople: [],
|
||||
@@ -108,7 +117,7 @@ describe("MobileEditModal", () => {
|
||||
it("renders close button", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const closeBtn = document.querySelector(".modal-close");
|
||||
const closeBtn = document.querySelector(".btn-nav");
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -116,7 +125,7 @@ describe("MobileEditModal", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const closeBtn = document.querySelector(".modal-close");
|
||||
const closeBtn = document.querySelector(".btn-nav");
|
||||
if (closeBtn) {
|
||||
fireEvent.click(closeBtn);
|
||||
}
|
||||
@@ -191,7 +200,7 @@ describe("MobileEditModal", () => {
|
||||
render(<MobileEditModal {...defaultProps} hasValidationErrors={true} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(saveBtn).toBeDisabled();
|
||||
expect(saveBtn).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("renders add intake button", () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -63,6 +64,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -81,6 +83,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -100,6 +103,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -118,6 +122,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -136,6 +141,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -154,6 +160,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -175,6 +182,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -187,8 +195,9 @@ describe("UserFilterModal", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose and onOpenMedDetail when medication clicked", () => {
|
||||
it("calls onClearUser and onOpenMedDetail when medication clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
const onClearUser = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -198,6 +207,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={onClearUser}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -207,7 +217,7 @@ describe("UserFilterModal", () => {
|
||||
fireEvent.click(medItem);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onClearUser).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenMedDetail).toHaveBeenCalledWith(mockMedication);
|
||||
});
|
||||
|
||||
@@ -222,6 +232,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -243,6 +254,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -272,6 +284,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("useMedications", () => {
|
||||
expect(result.current.meds).toEqual(mockMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
});
|
||||
|
||||
it("handles API error gracefully", async () => {
|
||||
@@ -123,7 +123,7 @@ describe("useMedications", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE", credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
expect(mockResetForm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -607,10 +607,7 @@ describe("DashboardPage with medications", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Aspirin has intakeRemindersEnabled and notes
|
||||
const reminderIcons = document.querySelectorAll(".reminder-icon");
|
||||
expect(reminderIcons.length).toBeGreaterThan(0);
|
||||
|
||||
// Aspirin has notes
|
||||
const notesIcons = document.querySelectorAll(".notes-icon");
|
||||
expect(notesIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,8 @@ const createMockFormHook = (overrides = {}) => ({
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
medicationStartDate: "",
|
||||
doseUnit: "mg" as const,
|
||||
},
|
||||
setForm: vi.fn(),
|
||||
editingId: null,
|
||||
@@ -132,6 +134,10 @@ vi.mock("../../context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/Auth", () => ({
|
||||
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -156,7 +162,8 @@ describe("MedicationsPage", () => {
|
||||
it("renders list-first view with new button", () => {
|
||||
renderPage();
|
||||
expect(screen.getByText(/medications\.list\.title/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/form\.newEntry/i)).toBeInTheDocument();
|
||||
// Button text and form heading both contain "form.newEntry" in the DOM
|
||||
expect(screen.getAllByText(/form\.newEntry/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("opens form after clicking new button", () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type Medication = {
|
||||
lastStockCorrectionAt?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
medicationStartDate?: string | null;
|
||||
blisters: Blister[]; // Legacy array format
|
||||
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||
imageUrl?: string | null;
|
||||
@@ -58,6 +59,8 @@ export type Medication = {
|
||||
prescriptionLowRefillThreshold?: number;
|
||||
prescriptionExpiryDate?: string | null;
|
||||
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
|
||||
isObsolete?: boolean;
|
||||
obsoleteAt?: string | null;
|
||||
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
@@ -114,6 +117,7 @@ export type FormState = {
|
||||
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
||||
pillWeightMg: string;
|
||||
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
medicationStartDate: string;
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
prescriptionEnabled: boolean;
|
||||
|
||||
@@ -71,11 +71,56 @@ export function getRegionFromTimezone(): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for formatting based on app language and timezone region.
|
||||
* Combines app language (en/de) with region from timezone (DE/US/etc.)
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE (English text, German format)
|
||||
* Map region code to the region's primary language for date/number formatting.
|
||||
* This ensures dates use regional conventions (e.g., dots in Germany)
|
||||
* regardless of the app's UI language.
|
||||
*/
|
||||
const REGION_TO_LANG: Record<string, string> = {
|
||||
DE: "de",
|
||||
AT: "de",
|
||||
CH: "de",
|
||||
GB: "en",
|
||||
IE: "en",
|
||||
FR: "fr",
|
||||
ES: "es",
|
||||
IT: "it",
|
||||
NL: "nl",
|
||||
BE: "nl",
|
||||
PL: "pl",
|
||||
CZ: "cs",
|
||||
SE: "sv",
|
||||
NO: "nb",
|
||||
DK: "da",
|
||||
FI: "fi",
|
||||
GR: "el",
|
||||
PT: "pt",
|
||||
RU: "ru",
|
||||
UA: "uk",
|
||||
HU: "hu",
|
||||
RO: "ro",
|
||||
US: "en",
|
||||
CA: "en",
|
||||
MX: "es",
|
||||
BR: "pt",
|
||||
AR: "es",
|
||||
JP: "ja",
|
||||
CN: "zh",
|
||||
HK: "zh",
|
||||
SG: "en",
|
||||
KR: "ko",
|
||||
AE: "ar",
|
||||
IN: "en",
|
||||
AU: "en",
|
||||
NZ: "en",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get locale for text-based date formatting (weekday names, month names).
|
||||
* Uses the app's UI language + timezone region so text appears in the app language
|
||||
* while regional conventions (day-first order) are respected.
|
||||
*
|
||||
* @param appLanguage - The app's UI language (e.g., 'en', 'de')
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE
|
||||
* → "Thu, 05. Feb." (English names, German order)
|
||||
*/
|
||||
export function getSystemLocale(appLanguage?: string): string {
|
||||
const region = getRegionFromTimezone();
|
||||
@@ -89,6 +134,25 @@ export function getSystemLocale(appLanguage?: string): string {
|
||||
return navigator.language || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for purely numeric date/number formatting.
|
||||
* Uses the region's native language so separators match regional conventions
|
||||
* (e.g., dots in Germany: 14.02.2026, slashes in US: 02/14/2026).
|
||||
*
|
||||
* Only use this for numeric-only output (2-digit day/month/year, no text).
|
||||
* For output that includes weekday or month names, use getSystemLocale() instead.
|
||||
*/
|
||||
export function getNumericLocale(): string {
|
||||
const region = getRegionFromTimezone();
|
||||
|
||||
if (region) {
|
||||
const regionLang = REGION_TO_LANG[region] || "en";
|
||||
return `${regionLang}-${region}`;
|
||||
}
|
||||
|
||||
return navigator.language || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number using the current locale with optional decimal places
|
||||
*/
|
||||
@@ -114,7 +178,7 @@ export function formatDateTime(iso: string | null | undefined, locale?: string):
|
||||
if (!match) return "-";
|
||||
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
const effectiveLocale = locale ?? getSystemLocale();
|
||||
const effectiveLocale = locale ?? getNumericLocale();
|
||||
|
||||
// Create a date object for formatting, but use local timezone interpretation
|
||||
// by creating the date without the Z suffix
|
||||
@@ -136,6 +200,20 @@ export function formatDateTime(iso: string | null | undefined, locale?: string):
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date-only string (YYYY-MM-DD) or ISO datetime to a localized date (no time).
|
||||
*/
|
||||
export function formatDate(dateStr: string | null | undefined, locale?: string): string {
|
||||
if (!dateStr) return "-";
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!match) return "-";
|
||||
const [, year, month, day] = match;
|
||||
const d = new Date(`${year}-${month}-${day}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return "-";
|
||||
const effectiveLocale = locale ?? getNumericLocale();
|
||||
return d.toLocaleDateString(effectiveLocale, { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a number to 2 digits with leading zero
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user