fix: unify stock semantics across planner and scheduler (#245)
* fix: unify stock semantics across planner and scheduler * fix: stabilize dashboard hmr and align stock helper tests
This commit is contained in:
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -792,26 +792,37 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const [settingsRow] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
// Create a map of medication ID to taken dose count
|
||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
takenDoses.forEach((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
||||
if (!takenDosesMap.has(medId)) {
|
||||
takenDosesMap.set(medId, []);
|
||||
}
|
||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
||||
}
|
||||
if (parts.length < 3) return;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) return;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
||||
? rawTakenAt < 1_000_000_000_000
|
||||
? rawTakenAt * 1000
|
||||
: rawTakenAt
|
||||
: new Date(dose.takenAt).getTime();
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
});
|
||||
|
||||
// Use current time as the reference point for "available" stock
|
||||
@@ -838,66 +849,106 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
? looseTablets + stockAdjustment
|
||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||
|
||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
||||
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
|
||||
// Use the same logic as frontend: generate expected doses and check which are marked
|
||||
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
|
||||
// Build a Set of taken dose IDs for quick lookup
|
||||
const takenDoseIds = new Set(
|
||||
takenDoses
|
||||
.filter((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
|
||||
})
|
||||
.map((dose) => dose.doseId)
|
||||
);
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
// Count consumed pills by generating expected doses and checking if they're taken
|
||||
let consumedUntilNow = 0;
|
||||
const msPerDay = 86400000;
|
||||
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
// After a stock correction, start counting from the NEXT scheduled
|
||||
// dose, because the user's pill count already reflects all
|
||||
// consumption up to the correction time.
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
||||
effectiveStart = stockCorrectionCutoff + period;
|
||||
} else {
|
||||
effectiveStart = blisterStart.getTime();
|
||||
}
|
||||
if (effectiveStart > now.getTime()) return;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
const peopleForThisIntake = intakePerson
|
||||
? [intakePerson]
|
||||
: fallbackPeople.length > 0
|
||||
? fallbackPeople
|
||||
: [null];
|
||||
|
||||
// Get the people for this intake (from intakes array or medication takenBy)
|
||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const takenByFallback: (string | null)[] = takenByJson.length > 0 ? takenByJson : [null];
|
||||
const peopleForThisIntake: (string | null)[] = intakePerson ? [intakePerson] : takenByFallback;
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
// Generate expected dose IDs and check if they're taken
|
||||
for (let i = 0; i < occurrences; i++) {
|
||||
const doseDate = new Date(effectiveStart + i * period);
|
||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
||||
if (effectiveStart <= now.getTime()) {
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
// Check if each person has taken this dose
|
||||
for (const person of peopleForThisIntake) {
|
||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
if (takenDoseIds.has(doseId)) {
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumedUntilNow += blister.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||
|
||||
@@ -943,6 +994,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
medicationId: row.id,
|
||||
medicationName: row.name,
|
||||
totalPills: currentStock,
|
||||
currentPills: currentStock,
|
||||
plannerUsage: usageTotal,
|
||||
blisterSize: pillsPerBlister,
|
||||
blistersNeeded,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { medications, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
import type { ServiceLogger } from "../utils/logger.js";
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
parseBlisters,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseReminderState,
|
||||
parseTakenByJson,
|
||||
type ReminderState,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
@@ -119,10 +121,6 @@ export async function updateUserReminderSentTime(
|
||||
}
|
||||
}
|
||||
|
||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
return parseBlisters(row);
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
@@ -142,7 +140,8 @@ async function getMedicationsNeedingReminder(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
language: Language
|
||||
language: Language,
|
||||
stockCalculationMode: "automatic" | "manual"
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -150,15 +149,144 @@ async function getMedicationsNeedingReminder(
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const takenDoseRows = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
for (const dose of takenDoseRows) {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) continue;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
||||
? rawTakenAt < 1_000_000_000_000
|
||||
? rawTakenAt * 1000
|
||||
: rawTakenAt
|
||||
: new Date(dose.takenAt).getTime();
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
}
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
const now = Date.now();
|
||||
const msPerDay = 86_400_000;
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills =
|
||||
const intakes = parseIntakesJson(
|
||||
row.intakesJson,
|
||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||
row.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||
|
||||
const originalTotalPills =
|
||||
(row.packageType ?? "blister") === "bottle"
|
||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
let consumed = 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [null];
|
||||
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumed += blister.usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const currentPills = Math.max(0, originalTotalPills - consumed);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
|
||||
|
||||
if (daysLeft === null) continue;
|
||||
|
||||
@@ -168,7 +296,7 @@ async function getMedicationsNeedingReminder(
|
||||
if (isCritical || isLow) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
medsLeft: totalPills,
|
||||
medsLeft: currentPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
isCritical,
|
||||
@@ -200,6 +328,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
||||
}));
|
||||
}
|
||||
|
||||
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
|
||||
export async function getMedicationsNeedingReminderForTests(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
language: Language,
|
||||
stockCalculationMode: "automatic" | "manual"
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical: boolean;
|
||||
}>
|
||||
> {
|
||||
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
|
||||
}
|
||||
|
||||
async function sendReminderEmail(
|
||||
email: string,
|
||||
lowStock: LowStockItem[],
|
||||
@@ -403,7 +550,8 @@ async function checkAndSendReminderForUser(
|
||||
settings.userId,
|
||||
settings.reminderDaysBefore,
|
||||
settings.lowStockDays,
|
||||
language
|
||||
language,
|
||||
settings.stockCalculationMode
|
||||
);
|
||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: async () => 1,
|
||||
}));
|
||||
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function seedAnonymousUser() {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||
args: [1, "anon", "anonymous"],
|
||||
});
|
||||
}
|
||||
|
||||
async function setStockMode(mode: "automatic" | "manual") {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||
VALUES (?, ?, 7, 365, 'en')`,
|
||||
args: [1, mode],
|
||||
});
|
||||
}
|
||||
|
||||
async function createMedication(options: {
|
||||
name: string;
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
stockAdjustment?: number;
|
||||
lastStockCorrectionAt?: number | null;
|
||||
isObsolete?: boolean;
|
||||
takenBy?: string[];
|
||||
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||
}) {
|
||||
const {
|
||||
name,
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
stockAdjustment = 0,
|
||||
lastStockCorrectionAt = null,
|
||||
isObsolete = false,
|
||||
takenBy = [],
|
||||
intakes,
|
||||
} = options;
|
||||
|
||||
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||
const intakesJson = JSON.stringify(
|
||||
intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
takenBy: i.takenBy ?? null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, taken_by_json, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
stock_adjustment, last_stock_correction_at,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
is_obsolete, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
JSON.stringify(takenBy),
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
intakesJson,
|
||||
isObsolete ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function markDoseTaken(options: {
|
||||
medicationId: number;
|
||||
blisterIdx: number;
|
||||
doseDateOnlyMs: number;
|
||||
takenAtMs: number;
|
||||
personSuffix?: string;
|
||||
}) {
|
||||
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||
});
|
||||
}
|
||||
|
||||
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate, endDate },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const rows = response.json();
|
||||
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||
expect(row).toBeDefined();
|
||||
return row;
|
||||
}
|
||||
|
||||
function toDateOnlyMs(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
}
|
||||
|
||||
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(medicationRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
await seedAnonymousUser();
|
||||
});
|
||||
|
||||
it("keeps automatic mode current stock in sync", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Auto Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(10);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Correction";
|
||||
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
lastStockCorrectionAt: correctionMs,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan5DateOnly,
|
||||
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||
});
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan6DateOnly,
|
||||
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("counts early taken dose in automatic mode without drift", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Early Taken";
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
tomorrow.setHours(20, 0, 0, 0);
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||
});
|
||||
|
||||
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: tomorrowDateOnly,
|
||||
takenAtMs: now.getTime(),
|
||||
});
|
||||
|
||||
const rangeStart = new Date(now);
|
||||
rangeStart.setDate(now.getDate() - 1);
|
||||
const rangeEnd = new Date(now);
|
||||
rangeEnd.setDate(now.getDate() + 7);
|
||||
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(9);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Mixed TakenBy";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
takenBy: ["Alice", "Bob"],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||
],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
expect(usageRow.currentPills).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||
await setStockMode("automatic");
|
||||
await createMedication({
|
||||
name: "Obsolete Med",
|
||||
isObsolete: true,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,31 +15,12 @@ import type { Coverage, Medication, RefillEntry, StockThresholds } from "../type
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||
|
||||
// =============================================================================
|
||||
// Local Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate blister stock - divides current pills into full blisters and partial
|
||||
*/
|
||||
function getBlisterStock(
|
||||
currentPills: number,
|
||||
pillsPerBlister: number,
|
||||
originalLooseTablets: number,
|
||||
_originalTotalPills: number
|
||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
||||
}
|
||||
const safeCurrent = Math.max(0, currentPills);
|
||||
const loosePills = Math.min(safeCurrent, Math.max(0, originalLooseTablets));
|
||||
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
||||
const fullBlisters = Math.floor(sealedPills / pillsPerBlister);
|
||||
const openBlisterPills = sealedPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full blisters column
|
||||
*/
|
||||
@@ -230,7 +211,7 @@ export function MedDetailModal({
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass;
|
||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
||||
const stock = splitCurrentBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets);
|
||||
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||
|
||||
@@ -212,7 +212,17 @@ export interface AppContextValue {
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
|
||||
|
||||
const AppContext = (() => {
|
||||
const globalRef = globalThis as typeof globalThis & {
|
||||
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
|
||||
};
|
||||
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
|
||||
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
|
||||
}
|
||||
return globalRef[APP_CONTEXT_SINGLETON_KEY];
|
||||
})();
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
|
||||
@@ -4,57 +4,16 @@ import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { Coverage } from "../types";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// Helper function to calculate blister stock
|
||||
export function getBlisterStock(
|
||||
totalPills: number,
|
||||
pillsPerBlister: number,
|
||||
_looseTablets: number,
|
||||
_originalTotal: number
|
||||
) {
|
||||
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
|
||||
const openBlisterPills = totalPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
}
|
||||
|
||||
// Helper to format full blisters
|
||||
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||
}
|
||||
|
||||
// Helper to format open blister and loose pills
|
||||
export function formatOpenBlisterAndLoose(
|
||||
openBlisterPills: number,
|
||||
loosePills: number,
|
||||
pillsPerBlister: number,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (openBlisterPills === 0 && loosePills === 0) return "-";
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||
}
|
||||
|
||||
// Get total pills for a medication (packageType-aware)
|
||||
export function getMedTotal(med: {
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment?: number | null;
|
||||
packageType?: string;
|
||||
}): number {
|
||||
if (med.packageType === "bottle") {
|
||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
import {
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
getBlisterStock,
|
||||
getMedTotal,
|
||||
getReminderStatusData,
|
||||
userStorageKey,
|
||||
} from "./dashboard-helpers";
|
||||
|
||||
// Notification bell SVG icon (no emoji)
|
||||
function NotificationBellIcon() {
|
||||
@@ -76,108 +35,6 @@ function NotificationBellIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
// Get structured reminder status data
|
||||
export function getReminderStatusData(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
_allLowCoverage: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastAutoEmailSent: string | null,
|
||||
_lastNotificationType: string | null,
|
||||
_lastNotificationChannel: string | null,
|
||||
lastReminderMedName: string | null,
|
||||
lastReminderTakenBy: string | null,
|
||||
lastStockReminderSent: string | null,
|
||||
_lastStockReminderChannel: string | null,
|
||||
lastStockReminderMedNames: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): {
|
||||
status: { text: string; className: string };
|
||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||
lastStockSent: { date: string; medNames: string | null } | null;
|
||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||
} {
|
||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.daysLeft === null) continue;
|
||||
|
||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||
const isLow = c.daysLeft < lowStockDays;
|
||||
if (!isCritical && !isLow) continue;
|
||||
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||
}
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
// Determine status
|
||||
let status: { text: string; className: string };
|
||||
if (criticalCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||
className: "danger",
|
||||
};
|
||||
} else if (lowCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
||||
className: "warning",
|
||||
};
|
||||
} else {
|
||||
status = {
|
||||
text: t("dashboard.reminders.allOk"),
|
||||
className: "success",
|
||||
};
|
||||
}
|
||||
|
||||
// Parse last stock reminder sent info (from dedicated stock tracking columns)
|
||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||
if (lastStockReminderSent) {
|
||||
const sentDate = new Date(lastStockReminderSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastStockSent = {
|
||||
date: formattedDate,
|
||||
medNames: lastStockReminderMedNames,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse last intake reminder sent info (from intake tracking columns)
|
||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||
if (lastAutoEmailSent) {
|
||||
const sentDate = new Date(lastAutoEmailSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastIntakeSent = {
|
||||
date: formattedDate,
|
||||
medName: lastReminderMedName,
|
||||
takenBy: lastReminderTakenBy,
|
||||
};
|
||||
}
|
||||
|
||||
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -562,37 +562,12 @@ export function MedicationsPage() {
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showEditModal, closeEditModal]);
|
||||
|
||||
// Handle edit button click - open modal on mobile, switch to form on desktop
|
||||
const normalizeMedicationForEdit = useCallback(
|
||||
(med: Medication): Medication => {
|
||||
if (med.packageType !== "blister") return med;
|
||||
|
||||
const pillsPerPack = Math.max(1, med.blistersPerPack * med.pillsPerBlister);
|
||||
const fallbackStock = Math.max(0, getMedTotal(med));
|
||||
const currentStock = Math.max(0, Math.round(coverageByMed[med.name]?.medsLeft ?? fallbackStock));
|
||||
const nextPackCount = Math.floor(currentStock / pillsPerPack);
|
||||
const nextLooseTablets = currentStock % pillsPerPack;
|
||||
|
||||
if (nextPackCount === med.packCount && nextLooseTablets === med.looseTablets) {
|
||||
return med;
|
||||
}
|
||||
|
||||
return {
|
||||
...med,
|
||||
packCount: nextPackCount,
|
||||
looseTablets: nextLooseTablets,
|
||||
};
|
||||
},
|
||||
[coverageByMed]
|
||||
);
|
||||
|
||||
function handleEditClick(med: Medication) {
|
||||
const normalizedMed = normalizeMedicationForEdit(med);
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
@@ -602,17 +577,16 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
}
|
||||
|
||||
function handleViewClick(med: Medication) {
|
||||
const normalizedMed = normalizeMedicationForEdit(med);
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
@@ -622,7 +596,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
setActiveTab("general");
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
}
|
||||
|
||||
@@ -685,13 +659,13 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
startEdit(normalizeMedicationForEdit(medicationToEdit), openEditModal);
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
setViewMode("form");
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("editMedId");
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}, [allMeds, normalizeMedicationForEdit, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
}, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
|
||||
const selectedMedication = useMemo(() => {
|
||||
if (!editingId) return null;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Coverage } from "../types";
|
||||
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||
|
||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
export function getBlisterStock(
|
||||
totalPills: number,
|
||||
pillsPerBlister: number,
|
||||
looseTablets: number,
|
||||
_originalTotal: number
|
||||
) {
|
||||
return splitCurrentBlisterStock(totalPills, pillsPerBlister, looseTablets);
|
||||
}
|
||||
|
||||
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||
}
|
||||
|
||||
export function formatOpenBlisterAndLoose(
|
||||
openBlisterPills: number,
|
||||
loosePills: number,
|
||||
pillsPerBlister: number,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (openBlisterPills > 0 && loosePills > 0) {
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")} + ${loosePills} ${t("modal.loosePills")}`;
|
||||
}
|
||||
if (openBlisterPills > 0) {
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||
}
|
||||
if (loosePills > 0) {
|
||||
return `${loosePills} ${t("modal.loosePills")}`;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function getMedTotal(med: {
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment?: number | null;
|
||||
packageType?: string;
|
||||
}): number {
|
||||
return getMedTotalFromTypes(med);
|
||||
}
|
||||
|
||||
export function getReminderStatusData(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
_allLowCoverage: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastAutoEmailSent: string | null,
|
||||
_lastNotificationType: string | null,
|
||||
_lastNotificationChannel: string | null,
|
||||
lastReminderMedName: string | null,
|
||||
lastReminderTakenBy: string | null,
|
||||
lastStockReminderSent: string | null,
|
||||
_lastStockReminderChannel: string | null,
|
||||
lastStockReminderMedNames: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): {
|
||||
status: { text: string; className: string };
|
||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||
lastStockSent: { date: string; medNames: string | null } | null;
|
||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||
} {
|
||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.daysLeft === null) continue;
|
||||
|
||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||
const isLow = c.daysLeft < lowStockDays;
|
||||
if (!isCritical && !isLow) continue;
|
||||
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||
}
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
let status: { text: string; className: string };
|
||||
if (criticalCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||
className: "danger",
|
||||
};
|
||||
} else if (lowCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
||||
className: "warning",
|
||||
};
|
||||
} else {
|
||||
status = {
|
||||
text: t("dashboard.reminders.allOk"),
|
||||
className: "success",
|
||||
};
|
||||
}
|
||||
|
||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||
if (lastStockReminderSent) {
|
||||
const sentDate = new Date(lastStockReminderSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastStockSent = {
|
||||
date: formattedDate,
|
||||
medNames: lastStockReminderMedNames,
|
||||
};
|
||||
}
|
||||
|
||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||
if (lastAutoEmailSent) {
|
||||
const sentDate = new Date(lastAutoEmailSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastIntakeSent = {
|
||||
date: formattedDate,
|
||||
medName: lastReminderMedName,
|
||||
takenBy: lastReminderTakenBy,
|
||||
};
|
||||
}
|
||||
|
||||
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DashboardPage } from "../../pages/DashboardPage";
|
||||
import {
|
||||
DashboardPage,
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
getBlisterStock,
|
||||
getMedTotal,
|
||||
getReminderStatusData,
|
||||
userStorageKey,
|
||||
} from "../../pages/DashboardPage";
|
||||
} from "../../pages/dashboard-helpers";
|
||||
|
||||
// Mock data for tests with medications
|
||||
const mockMeds = [
|
||||
@@ -198,7 +198,7 @@ describe("DashboardPage helper functions", () => {
|
||||
});
|
||||
|
||||
it("calculates blister stock breakdown", () => {
|
||||
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 7 });
|
||||
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 0 });
|
||||
});
|
||||
|
||||
it("formats blister and open blister labels", () => {
|
||||
@@ -206,7 +206,7 @@ describe("DashboardPage helper functions", () => {
|
||||
expect(formatFullBlisters(1, t)).toBe("1 common.blister");
|
||||
expect(formatFullBlisters(3, t)).toBe("3 common.blisters");
|
||||
expect(formatOpenBlisterAndLoose(0, 0, 10, t)).toBe("-");
|
||||
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills");
|
||||
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills + 4 modal.loosePills");
|
||||
});
|
||||
|
||||
it("computes total pills for blister and bottle types", () => {
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("getBlisterStock", () => {
|
||||
|
||||
const result = getBlisterStock(med);
|
||||
expect(result.fullBlisters).toBe(2); // 25 / 10 = 2
|
||||
expect(result.openBlisterPills).toBe(5); // 25 % 10 = 5
|
||||
expect(result.openBlisterPills).toBe(0); // 20 % 10 = 0 after preserving loose tablets
|
||||
expect(result.loosePills).toBe(5);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
currentPills?: number;
|
||||
plannerUsage: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { BlisterStock, Medication } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { splitCurrentBlisterStock } from "./stock";
|
||||
|
||||
/**
|
||||
* Map timezone to region code (ISO 3166-1 alpha-2).
|
||||
@@ -302,12 +304,7 @@ export function getExpiryClass(expiryDate: string | null | undefined, thresholdD
|
||||
* Calculate blister stock breakdown for a medication
|
||||
*/
|
||||
export function getBlisterStock(med: Medication): BlisterStock {
|
||||
const total =
|
||||
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const bSize = med.pillsPerBlister;
|
||||
const fullBlisters = Math.floor(total / bSize);
|
||||
const openBlisterPills = total % bSize;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
return splitCurrentBlisterStock(getMedTotal(med), med.pillsPerBlister, med.looseTablets);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
export * from "./formatters";
|
||||
export * from "./ics";
|
||||
export * from "./schedule";
|
||||
export * from "./stock";
|
||||
export * from "./storage";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export type BlisterStockSplit = {
|
||||
fullBlisters: number;
|
||||
openBlisterPills: number;
|
||||
loosePills: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split current blister stock into sealed full blisters, open blister pills,
|
||||
* and loose pills using the configured loose-tablets baseline.
|
||||
*/
|
||||
export function splitCurrentBlisterStock(
|
||||
currentPills: number,
|
||||
pillsPerBlister: number,
|
||||
configuredLooseTablets: number
|
||||
): BlisterStockSplit {
|
||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: Math.max(0, currentPills) };
|
||||
}
|
||||
|
||||
const safeCurrent = Math.max(0, currentPills);
|
||||
const loosePills = Math.min(safeCurrent, Math.max(0, configuredLooseTablets));
|
||||
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
||||
|
||||
return {
|
||||
fullBlisters: Math.floor(sealedPills / pillsPerBlister),
|
||||
openBlisterPills: sealedPills % pillsPerBlister,
|
||||
loosePills,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper when medication object already contains stock fields.
|
||||
*/
|
||||
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
||||
const total =
|
||||
(med.packageType === "bottle"
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0)) ?? 0;
|
||||
|
||||
return splitCurrentBlisterStock(total, med.pillsPerBlister, med.looseTablets);
|
||||
}
|
||||
Reference in New Issue
Block a user