From 68ab79c713c90b320304d737bc09587d5bbb6e11 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 20 Mar 2026 14:58:25 +0100 Subject: [PATCH] feat: enable weekday-based medication scheduling Closes #463 --- backend/package-lock.json | 4 +- backend/src/db/db-utils.ts | 16 +- backend/src/routes/export.ts | 46 +- backend/src/routes/medications.ts | 188 ++++---- backend/src/services/coverage.ts | 30 +- backend/src/services/current-stock.ts | 41 +- backend/src/services/reminder-scheduler.ts | 44 +- backend/src/test/integration.test.ts | 83 ++-- backend/src/test/services.test.ts | 153 ++++++- backend/src/test/share.test.ts | 1 - backend/src/utils/scheduler-utils.ts | 416 ++++++++++++++---- frontend/e2e/share-schedule.spec.ts | 24 +- frontend/package-lock.json | 4 +- frontend/src/components/MedDetailModal.tsx | 35 +- frontend/src/components/MobileEditModal.tsx | 96 +++- frontend/src/components/ReportModal.tsx | 74 ++-- frontend/src/components/SharedSchedule.tsx | 86 +--- frontend/src/components/UserFilterModal.tsx | 34 +- frontend/src/hooks/useMedicationForm.ts | 11 +- frontend/src/i18n/de.json | 23 + frontend/src/i18n/en.json | 23 + frontend/src/pages/DashboardPage.tsx | 75 +--- frontend/src/pages/MedicationsPage.tsx | 271 ++++++++---- frontend/src/pages/SchedulePage.tsx | 60 +-- .../test/components/MobileEditModal.test.tsx | 57 ++- .../src/test/components/ReportModal.test.tsx | 80 +++- .../src/test/pages/MedicationsPage.test.tsx | 61 +++ frontend/src/test/utils/ics.test.ts | 50 +++ frontend/src/test/utils/intake-units.test.ts | 23 + frontend/src/test/utils/schedule.test.ts | 223 ++++++++++ frontend/src/types/index.ts | 6 + frontend/src/utils/ics.ts | 34 +- frontend/src/utils/intake-schedule.ts | 139 ++++++ frontend/src/utils/intake-units.ts | 15 + frontend/src/utils/schedule.ts | 171 ++++--- 35 files changed, 1856 insertions(+), 841 deletions(-) create mode 100644 frontend/src/test/utils/intake-units.test.ts create mode 100644 frontend/src/utils/intake-schedule.ts create mode 100644 frontend/src/utils/intake-units.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index bde9a4a..7495c2d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-backend", - "version": "1.20.1", + "version": "1.20.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-backend", - "version": "1.20.1", + "version": "1.20.2", "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index 7488341..d130a3a 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url"; import type { Client } from "@libsql/client"; import type { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; -import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js"; +import { + forEachScheduledOccurrenceInRange, + getDateOnlyTimestamp, + getScheduleMatchWindowMs, + parseIntakesJson, + parseLocalDateTime, +} from "../utils/scheduler-utils.js"; // Get migrations folder path (relative to this file's location) const __filename = fileURLToPath(import.meta.url); @@ -363,9 +369,9 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: if (every <= 0 || Number.isNaN(start.getTime())) continue; const validDates = new Set(); - for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) { - validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); - } + forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => { + validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs))); + }); validDatesByIntake.set(idx, validDates); } @@ -388,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: const intake = intakes[intakeIdx]; if (!intake) continue; - const halfInterval = (intake.every * MS_PER_DAY) / 2; + const halfInterval = getScheduleMatchWindowMs(intake); let bestMatch: number | null = null; let bestDist = Infinity; diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index 02470dc..ecbc733 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -16,14 +16,14 @@ import { validationErrorSchema, } from "../utils/openapi-route-standards.js"; import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js"; -import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js"; +import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js"; const IMAGES_DIR = resolve(getDataDir(), "images"); // ============================================================================= // Export Format Version (bump this when format changes) // ============================================================================= -const EXPORT_VERSION = "1.3"; +const EXPORT_VERSION = "1.4"; // ============================================================================= // Zod Schemas for Import Validation @@ -33,6 +33,8 @@ const scheduleSchema = z.object({ usage: z.number().nonnegative(), every: z.number().int().min(1), start: z.string(), // ISO datetime string + scheduleMode: z.unknown().optional(), + weekdays: z.unknown().optional(), intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(), remind: z.boolean().optional().default(false), takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field) @@ -237,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{ usage: number; every: number; start: string; + scheduleMode: "interval" | "weekdays"; + weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">; intakeUnit: "ml" | "tsp" | "tbsp" | null; remind: boolean; takenBy: string | null; @@ -252,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{ usage: intake.usage, every: intake.every, start: intake.start, - intakeUnit: null, + scheduleMode: intake.scheduleMode ?? "interval", + weekdays: intake.weekdays ?? [], + intakeUnit: intake.intakeUnit ?? null, remind: intake.intakeRemindersEnabled, takenBy: intake.takenBy, // Per-intake takenBy })); @@ -671,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) { const exportIdToNewId = new Map(); for (const med of importData.medications) { - // Convert schedules to both legacy and new formats - const usageJson = JSON.stringify(med.schedules.map((s) => s.usage)); - const everyJson = JSON.stringify(med.schedules.map((s) => s.every)); - const startJson = JSON.stringify(med.schedules.map((s) => s.start)); + const normalizedSchedules = med.schedules.map((schedule) => + normalizeIntake({ + usage: schedule.usage, + every: schedule.every, + start: schedule.start, + scheduleMode: schedule.scheduleMode, + weekdays: schedule.weekdays, + intakeUnit: schedule.intakeUnit ?? null, + takenBy: schedule.takenBy || null, + intakeRemindersEnabled: schedule.remind ?? false, + }) + ); + const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage)); + const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every)); + const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start)); const takenByJson = JSON.stringify(med.takenBy); - // Build intakesJson array (new unified format with per-intake takenBy) - const intakesJson = JSON.stringify( - med.schedules.map((s) => ({ - usage: s.usage, - every: s.every, - start: s.start, - intakeUnit: s.intakeUnit ?? null, - takenBy: s.takenBy || null, - intakeRemindersEnabled: s.remind ?? false, - })) - ); + const intakesJson = JSON.stringify(normalizedSchedules); // Check if any schedule has remind enabled - const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled; + const intakeRemindersEnabled = + normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled; const [inserted] = await db .insert(medications) diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index d62bd55..02a2760 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -29,7 +29,13 @@ import { PACKAGE_TYPES, } from "../utils/package-profiles.js"; import { + countScheduledOccurrencesInRange, + forEachScheduledOccurrenceInRange, + getDateOnlyTimestamp, + getNextScheduledOccurrenceTime, + getScheduleMatchWindowMs, type Intake, + normalizeIntake, normalizeIntakeUsageForStock, parseIntakesJson, parseLocalDateTime, @@ -100,6 +106,8 @@ const intakeSchema = z.object({ usage: z.number().nonnegative(), every: z.number().int().min(1), start: z.string().datetime({ local: true }), + scheduleMode: z.unknown().optional(), + weekdays: z.unknown().optional(), intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(), takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting @@ -274,6 +282,11 @@ const intakeOpenApiSchema = { usage: { type: "number", minimum: 0 }, every: { type: "integer", minimum: 1 }, start: { type: "string", description: "ISO datetime string; timezone suffix optional." }, + scheduleMode: { type: "string", enum: ["interval", "weekdays"] }, + weekdays: { + type: "array", + items: { type: "string", enum: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] }, + }, intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] }, takenBy: { type: ["string", "null"], maxLength: 100 }, intakeRemindersEnabled: { type: "boolean" }, @@ -359,6 +372,8 @@ const medicationBodyOpenApiSchema = { usage: 1, every: 8, start: "2026-03-11T08:00:00.000Z", + scheduleMode: "interval", + weekdays: [], takenBy: "Daniel", intakeRemindersEnabled: true, }, @@ -664,25 +679,20 @@ export async function medicationRoutes(app: FastifyInstance) { // Convert to unified intakes format let intakes: Intake[]; if (inputIntakes) { - // New format with per-intake takenBy - intakes = inputIntakes.map((i) => ({ - usage: i.usage, - every: i.every, - start: i.start, - intakeUnit: i.intakeUnit ?? null, - takenBy: i.takenBy || null, - intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, - })); + intakes = inputIntakes.map((intake) => normalizeIntake(intake)); } else if (inputBlisters) { - // Legacy format - convert to new format - intakes = inputBlisters.map((b) => ({ - usage: b.usage, - every: b.every, - start: b.start, - intakeUnit: null, - takenBy: null, // No per-intake takenBy from legacy - intakeRemindersEnabled: intakeRemindersEnabled ?? false, - })); + intakes = inputBlisters.map((blister) => + normalizeIntake( + { + usage: blister.usage, + every: blister.every, + start: blister.start, + intakeUnit: null, + takenBy: null, + }, + intakeRemindersEnabled ?? false + ) + ); } else { return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); } @@ -840,25 +850,20 @@ export async function medicationRoutes(app: FastifyInstance) { // Convert to unified intakes format let intakes: Intake[]; if (inputIntakes) { - // New format with per-intake takenBy - intakes = inputIntakes.map((i) => ({ - usage: i.usage, - every: i.every, - start: i.start, - intakeUnit: i.intakeUnit ?? null, - takenBy: i.takenBy || null, - intakeRemindersEnabled: i.intakeRemindersEnabled ?? false, - })); + intakes = inputIntakes.map((intake) => normalizeIntake(intake)); } else if (inputBlisters) { - // Legacy format - convert to new format - intakes = inputBlisters.map((b) => ({ - usage: b.usage, - every: b.every, - start: b.start, - intakeUnit: null, - takenBy: null, // No per-intake takenBy from legacy - intakeRemindersEnabled: intakeRemindersEnabled ?? false, - })); + intakes = inputBlisters.map((blister) => + normalizeIntake( + { + usage: blister.usage, + every: blister.every, + start: blister.start, + intakeUnit: null, + takenBy: null, + }, + intakeRemindersEnabled ?? false + ) + ); } else { return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); } @@ -942,8 +947,7 @@ export async function medicationRoutes(app: FastifyInstance) { if (allDoses.length > 0) { // Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs const now = new Date(); - const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const MS_PER_DAY = 86_400_000; + const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) { const oldIntake = oldIntakes[idx]; @@ -954,44 +958,45 @@ export async function medicationRoutes(app: FastifyInstance) { const oldStart = parseLocalDateTime(oldIntake.start); const newStart = parseLocalDateTime(newIntake.start); - const oldEvery = oldIntake.every; - const newEvery = newIntake.every; - - // Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs) + // Check if start date or schedule changed (time-of-day changes don't matter for dateOnlyMs) const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime(); const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime(); - if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) { + const scheduleUnchanged = + oldStartDateOnly === newStartDateOnly && + oldIntake.every === newIntake.every && + oldIntake.scheduleMode === newIntake.scheduleMode && + (oldIntake.weekdays ?? []).join(",") === (newIntake.weekdays ?? []).join(","); + + if (scheduleUnchanged) { continue; // No schedule change that affects dose IDs } // Build set of new valid dateOnlyMs values for this intake const newDates = new Set(); - for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) { - newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); - } + forEachScheduledOccurrenceInRange(newIntake, newStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => { + newDates.add(getDateOnlyTimestamp(new Date(occurrenceMs))); + }); // Build set of old dateOnlyMs values with mapping to nearest new date const oldToNewMap = new Map(); - for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) { - const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - // Find the closest new date within ±(newEvery/2) days - const halfInterval = (newEvery * MS_PER_DAY) / 2; + const scheduleMatchWindowMs = getScheduleMatchWindowMs(newIntake); + forEachScheduledOccurrenceInRange(oldIntake, oldStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => { + const oldDateMs = getDateOnlyTimestamp(new Date(occurrenceMs)); let bestMatch: number | null = null; - let bestDist = Infinity; + let bestDistance = Infinity; for (const newDateMs of newDates) { - const dist = Math.abs(newDateMs - oldDateMs); - if (dist < bestDist && dist <= halfInterval) { - bestDist = dist; + const distance = Math.abs(newDateMs - oldDateMs); + if (distance < bestDistance && distance <= scheduleMatchWindowMs) { + bestDistance = distance; bestMatch = newDateMs; } } if (bestMatch !== null && bestMatch !== oldDateMs) { oldToNewMap.set(oldDateMs, bestMatch); - // Remove matched new date to prevent double-mapping newDates.delete(bestMatch); } - } + }); // Apply migrations to dose tracking entries if (oldToNewMap.size > 0) { @@ -1503,6 +1508,8 @@ export async function medicationRoutes(app: FastifyInstance) { usage: normalizeIntakeUsageForStock(i, medForm, row.packageType), every: i.every, start: i.start, + scheduleMode: i.scheduleMode, + weekdays: i.weekdays, })); const pillsPerBlister = row.pillsPerBlister ?? 1; const packCount = row.packCount ?? 1; @@ -1523,8 +1530,6 @@ export async function medicationRoutes(app: FastifyInstance) { // Count consumed pills by generating expected doses and checking if they're taken let consumedUntilNow = 0; - const msPerDay = 86400000; - if (isTopical) { consumedUntilNow = 0; } else if (stockCalculationMode === "automatic") { @@ -1532,16 +1537,11 @@ export async function medicationRoutes(app: FastifyInstance) { 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 effectiveStart = + stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart + ? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false) + : blisterStart; + if (effectiveStart === null) return; const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; @@ -1559,25 +1559,20 @@ export async function medicationRoutes(app: FastifyInstance) { let lastAutoConsumedDateMs = 0; if (effectiveStart <= now.getTime()) { - const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1; + const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange( + blister, + effectiveStart, + now.getTime() + ); timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; - const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); - lastAutoConsumedDateMs = new Date( - lastDoseTime.getFullYear(), - lastDoseTime.getMonth(), - lastDoseTime.getDate() - ).getTime(); + if (lastOccurrenceMs !== null) { + lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs)); + } } const stockCorrectionDateOnly = - stockCorrectionCutoff > 0 - ? new Date( - new Date(stockCorrectionCutoff).getFullYear(), - new Date(stockCorrectionCutoff).getMonth(), - new Date(stockCorrectionCutoff).getDate() - ).getTime() - : 0; + stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); let earlyTakenConsumed = 0; @@ -1768,34 +1763,19 @@ export async function medicationRoutes(app: FastifyInstance) { } function calculateUsageInRange( - blisters: Array<{ usage: number; every: number; start: string }>, + blisters: Array>, start: Date, end: Date ) { + if (end.getTime() <= start.getTime()) { + return 0; + } + let total = 0; - const msPerDay = 86400000; blisters.forEach((blister) => { - const blisterStart = parseLocalDateTime(blister.start); - if (Number.isNaN(blisterStart.getTime())) return; - - const every = Math.max(1, blister.every); - - // Skip ahead to the first occurrence at or after start to avoid - // iterating through months/years of past doses - const dt = new Date(blisterStart); - if (dt < start) { - const daysToSkip = Math.floor((start.getTime() - dt.getTime()) / (every * msPerDay)); - dt.setDate(dt.getDate() + daysToSkip * every); - // Fine-tune: advance until we reach or pass start - while (dt < start) { - dt.setDate(dt.getDate() + every); - } - } - - // Count occurrences in [start, end) - for (; dt < end; dt.setDate(dt.getDate() + every)) { + forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => { total += blister.usage; - } + }); }); return Number(total.toFixed(2)); } diff --git a/backend/src/services/coverage.ts b/backend/src/services/coverage.ts index c0b93d1..efbe377 100644 --- a/backend/src/services/coverage.ts +++ b/backend/src/services/coverage.ts @@ -1,14 +1,14 @@ import type { doseTracking, medications } from "../db/schema.js"; import { isAmountBasedPackageType } from "../utils/package-profiles.js"; import { + getAverageOccurrencesPerDay, + getNextScheduledOccurrenceTime, getTodayInTimezone, type Intake, normalizeIntakeUsageForStock, parseIntakesJson, - parseLocalDateTime, } from "../utils/scheduler-utils.js"; -const MS_PER_DAY = 86_400_000; const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; type MedicationRow = typeof medications.$inferSelect; @@ -60,35 +60,27 @@ function computeCapacity(medication: MedicationRow): number { function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number { return intakes.reduce((sum, intake) => { - if (intake.every <= 0) return sum; const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType); - return sum + normalizedUsage / intake.every; + return sum + normalizedUsage * getAverageOccurrencesPerDay(intake); }, 0); } function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null { const today = parseDateOnly(todayDateOnly); - let nextDate: Date | null = null; + let nextOccurrenceMs: number | null = null; for (const intake of intakes) { - if (intake.every <= 0) continue; - - const startDate = parseLocalDateTime(intake.start); - const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0); - - let candidate = startDateOnly; - if (candidate.getTime() < today.getTime()) { - const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY); - const intervals = Math.ceil(elapsedDays / intake.every); - candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY); + const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true); + if (occurrenceMs === null) { + continue; } - if (!nextDate || candidate.getTime() < nextDate.getTime()) { - nextDate = candidate; + if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) { + nextOccurrenceMs = occurrenceMs; } } - return nextDate ? toDateOnlyString(nextDate) : null; + return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs)); } function computeTakenAmount( @@ -188,7 +180,7 @@ export function buildSharedMedicationOverview(options: { const currentStock = Math.max(0, Math.floor(rawCurrentStock)); const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null; const depletionDate = - daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY)); + daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000)); const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays); return { name: medication.name, diff --git a/backend/src/services/current-stock.ts b/backend/src/services/current-stock.ts index 57aa3db..6e16cb3 100644 --- a/backend/src/services/current-stock.ts +++ b/backend/src/services/current-stock.ts @@ -1,6 +1,9 @@ import type { doseTracking, medications } from "../db/schema.js"; import { isAmountBasedPackageType } from "../utils/package-profiles.js"; import { + countScheduledOccurrencesInRange, + getDateOnlyTimestamp, + getNextScheduledOccurrenceTime, normalizeIntakeUsageForStock, parseIntakesJson, parseLocalDateTime, @@ -10,7 +13,6 @@ import { type MedicationRow = typeof medications.$inferSelect; type DoseRow = typeof doseTracking.$inferSelect; -const MS_PER_DAY = 86_400_000; const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; function getDoseTakenAtMs(dose: DoseRow): number { @@ -60,15 +62,11 @@ export function computeMedicationCurrentStock(options: { const intakeStart = parseLocalDateTime(intake.start).getTime(); if (Number.isNaN(intakeStart)) return; - const period = Math.max(1, intake.every) * MS_PER_DAY; - let effectiveStart: number; - if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) { - const elapsedSinceStart = stockCorrectionCutoff - intakeStart; - const periodsElapsed = Math.floor(elapsedSinceStart / period); - effectiveStart = intakeStart + (periodsElapsed + 1) * period; - } else { - effectiveStart = intakeStart; - } + const effectiveStart = + stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart + ? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false) + : intakeStart; + if (effectiveStart === null) return; let peopleForThisIntake: Array; if (intake.takenBy) { @@ -81,25 +79,20 @@ export function computeMedicationCurrentStock(options: { let lastAutoConsumedDateMs = 0; if (effectiveStart <= nowMs) { - const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1; + const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange( + intake, + effectiveStart, + nowMs + ); consumed += occurrences * usage * peopleForThisIntake.length; - const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); - lastAutoConsumedDateMs = new Date( - lastDoseTime.getFullYear(), - lastDoseTime.getMonth(), - lastDoseTime.getDate() - ).getTime(); + if (lastOccurrenceMs !== null) { + lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs)); + } } const stockCorrectionDateOnly = - stockCorrectionCutoff > 0 - ? new Date( - new Date(stockCorrectionCutoff).getFullYear(), - new Date(stockCorrectionCutoff).getMonth(), - new Date(stockCorrectionCutoff).getDate() - ).getTime() - : 0; + stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); for (const dose of relevantDoses) { diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 94f9605..d3d7fc8 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -18,10 +18,13 @@ import { import { type Blister, calculateDepletionInfo, + countScheduledOccurrencesInRange, createDefaultReminderState, formatInTimezone, getCurrentHourInTimezone, + getDateOnlyTimestamp, getMsUntilNextCheck, + getNextScheduledOccurrenceTime, getNextScheduledTime, getTimezone, getTodayInTimezone, @@ -271,7 +274,6 @@ async function getMedicationsNeedingReminder( const lowStock: LowStockItem[] = []; const now = Date.now(); - const msPerDay = 86_400_000; for (const row of rows) { const packageType = normalizePackageType(row.packageType); @@ -288,6 +290,8 @@ async function getMedicationsNeedingReminder( usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType), every: i.every, start: i.start, + scheduleMode: i.scheduleMode, + weekdays: i.weekdays, })); const originalTotalPills = isAmountBasedPackageType(packageType) @@ -304,16 +308,11 @@ async function getMedicationsNeedingReminder( 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 effectiveStart = + stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart + ? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false) + : blisterStart; + if (effectiveStart === null) return; const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; @@ -331,25 +330,20 @@ async function getMedicationsNeedingReminder( let lastAutoConsumedDateMs = 0; if (effectiveStart <= now) { - const occurrences = Math.floor((now - effectiveStart) / period) + 1; + const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange( + blister, + effectiveStart, + now + ); timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length; - const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); - lastAutoConsumedDateMs = new Date( - lastDoseTime.getFullYear(), - lastDoseTime.getMonth(), - lastDoseTime.getDate() - ).getTime(); + if (lastOccurrenceMs !== null) { + lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs)); + } } const stockCorrectionDateOnly = - stockCorrectionCutoff > 0 - ? new Date( - new Date(stockCorrectionCutoff).getFullYear(), - new Date(stockCorrectionCutoff).getMonth(), - new Date(stockCorrectionCutoff).getDate() - ).getTime() - : 0; + stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); let earlyTakenConsumed = 0; diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 22b9a61..dbd4eff 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -942,17 +942,17 @@ describe("Integration Tests", () => { // --------------------------------------------------------------------------- describe("Planner usage calculation", () => { + const plannerWindowStart = "2030-01-15T00:00:00.000Z"; + const futureDailyStart = "2030-01-15T08:00:00.000Z"; + const futureEveningStart = "2030-01-15T20:00:00.000Z"; + const tenDayPlanEnd = "2030-01-24T23:59:59.999Z"; + const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z"; + it("should calculate correct usage for daily medication", async () => { // Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total - // Schedule: 1 pill daily starting tomorrow (future date) - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(8, 0, 0, 0); - const intakeStart = tomorrow.toISOString(); - - const planEnd = new Date(tomorrow); - planEnd.setDate(planEnd.getDate() + 10); - const planEndStr = planEnd.toISOString(); + // Schedule: 1 pill daily starting on a fixed future winter date. + // This avoids daylight-saving-time edge cases in local test environments. + const intakeStart = futureDailyStart; await app.inject({ method: "POST", @@ -972,8 +972,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: intakeStart, - endDate: planEndStr, // 10 days + startDate: plannerWindowStart, + endDate: tenDayPlanEnd, }, }); @@ -988,15 +988,8 @@ describe("Integration Tests", () => { it("should detect insufficient stock", async () => { // Create medication: 1 pack × 1 blister × 5 pills = 5 pills total - // Schedule: 1 pill daily starting tomorrow - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(8, 0, 0, 0); - const intakeStart = tomorrow.toISOString(); - - const planEnd = new Date(tomorrow); - planEnd.setDate(planEnd.getDate() + 10); - const planEndStr = planEnd.toISOString(); + // Schedule: 1 pill daily starting on a fixed future winter date. + const intakeStart = futureDailyStart; await app.inject({ method: "POST", @@ -1016,8 +1009,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: intakeStart, - endDate: planEndStr, + startDate: plannerWindowStart, + endDate: tenDayPlanEnd, }, }); @@ -1029,15 +1022,8 @@ describe("Integration Tests", () => { it("should calculate weekly medication usage correctly", async () => { // Create medication: 10 pills total - // Schedule: 1 pill every 7 days starting tomorrow - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(8, 0, 0, 0); - const intakeStart = tomorrow.toISOString(); - - const planEnd = new Date(tomorrow); - planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses - const planEndStr = planEnd.toISOString(); + // Schedule: 1 pill every 7 days starting on a fixed future winter date. + const intakeStart = futureDailyStart; await app.inject({ method: "POST", @@ -1056,8 +1042,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: intakeStart, - endDate: planEndStr, + startDate: plannerWindowStart, + endDate: thirtyFiveDayPlanEnd, }, }); @@ -1070,18 +1056,8 @@ describe("Integration Tests", () => { it("should handle multiple intake schedules per medication", async () => { // Create medication with morning and evening doses // 30 pills total, 1.5 pills per day (1 morning + 0.5 evening) - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(8, 0, 0, 0); - const morningStart = tomorrow.toISOString(); - - const eveningStart = new Date(tomorrow); - eveningStart.setHours(20, 0, 0, 0); - const eveningStartStr = eveningStart.toISOString(); - - const planEnd = new Date(tomorrow); - planEnd.setDate(planEnd.getDate() + 10); - const planEndStr = planEnd.toISOString(); + const morningStart = futureDailyStart; + const eveningStartStr = futureEveningStart; await app.inject({ method: "POST", @@ -1103,8 +1079,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: morningStart, - endDate: planEndStr, + startDate: plannerWindowStart, + endDate: tenDayPlanEnd, }, }); @@ -1116,14 +1092,7 @@ describe("Integration Tests", () => { it("should calculate correct blisters needed", async () => { // 10 pills per blister, need 25 pills → need 3 blisters - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(8, 0, 0, 0); - const intakeStart = tomorrow.toISOString(); - - const planEnd = new Date(tomorrow); - planEnd.setDate(planEnd.getDate() + 10); - const planEndStr = planEnd.toISOString(); + const intakeStart = futureDailyStart; await app.inject({ method: "POST", @@ -1142,8 +1111,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: intakeStart, - endDate: planEndStr, + startDate: plannerWindowStart, + endDate: tenDayPlanEnd, }, }); diff --git a/backend/src/test/services.test.ts b/backend/src/test/services.test.ts index 19f3186..a7248a2 100644 --- a/backend/src/test/services.test.ts +++ b/backend/src/test/services.test.ts @@ -6,22 +6,30 @@ import { calculateDailyUsage, calculateDepletionInfo, cleanOldIntakeReminders, + countScheduledOccurrencesInRange, createDefaultIntakeReminderState, createDefaultReminderState, + forEachScheduledOccurrenceInRange, formatInTimezone, + getAverageOccurrencesPerDay, getCurrentHourInTimezone, + getMaxScheduledGapDays, getMsUntilNextCheck, + getNextScheduledOccurrenceTime, getNextScheduledTime, getTimezone, getTodayInTimezone, getTodaysIntakes, getUpcomingIntakes, type Intake, + normalizeIntake, parseBlisters, parseIntakeReminderState, + parseIntakesJson, parseReminderState, parseTakenByJson, personTakesMedication, + type Weekday, } from "../utils/scheduler-utils.js"; // Helper to convert Blister to Intake for tests @@ -267,6 +275,77 @@ describe("Scheduler Utils - Blister Parsing", () => { }); }); +describe("Scheduler Utils - Intake Schedule Normalization", () => { + describe("normalizeIntake", () => { + it("keeps interval schedules backward-compatible by default", () => { + const intake = normalizeIntake({ + usage: 2, + every: 3, + start: "2025-01-01T08:00:00", + }); + + expect(intake).toMatchObject({ + usage: 2, + every: 3, + start: "2025-01-01T08:00:00", + scheduleMode: "interval", + weekdays: [], + }); + }); + + it("normalizes malformed weekday schedules to the start date weekday", () => { + const intake = normalizeIntake({ + usage: 1, + every: 99, + start: "2025-01-06T08:00:00", + scheduleMode: "weekdays", + weekdays: ["bogus", null], + }); + + expect(intake.scheduleMode).toBe("weekdays"); + expect(intake.every).toBe(1); + expect(intake.weekdays).toEqual(["mon"]); + }); + }); + + describe("parseIntakesJson", () => { + it("falls back to legacy interval data when unified intakes are absent", () => { + const intakes = parseIntakesJson( + null, + { + usageJson: "[1,2]", + everyJson: "[1,3]", + startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]', + }, + true + ); + + expect(intakes).toEqual([ + { + usage: 1, + every: 1, + start: "2025-01-01T08:00:00", + scheduleMode: "interval", + weekdays: [], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: true, + }, + { + usage: 2, + every: 3, + start: "2025-01-02T20:00:00", + scheduleMode: "interval", + weekdays: [], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: true, + }, + ]); + }); + }); +}); + describe("Scheduler Utils - Daily Usage Calculation", () => { describe("calculateDailyUsage", () => { it("should calculate daily usage for single daily dose", () => { @@ -306,6 +385,71 @@ describe("Scheduler Utils - Daily Usage Calculation", () => { }); }); +describe("Scheduler Utils - Schedule Occurrence Calculation", () => { + it("calculates average usage and gap length for weekday schedules", () => { + const weekdaysSchedule = { + every: 1, + start: "2025-01-06T09:00:00", + scheduleMode: "weekdays" as const, + weekdays: ["mon", "wed", "fri"] satisfies Weekday[], + }; + + expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5); + expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3); + expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5); + expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2); + }); + + it("finds the next weekday occurrence after a given timestamp", () => { + const schedule = { + every: 1, + start: "2025-01-06T09:00:00", + scheduleMode: "weekdays" as const, + weekdays: ["mon", "wed", "fri"] satisfies Weekday[], + }; + + const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime(); + const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs); + + expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime()); + }); + + it("iterates weekday occurrences in canonical order within a range", () => { + const schedule = { + every: 1, + start: "2025-01-06T09:00:00", + scheduleMode: "weekdays" as const, + weekdays: ["wed", "mon", "fri"] satisfies Weekday[], + }; + const occurrences: number[] = []; + + forEachScheduledOccurrenceInRange( + schedule, + new Date(2025, 0, 6, 0, 0, 0).getTime(), + new Date(2025, 0, 12, 23, 59, 59).getTime(), + (occurrenceMs) => { + occurrences.push(occurrenceMs); + } + ); + + expect(occurrences.sort((a, b) => a - b)).toEqual([ + new Date(2025, 0, 6, 9, 0, 0).getTime(), + new Date(2025, 0, 8, 9, 0, 0).getTime(), + new Date(2025, 0, 10, 9, 0, 0).getTime(), + ]); + expect( + countScheduledOccurrencesInRange( + schedule, + new Date(2025, 0, 6, 0, 0, 0).getTime(), + new Date(2025, 0, 12, 23, 59, 59).getTime() + ) + ).toEqual({ + count: 3, + lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(), + }); + }); +}); + describe("Scheduler Utils - Depletion Calculation", () => { describe("calculateDepletionInfo", () => { it("should calculate days left correctly", () => { @@ -378,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => { expect(result[0].pillWeightMg).toBe(500); }); - it("should skip blisters with zero interval", () => { + it("should treat zero interval as a daily fallback", () => { const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })]; const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); - expect(result).toEqual([]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + medName: "TestMed", + usage: 1, + takenBy: null, + }); }); it("should handle multiple blisters", () => { diff --git a/backend/src/test/share.test.ts b/backend/src/test/share.test.ts index e1b01ab..854b95d 100644 --- a/backend/src/test/share.test.ts +++ b/backend/src/test/share.test.ts @@ -10,7 +10,6 @@ import { createTestMedication, createTestShareToken, createTestUser, - setUserSettings, type TestContext, } from "./setup.js"; diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index 4e2aa47..b5e3328 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -6,14 +6,34 @@ import { getDateLocale, type Language } from "../i18n/translations.js"; import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js"; +export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; + +export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number]; +export type IntakeScheduleMode = "interval" | "weekdays"; + +type ScheduleLike = { + every: number; + start: string; + scheduleMode?: IntakeScheduleMode; + weekdays?: Weekday[]; +}; + // Legacy type - individual blister schedule (DEPRECATED: use Intake instead) -export type Blister = { usage: number; every: number; start: string }; +export type Blister = { + usage: number; + every: number; + start: string; + scheduleMode?: IntakeScheduleMode; + weekdays?: Weekday[]; +}; // New unified intake type with per-intake takenBy export type Intake = { usage: number; every: number; start: string; + scheduleMode?: IntakeScheduleMode; + weekdays?: Weekday[]; intakeUnit?: "ml" | "tsp" | "tbsp" | null; takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy) intakeRemindersEnabled: boolean; @@ -22,6 +42,278 @@ export type Intake = { const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" => value === "ml" || value === "tsp" || value === "tbsp"; +const weekdayToJavascriptDay: Record = { + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, + sun: 0, +}; + +function isWeekday(value: unknown): value is Weekday { + return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday); +} + +function normalizeScheduleMode(value: unknown): IntakeScheduleMode { + return value === "weekdays" ? "weekdays" : "interval"; +} + +function toDateOnly(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); +} + +export function getDateOnlyTimestamp(date: Date): number { + return toDateOnly(date).getTime(); +} + +export function getWeekdayFromDate(date: Date): Weekday { + const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay()); + return weekday ?? "mon"; +} + +export function getWeekdayFromStart(start: string): Weekday { + const startDate = parseLocalDateTime(start); + if (Number.isNaN(startDate.getTime())) { + return "mon"; + } + return getWeekdayFromDate(startDate); +} + +export function normalizeWeekdays(value: unknown, start: string): Weekday[] { + if (!Array.isArray(value)) { + return [getWeekdayFromStart(start)]; + } + + const uniqueWeekdays = new Set(); + for (const weekday of value) { + if (isWeekday(weekday)) { + uniqueWeekdays.add(weekday); + } + } + + const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday)); + return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)]; +} + +function createOccurrenceAtDate(date: Date, startDate: Date): number { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + startDate.getHours(), + startDate.getMinutes(), + startDate.getSeconds(), + startDate.getMilliseconds() + ).getTime(); +} + +function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] { + if (schedule.scheduleMode !== "weekdays") { + return []; + } + + if (schedule.weekdays && schedule.weekdays.length > 0) { + return schedule.weekdays; + } + + return [getWeekdayFromStart(schedule.start)]; +} + +export function getAverageOccurrencesPerDay( + schedule: Pick +): number { + if (schedule.scheduleMode === "weekdays") { + return getNormalizedWeekdays(schedule).length / 7; + } + + return 1 / Math.max(1, schedule.every); +} + +export function getMaxScheduledGapDays( + schedule: Pick +): number { + if (schedule.scheduleMode !== "weekdays") { + return Math.max(1, schedule.every); + } + + const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday)); + if (weekdays.length === 0) { + return 7; + } + + let maxGap = 0; + for (let index = 0; index < weekdays.length; index++) { + const current = weekdays[index]; + const next = weekdays[(index + 1) % weekdays.length]; + const gap = index === weekdays.length - 1 ? next + 7 - current : next - current; + if (gap > maxGap) { + maxGap = gap; + } + } + + return maxGap || 7; +} + +export function getScheduleMatchWindowMs( + schedule: Pick +): number { + return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2; +} + +export function getNextScheduledOccurrenceTime( + schedule: Pick, + fromMs: number, + inclusive: boolean = true +): number | null { + const startDate = parseLocalDateTime(schedule.start); + const startTime = startDate.getTime(); + if (Number.isNaN(startTime)) { + return null; + } + + const lowerBound = inclusive ? fromMs : fromMs + 1; + if (schedule.scheduleMode !== "weekdays") { + const period = Math.max(1, schedule.every) * 86_400_000; + if (startTime >= lowerBound) { + return startTime; + } + + const intervals = Math.ceil((lowerBound - startTime) / period); + return startTime + intervals * period; + } + + const candidateStart = Math.max(lowerBound, startTime); + const candidateDateOnly = toDateOnly(new Date(candidateStart)); + let nextOccurrence: number | null = null; + + for (const weekday of getNormalizedWeekdays(schedule)) { + const candidateDate = new Date(candidateDateOnly); + const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7; + candidateDate.setDate(candidateDate.getDate() + offsetDays); + + let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate); + if (occurrenceMs < candidateStart) { + candidateDate.setDate(candidateDate.getDate() + 7); + occurrenceMs = createOccurrenceAtDate(candidateDate, startDate); + } + + if (nextOccurrence === null || occurrenceMs < nextOccurrence) { + nextOccurrence = occurrenceMs; + } + } + + return nextOccurrence; +} + +export function forEachScheduledOccurrenceInRange( + schedule: Pick, + rangeStartMs: number, + rangeEndMs: number, + callback: (occurrenceMs: number) => void +): void { + if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) { + return; + } + + const startDate = parseLocalDateTime(schedule.start); + const startTime = startDate.getTime(); + if (Number.isNaN(startTime) || rangeEndMs < startTime) { + return; + } + + if (schedule.scheduleMode !== "weekdays") { + const period = Math.max(1, schedule.every) * 86_400_000; + let occurrenceMs = startTime; + if (occurrenceMs < rangeStartMs) { + const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period); + occurrenceMs += intervals * period; + } + + for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) { + if (occurrenceMs >= rangeStartMs) { + callback(occurrenceMs); + } + } + return; + } + + const lowerBound = Math.max(rangeStartMs, startTime); + const firstDateOnly = toDateOnly(new Date(lowerBound)); + + for (const weekday of getNormalizedWeekdays(schedule)) { + const occurrenceDate = new Date(firstDateOnly); + const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7; + occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays); + + let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate); + if (occurrenceMs < lowerBound) { + occurrenceDate.setDate(occurrenceDate.getDate() + 7); + occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate); + } + + while (occurrenceMs <= rangeEndMs) { + callback(occurrenceMs); + occurrenceDate.setDate(occurrenceDate.getDate() + 7); + occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate); + } + } +} + +export function countScheduledOccurrencesInRange( + schedule: Pick, + rangeStartMs: number, + rangeEndMs: number +): { count: number; lastOccurrenceMs: number | null } { + let count = 0; + let lastOccurrenceMs: number | null = null; + + forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => { + count += 1; + if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) { + lastOccurrenceMs = occurrenceMs; + } + }); + + return { count, lastOccurrenceMs }; +} + +export function normalizeIntake( + value: { + usage?: unknown; + every?: unknown; + start?: unknown; + scheduleMode?: unknown; + weekdays?: unknown; + intakeUnit?: unknown; + takenBy?: unknown; + intakeRemindersEnabled?: unknown; + }, + defaultIntakeRemindersEnabled: boolean = false +): Intake { + const start = typeof value.start === "string" ? value.start : new Date().toISOString(); + const scheduleMode = normalizeScheduleMode(value.scheduleMode); + let every = 1; + if (scheduleMode !== "weekdays") { + if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) { + every = value.every; + } + } + + return { + usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0, + every, + start, + scheduleMode, + weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [], + intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null, + takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null, + intakeRemindersEnabled: + typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled, + }; +} + /** * Normalize intake usage for stock math. * @@ -225,15 +517,7 @@ export function parseIntakesJson( try { const parsed = JSON.parse(intakesJson); if (Array.isArray(parsed) && parsed.length > 0) { - return parsed.map((intake: Record) => ({ - usage: typeof intake.usage === "number" ? intake.usage : 0, - every: typeof intake.every === "number" ? intake.every : 1, - start: typeof intake.start === "string" ? intake.start : new Date().toISOString(), - intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null, - takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null, - intakeRemindersEnabled: - typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false, - })); + return parsed.map((intake: Record) => normalizeIntake(intake)); } } catch { // Fall through to legacy parsing @@ -243,14 +527,18 @@ export function parseIntakesJson( // Fallback to legacy parallel arrays if (legacyRow) { const blisters = parseBlisters(legacyRow); - return blisters.map((b) => ({ - usage: b.usage, - every: b.every, - start: b.start, - intakeUnit: null, - takenBy: null, // Legacy format has no per-intake takenBy - intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false, - })); + return blisters.map((b) => + normalizeIntake( + { + usage: b.usage, + every: b.every, + start: b.start, + intakeUnit: null, + takenBy: null, + }, + medicationIntakeRemindersEnabled ?? false + ) + ); } return []; @@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[ /** Calculate daily usage from blisters */ export function calculateDailyUsage(blisters: Blister[]): number { - return blisters.reduce((sum, s) => sum + s.usage / s.every, 0); + return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0); } /** Calculate depletion information for a medication */ @@ -370,50 +658,31 @@ export function getTodaysIntakes( for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { const intake = intakes[blisterIdx]; - const startTime = parseLocalDateTime(intake.start).getTime(); - const intervalMs = intake.every * 24 * 60 * 60 * 1000; - - if (intervalMs <= 0) continue; - // Determine takenBy for this intake // If intake has its own takenBy, use it; otherwise null (no specific person) const effectiveTakenBy = intake.takenBy || null; - // Find all occurrences that fall within today - let currentTime = startTime; - - // If start is in the past, calculate the first occurrence on or after todayStart - if (currentTime < todayStart.getTime()) { - const elapsed = todayStart.getTime() - startTime; - const intervals = Math.floor(elapsed / intervalMs); - currentTime = startTime + intervals * intervalMs; - } - - // Collect all intakes for today - while (currentTime <= todayEnd.getTime()) { - if (currentTime >= todayStart.getTime()) { - const intakeDate = new Date(currentTime); - result.push({ - medName, - medicationId, - blisterIndex: blisterIdx, - usage: intake.usage, - intakeTime: intakeDate, - intakeTimeStr: intakeDate.toLocaleTimeString(locale, { - hour: "2-digit", - minute: "2-digit", - timeZone: timezone, - }), - takenBy: effectiveTakenBy, - pillWeightMg, - doseUnit, - }); - } - currentTime += intervalMs; - } + forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => { + const intakeDate = new Date(occurrenceMs); + result.push({ + medName, + medicationId, + blisterIndex: blisterIdx, + usage: intake.usage, + intakeTime: intakeDate, + intakeTimeStr: intakeDate.toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + timeZone: timezone, + }), + takenBy: effectiveTakenBy, + pillWeightMg, + doseUnit, + }); + }); } - return result; + return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime()); } /** @@ -444,40 +713,11 @@ export function getUpcomingIntakes( for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { const intake = intakes[blisterIdx]; - const startTime = parseLocalDateTime(intake.start).getTime(); - const intervalMs = intake.every * 24 * 60 * 60 * 1000; - - if (intervalMs <= 0) continue; - // Determine takenBy for this intake const effectiveTakenBy = intake.takenBy || null; - // Find the next scheduled intake time (could be today or in the future) - let nextTime = startTime; - - // If start is in the past, calculate occurrences - if (nextTime < now) { - const elapsed = now - startTime; - const intervals = Math.floor(elapsed / intervalMs); - - // Check the current occurrence (today's scheduled time, even if past) - const currentOccurrence = startTime + intervals * intervalMs; - // And the next occurrence - const nextOccurrence = startTime + (intervals + 1) * intervalMs; - - // If today's occurrence notification time falls in current minute and intake hasn't happened - const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000; - if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) { - nextTime = currentOccurrence; - } else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) { - // CATCH-UP: The notify window was missed (e.g. due to system sleep/restart) - // but the intake time is still in the future — include it so the advance - // reminder can still be sent rather than falling into a dead zone. - nextTime = currentOccurrence; - } else { - nextTime = nextOccurrence; - } - } + const nextTime = getNextScheduledOccurrenceTime(intake, now, true); + if (nextTime === null) continue; // Calculate when we should notify for this intake const notifyTime = nextTime - minutesBefore * 60 * 1000; diff --git a/frontend/e2e/share-schedule.spec.ts b/frontend/e2e/share-schedule.spec.ts index 0e04505..8a73d77 100644 --- a/frontend/e2e/share-schedule.spec.ts +++ b/frontend/e2e/share-schedule.spec.ts @@ -114,8 +114,10 @@ test.describe("Share Schedule", () => { const personSelect = modal.locator("select").first(); await expect(personSelect).toBeVisible(); - // Should contain Alice and Bob options - await expect(personSelect.locator("option")).toHaveCount(2); + // Should contain Alice and Bob options. + // The dialog can also include an "all people" option, so assert presence instead of exact count. + await expect(personSelect.locator('option[value="Alice"]')).toBeAttached(); + await expect(personSelect.locator('option[value="Bob"]')).toBeAttached(); // Close await page.locator("button.modal-close").click(); @@ -187,7 +189,7 @@ test.describe("Share Schedule", () => { await expect(sharedSchedule).toBeVisible({ timeout: 10000 }); // The page should show Alice's medication name - const content = sharedSchedule.getByText(MED_ALICE); + const content = sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first(); try { await expect(content).toBeVisible({ timeout: 10000 }); } catch { @@ -236,12 +238,16 @@ test.describe("Share Schedule", () => { await expect(sharedSchedule).toBeVisible({ timeout: 10000 }); try { - await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 }); + await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({ + timeout: 10000, + }); } catch { await page.reload(); await page.waitForLoadState("networkidle"); await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 }); - await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 }); + await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({ + timeout: 10000, + }); } // Visit Bob's share — should show Bob's med @@ -251,12 +257,16 @@ test.describe("Share Schedule", () => { await expect(sharedSchedule).toBeVisible({ timeout: 10000 }); try { - await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 }); + await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({ + timeout: 10000, + }); } catch { await page.reload(); await page.waitForLoadState("networkidle"); await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 }); - await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 }); + await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({ + timeout: 10000, + }); } }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1da94f..f0f5a8a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-frontend", - "version": "1.20.1", + "version": "1.20.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-frontend", - "version": "1.20.1", + "version": "1.20.2", "dependencies": { "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index 1afda92..1c553e6 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -20,11 +20,14 @@ import { getMedDisplayName, getMedTotal, getPackageSize, + type IntakeUnit, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, } from "../types"; import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils"; +import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule"; +import { getLiquidCountUnitLabel } from "../utils/intake-units"; import { getStockStatus } from "../utils/schedule"; import { splitCurrentBlisterStock } from "../utils/stock"; @@ -254,32 +257,16 @@ export function MedDetailModal({ const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage; const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1)); const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle)); - const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => { + const getScheduleUsageLabel = (usage: number, intakeUnit?: IntakeUnit | null) => { if (isLiquidContainerPackageType(selectedMed.packageType)) { - if (intakeUnit === "tsp") { - return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`; - } - if (intakeUnit === "tbsp") { - return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`; - } - return `${usage} ${t("form.packageAmountUnitMl")}`; + return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`; } if (isTubePackageType(selectedMed.packageType)) { return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`; } return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`; }; - const scheduleIntakes = - selectedMed.intakes && selectedMed.intakes.length > 0 - ? selectedMed.intakes - : selectedMed.blisters.map((blister) => ({ - usage: blister.usage, - every: blister.every, - start: blister.start, - takenBy: null, - intakeRemindersEnabled: false, - intakeUnit: null, - })); + const scheduleIntakes = getMedicationIntakes(selectedMed); const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true); const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => { let normalizedFull = Math.max(0, nextFull); @@ -969,7 +956,7 @@ export function MedDetailModal({ {/* Intake Schedule Section */} - {selectedMed.blisters.length > 0 && ( + {scheduleIntakes.length > 0 && (

