feat: enable weekday-based medication scheduling

Closes #463
This commit is contained in:
Daniel Volz
2026-03-20 14:58:25 +01:00
committed by GitHub
parent 29f4c4e48d
commit 68ab79c713
35 changed files with 1856 additions and 841 deletions
+11 -5
View File
@@ -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<number>();
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;
+27 -19
View File
@@ -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<string, number>();
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)
+84 -104
View File
@@ -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<number>();
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<number, number>();
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<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
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));
}
+11 -19
View File
@@ -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,
+17 -24
View File
@@ -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<string | null>;
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) {
+19 -25
View File
@@ -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;
+26 -57
View File
@@ -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,
},
});
+151 -2
View File
@@ -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", () => {
-1
View File
@@ -10,7 +10,6 @@ import {
createTestMedication,
createTestShareToken,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
+328 -88
View File
@@ -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<Weekday, number> = {
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<Weekday>();
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<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode === "weekdays") {
return getNormalizedWeekdays(schedule).length / 7;
}
return 1 / Math.max(1, schedule.every);
}
export function getMaxScheduledGapDays(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): 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<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
}
export function getNextScheduledOccurrenceTime(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
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<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
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<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
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<string, unknown>) => ({
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<string, unknown>) => 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;