diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index 4fced15..d48a5a3 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -23,7 +23,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images"); // ============================================================================= // Export Format Version (bump this when format changes) // ============================================================================= -const EXPORT_VERSION = "1.4"; +const EXPORT_VERSION = "1.5"; // ============================================================================= // Zod Schemas for Import Validation @@ -96,7 +96,8 @@ const doseHistorySchema = z.object({ const refillHistoryExportSchema = z.object({ medicationRef: z.string(), // References _exportId packsAdded: z.number().int().min(0).default(0), - loosePillsAdded: z.number().int().min(0).default(0), + loosePillsAdded: z.number().int().min(0).optional(), + quantityAdded: z.number().int().min(0).optional(), usedPrescription: z.boolean().default(false), refillDate: z.string(), // ISO datetime }); @@ -108,37 +109,44 @@ const shareLinkSchema = z.object({ regenerateToken: z.boolean().default(true), }); -const settingsExportSchema = z - .object({ - // Email notifications - emailEnabled: z.boolean().default(false), - notificationEmail: z.string().nullable().optional(), - emailStockReminders: z.boolean().default(true), - emailIntakeReminders: z.boolean().default(true), - emailPrescriptionReminders: z.boolean().default(true), - // Push notifications - shoutrrrEnabled: z.boolean().optional(), - shoutrrrUrl: z.string().nullable().optional(), - shoutrrrStockReminders: z.boolean().default(true), - shoutrrrIntakeReminders: z.boolean().default(true), - shoutrrrPrescriptionReminders: z.boolean().default(true), - // Reminder settings - reminderDaysBefore: z.number().int().default(7), - repeatDailyReminders: z.boolean().default(false), - skipRemindersForTakenDoses: z.boolean().default(false), - repeatRemindersEnabled: z.boolean().default(false), - reminderRepeatIntervalMinutes: z.number().int().default(30), - maxNaggingReminders: z.number().int().default(5), - // Stock thresholds - lowStockDays: z.number().int().default(30), - normalStockDays: z.number().int().default(90), - highStockDays: z.number().int().default(180), - expiryWarningDays: z.number().int().default(90), - // UI preferences - language: z.string().default("en"), - stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"), - shareStockStatus: z.boolean().default(true), - shareMedicationOverview: z.boolean().default(false), +const settingsSchemaBase = z.object({ + // Email notifications + emailEnabled: z.boolean().default(false), + notificationEmail: z.string().nullable().optional(), + emailStockReminders: z.boolean().default(true), + emailIntakeReminders: z.boolean().default(true), + emailPrescriptionReminders: z.boolean().default(true), + // Push notifications + shoutrrrEnabled: z.boolean().optional(), + shoutrrrUrl: z.string().nullable().optional(), + shoutrrrStockReminders: z.boolean().default(true), + shoutrrrIntakeReminders: z.boolean().default(true), + shoutrrrPrescriptionReminders: z.boolean().default(true), + // Reminder settings + reminderDaysBefore: z.number().int().default(7), + repeatDailyReminders: z.boolean().default(false), + skipRemindersForTakenDoses: z.boolean().default(false), + repeatRemindersEnabled: z.boolean().default(false), + reminderRepeatIntervalMinutes: z.number().int().default(30), + maxNaggingReminders: z.number().int().default(5), + // Stock thresholds + lowStockDays: z.number().int().default(30), + normalStockDays: z.number().int().default(90), + highStockDays: z.number().int().default(180), + expiryWarningDays: z.number().int().default(90), + // UI preferences + language: z.string().default("en"), + stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"), + shareMedicationOverview: z.boolean().default(false), +}); + +const exportSettingsSchema = settingsSchemaBase.optional(); + +const importSettingsSchema = settingsSchemaBase + .extend({ + // Accept the removed field from legacy exports so old backups still import, + // but do not map it back into current runtime settings. + shareStockStatus: z.boolean().optional(), }) .optional(); @@ -149,7 +157,7 @@ const importDataSchema = z.object({ medications: z.array(medicationExportSchema).default([]), doseHistory: z.array(doseHistorySchema).default([]), refillHistory: z.array(refillHistoryExportSchema).default([]), - settings: settingsExportSchema, + settings: importSettingsSchema, shareLinks: z.array(shareLinkSchema).default([]), }); @@ -210,7 +218,7 @@ const importBodyOpenApiSchema = { }, ], doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }], - refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }], + refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }], settings: { language: "en", stockCalculationMode: "automatic" }, shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }], }, @@ -370,6 +378,7 @@ export async function exportRoutes(app: FastifyInstance) { // 1. Load all medications const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); + const medicationById = new Map(meds.map((med) => [med.id, med])); // Build medication ID to export ID mapping const medIdToExportId = new Map(); @@ -509,7 +518,6 @@ export async function exportRoutes(app: FastifyInstance) { expiryWarningDays: settings.expiryWarningDays, language: settings.language, stockCalculationMode: settings.stockCalculationMode, - shareStockStatus: settings.shareStockStatus, shareMedicationOverview: settings.shareMedicationOverview ?? false, } : undefined; @@ -548,6 +556,13 @@ export async function exportRoutes(app: FastifyInstance) { .map((refill) => { const exportId = medIdToExportId.get(refill.medicationId); if (!exportId) return null; // Orphaned refill, skip + const medication = medicationById.get(refill.medicationId); + const packageType = normalizePackageType(medication?.packageType); + const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1)); + const quantityAdded = + packageType === "bottle" || packageType === "tube" || packageType === "liquid_container" + ? (refill.loosePillsAdded ?? 0) + : (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0); // Safely convert refillDate to ISO string let refillDateIso: string; @@ -568,6 +583,7 @@ export async function exportRoutes(app: FastifyInstance) { medicationRef: exportId, packsAdded: refill.packsAdded ?? 0, loosePillsAdded: refill.loosePillsAdded ?? 0, + quantityAdded, usedPrescription: refill.usedPrescription ?? false, refillDate: refillDateIso, }; @@ -778,6 +794,8 @@ export async function exportRoutes(app: FastifyInstance) { // 5. Import settings if (importData.settings) { + // Legacy exports may still contain shareStockStatus. The current app no longer + // uses that setting, so imports accept it for compatibility and then ignore it. await db.insert(userSettings).values({ userId, emailEnabled: importData.settings.emailEnabled ?? false, @@ -802,7 +820,6 @@ export async function exportRoutes(app: FastifyInstance) { expiryWarningDays: importData.settings.expiryWarningDays ?? 90, language: importData.settings.language ?? "en", stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic", - shareStockStatus: importData.settings.shareStockStatus ?? true, shareMedicationOverview: importData.settings.shareMedicationOverview ?? false, }); } @@ -830,7 +847,7 @@ export async function exportRoutes(app: FastifyInstance) { medicationId: newMedId, userId, packsAdded: refill.packsAdded ?? 0, - loosePillsAdded: refill.loosePillsAdded ?? 0, + loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0, usedPrescription: refill.usedPrescription ?? false, refillDate: new Date(refill.refillDate), }); diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index b1a2c73..d6f942e 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -1203,15 +1203,18 @@ export async function medicationRoutes(app: FastifyInstance) { const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType); const allowsBottleCapacityUpdate = packageType === "bottle"; if (allowsAmountBaseUpdate) { - if (totalPills !== undefined) updateFields.totalPills = totalPills; - if (looseTablets !== undefined) updateFields.looseTablets = looseTablets; + const normalizedAmountBase = looseTablets ?? totalPills; + if (normalizedAmountBase !== undefined) { + updateFields.totalPills = normalizedAmountBase; + updateFields.looseTablets = normalizedAmountBase; + } if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; } if (allowsBottleCapacityUpdate && totalPills !== undefined) { updateFields.totalPills = totalPills; } if (packCount !== undefined) updateFields.packCount = packCount; - if (looseTablets !== undefined) { + if (!allowsAmountBaseUpdate && looseTablets !== undefined) { updateFields.looseTablets = looseTablets; } diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index d173ea9..3e30104 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -2,9 +2,10 @@ import { and, desc, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; -import { medications, refillHistory } from "../db/schema.js"; +import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { computeMedicationCurrentStock } from "../services/current-stock.js"; import type { AuthUser } from "../types/fastify.js"; import { applyOpenApiRouteStandards, @@ -18,10 +19,11 @@ const refillSchema = z .object({ packsAdded: z.number().int().min(0).default(0), loosePillsAdded: z.number().int().min(0).default(0), + quantityAdded: z.number().int().min(0).default(0), usePrescription: z.boolean().default(false), }) - .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, { - message: "Must add at least one pack or some loose pills", + .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, { + message: "Must add at least one pack or some quantity", }); const refillBodyOpenApiSchema = { @@ -29,12 +31,14 @@ const refillBodyOpenApiSchema = { properties: { packsAdded: { type: "integer", minimum: 0, default: 0 }, loosePillsAdded: { type: "integer", minimum: 0, default: 0 }, + quantityAdded: { type: "integer", minimum: 0, default: 0 }, usePrescription: { type: "boolean", default: false }, }, - description: "Provide at least one pack or some loose pills.", + description: "Provide at least one pack or some quantity.", example: { packsAdded: 1, loosePillsAdded: 4, + quantityAdded: 4, usePrescription: true, }, } as const; @@ -49,6 +53,7 @@ const refillResponseSchema = { id: { type: "number" }, packsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" }, + quantityAdded: { type: "number" }, totalPillsAdded: { type: "number" }, refillDate: { type: "string", format: "date-time" }, }, @@ -80,6 +85,7 @@ const refillHistoryItemSchema = { id: { type: "number" }, packsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" }, + quantityAdded: { type: "number" }, totalPillsAdded: { type: "number" }, usedPrescription: { type: "boolean" }, refillDate: { type: "string", format: "date-time" }, @@ -136,11 +142,12 @@ export async function refillRoutes(app: FastifyInstance) { .where(and(eq(medications.id, medId), eq(medications.userId, userId))); if (!med) return reply.notFound("Medication not found"); - const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; + const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data; const packageType = normalizePackageType(med.packageType); const isBottle = packageType === "bottle"; const isAmountBased = isAmountBasedPackageType(packageType); const isCountBasedAmountPackage = isAmountBased && !isBottle; + const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); const fallbackAmountPerPackage = Math.max( @@ -153,7 +160,9 @@ export async function refillRoutes(app: FastifyInstance) { : fallbackAmountPerPackage; const requestedPackAdds = Math.max(0, packsAdded); - const requestedAmountAdds = Math.max(0, loosePillsAdded); + const requestedLooseAdds = Math.max(0, loosePillsAdded); + const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds); + const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds; const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage)); let effectivePacksAdded = requestedPackAdds; @@ -166,6 +175,9 @@ export async function refillRoutes(app: FastifyInstance) { ? effectivePacksAdded * amountPerPackage : requestedAmountAdds; const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; + const totalPillsAdded = isAmountBased + ? effectiveLoosePillsAdded + : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { return reply.status(400).send({ error: "Must add at least one pack or some loose pills" }); @@ -183,11 +195,40 @@ export async function refillRoutes(app: FastifyInstance) { } } - // Update medication stock - const newPackCount = med.packCount + effectivePacksAdded; - const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; - const previousAmountBase = med.totalPills ?? med.looseTablets; - const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded; + const refillBaselineAt = new Date(); + const [settings] = await db + .select({ stockCalculationMode: userSettings.stockCalculationMode }) + .from(userSettings) + .where(eq(userSettings.userId, userId)); + const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic"; + const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); + const currentStockAtRefill = computeMedicationCurrentStock({ + medication: med, + doses, + stockCalculationMode, + nowMs: refillBaselineAt.getTime(), + }); + const targetCurrentStock = currentStockAtRefill + totalPillsAdded; + + // Update medication stock. Refill establishes a new stock baseline at the current visible + // stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets. + let newPackCount = med.packCount + effectivePacksAdded; + let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; + let newStockAdjustment = med.stockAdjustment ?? 0; + let newTotalAmount = med.totalPills ?? med.looseTablets; + + if (isBottle) { + newLooseTablets = targetCurrentStock; + newStockAdjustment = 0; + } else if (isCountBasedAmountPackage) { + newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage)); + newLooseTablets = targetCurrentStock; + newTotalAmount = targetCurrentStock; + newStockAdjustment = 0; + } else { + const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets; + newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill; + } let consumedRefills = 0; if (usePrescription) { @@ -197,10 +238,10 @@ export async function refillRoutes(app: FastifyInstance) { ? Math.max(0, remainingPrescriptionRefills - consumedRefills) : (med.prescriptionRemainingRefills ?? null); - const refillBaselineAt = new Date(); const updatePayload: { packCount: number; looseTablets: number; + stockAdjustment: number; totalPills?: number; packageAmountValue?: number; prescriptionRemainingRefills: number | null; @@ -209,6 +250,7 @@ export async function refillRoutes(app: FastifyInstance) { } = { packCount: newPackCount, looseTablets: newLooseTablets, + stockAdjustment: newStockAdjustment, prescriptionRemainingRefills: newRemainingRefills, lastStockCorrectionAt: refillBaselineAt, updatedAt: refillBaselineAt, @@ -236,31 +278,20 @@ export async function refillRoutes(app: FastifyInstance) { }) .returning(); - // Calculate pills added for response (packageType-aware) - const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; - const totalPillsAdded = isAmountBased - ? effectiveLoosePillsAdded - : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded; - let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); - if (isCountBasedAmountPackage) { - newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0); - } else if (isBottle) { - newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0); - } - return { success: true, refill: { id: refill.id, packsAdded: effectivePacksAdded, loosePillsAdded: effectiveLoosePillsAdded, + quantityAdded: totalPillsAdded, totalPillsAdded, refillDate: refill.refillDate, }, newStock: { packCount: newPackCount, looseTablets: newLooseTablets, - totalPills: newTotalPills, + totalPills: targetCurrentStock, }, prescription: { used: usePrescription, @@ -316,6 +347,7 @@ export async function refillRoutes(app: FastifyInstance) { id: r.id, packsAdded: r.packsAdded, loosePillsAdded: r.loosePillsAdded, + quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, usedPrescription: r.usedPrescription ?? false, refillDate: r.refillDate, diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts index efa75f6..6b8215e 100644 --- a/backend/src/routes/report.ts +++ b/backend/src/routes/report.ts @@ -14,6 +14,7 @@ import { const reportDataSchema = z.object({ medicationIds: z.array(z.number().int().positive()).min(1).max(100), + takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(), }); const reportDataBodyOpenApiSchema = { @@ -26,12 +27,27 @@ const reportDataBodyOpenApiSchema = { maxItems: 100, items: { type: "integer", minimum: 1 }, }, + takenByFilter: { + type: "array", + maxItems: 50, + items: { type: "string", minLength: 1, maxLength: 100 }, + }, }, example: { medicationIds: [1, 3, 5], + takenByFilter: ["Daniel"], }, } as const; +function matchesTakenByFilter(doseId: string, takenByFilter: Set | null): boolean { + if (!takenByFilter) return true; + const parts = doseId.split("-"); + if (parts.length < 4) return false; + const takenBy = parts.at(-1)?.trim(); + if (!takenBy) return false; + return takenByFilter.has(takenBy); +} + const reportDataResponseSchema = { type: "object", additionalProperties: { @@ -39,7 +55,7 @@ const reportDataResponseSchema = { properties: { dosesTaken: { type: "integer" }, automaticDosesTaken: { type: "integer" }, - dosesDismissed: { type: "integer" }, + dosesSkipped: { type: "integer" }, firstDoseAt: { type: "string" }, lastDoseAt: { type: "string" }, refills: { @@ -49,6 +65,7 @@ const reportDataResponseSchema = { properties: { packsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" }, + quantityAdded: { type: "integer" }, usedPrescription: { type: "boolean" }, refillDate: { type: "string", format: "date-time" }, }, @@ -93,10 +110,22 @@ export async function reportRoutes(app: FastifyInstance) { if (!parsed.success) return reply.status(400).send(parsed.error.format()); const userId = await getUserId(req, reply); - const { medicationIds } = parsed.data; + const { medicationIds, takenByFilter } = parsed.data; + const normalizedTakenByFilter = takenByFilter?.length + ? new Set(takenByFilter.map((value) => value.trim())) + : null; // Verify all medications belong to this user - const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)); + const userMeds = await db + .select({ + id: medications.id, + packageType: medications.packageType, + blistersPerPack: medications.blistersPerPack, + pillsPerBlister: medications.pillsPerBlister, + }) + .from(medications) + .where(eq(medications.userId, userId)); + const medMap = new Map(userMeds.map((med) => [med.id, med])); const userMedIds = new Set(userMeds.map((m) => m.id)); for (const id of medicationIds) { @@ -122,6 +151,7 @@ export async function reportRoutes(app: FastifyInstance) { for (const dose of allDoses) { const medId = Number.parseInt(dose.doseId.split("-")[0], 10); if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue; + if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue; if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, @@ -136,10 +166,16 @@ export async function reportRoutes(app: FastifyInstance) { { dosesTaken: number; automaticDosesTaken: number; - dosesDismissed: number; + dosesSkipped: number; firstDoseAt: string | null; lastDoseAt: string | null; - refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; + refills: { + packsAdded: number; + loosePillsAdded: number; + quantityAdded: number; + usedPrescription: boolean; + refillDate: string; + }[]; } > = {}; @@ -147,9 +183,12 @@ export async function reportRoutes(app: FastifyInstance) { const doses = dosesByMed.get(medId) ?? []; const takenDoses = doses.filter((d) => !d.dismissed); const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic"); - const dismissedDoses = doses.filter((d) => d.dismissed); + const skippedDoses = doses.filter((d) => d.dismissed); const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); + const medication = medMap.get(medId); + const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1)); + const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube"; // Get refills for this medication scoped to the authenticated user. const refills = await db @@ -160,12 +199,13 @@ export async function reportRoutes(app: FastifyInstance) { result[medId] = { dosesTaken: takenDoses.length, automaticDosesTaken: automaticTakenDoses.length, - dosesDismissed: dismissedDoses.length, + dosesSkipped: skippedDoses.length, firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null, lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null, refills: refills.map((r) => ({ packsAdded: r.packsAdded, loosePillsAdded: r.loosePillsAdded, + quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, usedPrescription: r.usedPrescription ?? false, refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate), })), diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 849b4d0..a6fa5f1 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -308,10 +308,10 @@ describe("E2E Tests with Real Routes", () => { expect(response.json().error).toBe("Access denied to medication"); }); - it("should aggregate taken/dismissed doses and refill history", async () => { + it("should aggregate taken/skipped doses and refill history", async () => { const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]); - // One taken dose and one dismissed dose for the same medication + // One taken dose and one skipped dose for the same medication await testClient.execute({ sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`, @@ -338,13 +338,14 @@ describe("E2E Tests with Real Routes", () => { expect(response.statusCode).toBe(200); const data = response.json(); expect(data[medId].dosesTaken).toBe(1); - expect(data[medId].dosesDismissed).toBe(1); + expect(data[medId].dosesSkipped).toBe(1); expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString()); expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString()); expect(data[medId].refills).toHaveLength(1); expect(data[medId].refills[0]).toMatchObject({ packsAdded: 2, loosePillsAdded: 5, + quantityAdded: 7, usedPrescription: true, }); }); @@ -376,6 +377,7 @@ describe("E2E Tests with Real Routes", () => { expect(data[medId].refills[0]).toMatchObject({ packsAdded: 1, loosePillsAdded: 0, + quantityAdded: 1, usedPrescription: false, }); }); @@ -2443,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => { expect(med.stockAdjustment).toBe(0); }); + it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`, + args: [userId], + }); + + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Liquid Stale Client Stock Correction", + medicationForm: "liquid", + packageType: "liquid_container", + doseUnit: "ml", + packCount: 7, + packageAmountValue: 150, + packageAmountUnit: "ml", + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 1050, + looseTablets: 1050, + blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + const correctionResponse = await app.inject({ + method: "PATCH", + url: `/medications/${medId}/stock-adjustment`, + payload: { + stockAdjustment: 0, + packCount: 1, + totalPills: 150, + }, + }); + expect(correctionResponse.statusCode).toBe(200); + + const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(afterCorrectionResponse.statusCode).toBe(200); + const correctedMed = afterCorrectionResponse.json().find((item: Record) => item.id === medId); + expect(correctedMed).toBeTruthy(); + expect(correctedMed.packCount).toBe(1); + expect(correctedMed.totalPills).toBe(150); + expect(correctedMed.looseTablets).toBe(150); + expect(correctedMed.stockAdjustment).toBe(0); + + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 1, loosePillsAdded: 0 }, + }); + expect(refillResponse.statusCode).toBe(200); + const refillData = refillResponse.json(); + expect(refillData.refill.quantityAdded).toBe(150); + expect(refillData.newStock.packCount).toBe(2); + expect(refillData.newStock.looseTablets).toBe(300); + expect(refillData.newStock.totalPills).toBe(300); + + const historyResponse = await app.inject({ + method: "GET", + url: `/medications/${medId}/refills`, + }); + expect(historyResponse.statusCode).toBe(200); + expect(historyResponse.json()[0].quantityAdded).toBe(150); + + const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(afterRefillResponse.statusCode).toBe(200); + const refilledMed = afterRefillResponse.json().find((item: Record) => item.id === medId); + expect(refilledMed).toBeTruthy(); + expect(refilledMed.packCount).toBe(2); + expect(refilledMed.totalPills).toBe(300); + expect(refilledMed.looseTablets).toBe(300); + }); + it("should persist stockAdjustment in GET /medications", async () => { const createResponse = await app.inject({ method: "POST", @@ -3048,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => { blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], }; + async function expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill, + expectedQuantityAdded, + expectedPacksAdded, + expectedAmountPerPackage, + }: { + medId: number; + refillData: { + refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number }; + newStock: { packCount: number; totalPills: number; looseTablets: number }; + }; + visibleStockBeforeRefill: number; + expectedQuantityAdded: number; + expectedPacksAdded: number; + expectedAmountPerPackage?: number; + }) { + expect(refillData.refill.packsAdded).toBe(expectedPacksAdded); + expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded); + expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded); + expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded); + + const historyResponse = await app.inject({ + method: "GET", + url: `/medications/${medId}/refills`, + }); + expect(historyResponse.statusCode).toBe(200); + expect(historyResponse.json()[0]).toMatchObject({ + packsAdded: expectedPacksAdded, + quantityAdded: expectedQuantityAdded, + totalPillsAdded: expectedQuantityAdded, + }); + + if (expectedAmountPerPackage) { + expect(refillData.newStock.packCount).toBe( + Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage)) + ); + } + } + it("should create and return bottle type medication", async () => { const response = await app.inject({ method: "POST", @@ -3241,6 +3359,196 @@ describe("E2E Tests with Real Routes", () => { }); }); + it.each([ + { + name: "bottle", + payload: { + ...bottleMedication, + totalPills: 100, + looseTablets: 10, + }, + refillPayload: { packsAdded: 0, loosePillsAdded: 100 }, + expectedVisibleStockBeforeRefill: 4, + expectedQuantityAdded: 100, + expectedResponsePacksAdded: 0, + expectedPackCount: 0, + expectedLooseTablets: 104, + expectedTotalPills: 104, + expectedPersistedTotalPills: 100, + expectedStockAdjustment: 0, + }, + { + name: "blister", + payload: { + ...blisterMedication, + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + }, + refillPayload: { packsAdded: 1, loosePillsAdded: 0 }, + expectedVisibleStockBeforeRefill: 4, + expectedQuantityAdded: 10, + expectedResponsePacksAdded: 1, + expectedPackCount: 2, + expectedLooseTablets: 0, + expectedTotalPills: 14, + expectedPersistedTotalPills: null, + expectedStockAdjustment: -6, + }, + { + name: "liquid_container", + payload: { + ...liquidContainerMedication, + packCount: 1, + packageAmountValue: 100, + packageAmountUnit: "ml", + totalPills: 10, + looseTablets: 10, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + refillPayload: { packsAdded: 1, loosePillsAdded: 0 }, + expectedVisibleStockBeforeRefill: 4, + expectedQuantityAdded: 100, + expectedResponsePacksAdded: 1, + expectedAmountPerPackage: 100, + expectedPackCount: 2, + expectedLooseTablets: 104, + expectedTotalPills: 104, + expectedPersistedTotalPills: 104, + expectedStockAdjustment: 0, + }, + ])("should refill from current visible stock after prior consumption for $name", async ({ + payload, + refillPayload, + expectedVisibleStockBeforeRefill, + expectedQuantityAdded, + expectedResponsePacksAdded, + expectedAmountPerPackage, + expectedPackCount, + expectedLooseTablets, + expectedTotalPills, + expectedPersistedTotalPills, + expectedStockAdjustment, + }) => { + await testClient.execute({ + sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`, + args: [userId], + }); + + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + for (let day = 1; day <= 6; day += 1) { + const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime(); + const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime(); + await testClient.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) + VALUES (?, ?, ?, 0)`, + args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs], + }); + } + + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: refillPayload, + }); + + expect(refillResponse.statusCode).toBe(200); + const refillData = refillResponse.json(); + await expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill: expectedVisibleStockBeforeRefill, + expectedQuantityAdded, + expectedPacksAdded: expectedResponsePacksAdded, + expectedAmountPerPackage, + }); + expect(refillData.newStock.packCount).toBe(expectedPackCount); + expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets); + expect(refillData.newStock.totalPills).toBe(expectedTotalPills); + + const medsResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(medsResponse.statusCode).toBe(200); + const med = medsResponse.json().find((item: Record) => item.id === medId); + expect(med).toBeTruthy(); + expect(med.packCount).toBe(expectedPackCount); + expect(med.looseTablets).toBe(expectedLooseTablets); + expect(med.totalPills).toBe(expectedPersistedTotalPills); + expect(med.stockAdjustment).toBe(expectedStockAdjustment); + }); + + it("should refill tube stock from the corrected visible baseline", async () => { + await testClient.execute({ + sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`, + args: [userId], + }); + + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + ...tubeMedication, + packCount: 1, + packageAmountValue: 80, + packageAmountUnit: "g", + totalPills: 10, + looseTablets: 10, + blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + const correctionResponse = await app.inject({ + method: "PATCH", + url: `/medications/${medId}/stock-adjustment`, + payload: { + stockAdjustment: -6, + looseTablets: 10, + totalPills: 10, + packageAmountValue: 80, + packCount: 1, + }, + }); + expect(correctionResponse.statusCode).toBe(200); + + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 1, loosePillsAdded: 0 }, + }); + + expect(refillResponse.statusCode).toBe(200); + const refillData = refillResponse.json(); + await expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill: 4, + expectedQuantityAdded: 80, + expectedPacksAdded: 1, + expectedAmountPerPackage: 80, + }); + expect(refillData.newStock.packCount).toBe(2); + expect(refillData.newStock.looseTablets).toBe(84); + expect(refillData.newStock.totalPills).toBe(84); + + const medsResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(medsResponse.statusCode).toBe(200); + const med = medsResponse.json().find((item: Record) => item.id === medId); + expect(med).toBeTruthy(); + expect(med.packCount).toBe(2); + expect(med.looseTablets).toBe(84); + expect(med.totalPills).toBe(84); + expect(med.stockAdjustment).toBe(0); + }); + it("should calculate correct refill totalPillsAdded for blister type", async () => { const createResponse = await app.inject({ method: "POST", @@ -3272,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => { }); it("should keep liquid_container refill additive and preserve amount baseline", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`, + args: [userId], + }); + const createResponse = await app.inject({ method: "POST", url: "/medications", @@ -3294,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => { expect(refillResponse.statusCode).toBe(200); const refillData = refillResponse.json(); - expect(refillData.refill.packsAdded).toBe(1); + await expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill: 180, + expectedQuantityAdded: 180, + expectedPacksAdded: 1, + expectedAmountPerPackage: 180, + }); expect(refillData.refill.loosePillsAdded).toBe(180); - expect(refillData.refill.totalPillsAdded).toBe(180); expect(refillData.newStock.totalPills).toBe(360); const medsResponse = await app.inject({ method: "GET", url: "/medications" }); @@ -3307,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => { expect(med.looseTablets).toBe(360); }); + it("should normalize liquid_container packCount to the full visible stock after refill", async () => { + await testClient.execute({ + sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`, + args: [userId], + }); + + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload: { + ...liquidContainerMedication, + packCount: 0, + packageAmountValue: 150, + totalPills: 300, + looseTablets: 300, + }, + }); + expect(createResponse.statusCode).toBe(200); + const medId = createResponse.json().id; + + const refillResponse = await app.inject({ + method: "POST", + url: `/medications/${medId}/refill`, + payload: { packsAdded: 5, loosePillsAdded: 0 }, + }); + + expect(refillResponse.statusCode).toBe(200); + const refillData = refillResponse.json(); + await expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill: 300, + expectedQuantityAdded: 750, + expectedPacksAdded: 5, + expectedAmountPerPackage: 150, + }); + expect(refillData.newStock.packCount).toBe(7); + expect(refillData.newStock.totalPills).toBe(1050); + + const medsResponse = await app.inject({ method: "GET", url: "/medications" }); + expect(medsResponse.statusCode).toBe(200); + const med = medsResponse.json().find((m: Record) => m.id === medId); + expect(med).toBeTruthy(); + expect(med.packCount).toBe(7); + expect(med.totalPills).toBe(1050); + expect(med.looseTablets).toBe(1050); + }); + it.each([ { name: "liquid_container", @@ -3323,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => { prescriptionLowRefillThreshold: 1, }, refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true }, + expectedVisibleStockBeforeRefill: 180, expectedPacksAdded: 1, expectedLooseAdded: 180, expectedRemainingRefills: 1, expectedTotalPills: 360, + expectedAmountPerPackage: 180, }, { name: "tube", @@ -3338,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => { prescriptionLowRefillThreshold: 1, }, refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true }, + expectedVisibleStockBeforeRefill: 80, expectedPacksAdded: 2, expectedLooseAdded: 80, expectedRemainingRefills: 1, expectedTotalPills: 160, + expectedAmountPerPackage: 40, }, ])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({ payload, refillPayload, + expectedVisibleStockBeforeRefill, expectedPacksAdded, expectedLooseAdded, expectedRemainingRefills, expectedTotalPills, + expectedAmountPerPackage, }) => { + await testClient.execute({ + sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`, + args: [userId], + }); + const createResponse = await app.inject({ method: "POST", url: "/medications", @@ -3367,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => { expect(refillResponse.statusCode).toBe(200); const refillData = refillResponse.json(); + await expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill: expectedVisibleStockBeforeRefill, + expectedQuantityAdded: expectedLooseAdded, + expectedPacksAdded, + expectedAmountPerPackage, + }); expect(refillData.refill.packsAdded).toBe(expectedPacksAdded); expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded); + expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded); expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded); expect(refillData.prescription.used).toBe(true); expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills); @@ -3382,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => { expect(historyResponse.json()[0]).toMatchObject({ packsAdded: expectedPacksAdded, loosePillsAdded: expectedLooseAdded, + quantityAdded: expectedLooseAdded, usedPrescription: true, }); }); @@ -3403,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => { expect(refillResponse.statusCode).toBe(200); const refillData = refillResponse.json(); - expect(refillData.refill.packsAdded).toBe(1); + await expectRefillInvariants({ + medId, + refillData, + visibleStockBeforeRefill: 80, + expectedQuantityAdded: 40, + expectedPacksAdded: 1, + expectedAmountPerPackage: 40, + }); expect(refillData.refill.loosePillsAdded).toBe(40); - expect(refillData.refill.totalPillsAdded).toBe(40); expect(refillData.newStock.totalPills).toBe(120); const medsResponse = await app.inject({ method: "GET", url: "/medications" }); diff --git a/backend/src/test/export.test.ts b/backend/src/test/export.test.ts index 410b806..a4c2bc0 100644 --- a/backend/src/test/export.test.ts +++ b/backend/src/test/export.test.ts @@ -411,6 +411,7 @@ describe("Export/Import API", () => { expect(data.settings.notificationEmail).toBe("test@example.com"); expect(data.settings.language).toBe("de"); expect(data.settings.lowStockDays).toBe(14); + expect(data.settings.shareStockStatus).toBeUndefined(); }); it("should exclude sensitive data by default", async () => { @@ -557,6 +558,45 @@ describe("Export/Import API", () => { expect(result.rows[0].loose_tablets).toBe(5); }); + it("accepts legacy shareStockStatus in imported settings but does not export or use it", async () => { + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [], + doseHistory: [], + refillHistory: [], + settings: { + language: "de", + stockCalculationMode: "automatic", + shareStockStatus: false, + }, + shareLinks: [], + }; + + const importResponse = await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + expect(importResponse.statusCode).toBe(200); + + const exportResponse = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(exportResponse.statusCode).toBe(200); + expect(exportResponse.json().settings.shareStockStatus).toBeUndefined(); + + const settingsRow = await ctx.client.execute({ + sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = ?", + args: [userId], + }); + expect(settingsRow.rows[0].share_medication_overview).toBe(0); + expect(settingsRow.rows[0].share_stock_status).toBe(1); + }); + it("should replace existing data on import", async () => { // Create existing medication await createTestMedication(ctx.client, { diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts index b95342c..f15e0dd 100644 --- a/backend/src/test/routes-real.test.ts +++ b/backend/src/test/routes-real.test.ts @@ -16,6 +16,8 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois OIDC_ENABLED: false, OIDC_PROVIDER_NAME: "SSO", NODE_ENV: "test", + PUBLIC_APP_URL: "https://app.example.com", + CORS_ORIGINS: "https://app.example.com", }; return { testClient: client, @@ -351,7 +353,7 @@ describe("Real route coverage: settings/export/report", () => { }); it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => { - fetchMock.mockResolvedValue({ ok: true }); + fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) }); const response = await app.inject({ method: "POST", @@ -361,6 +363,44 @@ describe("Real route coverage: settings/export/report", () => { expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + const headers = (requestInit?.headers ?? {}) as Record; + expect(headers["X-Sequence-ID"]).toEqual(expect.stringMatching(/^medassist-/)); + expect(JSON.parse(headers.Actions ?? "[]")).toEqual([ + { + action: "http", + label: "Take", + url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//), + method: "POST", + clear: false, + }, + { + action: "http", + label: "Skip", + url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//), + method: "POST", + clear: false, + }, + { + action: "view", + label: "View", + url: "https://app.example.com/dashboard", + clear: false, + }, + ]); + + const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups"); + expect(Number(groups.rows[0].count)).toBe(1); + + const storedGroup = await testClient.execute( + "SELECT ntfy_original_message_id FROM notification_action_groups LIMIT 1" + ); + expect(storedGroup.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-test-message-id" })]); + + const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens"); + expect(Number(tokens.rows[0].count)).toBe(3); }); it("sendShoutrrrNotification blocks localhost/private targets", async () => { @@ -370,11 +410,12 @@ describe("Real route coverage: settings/export/report", () => { }); it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => { - fetchMock.mockResolvedValue({ ok: true }); + fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-message-id" }) }); const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title รค", "Message"); expect(result.success).toBe(true); + expect(result.providerMessageId).toBe("ntfy-message-id"); expect(fetchMock).toHaveBeenCalledWith( "https://ntfy.sh/mytopic", expect.objectContaining({ @@ -589,8 +630,35 @@ describe("Real route coverage: settings/export/report", () => { expect(response.statusCode).toBe(200); const body = response.json(); expect(body[medId].dosesTaken).toBe(1); - expect(body[medId].dosesDismissed).toBe(1); + expect(body[medId].dosesSkipped).toBe(1); expect(body[medId].refills).toHaveLength(1); + expect(body[medId].refills[0].quantityAdded).toBe(22); + }); + + it("POST /medications/report-data filters dose counts by takenBy suffix when requested", async () => { + const medId = await seedMedication("Report Filter Med"); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [1, `${medId}-0-1700000000000-Alice`, 1700000000, 0], + }); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1], + }); + await testClient.execute({ + sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)", + args: [1, `${medId}-0-1700001200000-Bob`, 1700001200, 0], + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/report-data", + payload: { medicationIds: [medId], takenByFilter: ["Alice"] }, + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body[medId].dosesTaken).toBe(1); + expect(body[medId].dosesSkipped).toBe(1); }); it("GET /export includes medications, settings, doseHistory and refillHistory", async () => { @@ -621,7 +689,9 @@ describe("Real route coverage: settings/export/report", () => { expect(body.medications).toHaveLength(1); expect(body.doseHistory).toHaveLength(1); expect(body.refillHistory).toHaveLength(1); + expect(body.refillHistory[0].quantityAdded).toBe(23); expect(body.settings.language).toBe("de"); + expect(body.settings.shareStockStatus).toBeUndefined(); expect(body.shareLinks).toHaveLength(1); }); @@ -672,7 +742,15 @@ describe("Real route coverage: settings/export/report", () => { }, ], doseHistory: [], - refillHistory: [], + refillHistory: [ + { + medicationRef: "med-1", + packsAdded: 0, + quantityAdded: 4, + usedPrescription: false, + refillDate: "2026-01-02T08:00:00.000Z", + }, + ], settings: { emailEnabled: false, notificationEmail: null, @@ -708,10 +786,24 @@ describe("Real route coverage: settings/export/report", () => { }); expect(valid.statusCode).toBe(200); expect(valid.json().imported.medications).toBe(1); + expect(valid.json().imported.refillHistory).toBe(1); const rows = await testClient.execute({ sql: "SELECT name FROM medications WHERE user_id = 1", }); expect(rows.rows[0].name).toBe("Imported Med"); + + const refillRows = await testClient.execute({ + sql: "SELECT packs_added, loose_pills_added FROM refill_history WHERE user_id = 1", + }); + expect(refillRows.rows).toHaveLength(1); + expect(refillRows.rows[0].packs_added).toBe(0); + expect(refillRows.rows[0].loose_pills_added).toBe(4); + + const importedSettings = await testClient.execute({ + sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = 1", + }); + expect(importedSettings.rows[0].share_medication_overview).toBe(0); + expect(importedSettings.rows[0].share_stock_status).toBe(1); }); }); diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index 2a4c10c..97ecd84 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -1105,10 +1105,7 @@ export function MedDetailModal({ {(() => { - const total = isAmountBasedPackageType(selectedMed.packageType) - ? entry.loosePillsAdded - : entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + - entry.loosePillsAdded; + const total = entry.quantityAdded; return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`; })()} {entry.usedPrescription && ( diff --git a/frontend/src/components/ReportModal.tsx b/frontend/src/components/ReportModal.tsx index e44cb6d..4e9951c 100644 --- a/frontend/src/components/ReportModal.tsx +++ b/frontend/src/components/ReportModal.tsx @@ -6,6 +6,7 @@ import type { Medication } from "../types"; import { getMedDisplayName, getMedTotal, + getStockDisplayCapacity, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, @@ -27,10 +28,16 @@ type ReportData = Record< { dosesTaken: number; automaticDosesTaken: number; - dosesDismissed: number; + dosesSkipped: number; firstDoseAt: string | null; lastDoseAt: string | null; - refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; + refills: { + packsAdded: number; + loosePillsAdded?: number; + quantityAdded: number; + usedPrescription: boolean; + refillDate: string; + }[]; } >; @@ -121,7 +128,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) const res = await fetch("/api/medications/report-data", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ medicationIds: Array.from(selectedIds) }), + body: JSON.stringify({ + medicationIds: Array.from(selectedIds), + takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined, + }), credentials: "include", }); if (!res.ok) throw new Error("Failed to fetch report data"); @@ -374,7 +384,7 @@ function generateTextReport( lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister))); if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets))); } else { - lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets))); + lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med)))); } lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t))); if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) @@ -415,12 +425,12 @@ function generateTextReport( const data = reportData[med.id]; if (data) { lines.push(h3(t("report.docIntakeHistory"))); - if (data.dosesTaken > 0 || data.dosesDismissed > 0) { + if (data.dosesTaken > 0 || data.dosesSkipped > 0) { lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken))); if (data.automaticDosesTaken > 0) { lines.push(item(`๐Ÿค– ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken))); } - if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed))); + if (data.dosesSkipped > 0) lines.push(item(t("report.docDosesSkipped"), String(data.dosesSkipped))); if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt))); if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt))); } else { @@ -432,7 +442,7 @@ function generateTextReport( if (data.refills.length > 0) { lines.push(h3(t("report.docRefillHistory"))); for (const r of data.refills) { - let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`; + let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`; if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`; lines.push(fmt === "md" ? `- ${entry}` : ` โ€ข ${entry}`); } @@ -572,7 +582,7 @@ function buildPrintHtml( if (med.looseTablets > 0) s += `${escHtml(t("report.docLoosePills"))}${med.looseTablets}`; } else { - s += `${escHtml(getTotalCapacityLabel(med, t))}${med.totalPills ?? med.looseTablets}`; + s += `${escHtml(getTotalCapacityLabel(med, t))}${getStockDisplayCapacity(med)}`; } s += `${escHtml(t("report.docCurrentStock"))}${escHtml(getCurrentStockText(med, t))}`; if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) @@ -616,14 +626,14 @@ function buildPrintHtml( // Intake history if (data) { s += `

${escHtml(t("report.docIntakeHistory"))}

`; - if (data.dosesTaken > 0 || data.dosesDismissed > 0) { + if (data.dosesTaken > 0 || data.dosesSkipped > 0) { s += ``; s += ``; if (data.automaticDosesTaken > 0) { s += ``; } - if (data.dosesDismissed > 0) - s += ``; + if (data.dosesSkipped > 0) + s += ``; if (data.firstDoseAt) s += ``; if (data.lastDoseAt) @@ -638,7 +648,7 @@ function buildPrintHtml( s += `

${escHtml(t("report.docRefillHistory"))}

`; s += `
    `; for (const r of data.refills) { - let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`; + let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`; if (r.usedPrescription) entry += ` ${escHtml(t("report.docRefillPrescription"))}`; s += `
  • ${entry}
  • `; } diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 2d68e10..190f5ad 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -39,6 +39,7 @@ export function SharedSchedule() { const [takenDoses, setTakenDoses] = useState>(new Set()); const [automaticTakenDoses, setAutomaticTakenDoses] = useState>(new Set()); const [dismissedDoses, setDismissedDoses] = useState>(new Set()); + const mutationInFlightRef = useRef(0); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [showPastDays, setShowPastDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false); @@ -183,15 +184,23 @@ export function SharedSchedule() { // Separates taken and dismissed doses (like main app's useDoses hook) const loadTakenDoses = useCallback(async () => { if (!token) return; + if (mutationInFlightRef.current > 0) return; try { const res = await fetch(`/api/share/${token}/doses`); if (res.ok) { + if (mutationInFlightRef.current > 0) return; + const data = await res.json(); const taken = new Set(); const automatic = new Set(); const dismissed = new Set(); - for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; takenSource?: string }>) { - if (d.dismissed) { + for (const d of data.doses as Array<{ + doseId: string; + dismissed?: boolean; + skipped?: boolean; + takenSource?: string; + }>) { + if (d.skipped === true || d.dismissed === true) { dismissed.add(d.doseId); } else { taken.add(d.doseId); @@ -203,15 +212,9 @@ export function SharedSchedule() { setTakenDoses(taken); setAutomaticTakenDoses(automatic); setDismissedDoses(dismissed); - } else { - setTakenDoses(new Set()); - setAutomaticTakenDoses(new Set()); - setDismissedDoses(new Set()); } } catch { - setTakenDoses(new Set()); - setAutomaticTakenDoses(new Set()); - setDismissedDoses(new Set()); + // Keep the current optimistic/shared state on transient read errors. } }, [token]); @@ -232,12 +235,26 @@ export function SharedSchedule() { } async function markDoseTaken(doseId: string) { + if (dismissedDoses.has(doseId)) { + return; + } + + const wasTaken = takenDoses.has(doseId); + const wasSkipped = dismissedDoses.has(doseId); + const wasAutomatic = automaticTakenDoses.has(doseId); + // Optimistic update + mutationInFlightRef.current++; setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); + setDismissedDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); @@ -266,16 +283,104 @@ export function SharedSchedule() { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); - next.delete(doseId); + if (wasTaken) { + next.add(doseId); + } else { + next.delete(doseId); + } + return next; + }); + setDismissedDoses((prev) => { + const next = new Set(prev); + if (wasSkipped) { + next.add(doseId); + } else { + next.delete(doseId); + } + return next; + }); + setAutomaticTakenDoses((prev) => { + const next = new Set(prev); + if (wasAutomatic) { + next.add(doseId); + } return next; }); } finally { + mutationInFlightRef.current--; + loadTakenDoses(); + } + } + + async function markDoseSkipped(doseId: string) { + if (takenDoses.has(doseId)) { + return; + } + + const wasTaken = takenDoses.has(doseId); + const wasSkipped = dismissedDoses.has(doseId); + const wasAutomatic = automaticTakenDoses.has(doseId); + + mutationInFlightRef.current++; + setDismissedDoses((prev) => { + const next = new Set(prev); + next.add(doseId); + return next; + }); + setTakenDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + setAutomaticTakenDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + + try { + const response = await fetch(`/api/share/${token}/doses/skip`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ doseId }), + }); + if (!response.ok) { + throw new Error("Failed to mark shared dose as skipped"); + } + } catch { + setDismissedDoses((prev) => { + const next = new Set(prev); + if (wasSkipped) { + next.add(doseId); + } else { + next.delete(doseId); + } + return next; + }); + setTakenDoses((prev) => { + const next = new Set(prev); + if (wasTaken) { + next.add(doseId); + } + return next; + }); + setAutomaticTakenDoses((prev) => { + const next = new Set(prev); + if (wasAutomatic) { + next.add(doseId); + } + return next; + }); + } finally { + mutationInFlightRef.current--; loadTakenDoses(); } } async function undoDoseTaken(doseId: string) { + const wasAutomatic = automaticTakenDoses.has(doseId); // Optimistic update + mutationInFlightRef.current++; setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); @@ -299,9 +404,100 @@ export function SharedSchedule() { next.add(doseId); return next; }); + setAutomaticTakenDoses((prev) => { + const next = new Set(prev); + if (wasAutomatic) { + next.add(doseId); + } + return next; + }); + } finally { + mutationInFlightRef.current--; + loadTakenDoses(); } } + async function undoDoseSkipped(doseId: string) { + const wasSkipped = dismissedDoses.has(doseId); + + mutationInFlightRef.current++; + setDismissedDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + + try { + await fetch(`/api/share/${token}/doses/skip/${encodeURIComponent(doseId)}`, { + method: "DELETE", + }); + } catch { + setDismissedDoses((prev) => { + const next = new Set(prev); + if (wasSkipped) { + next.add(doseId); + } + return next; + }); + } finally { + mutationInFlightRef.current--; + loadTakenDoses(); + } + } + + const renderDoseActionButtons = (options: { + doseId: string; + isTaken: boolean; + isSkipped: boolean; + isAutomaticallyTaken: boolean; + isEmpty: boolean; + }) => { + const takeButton = options.isTaken ? ( + + ) : ( + + ); + + const skipButton = options.isSkipped ? ( + + ) : ( + + ); + + return ( + <> + {takeButton} + {skipButton} + + ); + }; + const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId); useEffect(() => { @@ -934,6 +1130,7 @@ export function SharedSchedule() { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); + const isSkipped = dismissedDoses.has(dose.id); const doseClasses = ["dose-item", "past"]; if (isTaken) doseClasses.push("all-taken"); if (isEmpty) doseClasses.push("med-empty"); @@ -948,37 +1145,17 @@ export function SharedSchedule() { )}
    -
    +
    {dose.takenBy && {dose.takenBy}} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId: dose.id, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty, + })}
    @@ -1149,7 +1326,8 @@ export function SharedSchedule() { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); - const isOverdue = dose.when < Date.now() && !isTaken; + const isSkipped = dismissedDoses.has(dose.id); + const isOverdue = dose.when < Date.now() && !isTaken && !isSkipped && !isEmpty; const doseClasses = ["dose-item"]; if (isOverdue) doseClasses.push("overdue"); if (isTaken) doseClasses.push("all-taken"); @@ -1166,38 +1344,16 @@ export function SharedSchedule() {
    {dose.takenBy && {dose.takenBy}} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId: dose.id, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty, + })}
    @@ -1351,6 +1507,7 @@ export function SharedSchedule() { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); + const isSkipped = dismissedDoses.has(dose.id); const doseClasses = ["dose-item", "future"]; if (isTaken) doseClasses.push("all-taken"); if (isEmpty) doseClasses.push("med-empty"); @@ -1365,37 +1522,17 @@ export function SharedSchedule() { )}
    -
    +
    {dose.takenBy && {dose.takenBy}} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId: dose.id, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty: true, + })}
    diff --git a/frontend/src/components/medications/MedicationListSection.tsx b/frontend/src/components/medications/MedicationListSection.tsx index a8092f2..65d1ad6 100644 --- a/frontend/src/components/medications/MedicationListSection.tsx +++ b/frontend/src/components/medications/MedicationListSection.tsx @@ -50,7 +50,6 @@ export function MedicationListSection({ const renderImageAvatar = (med: Medication) => ( med.imageUrl && onImagePreview(med)} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && med.imageUrl) { onImagePreview(med); @@ -146,8 +145,7 @@ export function MedicationListSection({ ) : ( - {t("medications.details.totalCapacity")}:{" "} - {med.totalPills ?? med.looseTablets} + {t("medications.details.totalCapacity")}: {stockDisplayCapacity} )} diff --git a/frontend/src/hooks/useRefill.ts b/frontend/src/hooks/useRefill.ts index c2260bb..0d4d880 100644 --- a/frontend/src/hooks/useRefill.ts +++ b/frontend/src/hooks/useRefill.ts @@ -121,7 +121,12 @@ export function useRefill(): UseRefillReturn { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }), + body: JSON.stringify({ + packsAdded: refillPacks, + loosePillsAdded: refillLoose, + quantityAdded: refillLoose, + usePrescription, + }), }); if (res.ok) { const data = await res.json(); @@ -267,6 +272,7 @@ export function useRefill(): UseRefillReturn { // Keep packageAmountValue (ml per bottle) and update capacity base by bottle count. patchBody.packCount = correctedLiquidBottleCount; patchBody.totalPills = liquidStructuralMax; + patchBody.looseTablets = liquidStructuralMax; } else if (!isAmountPackage) { patchBody.looseTablets = finalLoosePills; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index d54eeb3..f9fd0a5 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,7 +1,8 @@ /* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */ import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; import { ConfirmModal, MedicationAvatar } from "../components"; import { useAuth } from "../components/Auth"; import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection"; @@ -28,9 +29,43 @@ import { userStorageKey, } from "./dashboard-helpers"; +function getRouteDateKey(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getMedicationIdFromNotificationDoseId(doseId: string | null): string | null { + if (!doseId) { + return null; + } + + const [rawMedicationId] = doseId.split("-"); + return rawMedicationId?.trim() ? rawMedicationId : null; +} + +function findFocusTargetElement(doseId: string | null, medId: string | null): HTMLElement | null { + if (doseId) { + const elements = Array.from(document.querySelectorAll("[data-dose-id]")); + const doseElement = elements.find((element) => element.dataset.doseId === doseId); + if (doseElement) { + return doseElement.closest("[data-med-id]") ?? doseElement; + } + } + + if (medId) { + const elements = Array.from(document.querySelectorAll("[data-med-id]")); + return elements.find((element) => element.dataset.medId === medId) ?? null; + } + + return null; +} + export function DashboardPage() { const { t, i18n } = useTranslation(); const { user } = useAuth(); + const location = useLocation(); const { meds, loading, @@ -49,9 +84,12 @@ export function DashboardPage() { todayDay, futureDays, takenDoses, + skippedDoses, dismissedDoses, markDoseTaken, + markDoseSkipped, undoDoseTaken, + undoDoseSkipped, manuallyCollapsedDays, manuallyExpandedDays, toggleDayCollapse, @@ -71,8 +109,147 @@ export function DashboardPage() { const [clearingMissed, setClearingMissed] = useState(false); const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false); const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null); + const notificationFocusAppliedRef = useRef(null); - const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId); + const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]); + + const notificationTarget = useMemo(() => { + const params = new URLSearchParams(location.search); + const date = params.get("day")?.trim() ?? params.get("date")?.trim() ?? ""; + const doseId = params.get("dose")?.trim() ?? params.get("doseId")?.trim() ?? ""; + const medId = + params.get("med")?.trim() ?? params.get("medId")?.trim() ?? getMedicationIdFromNotificationDoseId(doseId) ?? ""; + if (!date && !doseId && !medId) { + return null; + } + + return { + date: date || null, + doseId: doseId || null, + medId: medId || null, + key: `${date}|${doseId}|${medId}`, + }; + }, [location.search]); + + const targetDayState = useMemo(() => { + if (!notificationTarget?.date) { + return null; + } + + const todayDateKey = todayDay ? getRouteDateKey(todayDay.date) : null; + if (todayDay && todayDateKey === notificationTarget.date) { + const allDoseIds = todayDay.meds.flatMap((item) => expandDoseIds(item.doses)); + const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id)); + const isAutoCollapsed = allDayTaken; + const isManuallyExpanded = manuallyExpandedDays.has(todayDay.dateStr); + const isManuallyCollapsed = manuallyCollapsedDays.has(todayDay.dateStr); + const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; + return { day: todayDay, isAutoCollapsed, isCollapsed, section: "today" as const }; + } + + const pastDay = pastDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date); + if (pastDay) { + const isAutoCollapsed = true; + const isCollapsed = !manuallyExpandedDays.has(pastDay.dateStr); + return { day: pastDay, isAutoCollapsed, isCollapsed, section: "past" as const }; + } + + const futureDay = futureDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date); + if (futureDay) { + const isAutoCollapsed = true; + const isCollapsed = !manuallyExpandedDays.has(futureDay.dateStr); + return { day: futureDay, isAutoCollapsed, isCollapsed, section: "future" as const }; + } + + return null; + }, [ + notificationTarget, + todayDay, + pastDays, + futureDays, + manuallyExpandedDays, + manuallyCollapsedDays, + isDoseTakenForDisplay, + ]); + + useEffect(() => { + if (!notificationTarget || !targetDayState) { + return; + } + + if (targetDayState.section === "past" && !showPastDays) { + setShowPastDays(true); + } + + if (targetDayState.section === "future" && !showFutureDays) { + setShowFutureDays(true); + } + + if (targetDayState.isCollapsed) { + toggleDayCollapse(targetDayState.day.dateStr, targetDayState.isAutoCollapsed); + } + }, [ + notificationTarget, + targetDayState, + setShowPastDays, + setShowFutureDays, + showPastDays, + showFutureDays, + toggleDayCollapse, + ]); + + useEffect(() => { + if (!notificationTarget) { + notificationFocusAppliedRef.current = null; + return; + } + + if (loading || settingsLoading) { + return; + } + + if (!targetDayState) { + return; + } + + if (notificationFocusAppliedRef.current === notificationTarget.key) { + return; + } + + let correctionTimerId: number | null = null; + + const scrollTargetIntoView = () => { + const targetElement = findFocusTargetElement(notificationTarget.doseId, notificationTarget.medId); + + if (!targetElement) { + return false; + } + + targetElement.scrollIntoView({ behavior: "smooth", block: "start" }); + return true; + }; + + const frameId = requestAnimationFrame(() => { + if (!scrollTargetIntoView()) { + return; + } + + correctionTimerId = window.setTimeout(() => { + if (!scrollTargetIntoView()) { + return; + } + + notificationFocusAppliedRef.current = notificationTarget.key; + }, 220); + }); + + return () => { + cancelAnimationFrame(frameId); + if (correctionTimerId !== null) { + window.clearTimeout(correctionTimerId); + } + }; + }, [notificationTarget, targetDayState, loading, settingsLoading]); // Get structured reminder data const reminderData = getReminderStatusData( @@ -153,6 +330,59 @@ export function DashboardPage() { } }; + const renderDoseActionButtons = (options: { + doseId: string; + isTaken: boolean; + isSkipped: boolean; + isAutomaticallyTaken: boolean; + isEmpty: boolean; + }) => { + const takeButton = options.isTaken ? ( + + ) : ( + + ); + + const skipButton = options.isSkipped ? ( + + ) : ( + + ); + + return ( + <> + {takeButton} + {skipButton} + + ); + }; + const requestMarkObsolete = (med: { id: number; name: string }) => { setObsoleteCandidate(med); setShowObsoleteConfirm(true); @@ -708,6 +938,7 @@ export function DashboardPage() { return (
    0 ? "past-missed" : ""}`} >
    +
    { const doseId = getDoseId(dose.id, person); const isTaken = isDoseTakenForDisplay(doseId); + const isSkipped = skippedDoses.has(doseId); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + const personClasses = ["dose-person"]; + if (isTaken) personClasses.push("taken"); + if (isSkipped) personClasses.push("skipped"); + if (notificationTarget?.doseId === doseId) + personClasses.push("notification-focus-target"); return ( -
    +
    {person && ( )} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty, + })}
    ); })} @@ -1023,6 +1246,7 @@ export function DashboardPage() { return (
    +
    {item.doses.map((dose) => { - const isOverdue = dose.when < Date.now(); + const isOverdue = dose.when < Date.now() && !isEmpty; const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; const allTaken = people.every((person) => isDoseTakenForDisplay(getDoseId(dose.id, person)) @@ -1159,10 +1390,20 @@ export function DashboardPage() { {people.map((person) => { const doseId = getDoseId(dose.id, person); const isTaken = isDoseTakenForDisplay(doseId); + const isSkipped = skippedDoses.has(doseId); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + const personClasses = ["dose-person"]; + if (isTaken) personClasses.push("taken"); + if (isSkipped) personClasses.push("skipped"); + if (notificationTarget?.doseId === doseId) + personClasses.push("notification-focus-target"); return ( -
    +
    {person && ( )} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty, + })}
    ); })} @@ -1296,6 +1512,7 @@ export function DashboardPage() { return (
    +
    { const doseId = getDoseId(dose.id, person); const isTaken = isDoseTakenForDisplay(doseId); + const isSkipped = skippedDoses.has(doseId); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + const personClasses = ["dose-person"]; + if (isTaken) personClasses.push("taken"); + if (isSkipped) personClasses.push("skipped"); + if (notificationTarget?.doseId === doseId) + personClasses.push("notification-focus-target"); return ( -
    +
    {person && ( )} - {isTaken ? ( - - ) : ( - - )} + {renderDoseActionButtons({ + doseId, + isTaken, + isSkipped, + isAutomaticallyTaken, + isEmpty: true, + })}
    ); })} diff --git a/frontend/src/test/components/MedDetailModal.test.tsx b/frontend/src/test/components/MedDetailModal.test.tsx index 84d7eea..62841d3 100644 --- a/frontend/src/test/components/MedDetailModal.test.tsx +++ b/frontend/src/test/components/MedDetailModal.test.tsx @@ -697,7 +697,7 @@ describe("MedDetailModal with refill history", () => { it("shows refill history when expanded", () => { const refillHistory: RefillEntry[] = [ - { id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 }, + { id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 }, ]; render(); @@ -710,7 +710,7 @@ describe("MedDetailModal with refill history", () => { it("calls onRefillHistoryExpandedChange when toggle clicked", () => { const onRefillHistoryExpandedChange = vi.fn(); const refillHistory: RefillEntry[] = [ - { id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 }, + { id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 }, ]; render( diff --git a/frontend/src/test/components/ReportModal.test.tsx b/frontend/src/test/components/ReportModal.test.tsx index 6e47fed..5081f8c 100644 --- a/frontend/src/test/components/ReportModal.test.tsx +++ b/frontend/src/test/components/ReportModal.test.tsx @@ -42,7 +42,7 @@ describe("ReportModal", () => { json: async () => ({ 1: { dosesTaken: 2, - dosesDismissed: 0, + dosesSkipped: 0, firstDoseAt: "2026-01-01T08:00:00.000Z", lastDoseAt: "2026-01-02T08:00:00.000Z", refills: [], @@ -74,7 +74,7 @@ describe("ReportModal", () => { 1: { dosesTaken: 1, automaticDosesTaken: 0, - dosesDismissed: 0, + dosesSkipped: 0, firstDoseAt: "2026-02-03T12:00:00.000Z", lastDoseAt: null, refills: [], @@ -121,7 +121,7 @@ describe("ReportModal", () => { 1: { dosesTaken: 0, automaticDosesTaken: 0, - dosesDismissed: 0, + dosesSkipped: 0, firstDoseAt: null, lastDoseAt: null, refills: [], @@ -183,13 +183,14 @@ describe("ReportModal", () => { 1: { dosesTaken: 1, automaticDosesTaken: 0, - dosesDismissed: 0, + dosesSkipped: 0, firstDoseAt: "2026-03-03T12:00:00.000Z", lastDoseAt: null, refills: [ { packsAdded: 1, loosePillsAdded: 0, + quantityAdded: 20, usedPrescription: false, refillDate: "2026-03-04", }, @@ -251,6 +252,81 @@ describe("ReportModal", () => { expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled(); }); + it("sends the selected person filter with the report request and clears it for all people", async () => { + const onClose = vi.fn(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + 1: { + dosesTaken: 2, + automaticDosesTaken: 0, + dosesSkipped: 1, + firstDoseAt: "2026-01-01T08:00:00.000Z", + lastDoseAt: "2026-01-02T08:00:00.000Z", + refills: [], + }, + 2: { + dosesTaken: 1, + automaticDosesTaken: 0, + dosesSkipped: 0, + firstDoseAt: "2026-01-01T08:00:00.000Z", + lastDoseAt: "2026-01-02T08:00:00.000Z", + refills: [], + }, + }), + }); + + const firstRender = render( + + ); + + fireEvent.click(screen.getByRole("checkbox", { name: "Alice" })); + fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i })); + fireEvent.click(screen.getByRole("button", { name: /report\.generate/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/medications/report-data", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }), + }) + ); + }); + + (global.fetch as ReturnType).mockClear(); + firstRender.unmount(); + render( + + ); + fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i })); + fireEvent.click(screen.getByRole("button", { name: /report\.generate/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/medications/report-data", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }), + }) + ); + }); + }); + it("generates markdown report and keeps modal open on fetch error", async () => { const onClose = vi.fn(); (global.fetch as ReturnType).mockResolvedValue({ ok: false }); diff --git a/frontend/src/test/components/SharedSchedule.test.tsx b/frontend/src/test/components/SharedSchedule.test.tsx index b5dac66..d34e637 100644 --- a/frontend/src/test/components/SharedSchedule.test.tsx +++ b/frontend/src/test/components/SharedSchedule.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SharedSchedule } from "../../components/SharedSchedule"; @@ -168,10 +168,58 @@ function createSharedDataWithTodayDose(referenceNow: Date) { }; } +function createSharedDoseFetchMock(options: { + token?: string; + sharedData: ReturnType; + initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>; +}) { + const token = options.token ?? "token-123"; + const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }])); + const requests: Array<{ url: string; method: string; body?: unknown }> = []; + + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const body = + typeof init?.body === "string" && init.body.length > 0 + ? (JSON.parse(init.body) as { doseId: string }) + : undefined; + requests.push({ url, method, body }); + + if (url === `/api/share/${token}` && method === "GET") { + return { ok: true, json: async () => options.sharedData }; + } + + if (url === `/api/share/${token}/doses` && method === "GET") { + return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) }; + } + + if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) { + doseState.set(body.doseId, { doseId: body.doseId, skipped: true }); + return { ok: true, json: async () => ({}) }; + } + + if (url === `/api/share/${token}/doses` && method === "POST" && body?.doseId) { + doseState.set(body.doseId, { doseId: body.doseId, takenSource: "manual" }); + return { ok: true, json: async () => ({}) }; + } + + if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") { + const doseId = decodeURIComponent(url.split("/").at(-1) ?? ""); + doseState.delete(doseId); + return { ok: true, json: async () => ({}) }; + } + + return Promise.reject(new Error(`Unexpected request: ${method} ${url}`)); + }); + + return { fetchMock, requests, getDoses: () => Array.from(doseState.values()) }; +} + describe("SharedSchedule", () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); + globalThis.fetch = vi.fn() as unknown as typeof fetch; vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType); vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {}); }); @@ -183,7 +231,7 @@ describe("SharedSchedule", () => { it("renders shared schedule shell for valid token", async () => { (globalThis.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { - if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); } if (url === "/api/share/token-123") { @@ -247,7 +295,7 @@ describe("SharedSchedule", () => { it("renders generic error when loading share data fails", async () => { (globalThis.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { - if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); } if (url === "/api/share/token-123") { @@ -270,7 +318,7 @@ describe("SharedSchedule", () => { const sharedData = createSharedDataWithTodayDose(referenceNow); (globalThis.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { - if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) { return Promise.resolve({ ok: true, json: () => @@ -296,7 +344,7 @@ describe("SharedSchedule", () => { const sharedData = createSharedDataWithEmbeddedOverview(); (globalThis.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { - if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); } if (url === "/api/share/token-123") { @@ -318,4 +366,90 @@ describe("SharedSchedule", () => { expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0); expect(screen.getByText("share.noSchedule")).toBeInTheDocument(); }); + + it("skips a neutral shared dose via the skip endpoint", async () => { + const referenceNow = new Date(); + referenceNow.setHours(12, 0, 0, 0); + vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime()); + const sharedData = createSharedDataWithTodayDose(referenceNow); + const { fetchMock, requests } = createSharedDoseFetchMock({ sharedData }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("dose.skip")); + + await waitFor(() => { + expect(requests).toContainEqual({ + url: "/api/share/token-123/doses/skip", + method: "POST", + body: { doseId: sharedData.automaticDoseId }, + }); + expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument(); + }); + }); + + it("undoes a skipped shared dose via the delete skip endpoint", async () => { + const referenceNow = new Date(); + referenceNow.setHours(12, 0, 0, 0); + vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime()); + const sharedData = createSharedDataWithTodayDose(referenceNow); + const { fetchMock, requests } = createSharedDoseFetchMock({ + sharedData, + initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }], + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("dose.undoSkip")); + + await waitFor(() => { + expect(requests).toContainEqual({ + url: `/api/share/token-123/doses/skip/${sharedData.automaticDoseId}`, + method: "DELETE", + }); + expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument(); + }); + }); + + it("takes a skipped shared dose again via the take endpoint", async () => { + const referenceNow = new Date(); + referenceNow.setHours(12, 0, 0, 0); + vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime()); + const sharedData = createSharedDataWithTodayDose(referenceNow); + const { fetchMock, requests, getDoses } = createSharedDoseFetchMock({ + sharedData, + initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }], + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("dose.take")); + + await waitFor(() => { + expect(requests).toContainEqual({ + url: "/api/share/token-123/doses", + method: "POST", + body: { doseId: sharedData.automaticDoseId }, + }); + expect(getDoses()).toEqual([ + expect.objectContaining({ doseId: sharedData.automaticDoseId, takenSource: "manual" }), + ]); + expect(document.querySelector(".day-block.today")).toHaveClass("all-taken"); + }); + }); }); diff --git a/frontend/src/test/components/SharedScheduleTodayOnly.test.tsx b/frontend/src/test/components/SharedScheduleTodayOnly.test.tsx index 16a948a..74ead2a 100644 --- a/frontend/src/test/components/SharedScheduleTodayOnly.test.tsx +++ b/frontend/src/test/components/SharedScheduleTodayOnly.test.tsx @@ -77,7 +77,7 @@ describe("SharedSchedule today-only", () => { const sharedData = createSharedData(); (globalThis.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { - if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); } if (url === "/api/share/token-123") { diff --git a/frontend/src/test/hooks/useRefill.test.ts b/frontend/src/test/hooks/useRefill.test.ts index ddf2ee0..7805b27 100644 --- a/frontend/src/test/hooks/useRefill.test.ts +++ b/frontend/src/test/hooks/useRefill.test.ts @@ -31,7 +31,9 @@ describe("useRefill", () => { }); it("loads refill history", async () => { - const mockHistory = [{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: "2024-03-15T10:00:00Z" }]; + const mockHistory = [ + { id: 1, packsAdded: 2, loosePillsAdded: 0, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" }, + ]; (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, @@ -49,7 +51,7 @@ describe("useRefill", () => { it("handles refill history with refills wrapper", async () => { const mockHistory = { - refills: [{ id: 1, packsAdded: 2, createdAt: "2024-03-15T10:00:00Z" }], + refills: [{ id: 1, packsAdded: 2, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" }], }; (global.fetch as ReturnType).mockResolvedValueOnce({ @@ -162,7 +164,7 @@ describe("useRefill", () => { "/api/medications/1/refill", expect.objectContaining({ method: "POST", - body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, usePrescription: false }), + body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, quantityAdded: 0, usePrescription: false }), }) ); expect(fetch).toHaveBeenNthCalledWith( @@ -505,6 +507,53 @@ describe("useRefill", () => { }); }); + it("keeps liquid stock correction base fields aligned", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true }); + + const liquidMed: Medication = { + id: 12, + name: "Aligned Liquid", + medicationForm: "liquid", + packageType: "liquid_container", + doseUnit: "ml", + packCount: 1, + packageAmountValue: 180, + packageAmountUnit: "ml", + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 180, + looseTablets: 180, + stockAdjustment: 0, + takenBy: [], + blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }], + updatedAt: null, + }; + + const mockLoadMeds = vi.fn(); + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.openEditStockModal(liquidMed, { + all: [{ name: liquidMed.name, medsLeft: 180, daysLeft: 36 }] as Coverage[], + }); + result.current.setEditStockFullBlisters(2); + result.current.setEditStockPartialBlisterPills(300); + }); + + await act(async () => { + await result.current.submitStockCorrection(12, liquidMed, mockLoadMeds); + }); + + const [, requestInit] = (global.fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(requestInit.body as string); + expect(body).toEqual({ + stockAdjustment: -60, + packCount: 2, + totalPills: 360, + looseTablets: 360, + }); + }); + it("stock correction uses loose tablets rather than bottle capacity as the base", async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true }); diff --git a/frontend/src/test/pages/DashboardPage.test.tsx b/frontend/src/test/pages/DashboardPage.test.tsx index 3ccc456..c6e1098 100644 --- a/frontend/src/test/pages/DashboardPage.test.tsx +++ b/frontend/src/test/pages/DashboardPage.test.tsx @@ -130,6 +130,13 @@ const mockTodayDay = { ], }; +function getRouteDateKey(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + // Default mock factory const createMockAppContext = (overrides = {}) => ({ meds: [], @@ -321,6 +328,7 @@ describe("DashboardPage", () => { vi.clearAllMocks(); localStorage.clear(); mockContextValue = createMockAppContext(); + HTMLElement.prototype.scrollIntoView = vi.fn(); }); it("renders dashboard page", () => { @@ -505,6 +513,7 @@ describe("DashboardPage interactions", () => { vi.clearAllMocks(); localStorage.clear(); mockContextValue = createMockAppContext(); + HTMLElement.prototype.scrollIntoView = vi.fn(); }); it("has schedule days options", () => { @@ -539,6 +548,91 @@ describe("DashboardPage interactions", () => { expect(setScheduleDays).toHaveBeenCalledWith(90); }); + it("highlights and scrolls to the notification-linked dashboard dose", async () => { + const doseId = String(mockTodayDay.meds[0].doses[0].id); + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + todayDay: mockTodayDay, + }); + + render( + + + + ); + + await waitFor(() => { + const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`); + const targetRow = document.querySelector('[data-med-id="1"]'); + expect(targetDose).toHaveClass("notification-focus-target"); + expect(targetRow).toHaveClass("notification-focus-target-row"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); + }); + }); + + it("supports the shorter dashboard notification query params", async () => { + const doseId = String(mockTodayDay.meds[0].doses[0].id); + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + todayDay: mockTodayDay, + }); + + render( + + + + ); + + await waitFor(() => { + const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`); + const targetRow = document.querySelector('[data-med-id="1"]'); + expect(targetDose).toHaveClass("notification-focus-target"); + expect(targetRow).toHaveClass("notification-focus-target-row"); + }); + }); + + it("scrolls to the notification-linked dashboard dose after schedule data loads", async () => { + const doseId = String(mockTodayDay.meds[0].doses[0].id); + mockContextValue = createMockAppContext(); + + const { rerender } = render( + + + + ); + + expect(document.querySelector(`[data-dose-id="${doseId}"]`)).toBeNull(); + expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + todayDay: mockTodayDay, + }); + + rerender( + + + + ); + + await waitFor(() => { + const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`); + expect(targetDose).toHaveClass("notification-focus-target"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); + }); + it("hides past and future sections when upcomingTodayOnly is enabled", () => { mockContextValue = createMockAppContext({ settings: { diff --git a/frontend/src/test/types.test.ts b/frontend/src/test/types.test.ts index 431d4d3..29fc70c 100644 --- a/frontend/src/test/types.test.ts +++ b/frontend/src/test/types.test.ts @@ -134,6 +134,20 @@ describe("getMedTotal", () => { expect(getMedTotal(tube)).toBe(604); expect(getMedTotal(liquid)).toBe(450); }); + + it("prefers canonical amount-base stock over compatibility mirror fields", () => { + const liquid = { + packageType: "liquid_container" as const, + packCount: 2, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 300, + looseTablets: 150, + stockAdjustment: 0, + }; + + expect(getMedTotal(liquid)).toBe(150); + }); }); describe("getPackageSize", () => { @@ -200,7 +214,7 @@ describe("getPackageSize", () => { expect(getPackageSize(med)).toBe(80); }); - it("returns totalPills for tube/liquid container package size", () => { + it("returns canonical amount-base stock for tube/liquid container package size", () => { const tube = { packageType: "tube" as const, packCount: 4, @@ -221,6 +235,19 @@ describe("getPackageSize", () => { expect(getPackageSize(tube)).toBe(600); expect(getPackageSize(liquid)).toBe(450); }); + + it("prefers canonical amount-base stock for package size when compatibility mirror drifts", () => { + const tube = { + packageType: "tube" as const, + packCount: 2, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 300, + looseTablets: 150, + }; + + expect(getPackageSize(tube)).toBe(150); + }); }); describe("getStockDisplayCapacity", () => { diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index 3751760..73da28e 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -1264,14 +1264,14 @@ describe("getStockStatus", () => { expect(result.className).toBe("danger"); }); - it("returns out-of-stock when daysLeft is 0", () => { + it("returns critical when daysLeft is 0 but stock remains", () => { const result = getStockStatus(0, 5, thresholds); - expect(result.level).toBe("out-of-stock"); + expect(result.level).toBe("critical"); expect(result.className).toBe("danger"); }); it("returns high when daysLeft > highStockDays", () => { - const result = getStockStatus(200, 100, thresholds); + const result = getStockStatus(181, 100, thresholds); expect(result.level).toBe("high"); expect(result.className).toBe("high"); }); @@ -1377,9 +1377,9 @@ describe("getStockStatus", () => { const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container"); expect(resultCritical.level).toBe("critical"); - // daysLeft = 0 (out of stock) + // daysLeft = 0 with stock remaining is still critical, not empty const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container"); - expect(resultEmpty.level).toBe("out-of-stock"); + expect(resultEmpty.level).toBe("critical"); }); }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 005b36e..8bdfa8b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -188,7 +188,8 @@ export type PlannerRow = { export type RefillEntry = { id: number; packsAdded: number; - loosePillsAdded: number; + loosePillsAdded?: number; + quantityAdded: number; usedPrescription?: boolean; refillDate: string; }; @@ -409,10 +410,11 @@ export function getMedTotal(med: MedLike): number { return med.looseTablets + (med.stockAdjustment ?? 0); } - // Amount-based package types store their current base stock directly - // in totalPills (fallback looseTablets for legacy rows). + // Amount-based package types use the same canonical base field as the backend: + // looseTablets stores the current amount baseline, while totalPills is kept in sync + // for compatibility and UI helpers. if (isAmountBasedPackageType(med.packageType)) { - const baseStock = med.totalPills ?? med.looseTablets; + const baseStock = med.looseTablets ?? med.totalPills ?? 0; return baseStock + (med.stockAdjustment ?? 0); } // For blister type, calculate from packs + loose @@ -425,9 +427,9 @@ export function getPackageSize(med: MedLike): number { return med.totalPills ?? med.looseTablets; } - // Amount-based package types use totalPills as base capacity + // Amount-based package types reuse the backend canonical amount baseline. if (isAmountBasedPackageType(med.packageType)) { - return med.totalPills ?? med.looseTablets; + return med.looseTablets ?? med.totalPills ?? 0; } // For blister type, calculate from packs + loose return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets; diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index 7703e4b..7d3d360 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -293,8 +293,8 @@ export function getStockStatus( thresholds: StockThresholds, packageType?: PackageType ): StockStatus { - // Out of stock or completely depleted = danger (red) - if (medsLeft <= 0 || daysLeft === 0) { + // Only a real zero-or-below stock count is out of stock. + if (medsLeft <= 0) { return { level: "out-of-stock", className: "danger", label: "status.outOfStock" }; }
${escHtml(t("report.docDosesTaken"))}${data.dosesTaken}
${escHtml(`๐Ÿค– ${t("report.docDosesTakenAutomatic")}`)}${data.automaticDosesTaken}
${escHtml(t("report.docDosesDismissed"))}${data.dosesDismissed}
${escHtml(t("report.docDosesSkipped"))}${data.dosesSkipped}
${escHtml(t("report.docFirstDose"))}${formatDate(data.firstDoseAt)}