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:
Daniel Volz
2026-02-15 23:23:38 +01:00
committed by GitHub
parent c47a35d642
commit e26f00557a
38 changed files with 2042 additions and 907 deletions
@@ -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;
+14
View File
@@ -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
}
]
}
+5
View File
@@ -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`,
+3
View File
@@ -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"),
+9
View File
@@ -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,
+99 -3
View File
@@ -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
+48 -19
View File
@@ -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);
+11 -3
View File
@@ -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(
+3
View File
@@ -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,
+3
View File
@@ -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,
+16
View File
@@ -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
View File
@@ -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,
+5
View File
@@ -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}
/>
+12 -1
View File
@@ -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" }}>
+38
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+7 -4
View File
@@ -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}>
<button className="lightbox-close" onClick={onClose}>
×
</button>
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
<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>
);
}
+11 -2
View File
@@ -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>
+461 -431
View File
@@ -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,458 +159,473 @@ export function MobileEditModal({
onSaveMedication(e);
}}
>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.general")}</h4>
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required
/>
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<fieldset className="readonly-fieldset" disabled={readOnlyMode}>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.general")}</h4>
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required={!readOnlyMode}
/>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
</div>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.totalPills}
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</label>
<label className="full">
{t("form.expiryDate")}
<input
type="date"
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
</div>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
<label className="full">
{t("prescription.enabled")}
<label className="toggle-switch small">
<input
type="checkbox"
checked={form.prescriptionEnabled}
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
</label>
{form.prescriptionEnabled && (
<>
<label className="prescription-field">
{t("prescription.authorizedRefills")}
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className="full">
{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">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionAuthorizedRefills}
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
/>
</label>
<label className="prescription-field">
{t("prescription.remainingRefills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionRemainingRefills}
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.lowThreshold")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionLowRefillThreshold}
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.expiryDate")}
<input
type="date"
value={form.prescriptionExpiryDate}
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
/>
</label>
</>
)}
</div>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
</div>
<div className="full form-category refill-section">
<h4 className="form-category-title">{t("refill.title")}</h4>
{editingId ? (
<>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label className="full">
{t("refill.pillsToAdd")}
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
)}
<div className="refill-submit-row full">
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
{form.prescriptionEnabled && (
<div className="refill-prescription-row full">
<label className="refill-prescription-toggle">
<input
type="checkbox"
checked={usePrescriptionRefill}
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
/>
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
</label>
<span className="refill-remaining-badge">
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
</span>
</div>
)}
</>
) : (
<p className="refill-unavailable">{t("refill.saveFirst", "Save medication first to enable refill")}</p>
)}
</div>
{editingId && (
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{currentMed?.imageUrl ? (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
<label>
{t("form.blistersPerPack")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
<>
<label>
{t("form.totalCapacity")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.totalPills}
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
</div>
)}
<div className="full form-category intake-section">
<div className="form-category-header">
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
<button
type="button"
className="ghost add-blister"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
</div>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={intake.usage}
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={intake.every}
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<input
type="date"
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{form.takenBy.length === 0 ? null : (
<label className="compact full-row">
<span>{t("form.blisters.takenByIntake")}</span>
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">🔔</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
{form.intakes.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
{t("common.remove")}
</label>
<label className="full">
{t("form.expiryDate")}
<DateInput
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
</div>
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
<label className="full">
{t("prescription.enabled")}
<label className="toggle-switch small">
<input
type="checkbox"
checked={form.prescriptionEnabled}
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</label>
{form.prescriptionEnabled && (
<>
<label className="prescription-field">
{t("prescription.authorizedRefills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionAuthorizedRefills}
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.remainingRefills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionRemainingRefills}
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.lowThreshold")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.prescriptionLowRefillThreshold}
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
/>
</label>
<label className="prescription-field">
{t("prescription.expiryDate")}
<DateInput
value={form.prescriptionExpiryDate}
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
/>
</label>
</>
)}
</div>
{!readOnlyMode && (
<div className="full form-category refill-section">
<h4 className="form-category-title">{t("refill.title")}</h4>
{editingId ? (
<>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label className="full">
{t("refill.pillsToAdd")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<div className="refill-submit-row full">
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
{form.prescriptionEnabled && (
<div className="refill-prescription-row full">
<label className="refill-prescription-toggle">
<input
type="checkbox"
checked={usePrescriptionRefill}
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
/>
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
</label>
<span className="refill-remaining-badge">
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
</span>
</div>
)}
</>
) : (
<p className="refill-unavailable">
{t("refill.saveFirst", "Save medication first to enable refill")}
</p>
)}
</div>
)}
{editingId && (
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{currentMed?.imageUrl ? (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
) : (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
)}
</div>
)}
<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"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
)}
</div>
))}
</div>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={intake.usage}
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={intake.every}
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{form.takenBy.length === 0 ? null : (
<label className="compact full-row">
<span>{t("form.blisters.takenByIntake")}</span>
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">🔔</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
{t("common.remove")}
</button>
)}
</div>
))}
</div>
</fieldset>
<div className="modal-footer">
<button type="button" className="ghost" onClick={onClose}>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
{readOnlyMode ? t("common.close") : t("common.cancel")}
</button>
{!readOnlyMode && (
<button
type="submit"
disabled={saving || (!formChanged && (formSaved || !!editingId))}
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
)}
</div>
</form>
</div>
+40 -4
View File
@@ -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">
+2
View File
@@ -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";
+8 -10
View File
@@ -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();
+2
View File
@@ -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,
+1 -1
View File
@@ -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([]))
+17 -2
View File
@@ -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"
+17 -2
View File
@@ -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"
+26 -34
View File
@@ -607,42 +607,34 @@ 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-text">{row.name}</span>
{med?.takenBy &&
med.takenBy.length > 0 &&
med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
>
{person}
<span className="med-name-block-dash">
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && (
<span className="med-taken-by-line">
{med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
>
{person}
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"}
</span>
))}
</span>
))}
)}
</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>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</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"
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -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
View File
@@ -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 {
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: flex;
flex-direction: column;
gap: 0.75rem;
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", () => {
+4
View File
@@ -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;
+83 -5
View File
@@ -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
*/