fix(refill): stabilize stock and amount package semantics
This commit is contained in:
@@ -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<number, string>();
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> | 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),
|
||||
})),
|
||||
|
||||
@@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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" });
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<string, string>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1105,10 +1105,7 @@ export function MedDetailModal({
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
{(() => {
|
||||
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 && (
|
||||
|
||||
@@ -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 += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||
} else {
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
|
||||
}
|
||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -616,14 +626,14 @@ function buildPrintHtml(
|
||||
// Intake history
|
||||
if (data) {
|
||||
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
|
||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||
if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
|
||||
s += `<table><tbody>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
||||
if (data.automaticDosesTaken > 0) {
|
||||
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
||||
}
|
||||
if (data.dosesDismissed > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||
if (data.dosesSkipped > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesSkipped"))}</td><td>${data.dosesSkipped}</td></tr>`;
|
||||
if (data.firstDoseAt)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
|
||||
if (data.lastDoseAt)
|
||||
@@ -638,7 +648,7 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
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 += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function SharedSchedule() {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(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<string>();
|
||||
const automatic = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
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 ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn skip"
|
||||
onClick={() => markDoseSkipped(options.doseId)}
|
||||
title={t("dose.markAsSkipped")}
|
||||
disabled={options.isTaken}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
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() {
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||
>
|
||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(dose.id)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(dose.id)}
|
||||
disabled={isEmpty}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId: dose.id,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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() {
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(dose.id)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(dose.id)}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId: dose.id,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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() {
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||
>
|
||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(dose.id)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(dose.id)}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
disabled={true}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId: dose.id,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,6 @@ export function MedicationListSection({
|
||||
const renderImageAvatar = (med: Medication) => (
|
||||
<span
|
||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||
onClick={() => med.imageUrl && onImagePreview(med)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
|
||||
onImagePreview(med);
|
||||
@@ -146,8 +145,7 @@ export function MedicationListSection({
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{t("medications.details.totalCapacity")}:{" "}
|
||||
<strong>{med.totalPills ?? med.looseTablets}</strong>
|
||||
{t("medications.details.totalCapacity")}: <strong>{stockDisplayCapacity}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>("[data-dose-id]"));
|
||||
const doseElement = elements.find((element) => element.dataset.doseId === doseId);
|
||||
if (doseElement) {
|
||||
return doseElement.closest<HTMLElement>("[data-med-id]") ?? doseElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (medId) {
|
||||
const elements = Array.from(document.querySelectorAll<HTMLElement>("[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<string | null>(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 ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn skip"
|
||||
onClick={() => markDoseSkipped(options.doseId)}
|
||||
title={t("dose.markAsSkipped")}
|
||||
disabled={options.isTaken}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
||||
setObsoleteCandidate(med);
|
||||
setShowObsoleteConfirm(true);
|
||||
@@ -708,6 +938,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
data-date-key={getRouteDateKey(day.date)}
|
||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
||||
>
|
||||
<div
|
||||
@@ -753,8 +984,15 @@ export function DashboardPage() {
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||
rowClasses.push("notification-focus-target-row");
|
||||
}
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={rowClasses.join(" ")}
|
||||
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||
>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -828,10 +1066,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 (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
key={doseId}
|
||||
data-dose-id={doseId}
|
||||
className={personClasses.join(" ")}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -843,38 +1091,13 @@ export function DashboardPage() {
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={
|
||||
isEmpty
|
||||
? t("common.outOfStockTakeBlocked")
|
||||
: t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1023,6 +1246,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
data-date-key={getRouteDateKey(day.date)}
|
||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
||||
>
|
||||
<div
|
||||
@@ -1067,8 +1291,15 @@ export function DashboardPage() {
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||
rowClasses.push("notification-focus-target-row");
|
||||
}
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={rowClasses.join(" ")}
|
||||
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||
>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1126,7 +1357,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{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 (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
key={doseId}
|
||||
data-dose-id={doseId}
|
||||
className={personClasses.join(" ")}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -1174,38 +1415,13 @@ export function DashboardPage() {
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={
|
||||
isEmpty
|
||||
? t("common.outOfStockTakeBlocked")
|
||||
: t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1296,6 +1512,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
data-date-key={getRouteDateKey(day.date)}
|
||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
||||
>
|
||||
<div
|
||||
@@ -1340,8 +1557,15 @@ export function DashboardPage() {
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||
rowClasses.push("notification-focus-target-row");
|
||||
}
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={rowClasses.join(" ")}
|
||||
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||
>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1430,10 +1654,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 (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
key={doseId}
|
||||
data-dose-id={doseId}
|
||||
className={personClasses.join(" ")}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -1445,34 +1679,13 @@ export function DashboardPage() {
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take out-of-stock`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={t("common.outOfStockTakeBlocked")}
|
||||
disabled={true}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">⊘</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof vi.fn>).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(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
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<typeof vi.fn>).mockClear();
|
||||
firstRender.unmount();
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
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<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||
|
||||
@@ -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<typeof createSharedDataWithTodayDose>;
|
||||
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<typeof setInterval>);
|
||||
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
});
|
||||
@@ -183,7 +231,7 @@ describe("SharedSchedule", () => {
|
||||
|
||||
it("renders shared schedule shell for valid token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("SharedSchedule today-only", () => {
|
||||
const sharedData = createSharedData();
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).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") {
|
||||
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/dashboard?day=${getRouteDateKey(mockTodayDay.date)}&dose=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(document.querySelector(`[data-dose-id="${doseId}"]`)).toBeNull();
|
||||
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
todayDay: mockTodayDay,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user