From 612aa007aa13d33c794f978c88533727e0a814b6 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 21 Feb 2026 15:24:53 +0100 Subject: [PATCH] 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 --- backend/src/routes/medications.ts | 166 ++++++--- backend/src/services/reminder-scheduler.ts | 172 ++++++++- .../src/test/stock-semantics-parity.test.ts | 350 ++++++++++++++++++ frontend/src/components/MedDetailModal.tsx | 23 +- frontend/src/context/AppContext.tsx | 12 +- frontend/src/pages/DashboardPage.tsx | 159 +------- frontend/src/pages/MedicationsPage.tsx | 38 +- frontend/src/pages/dashboard-helpers.ts | 147 ++++++++ .../src/test/pages/DashboardPage.test.tsx | 8 +- frontend/src/test/utils/formatters.test.ts | 2 +- frontend/src/types/index.ts | 1 + frontend/src/utils/formatters.ts | 9 +- frontend/src/utils/index.ts | 1 + frontend/src/utils/stock.ts | 43 +++ 14 files changed, 846 insertions(+), 285 deletions(-) create mode 100644 backend/src/test/stock-semantics-parity.test.ts create mode 100644 frontend/src/pages/dashboard-helpers.ts create mode 100644 frontend/src/utils/stock.ts diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 6da4ac8..4e9e13b 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -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(); + const takenDoseIdsByMed = new Map>(); + const takenDoseTimestamps = new Map(); 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(); // 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, diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index f8fbd3f..8611a15 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -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 { 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>(); + const takenDoseTimestamps = new Map(); + 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(); + + 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); diff --git a/backend/src/test/stock-semantics-parity.test.ts b/backend/src/test/stock-semantics-parity.test.ts new file mode 100644 index 0000000..fa01e69 --- /dev/null +++ b/backend/src/test/stock-semantics-parity.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index a812ecd..2952b6d 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -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); diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index 5d8a35c..789e181 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -212,7 +212,17 @@ export interface AppContextValue { // Context // ============================================================================= -const AppContext = createContext(null); +const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__"; + +const AppContext = (() => { + const globalRef = globalThis as typeof globalThis & { + [APP_CONTEXT_SINGLETON_KEY]?: React.Context; + }; + if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) { + globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext(null); + } + return globalRef[APP_CONTEXT_SINGLETON_KEY]; +})(); // Helper for user-specific localStorage keys function userStorageKey(userId: number | undefined, key: string): string { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index f2edfec..1b69b71 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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, - 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(); - - 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(); diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 7d53918..6f1298a 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -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; diff --git a/frontend/src/pages/dashboard-helpers.ts b/frontend/src/pages/dashboard-helpers.ts new file mode 100644 index 0000000..9b46830 --- /dev/null +++ b/frontend/src/pages/dashboard-helpers.ts @@ -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, + 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(); + + 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 }; +} diff --git a/frontend/src/test/pages/DashboardPage.test.tsx b/frontend/src/test/pages/DashboardPage.test.tsx index 64ed0fa..24b788f 100644 --- a/frontend/src/test/pages/DashboardPage.test.tsx +++ b/frontend/src/test/pages/DashboardPage.test.tsx @@ -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", () => { diff --git a/frontend/src/test/utils/formatters.test.ts b/frontend/src/test/utils/formatters.test.ts index ccd628c..6cc0f90 100644 --- a/frontend/src/test/utils/formatters.test.ts +++ b/frontend/src/test/utils/formatters.test.ts @@ -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); }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b53b202..645ae46 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -69,6 +69,7 @@ export type PlannerRow = { medicationId: number; medicationName: string; totalPills: number; + currentPills?: number; plannerUsage: number; blisterSize: number; blistersNeeded: number; diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts index 166cc55..aa26f8f 100644 --- a/frontend/src/utils/formatters.ts +++ b/frontend/src/utils/formatters.ts @@ -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); } /** diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index ff1d186..8f5c330 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -5,4 +5,5 @@ export * from "./formatters"; export * from "./ics"; export * from "./schedule"; +export * from "./stock"; export * from "./storage"; diff --git a/frontend/src/utils/stock.ts b/frontend/src/utils/stock.ts new file mode 100644 index 0000000..e03486d --- /dev/null +++ b/frontend/src/utils/stock.ts @@ -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); +}