{t("modal.intakeSchedule")}{" "} @@ -985,7 +972,7 @@ export function MedDetailModal({ const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0); const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount; const showIntakeBell = intake.intakeRemindersEnabled === true; - const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`; + const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`; return (
@@ -993,9 +980,7 @@ export function MedDetailModal({ {getScheduleUsageLabel(totalUsage, intake.intakeUnit)} {showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`} - - {intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })} - + {getIntakeFrequencyText(intake, t)} {hasPerIntakeTakenBy && {intake.takenBy}} {t("modal.at")}{" "} @@ -1166,7 +1151,7 @@ export function MedDetailModal({
{form.intakes.map((intake, idx) => { - const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`; + const scheduleMode = getIntakeScheduleMode(intake); + const selectedWeekdays = intake.weekdays ?? []; + const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${scheduleMode}-${selectedWeekdays.join("")}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`; return (
+ {scheduleMode === "interval" ? ( + + ) : ( + + )}
- {(med.intakes ?? med.blisters).map((s, idx) => ( + {getMedicationIntakes(med).map((s, idx) => (
- {s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "} - {s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "} + {s.usage} {getMedicationUsageUnitLabel(med, s.usage)} · {getIntakeFrequencyText(s, t)} ·{" "} {t("form.blisters.from")} {formatDateTime(s.start)} - {"takenBy" in s && (s as import("../types").Intake).takenBy && ( - · {(s as import("../types").Intake).takenBy} - )} - {"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && ( + {s.takenBy && · {s.takenBy}} + {s.intakeRemindersEnabled && ( {" "}
- {form.intakes.map((intake, idx) => ( -
-
- - - - - {isLiquidContainerPackageType(form.packageType) && ( + {form.intakes.map((intake, idx) => { + const scheduleMode = getIntakeScheduleMode(intake); + const selectedWeekdays = intake.weekdays ?? []; + return ( +
+
+ - )} - {form.takenBy.length === 0 ? null : ( - - )} -
- - -
+ {!readOnlyView && form.intakes.length > 1 && ( + + )}
- {!readOnlyView && form.intakes.length > 1 && ( - - )} -
- ))} + ); + })}
{/* end schedule tab */} @@ -1845,7 +1920,9 @@ export function MedicationsPage() { diff --git a/frontend/src/pages/SchedulePage.tsx b/frontend/src/pages/SchedulePage.tsx index de7f034..640ec91 100644 --- a/frontend/src/pages/SchedulePage.tsx +++ b/frontend/src/pages/SchedulePage.tsx @@ -5,10 +5,11 @@ 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 type { Coverage, IntakeUnit } from "../types"; import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types"; import { formatNumber } from "../utils/formatters"; -import { isDoseDismissed } from "../utils/schedule"; +import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units"; +import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule"; // Helper for user-specific localStorage keys function userStorageKey(userId: number | undefined, key: string): string { @@ -105,41 +106,8 @@ export function SchedulePage() { status: { className: string; label: string } | null ) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule"; - const getClearMissedPayload = () => { - const medicationIds = new Set(); - let latestMissedDate: string | null = null; - - for (const day of pastDays) { - for (const item of day.meds) { - const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName); - if (!med) continue; - - const dismissedUntilDate = med.dismissedUntil ?? undefined; - const hasMissedDose = item.doses.some((dose) => { - if (isDoseDismissed(dose.id, dismissedUntilDate)) return false; - const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : []; - const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id]; - return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId)); - }); - - if (!hasMissedDose) continue; - - medicationIds.add(med.id); - const dayDate = day.date.toISOString().slice(0, 10); - if (!latestMissedDate || dayDate > latestMissedDate) { - latestMissedDate = dayDate; - } - } - } - - return { - medicationIds: [...medicationIds], - until: latestMissedDate, - }; - }; - const clearMissedDoses = async (missedCount: number) => { - const payload = getClearMissedPayload(); + const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses); if (payload.medicationIds.length === 0 || !payload.until) { setShowClearMissedConfirm(false); return; @@ -197,19 +165,7 @@ export function SchedulePage() { ? t("form.packageAmountUnitMl") : t("form.blisters.applications", { count: Math.abs(value) }); - const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => { - if (unit === "tsp") return usage * 5; - if (unit === "tbsp") return usage * 15; - return usage; - }; - - const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => { - if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) }); - if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) }); - return t("form.packageAmountUnitMl"); - }; - - const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => { + const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => { const normalizedUsage = Number(usage); if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) { return `0 ${t("form.packageAmountUnitMl")}`; @@ -220,13 +176,13 @@ export function SchedulePage() { } const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit); - return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`; + return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`; }; const formatDoseUsageLabel = ( med: (typeof meds)[number] | undefined, usage: number, - intakeUnit?: "ml" | "tsp" | "tbsp" | null + intakeUnit?: IntakeUnit | null ) => { if (isLiquidContainerPackageType(med?.packageType)) { return formatLiquidUsageLabel(usage, intakeUnit); @@ -240,7 +196,7 @@ export function SchedulePage() { const formatTotalUsageLabel = ( med: (typeof meds)[number] | undefined, total: number, - doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }> + doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }> ) => { if (isLiquidContainerPackageType(med?.packageType)) { if (doses && doses.length > 0) { diff --git a/frontend/src/test/components/MobileEditModal.test.tsx b/frontend/src/test/components/MobileEditModal.test.tsx index 29abe28..8d5f4c5 100644 --- a/frontend/src/test/components/MobileEditModal.test.tsx +++ b/frontend/src/test/components/MobileEditModal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import type { FormEvent } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MobileEditModal } from "../../components/MobileEditModal"; -import type { FormState } from "../../types"; +import type { FormState, WeekdayCode } from "../../types"; const defaultForm: FormState = { name: "", @@ -429,6 +429,61 @@ describe("MobileEditModal blister management", () => { expect(onSetIntakeValue).toHaveBeenCalled(); } }); + + it("shows weekday controls and validation error for weekday schedules", () => { + const form = { + ...defaultForm, + name: "Weekday Med", + intakes: [ + { + usage: "1", + every: "1", + startDate: "2024-01-01", + startTime: "09:00", + scheduleMode: "weekdays" as const, + weekdays: [], + takenBy: "", + intakeRemindersEnabled: false, + }, + ], + }; + + render(); + + fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" })); + + expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument(); + expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument(); + expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument(); + expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error"); + }); + + it("toggles weekday selections for weekday schedules", () => { + const onSetIntakeValue = vi.fn(); + const form = { + ...defaultForm, + name: "Weekday Med", + intakes: [ + { + usage: "1", + every: "1", + startDate: "2024-01-01", + startTime: "09:00", + scheduleMode: "weekdays" as const, + weekdays: ["wed"] satisfies WeekdayCode[], + takenBy: "", + intakeRemindersEnabled: false, + }, + ], + }; + + render(); + + fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" })); + fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon")); + + expect(onSetIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]); + }); }); describe("MobileEditModal form submission", () => { diff --git a/frontend/src/test/components/ReportModal.test.tsx b/frontend/src/test/components/ReportModal.test.tsx index 41764c9..dee32b4 100644 --- a/frontend/src/test/components/ReportModal.test.tsx +++ b/frontend/src/test/components/ReportModal.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ReportModal from "../../components/ReportModal"; import type { Medication } from "../../types"; +import { formatDate, formatDateTime } from "../../utils/formatters"; function createMedication(overrides: Partial = {}): Medication { return { @@ -65,6 +66,53 @@ describe("ReportModal", () => { expect(URL.createObjectURL).toHaveBeenCalled(); }); + it("renders shared formatter output in exported text reports", async () => { + const onClose = vi.fn(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + 1: { + dosesTaken: 1, + automaticDosesTaken: 0, + dosesDismissed: 0, + firstDoseAt: "2026-02-03T12:00:00.000Z", + lastDoseAt: null, + refills: [], + }, + }), + }); + + render( + + ); + + fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i })); + fireEvent.click(screen.getByRole("button", { name: /report\.generate/i })); + + await waitFor(() => { + expect(URL.createObjectURL).toHaveBeenCalled(); + }); + + const [blob] = (URL.createObjectURL as ReturnType).mock.calls.at(-1) ?? []; + expect(blob).toBeInstanceOf(Blob); + + const content = await (blob as Blob).text(); + + expect(content).toContain(formatDate("2026-02-01")); + expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z")); + expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("generates printable report when PDF format is selected", async () => { const onClose = vi.fn(); const mockWrite = vi.fn(); @@ -83,16 +131,35 @@ describe("ReportModal", () => { ok: true, json: async () => ({ 1: { - dosesTaken: 0, + dosesTaken: 1, + automaticDosesTaken: 0, dosesDismissed: 0, - firstDoseAt: null, + firstDoseAt: "2026-03-03T12:00:00.000Z", lastDoseAt: null, - refills: [], + refills: [ + { + packsAdded: 1, + loosePillsAdded: 0, + usedPrescription: false, + refillDate: "2026-03-04", + }, + ], }, }), }); - render(); + render( + + ); fireEvent.click(screen.getByRole("button", { name: /report\.generate/i })); await waitFor(() => { @@ -101,6 +168,11 @@ describe("ReportModal", () => { expect(mockClose).toHaveBeenCalled(); }); + const [html] = mockWrite.mock.calls.at(-1) ?? []; + expect(html).toContain(formatDate("2026-03-01")); + expect(html).toContain(formatDateTime("2026-03-02T08:30:00.000Z")); + expect(html).toContain(formatDate("2026-03-03T12:00:00.000Z")); + expect(html).toContain(formatDate("2026-03-04")); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx index c68a0c1..95fde37 100644 --- a/frontend/src/test/pages/MedicationsPage.test.tsx +++ b/frontend/src/test/pages/MedicationsPage.test.tsx @@ -253,6 +253,67 @@ describe("MedicationsPage", () => { expect(scheduleTab).toHaveAttribute("aria-selected", "true"); }); + it("shows weekday controls and validation error in the desktop schedule form", () => { + mockFormHookValue = createMockFormHook({ + formChanged: true, + form: { + ...createMockFormHook().form, + name: "Weekday Med", + intakes: [ + { + usage: "1", + every: "1", + startDate: "2024-01-01", + startTime: "09:00", + scheduleMode: "weekdays" as const, + weekdays: [], + takenBy: "", + intakeRemindersEnabled: false, + }, + ], + }, + }); + + renderPage(); + openNewMedicationForm(); + fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" })); + + expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument(); + expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument(); + expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument(); + expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error"); + }); + + it("toggles weekday selections in the desktop schedule form", () => { + const setIntakeValue = vi.fn(); + mockFormHookValue = createMockFormHook({ + setIntakeValue, + form: { + ...createMockFormHook().form, + name: "Weekday Med", + intakes: [ + { + usage: "1", + every: "1", + startDate: "2024-01-01", + startTime: "09:00", + scheduleMode: "weekdays" as const, + weekdays: ["wed"] as const, + takenBy: "", + intakeRemindersEnabled: false, + }, + ], + }, + }); + + renderPage(); + openNewMedicationForm(); + fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" })); + fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon")); + + expect(setIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]); + }); + it("opens report modal from list actions", () => { renderPage(); fireEvent.click(screen.getByText("report.button")); diff --git a/frontend/src/test/utils/ics.test.ts b/frontend/src/test/utils/ics.test.ts index 63acd81..465aede 100644 --- a/frontend/src/test/utils/ics.test.ts +++ b/frontend/src/test/utils/ics.test.ts @@ -151,4 +151,54 @@ describe("generateICS", () => { expect(() => generateICS(dailyMed)).not.toThrow(); expect(() => generateICS(weeklyMed)).not.toThrow(); }); + + it("exports weekday schedules with a weekly BYDAY rule", async () => { + const med = createTestMed({ + intakes: [ + { + usage: 1, + every: 1, + start: "2024-03-18T09:00:00", + scheduleMode: "weekdays", + weekdays: ["mon", "wed", "fri"], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: false, + }, + ], + }); + + generateICS(med); + + const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob; + const content = await blobArg.text(); + + expect(content).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"); + expect(content).not.toContain("RRULE:FREQ=DAILY;INTERVAL=1"); + }); + + it("keeps interval schedules exported as daily interval rules", async () => { + const med = createTestMed({ + intakes: [ + { + usage: 1, + every: 2, + start: "2024-03-15T09:00:00", + scheduleMode: "interval", + weekdays: [], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: false, + }, + ], + }); + + generateICS(med); + + const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob; + const content = await blobArg.text(); + + expect(content).toContain("RRULE:FREQ=DAILY;INTERVAL=2"); + expect(content).not.toContain("BYDAY="); + }); }); diff --git a/frontend/src/test/utils/intake-units.test.ts b/frontend/src/test/utils/intake-units.test.ts new file mode 100644 index 0000000..86a88cb --- /dev/null +++ b/frontend/src/test/utils/intake-units.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units"; + +describe("intake-units", () => { + it("keeps ml unchanged and converts teaspoon and tablespoon usage to ml", () => { + expect(convertLiquidUsageToMl(12, "ml")).toBe(12); + expect(convertLiquidUsageToMl(2, "tsp")).toBe(10); + expect(convertLiquidUsageToMl(3, "tbsp")).toBe(45); + }); + + it("returns the existing liquid usage labels for each intake unit", () => { + const t = vi.fn((key: string) => key); + + expect(getLiquidCountUnitLabel("ml", 2, t)).toBe("form.packageAmountUnitMl"); + expect(getLiquidCountUnitLabel("tsp", 2, t)).toBe("form.blisters.teaspoons"); + expect(getLiquidCountUnitLabel("tbsp", 3, t)).toBe("form.blisters.tablespoons"); + + expect(t).toHaveBeenNthCalledWith(1, "form.packageAmountUnitMl"); + expect(t).toHaveBeenNthCalledWith(2, "form.blisters.teaspoons", { count: 2 }); + expect(t).toHaveBeenNthCalledWith(3, "form.blisters.tablespoons", { count: 3 }); + }); +}); diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index 9c9fc7b..3751760 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Coverage, Medication, StockThresholds } from "../../types"; import { + buildClearMissedPayload, buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, @@ -278,6 +279,33 @@ describe("buildSchedulePreview", () => { expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id)); expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when)); }); + + it("falls back legacy blisters to schedule events with a null intake unit", () => { + const meds: Medication[] = [ + { + id: 1, + name: "Legacy Liquid", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 120, + looseTablets: 120, + takenBy: [], + packageType: "liquid_container", + medicationForm: "liquid", + blisters: [{ usage: 2, every: 1, start: "2024-03-15T09:00:00" }], + updatedAt: null, + }, + ]; + + const result = buildSchedulePreview(meds, "en", false); + + expect(result.totalBlisters).toBe(1); + expect(result.events[0]).toMatchObject({ + usage: 2, + intakeUnit: null, + }); + }); }); describe("calculateCoverage", () => { @@ -376,6 +404,41 @@ describe("calculateCoverage", () => { expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days }); + it("converts liquid intake units to ml for automatic coverage calculations", () => { + const meds: Medication[] = [ + { + id: 1, + name: "Liquid Med", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 120, + looseTablets: 120, + takenBy: [], + packageType: "liquid_container", + medicationForm: "liquid", + blisters: [], + intakes: [ + { + usage: 2, + every: 1, + start: "2024-03-14T09:00:00", + intakeUnit: "tbsp", + takenBy: null, + intakeRemindersEnabled: false, + }, + ], + updatedAt: null, + }, + ]; + + const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set()); + + expect(result.all).toHaveLength(1); + expect(result.all[0].medsLeft).toBe(60); + expect(result.all[0].daysLeft).toBe(2); + }); + it("per-intake takenBy counts person correctly in automatic mode", () => { // When intakes have per-intake takenBy, each person-intake pair is counted const meds: Medication[] = [ @@ -1987,6 +2050,83 @@ describe("dose tracking survives medication edits (regression)", () => { }); }); +describe("buildClearMissedPayload", () => { + it("collects unique missed medication ids and the latest missed day", () => { + const march10 = new Date("2024-03-10T09:00:00Z"); + const march11 = new Date("2024-03-11T09:00:00Z"); + const aspirinDoseMarch10 = "1-0-1710061200000"; + const aspirinDoseMarch11 = "1-0-1710147600000"; + const vitaminDDoseMarch11 = "2-0-1710147600000"; + const calciumDoseMarch11 = "3-0-1710147600000"; + + const pastDays = [ + { + date: march10, + meds: [{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["John"] }] }], + }, + { + date: march11, + meds: [ + { medName: "Aspirin", doses: [{ id: aspirinDoseMarch11, takenBy: ["John"] }] }, + { medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch11, takenBy: [] }] }, + { medName: "Calcium", doses: [{ id: calciumDoseMarch11, takenBy: [] }] }, + ], + }, + ]; + + const medications = [ + { id: 1, name: "Aspirin", dismissedUntil: null }, + { id: 2, name: "Vitamin D", dismissedUntil: null }, + { id: 3, name: "Calcium", dismissedUntil: "2024-03-11" }, + ]; + + const payload = buildClearMissedPayload( + pastDays, + medications, + new Set(), + new Set([`${aspirinDoseMarch11}-John`]) + ); + + expect(payload).toEqual({ + medicationIds: [1, 2], + until: "2024-03-11", + }); + }); + + it("returns an empty payload when every remaining missed dose is already resolved", () => { + const march10 = new Date("2024-03-10T09:00:00Z"); + const aspirinDoseMarch10 = "1-0-1710061200000"; + const vitaminDDoseMarch10 = "2-0-1710061200000"; + + const pastDays = [ + { + date: march10, + meds: [ + { medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["Alice"] }] }, + { medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch10, takenBy: [] }] }, + ], + }, + ]; + + const medications = [ + { id: 1, name: "Aspirin", dismissedUntil: null }, + { id: 2, name: "Vitamin D", dismissedUntil: "2024-03-10" }, + ]; + + const payload = buildClearMissedPayload( + pastDays, + medications, + new Set([`${aspirinDoseMarch10}-Alice`]), + new Set() + ); + + expect(payload).toEqual({ + medicationIds: [], + until: null, + }); + }); +}); + // ============================================================================= // Test Helpers // ============================================================================= @@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => { expect(past180.length).toBeGreaterThan(past90.length); }); }); + +describe("weekday intake schedules", () => { + beforeEach(() => { + vi.setSystemTime(new Date("2024-03-18T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("builds preview events only on selected weekdays", () => { + const meds: Medication[] = [ + { + id: 1, + name: "Weekday Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + packageType: "blister", + blisters: [], + intakes: [ + { + usage: 1, + every: 1, + start: "2024-03-18T09:00:00", + scheduleMode: "weekdays", + weekdays: ["mon", "wed", "fri"], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: false, + }, + ], + updatedAt: null, + }, + ]; + + const result = buildSchedulePreview(meds, "en", false); + const weekdayDateStrings = result.events.slice(0, 3).map((event) => event.dateStr); + + expect(weekdayDateStrings).toEqual(["Mon, Mar 18", "Wed, Mar 20", "Fri, Mar 22"]); + expect(result.totalBlisters).toBe(1); + }); + + it("uses weekday schedules when calculating coverage", () => { + const meds: Medication[] = [ + { + id: 1, + name: "Weekday Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + takenBy: [], + packageType: "blister", + blisters: [], + intakes: [ + { + usage: 1, + every: 1, + start: "2024-03-18T09:00:00", + scheduleMode: "weekdays", + weekdays: ["mon", "wed", "fri"], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: false, + }, + ], + updatedAt: null, + }, + ]; + + const preview = buildSchedulePreview(meds, "en", false); + const coverage = calculateCoverage(meds, preview.events, "en", 7, "automatic", new Set()); + + expect(coverage.all[0]).toMatchObject({ + name: "Weekday Med", + medsLeft: 9, + daysLeft: 21, + }); + }); +}); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6c33c91..370c2bf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -19,6 +19,8 @@ import { isAmountBasedPackageType } from "./package-profiles"; // Common medication dose units export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units"; +export type ScheduleMode = "interval" | "weekdays"; +export type WeekdayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid"; export type PillForm = "tablet" | "capsule"; @@ -49,6 +51,8 @@ export type Intake = { usage: number; every: number; start: string; + scheduleMode?: ScheduleMode | null; + weekdays?: WeekdayCode[] | null; intakeUnit?: IntakeUnit | null; takenBy: string | null; // Per-intake user assignment (single person or null) intakeRemindersEnabled: boolean; @@ -131,6 +135,8 @@ export type FormIntake = { every: string; startDate: string; startTime: string; + scheduleMode?: ScheduleMode; + weekdays?: WeekdayCode[]; intakeUnit?: IntakeUnit; takenBy: string; // Single person or empty string (empty = null for everyone) intakeRemindersEnabled: boolean; diff --git a/frontend/src/utils/ics.ts b/frontend/src/utils/ics.ts index 8018623..331c61c 100644 --- a/frontend/src/utils/ics.ts +++ b/frontend/src/utils/ics.ts @@ -4,6 +4,13 @@ import type { Medication } from "../types"; import { getMedDisplayName } from "../types"; +import { + getIntakeFrequencyText, + getIntakeScheduleMode, + getMedicationIntakes, + getWeekdayIcsCode, + normalizeWeekdays, +} from "./intake-schedule"; /** * Format a Date for ICS format (YYYYMMDDTHHMMSSZ) @@ -20,20 +27,33 @@ function formatICSDate(date: Date): string { */ export function generateICS(med: Medication): void { const displayName = getMedDisplayName(med); - const events = med.blisters - .map((blister, idx) => { - const start = new Date(blister.start); + const events = getMedicationIntakes(med) + .map((intake, idx) => { + const start = new Date(intake.start); const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration - const interval = blister.every; + const interval = intake.every; - const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`; + const pillInfo = `${intake.usage} pill${intake.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${intake.usage * med.pillWeightMg} mg)` : ""}`; const summary = `💊 ${displayName} - ${pillInfo}`; + const weekdayCodes = normalizeWeekdays(intake.weekdays); + const frequencyText = + getIntakeScheduleMode(intake) === "weekdays" + ? weekdayCodes.map(getWeekdayIcsCode).join(", ") + : getIntakeFrequencyText(intake, (key, options) => { + if (key === "common.daily") return "daily"; + if (key === "common.everyNDays") return `every ${options?.count ?? interval} days`; + return key; + }); + const rrule = + getIntakeScheduleMode(intake) === "weekdays" && weekdayCodes.length > 0 + ? `RRULE:FREQ=WEEKLY;BYDAY=${weekdayCodes.map(getWeekdayIcsCode).join(",")}` + : `RRULE:FREQ=DAILY;INTERVAL=${interval}`; const description = [ `Medication: ${displayName}`, med.genericName ? `Generic: ${med.genericName}` : "", med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "", `Dosage: ${pillInfo}`, - `Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`, + `Frequency: ${frequencyText}`, med.notes ? `Notes: ${med.notes}` : "", ] .filter(Boolean) @@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng DTSTAMP:${formatICSDate(new Date())} DTSTART:${formatICSDate(start)} DTEND:${formatICSDate(end)} -RRULE:FREQ=DAILY;INTERVAL=${interval} +${rrule} SUMMARY:${summary} DESCRIPTION:${description} BEGIN:VALARM diff --git a/frontend/src/utils/intake-schedule.ts b/frontend/src/utils/intake-schedule.ts new file mode 100644 index 0000000..4054148 --- /dev/null +++ b/frontend/src/utils/intake-schedule.ts @@ -0,0 +1,139 @@ +import type { Blister, Intake, ScheduleMode, WeekdayCode } from "../types"; + +type MedicationScheduleSource = { + intakes?: Intake[] | null; + blisters: Blister[]; + intakeRemindersEnabled?: boolean; +}; + +type IntakeScheduleLike = { + every?: number | string | null; + scheduleMode?: ScheduleMode | null; + weekdays?: ReadonlyArray | null; +}; + +type Translate = (key: string, options?: Record) => string; + +export const WEEKDAY_CODES: WeekdayCode[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + +const WEEKDAY_LABELS: Record = { + mon: { short: "form.blisters.weekdaysShort.mon", long: "form.blisters.weekdaysLong.mon", ics: "MO" }, + tue: { short: "form.blisters.weekdaysShort.tue", long: "form.blisters.weekdaysLong.tue", ics: "TU" }, + wed: { short: "form.blisters.weekdaysShort.wed", long: "form.blisters.weekdaysLong.wed", ics: "WE" }, + thu: { short: "form.blisters.weekdaysShort.thu", long: "form.blisters.weekdaysLong.thu", ics: "TH" }, + fri: { short: "form.blisters.weekdaysShort.fri", long: "form.blisters.weekdaysLong.fri", ics: "FR" }, + sat: { short: "form.blisters.weekdaysShort.sat", long: "form.blisters.weekdaysLong.sat", ics: "SA" }, + sun: { short: "form.blisters.weekdaysShort.sun", long: "form.blisters.weekdaysLong.sun", ics: "SU" }, +}; + +export function normalizeWeekdays(weekdays?: ReadonlyArray | null): WeekdayCode[] { + if (!Array.isArray(weekdays) || weekdays.length === 0) return []; + const normalizedSet = new Set(); + for (const day of weekdays) { + if (WEEKDAY_CODES.includes(day)) { + normalizedSet.add(day); + } + } + return WEEKDAY_CODES.filter((day) => normalizedSet.has(day)); +} + +export function hasSelectedWeekdays(weekdays?: ReadonlyArray | null): boolean { + return normalizeWeekdays(weekdays).length > 0; +} + +export function getIntakeScheduleMode(schedule: IntakeScheduleLike): ScheduleMode { + return schedule.scheduleMode === "weekdays" ? "weekdays" : "interval"; +} + +export function getNormalizedInterval(schedule: IntakeScheduleLike): number { + const parsedEvery = Number(schedule.every); + if (!Number.isFinite(parsedEvery) || parsedEvery <= 0) return 1; + return Math.floor(parsedEvery); +} + +export function getWeekdayCode(date: Date): WeekdayCode { + return WEEKDAY_CODES[(date.getDay() + 6) % 7]; +} + +export function getWeekdayLabel(day: WeekdayCode, t: Translate, format: "short" | "long" = "short"): string { + return t(WEEKDAY_LABELS[day][format]); +} + +export function getWeekdayIcsCode(day: WeekdayCode): string { + return WEEKDAY_LABELS[day].ics; +} + +export function toggleWeekdaySelection( + weekdays: ReadonlyArray | null | undefined, + day: WeekdayCode +): WeekdayCode[] { + const normalized = normalizeWeekdays(weekdays); + if (normalized.includes(day)) { + return normalized.filter((entry) => entry !== day); + } + return normalizeWeekdays([...normalized, day]); +} + +export function getMedicationIntakes(med: MedicationScheduleSource): Intake[] { + if (med.intakes && med.intakes.length > 0) { + return med.intakes; + } + return med.blisters.map((blister) => ({ + usage: blister.usage, + every: blister.every, + start: blister.start, + scheduleMode: "interval", + weekdays: [], + intakeUnit: null, + takenBy: null, + intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, + })); +} + +export function iterateIntakeOccurrences( + intake: IntakeScheduleLike, + start: Date, + end: Date, + callback: (occurrence: Date) => void +): void { + if (start > end) return; + + if (getIntakeScheduleMode(intake) === "weekdays") { + const weekdays = normalizeWeekdays(intake.weekdays); + if (weekdays.length === 0) return; + + const cursor = new Date(start); + while (cursor <= end) { + if (weekdays.includes(getWeekdayCode(cursor))) { + callback(new Date(cursor)); + } + cursor.setDate(cursor.getDate() + 1); + } + return; + } + + const interval = getNormalizedInterval(intake); + const cursor = new Date(start); + while (cursor <= end) { + callback(new Date(cursor)); + cursor.setDate(cursor.getDate() + interval); + } +} + +export function getIntakeDailyRate(schedule: IntakeScheduleLike): number { + if (getIntakeScheduleMode(schedule) === "weekdays") { + return normalizeWeekdays(schedule.weekdays).length / 7; + } + return 1 / getNormalizedInterval(schedule); +} + +export function getIntakeFrequencyText(schedule: IntakeScheduleLike, t: Translate): string { + if (getIntakeScheduleMode(schedule) === "weekdays") { + return normalizeWeekdays(schedule.weekdays) + .map((day) => getWeekdayLabel(day, t, "short")) + .join(", "); + } + + const every = getNormalizedInterval(schedule); + return every === 1 ? t("common.daily") : t("common.everyNDays", { count: every }); +} diff --git a/frontend/src/utils/intake-units.ts b/frontend/src/utils/intake-units.ts new file mode 100644 index 0000000..4ad670c --- /dev/null +++ b/frontend/src/utils/intake-units.ts @@ -0,0 +1,15 @@ +import type { IntakeUnit } from "../types"; + +type Translate = (key: string, options?: Record) => string; + +export function convertLiquidUsageToMl(usage: number, unit: IntakeUnit | null | undefined): number { + if (unit === "tsp") return usage * 5; + if (unit === "tbsp") return usage * 15; + return usage; +} + +export function getLiquidCountUnitLabel(unit: IntakeUnit | null | undefined, usage: number, t: Translate): string { + if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) }); + if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) }); + return t("form.packageAmountUnitMl"); +} diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index f1f631f..7703e4b 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -2,17 +2,10 @@ // Schedule Building and Coverage Calculations // ============================================================================= -import type { - Blister, - Coverage, - Intake, - Medication, - PackageType, - ScheduleEvent, - StockStatus, - StockThresholds, -} from "../types"; +import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types"; import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types"; +import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "./intake-schedule"; +import { convertLiquidUsageToMl } from "./intake-units"; export function parseLocalDateTime(isoString: string): Date { const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/); @@ -39,38 +32,7 @@ function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number { const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid"; if (!isLiquidStock) return usage; - if (intake.intakeUnit === "tsp") return usage * 5; - if (intake.intakeUnit === "tbsp") return usage * 15; - return usage; -} - -/** - * Get intakes for a medication, preferring new intakes format over legacy blisters - */ -function getIntakesForMed(med: Medication): Intake[] { - // Use new intakes array if available and non-empty - if (med.intakes && med.intakes.length > 0) { - return med.intakes; - } - // Fallback to legacy blisters (convert to Intake format) - return med.blisters.map((b) => ({ - usage: b.usage, - every: b.every, - start: b.start, - intakeUnit: null, - takenBy: null, // Legacy format has no per-intake takenBy - intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, - })); -} - -/** - * Get blisters for a medication (for backward compatibility with coverage calculations) - */ -function getBlistersForMed(med: Medication): Blister[] { - if (med.intakes && med.intakes.length > 0) { - return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); - } - return med.blisters; + return convertLiquidUsageToMl(usage, intake.intakeUnit); } /** @@ -90,13 +52,13 @@ export function buildSchedulePreview( end.setDate(end.getDate() + 180); // 6 months horizon meds.forEach((med) => { - const intakes = getIntakesForMed(med); + const intakes = getMedicationIntakes(med); intakes.forEach((intake, idx) => { const start = parseLocalDateTime(intake.start); if (Number.isNaN(start.getTime())) return; - for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) { + iterateIntakeOccurrences(intake, start, end, (d) => { const isPast = d < todayStart; - if (isPast && !includePast) continue; + if (isPast && !includePast) return; const whenMs = d.getTime(); // Use date-only timestamp for stable ID (immune to time changes) // This ensures changing intake times doesn't invalidate past dose tracking @@ -113,7 +75,7 @@ export function buildSchedulePreview( dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }), intakeRemindersEnabled: intake.intakeRemindersEnabled, }); - } + }); }); }); @@ -129,7 +91,7 @@ export function buildSchedulePreview( events, today: todayCount, nextThree: events.length, - totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0), + totalBlisters: meds.reduce((acc, med) => acc + getMedicationIntakes(med).length, 0), }; } @@ -147,10 +109,10 @@ export function calculateCoverage( ): { low: Coverage[]; all: Coverage[] } { const MS_PER_DAY = 86_400_000; const now = Date.now(); + const nowDate = new Date(now); const coverage: Coverage[] = meds.map((m) => { - const intakes = getIntakesForMed(m); - const blisters = getBlistersForMed(m); + const intakes = getMedicationIntakes(m); // Count unique people from all intakes (for per-intake takenBy) const uniquePeople = new Set(); intakes.forEach((intake) => { @@ -165,11 +127,9 @@ export function calculateCoverage( // one person's dose — do NOT multiply by personCount again. // For legacy intakes (no takenBy), the intake applies to ALL people. let dailyRate = 0; - blisters.forEach((_s, idx) => { - const intake = intakes[idx]; - if (!intake) return; + intakes.forEach((intake) => { const usageForStock = normalizeIntakeUsageForStock(intake, m); - const baseRate = intake.every > 0 ? usageForStock / intake.every : 0; + const baseRate = usageForStock * getIntakeDailyRate(intake); if (intake?.takenBy) { // Per-intake takenBy: this intake is for exactly 1 person dailyRate += baseRate; @@ -189,29 +149,11 @@ export function calculateCoverage( // time (early intake), that dose is also counted as consumed immediately. // This prevents double-counting: once the scheduled time arrives, the dose // was already counted via the early-taken path, not again via time. - blisters.forEach((s, blisterIdx) => { - const blisterStart = parseLocalDateTime(s.start).getTime(); - const period = Math.max(1, s.every) * MS_PER_DAY; - const intake = intakes[blisterIdx]; - if (!intake) return; + intakes.forEach((intake, blisterIdx) => { + const intakeStart = parseLocalDateTime(intake.start); + if (Number.isNaN(intakeStart.getTime())) return; const usageForStock = normalizeIntakeUsageForStock(intake, m); - // After a stock correction, start counting consumption from the NEXT - // scheduled dose on this blister's grid, because the user's pill count - // already reflects all consumption up to the correction time. - // We align to the schedule grid so that e.g. correction at 15:40 with - // a daily 15:42 dose counts today's 15:42 dose (2 min later), not - // tomorrow's dose (24h later as the old code did). - 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; - } - if (Number.isNaN(effectiveStart)) return; - const intakePerson = intake?.takenBy; // For per-intake takenBy, only count for that person @@ -223,18 +165,15 @@ export function calculateCoverage( let timeBasedConsumed = 0; let lastAutoConsumedDateMs = 0; - if (effectiveStart <= now) { - const occurrences = Math.floor((now - effectiveStart) / period) + 1; - timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length; - - // Date-only timestamp of the last auto-consumed dose - const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); + iterateIntakeOccurrences(intake, intakeStart, nowDate, (occurrence) => { + if (occurrence.getTime() <= stockCorrectionCutoff) return; + timeBasedConsumed += usageForStock * peopleForThisIntake.length; lastAutoConsumedDateMs = new Date( - lastDoseTime.getFullYear(), - lastDoseTime.getMonth(), - lastDoseTime.getDate() + occurrence.getFullYear(), + occurrence.getMonth(), + occurrence.getDate() ).getTime(); - } + }); // Early intakes: count future doses already marked as taken. // The cutoff is the later of: last auto-consumed date or stock correction date. @@ -276,16 +215,15 @@ export function calculateCoverage( const medId = parseInt(parts[0], 10); const blisterIdx = parseInt(parts[1], 10); const doseTimestamp = parseInt(parts[2], 10); - if (medId === m.id && blisters[blisterIdx]) { - const intake = intakes[blisterIdx]; - if (!intake) return; + const intake = intakes[blisterIdx]; + if (medId === m.id && intake) { const usageForStock = normalizeIntakeUsageForStock(intake, m); // Convert blister start to date-only for comparison (dose timestamps are date-only) - const blisterStartDate = new Date(blisters[blisterIdx].start); - const blisterStartDateOnly = new Date( - blisterStartDate.getFullYear(), - blisterStartDate.getMonth(), - blisterStartDate.getDate() + const intakeStartDate = new Date(intake.start); + const intakeStartDateOnly = new Date( + intakeStartDate.getFullYear(), + intakeStartDate.getMonth(), + intakeStartDate.getDate() ).getTime(); // Use actual takenAt timestamp for stock correction comparison. @@ -295,8 +233,8 @@ export function calculateCoverage( const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; if ( - !Number.isNaN(blisterStartDateOnly) && - doseTimestamp >= blisterStartDateOnly && + !Number.isNaN(intakeStartDateOnly) && + doseTimestamp >= intakeStartDateOnly && afterCorrectionOrNoCorrectionMs ) { consumed += usageForStock; @@ -618,3 +556,48 @@ export function computeMissedPastDoseIds( ); return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)); } + +export function buildClearMissedPayload( + pastDays: ReadonlyArray<{ + date: Date; + meds: ReadonlyArray<{ + medName: string; + doses: ReadonlyArray<{ id: string; takenBy: string[] }>; + }>; + }>, + medications: ReadonlyArray<{ id: number; name: string; genericName?: string | null; dismissedUntil?: string | null }>, + takenDoses: Set, + dismissedDoses: Set +): { medicationIds: number[]; until: string | null } { + const medicationIds = new Set(); + let latestMissedDate: string | null = null; + + for (const day of pastDays) { + for (const item of day.meds) { + const med = medications.find((candidate) => getMedDisplayName(candidate as Medication) === item.medName); + if (!med) continue; + + const dismissedUntilDate = med.dismissedUntil ?? undefined; + const hasMissedDose = item.doses.some((dose) => { + if (isDoseDismissed(dose.id, dismissedUntilDate)) { + return false; + } + + return expandDoseIds([dose]).some((doseId) => !takenDoses.has(doseId) && !dismissedDoses.has(doseId)); + }); + + if (!hasMissedDose) continue; + + medicationIds.add(med.id); + const dayDate = day.date.toISOString().slice(0, 10); + if (!latestMissedDate || dayDate > latestMissedDate) { + latestMissedDate = dayDate; + } + } + } + + return { + medicationIds: [...medicationIds], + until: latestMissedDate, + }; +}