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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.20.1", "version": "1.20.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.20.1", "version": "1.20.2",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+11 -5
View File
@@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url";
import type { Client } from "@libsql/client"; import type { Client } from "@libsql/client";
import type { drizzle } from "drizzle-orm/libsql"; import type { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator"; 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) // Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url); 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; if (every <= 0 || Number.isNaN(start.getTime())) continue;
const validDates = new Set<number>(); const validDates = new Set<number>();
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) { forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
} });
validDatesByIntake.set(idx, validDates); validDatesByIntake.set(idx, validDates);
} }
@@ -388,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
const intake = intakes[intakeIdx]; const intake = intakes[intakeIdx];
if (!intake) continue; if (!intake) continue;
const halfInterval = (intake.every * MS_PER_DAY) / 2; const halfInterval = getScheduleMatchWindowMs(intake);
let bestMatch: number | null = null; let bestMatch: number | null = null;
let bestDist = Infinity; let bestDist = Infinity;
+27 -19
View File
@@ -16,14 +16,14 @@ import {
validationErrorSchema, validationErrorSchema,
} from "../utils/openapi-route-standards.js"; } from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.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"); const IMAGES_DIR = resolve(getDataDir(), "images");
// ============================================================================= // =============================================================================
// Export Format Version (bump this when format changes) // Export Format Version (bump this when format changes)
// ============================================================================= // =============================================================================
const EXPORT_VERSION = "1.3"; const EXPORT_VERSION = "1.4";
// ============================================================================= // =============================================================================
// Zod Schemas for Import Validation // Zod Schemas for Import Validation
@@ -33,6 +33,8 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(), usage: z.number().nonnegative(),
every: z.number().int().min(1), every: z.number().int().min(1),
start: z.string(), // ISO datetime string start: z.string(), // ISO datetime string
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(), intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false), remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field) takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
@@ -237,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number; usage: number;
every: number; every: number;
start: string; start: string;
scheduleMode: "interval" | "weekdays";
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
intakeUnit: "ml" | "tsp" | "tbsp" | null; intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean; remind: boolean;
takenBy: string | null; takenBy: string | null;
@@ -252,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: intake.usage, usage: intake.usage,
every: intake.every, every: intake.every,
start: intake.start, start: intake.start,
intakeUnit: null, scheduleMode: intake.scheduleMode ?? "interval",
weekdays: intake.weekdays ?? [],
intakeUnit: intake.intakeUnit ?? null,
remind: intake.intakeRemindersEnabled, remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy takenBy: intake.takenBy, // Per-intake takenBy
})); }));
@@ -671,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>(); const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) { for (const med of importData.medications) {
// Convert schedules to both legacy and new formats const normalizedSchedules = med.schedules.map((schedule) =>
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage)); normalizeIntake({
const everyJson = JSON.stringify(med.schedules.map((s) => s.every)); usage: schedule.usage,
const startJson = JSON.stringify(med.schedules.map((s) => s.start)); 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); const takenByJson = JSON.stringify(med.takenBy);
// Build intakesJson array (new unified format with per-intake takenBy) const intakesJson = JSON.stringify(normalizedSchedules);
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,
}))
);
// Check if any schedule has remind enabled // 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 const [inserted] = await db
.insert(medications) .insert(medications)
+84 -104
View File
@@ -29,7 +29,13 @@ import {
PACKAGE_TYPES, PACKAGE_TYPES,
} from "../utils/package-profiles.js"; } from "../utils/package-profiles.js";
import { import {
countScheduledOccurrencesInRange,
forEachScheduledOccurrenceInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
getScheduleMatchWindowMs,
type Intake, type Intake,
normalizeIntake,
normalizeIntakeUsageForStock, normalizeIntakeUsageForStock,
parseIntakesJson, parseIntakesJson,
parseLocalDateTime, parseLocalDateTime,
@@ -100,6 +106,8 @@ const intakeSchema = z.object({
usage: z.number().nonnegative(), usage: z.number().nonnegative(),
every: z.number().int().min(1), every: z.number().int().min(1),
start: z.string().datetime({ local: true }), start: z.string().datetime({ local: true }),
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(), intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
@@ -274,6 +282,11 @@ const intakeOpenApiSchema = {
usage: { type: "number", minimum: 0 }, usage: { type: "number", minimum: 0 },
every: { type: "integer", minimum: 1 }, every: { type: "integer", minimum: 1 },
start: { type: "string", description: "ISO datetime string; timezone suffix optional." }, 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] }, intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] },
takenBy: { type: ["string", "null"], maxLength: 100 }, takenBy: { type: ["string", "null"], maxLength: 100 },
intakeRemindersEnabled: { type: "boolean" }, intakeRemindersEnabled: { type: "boolean" },
@@ -359,6 +372,8 @@ const medicationBodyOpenApiSchema = {
usage: 1, usage: 1,
every: 8, every: 8,
start: "2026-03-11T08:00:00.000Z", start: "2026-03-11T08:00:00.000Z",
scheduleMode: "interval",
weekdays: [],
takenBy: "Daniel", takenBy: "Daniel",
intakeRemindersEnabled: true, intakeRemindersEnabled: true,
}, },
@@ -664,25 +679,20 @@ export async function medicationRoutes(app: FastifyInstance) {
// Convert to unified intakes format // Convert to unified intakes format
let intakes: Intake[]; let intakes: Intake[];
if (inputIntakes) { if (inputIntakes) {
// New format with per-intake takenBy intakes = inputIntakes.map((intake) => normalizeIntake(intake));
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,
}));
} else if (inputBlisters) { } else if (inputBlisters) {
// Legacy format - convert to new format intakes = inputBlisters.map((blister) =>
intakes = inputBlisters.map((b) => ({ normalizeIntake(
usage: b.usage, {
every: b.every, usage: blister.usage,
start: b.start, every: blister.every,
intakeUnit: null, start: blister.start,
takenBy: null, // No per-intake takenBy from legacy intakeUnit: null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false, takenBy: null,
})); },
intakeRemindersEnabled ?? false
)
);
} else { } else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); 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 // Convert to unified intakes format
let intakes: Intake[]; let intakes: Intake[];
if (inputIntakes) { if (inputIntakes) {
// New format with per-intake takenBy intakes = inputIntakes.map((intake) => normalizeIntake(intake));
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,
}));
} else if (inputBlisters) { } else if (inputBlisters) {
// Legacy format - convert to new format intakes = inputBlisters.map((blister) =>
intakes = inputBlisters.map((b) => ({ normalizeIntake(
usage: b.usage, {
every: b.every, usage: blister.usage,
start: b.start, every: blister.every,
intakeUnit: null, start: blister.start,
takenBy: null, // No per-intake takenBy from legacy intakeUnit: null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false, takenBy: null,
})); },
intakeRemindersEnabled ?? false
)
);
} else { } else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" }); 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) { if (allDoses.length > 0) {
// Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs // Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs
const now = new Date(); const now = new Date();
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
const MS_PER_DAY = 86_400_000;
for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) { for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) {
const oldIntake = oldIntakes[idx]; const oldIntake = oldIntakes[idx];
@@ -954,44 +958,45 @@ export async function medicationRoutes(app: FastifyInstance) {
const oldStart = parseLocalDateTime(oldIntake.start); const oldStart = parseLocalDateTime(oldIntake.start);
const newStart = parseLocalDateTime(newIntake.start); const newStart = parseLocalDateTime(newIntake.start);
const oldEvery = oldIntake.every; // Check if start date or schedule changed (time-of-day changes don't matter for dateOnlyMs)
const newEvery = newIntake.every;
// Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs)
const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime(); const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime();
const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.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 continue; // No schedule change that affects dose IDs
} }
// Build set of new valid dateOnlyMs values for this intake // Build set of new valid dateOnlyMs values for this intake
const newDates = new Set<number>(); const newDates = new Set<number>();
for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) { forEachScheduledOccurrenceInRange(newIntake, newStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()); newDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
} });
// Build set of old dateOnlyMs values with mapping to nearest new date // Build set of old dateOnlyMs values with mapping to nearest new date
const oldToNewMap = new Map<number, number>(); const oldToNewMap = new Map<number, number>();
for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) { const scheduleMatchWindowMs = getScheduleMatchWindowMs(newIntake);
const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); forEachScheduledOccurrenceInRange(oldIntake, oldStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
// Find the closest new date within ±(newEvery/2) days const oldDateMs = getDateOnlyTimestamp(new Date(occurrenceMs));
const halfInterval = (newEvery * MS_PER_DAY) / 2;
let bestMatch: number | null = null; let bestMatch: number | null = null;
let bestDist = Infinity; let bestDistance = Infinity;
for (const newDateMs of newDates) { for (const newDateMs of newDates) {
const dist = Math.abs(newDateMs - oldDateMs); const distance = Math.abs(newDateMs - oldDateMs);
if (dist < bestDist && dist <= halfInterval) { if (distance < bestDistance && distance <= scheduleMatchWindowMs) {
bestDist = dist; bestDistance = distance;
bestMatch = newDateMs; bestMatch = newDateMs;
} }
} }
if (bestMatch !== null && bestMatch !== oldDateMs) { if (bestMatch !== null && bestMatch !== oldDateMs) {
oldToNewMap.set(oldDateMs, bestMatch); oldToNewMap.set(oldDateMs, bestMatch);
// Remove matched new date to prevent double-mapping
newDates.delete(bestMatch); newDates.delete(bestMatch);
} }
} });
// Apply migrations to dose tracking entries // Apply migrations to dose tracking entries
if (oldToNewMap.size > 0) { if (oldToNewMap.size > 0) {
@@ -1503,6 +1508,8 @@ export async function medicationRoutes(app: FastifyInstance) {
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType), usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
every: i.every, every: i.every,
start: i.start, start: i.start,
scheduleMode: i.scheduleMode,
weekdays: i.weekdays,
})); }));
const pillsPerBlister = row.pillsPerBlister ?? 1; const pillsPerBlister = row.pillsPerBlister ?? 1;
const packCount = row.packCount ?? 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 // Count consumed pills by generating expected doses and checking if they're taken
let consumedUntilNow = 0; let consumedUntilNow = 0;
const msPerDay = 86400000;
if (isTopical) { if (isTopical) {
consumedUntilNow = 0; consumedUntilNow = 0;
} else if (stockCalculationMode === "automatic") { } else if (stockCalculationMode === "automatic") {
@@ -1532,16 +1537,11 @@ export async function medicationRoutes(app: FastifyInstance) {
const blisterStart = parseLocalDateTime(blister.start).getTime(); const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return; if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay; const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
let effectiveStart: number; ? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) { : blisterStart;
const elapsedSinceStart = stockCorrectionCutoff - blisterStart; if (effectiveStart === null) return;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const intake = intakes[blisterIdx]; const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy; const intakePerson = intake?.takenBy;
@@ -1559,25 +1559,20 @@ export async function medicationRoutes(app: FastifyInstance) {
let lastAutoConsumedDateMs = 0; let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now.getTime()) { 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; timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = new Date( lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
lastDoseTime.getFullYear(), }
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
} }
const stockCorrectionDateOnly = const stockCorrectionDateOnly =
stockCorrectionCutoff > 0 stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0; let earlyTakenConsumed = 0;
@@ -1768,34 +1763,19 @@ export async function medicationRoutes(app: FastifyInstance) {
} }
function calculateUsageInRange( function calculateUsageInRange(
blisters: Array<{ usage: number; every: number; start: string }>, blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
start: Date, start: Date,
end: Date end: Date
) { ) {
if (end.getTime() <= start.getTime()) {
return 0;
}
let total = 0; let total = 0;
const msPerDay = 86400000;
blisters.forEach((blister) => { blisters.forEach((blister) => {
const blisterStart = parseLocalDateTime(blister.start); forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
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)) {
total += blister.usage; total += blister.usage;
} });
}); });
return Number(total.toFixed(2)); return Number(total.toFixed(2));
} }
+11 -19
View File
@@ -1,14 +1,14 @@
import type { doseTracking, medications } from "../db/schema.js"; import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js"; import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import { import {
getAverageOccurrencesPerDay,
getNextScheduledOccurrenceTime,
getTodayInTimezone, getTodayInTimezone,
type Intake, type Intake,
normalizeIntakeUsageForStock, normalizeIntakeUsageForStock,
parseIntakesJson, parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js"; } from "../utils/scheduler-utils.js";
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type MedicationRow = typeof medications.$inferSelect; type MedicationRow = typeof medications.$inferSelect;
@@ -60,35 +60,27 @@ function computeCapacity(medication: MedicationRow): number {
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number { function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
return intakes.reduce((sum, intake) => { return intakes.reduce((sum, intake) => {
if (intake.every <= 0) return sum;
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType); const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
return sum + normalizedUsage / intake.every; return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
}, 0); }, 0);
} }
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null { function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
const today = parseDateOnly(todayDateOnly); const today = parseDateOnly(todayDateOnly);
let nextDate: Date | null = null; let nextOccurrenceMs: number | null = null;
for (const intake of intakes) { for (const intake of intakes) {
if (intake.every <= 0) continue; const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
if (occurrenceMs === null) {
const startDate = parseLocalDateTime(intake.start); continue;
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);
} }
if (!nextDate || candidate.getTime() < nextDate.getTime()) { if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
nextDate = candidate; nextOccurrenceMs = occurrenceMs;
} }
} }
return nextDate ? toDateOnlyString(nextDate) : null; return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
} }
function computeTakenAmount( function computeTakenAmount(
@@ -188,7 +180,7 @@ export function buildSharedMedicationOverview(options: {
const currentStock = Math.max(0, Math.floor(rawCurrentStock)); const currentStock = Math.max(0, Math.floor(rawCurrentStock));
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null; const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
const depletionDate = 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); const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
return { return {
name: medication.name, name: medication.name,
+17 -24
View File
@@ -1,6 +1,9 @@
import type { doseTracking, medications } from "../db/schema.js"; import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js"; import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import { import {
countScheduledOccurrencesInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
normalizeIntakeUsageForStock, normalizeIntakeUsageForStock,
parseIntakesJson, parseIntakesJson,
parseLocalDateTime, parseLocalDateTime,
@@ -10,7 +13,6 @@ import {
type MedicationRow = typeof medications.$inferSelect; type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect; type DoseRow = typeof doseTracking.$inferSelect;
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getDoseTakenAtMs(dose: DoseRow): number { function getDoseTakenAtMs(dose: DoseRow): number {
@@ -60,15 +62,11 @@ export function computeMedicationCurrentStock(options: {
const intakeStart = parseLocalDateTime(intake.start).getTime(); const intakeStart = parseLocalDateTime(intake.start).getTime();
if (Number.isNaN(intakeStart)) return; if (Number.isNaN(intakeStart)) return;
const period = Math.max(1, intake.every) * MS_PER_DAY; const effectiveStart =
let effectiveStart: number; stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) { ? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
const elapsedSinceStart = stockCorrectionCutoff - intakeStart; : intakeStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period); if (effectiveStart === null) return;
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = intakeStart;
}
let peopleForThisIntake: Array<string | null>; let peopleForThisIntake: Array<string | null>;
if (intake.takenBy) { if (intake.takenBy) {
@@ -81,25 +79,20 @@ export function computeMedicationCurrentStock(options: {
let lastAutoConsumedDateMs = 0; let lastAutoConsumedDateMs = 0;
if (effectiveStart <= nowMs) { if (effectiveStart <= nowMs) {
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1; const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
intake,
effectiveStart,
nowMs
);
consumed += occurrences * usage * peopleForThisIntake.length; consumed += occurrences * usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = new Date( lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
lastDoseTime.getFullYear(), }
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
} }
const stockCorrectionDateOnly = const stockCorrectionDateOnly =
stockCorrectionCutoff > 0 stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
for (const dose of relevantDoses) { for (const dose of relevantDoses) {
+19 -25
View File
@@ -18,10 +18,13 @@ import {
import { import {
type Blister, type Blister,
calculateDepletionInfo, calculateDepletionInfo,
countScheduledOccurrencesInRange,
createDefaultReminderState, createDefaultReminderState,
formatInTimezone, formatInTimezone,
getCurrentHourInTimezone, getCurrentHourInTimezone,
getDateOnlyTimestamp,
getMsUntilNextCheck, getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime, getNextScheduledTime,
getTimezone, getTimezone,
getTodayInTimezone, getTodayInTimezone,
@@ -271,7 +274,6 @@ async function getMedicationsNeedingReminder(
const lowStock: LowStockItem[] = []; const lowStock: LowStockItem[] = [];
const now = Date.now(); const now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) { for (const row of rows) {
const packageType = normalizePackageType(row.packageType); const packageType = normalizePackageType(row.packageType);
@@ -288,6 +290,8 @@ async function getMedicationsNeedingReminder(
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType), usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
every: i.every, every: i.every,
start: i.start, start: i.start,
scheduleMode: i.scheduleMode,
weekdays: i.weekdays,
})); }));
const originalTotalPills = isAmountBasedPackageType(packageType) const originalTotalPills = isAmountBasedPackageType(packageType)
@@ -304,16 +308,11 @@ async function getMedicationsNeedingReminder(
const blisterStart = parseLocalDateTime(blister.start).getTime(); const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return; if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay; const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
let effectiveStart: number; ? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) { : blisterStart;
const elapsedSinceStart = stockCorrectionCutoff - blisterStart; if (effectiveStart === null) return;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const intake = intakes[blisterIdx]; const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy; const intakePerson = intake?.takenBy;
@@ -331,25 +330,20 @@ async function getMedicationsNeedingReminder(
let lastAutoConsumedDateMs = 0; let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) { 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; timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = new Date( lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
lastDoseTime.getFullYear(), }
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
} }
const stockCorrectionDateOnly = const stockCorrectionDateOnly =
stockCorrectionCutoff > 0 stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0; let earlyTakenConsumed = 0;
+26 -57
View File
@@ -942,17 +942,17 @@ describe("Integration Tests", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("Planner usage calculation", () => { 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 () => { it("should calculate correct usage for daily medication", async () => {
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total // Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
// Schedule: 1 pill daily starting tomorrow (future date) // Schedule: 1 pill daily starting on a fixed future winter date.
const tomorrow = new Date(); // This avoids daylight-saving-time edge cases in local test environments.
tomorrow.setDate(tomorrow.getDate() + 1); const intakeStart = futureDailyStart;
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
await app.inject({ await app.inject({
method: "POST", method: "POST",
@@ -972,8 +972,8 @@ describe("Integration Tests", () => {
method: "POST", method: "POST",
url: "/medications/usage", url: "/medications/usage",
payload: { payload: {
startDate: intakeStart, startDate: plannerWindowStart,
endDate: planEndStr, // 10 days endDate: tenDayPlanEnd,
}, },
}); });
@@ -988,15 +988,8 @@ describe("Integration Tests", () => {
it("should detect insufficient stock", async () => { it("should detect insufficient stock", async () => {
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total // Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
// Schedule: 1 pill daily starting tomorrow // Schedule: 1 pill daily starting on a fixed future winter date.
const tomorrow = new Date(); const intakeStart = futureDailyStart;
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();
await app.inject({ await app.inject({
method: "POST", method: "POST",
@@ -1016,8 +1009,8 @@ describe("Integration Tests", () => {
method: "POST", method: "POST",
url: "/medications/usage", url: "/medications/usage",
payload: { payload: {
startDate: intakeStart, startDate: plannerWindowStart,
endDate: planEndStr, endDate: tenDayPlanEnd,
}, },
}); });
@@ -1029,15 +1022,8 @@ describe("Integration Tests", () => {
it("should calculate weekly medication usage correctly", async () => { it("should calculate weekly medication usage correctly", async () => {
// Create medication: 10 pills total // Create medication: 10 pills total
// Schedule: 1 pill every 7 days starting tomorrow // Schedule: 1 pill every 7 days starting on a fixed future winter date.
const tomorrow = new Date(); const intakeStart = futureDailyStart;
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();
await app.inject({ await app.inject({
method: "POST", method: "POST",
@@ -1056,8 +1042,8 @@ describe("Integration Tests", () => {
method: "POST", method: "POST",
url: "/medications/usage", url: "/medications/usage",
payload: { payload: {
startDate: intakeStart, startDate: plannerWindowStart,
endDate: planEndStr, endDate: thirtyFiveDayPlanEnd,
}, },
}); });
@@ -1070,18 +1056,8 @@ describe("Integration Tests", () => {
it("should handle multiple intake schedules per medication", async () => { it("should handle multiple intake schedules per medication", async () => {
// Create medication with morning and evening doses // Create medication with morning and evening doses
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening) // 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
const tomorrow = new Date(); const morningStart = futureDailyStart;
tomorrow.setDate(tomorrow.getDate() + 1); const eveningStartStr = futureEveningStart;
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();
await app.inject({ await app.inject({
method: "POST", method: "POST",
@@ -1103,8 +1079,8 @@ describe("Integration Tests", () => {
method: "POST", method: "POST",
url: "/medications/usage", url: "/medications/usage",
payload: { payload: {
startDate: morningStart, startDate: plannerWindowStart,
endDate: planEndStr, endDate: tenDayPlanEnd,
}, },
}); });
@@ -1116,14 +1092,7 @@ describe("Integration Tests", () => {
it("should calculate correct blisters needed", async () => { it("should calculate correct blisters needed", async () => {
// 10 pills per blister, need 25 pills → need 3 blisters // 10 pills per blister, need 25 pills → need 3 blisters
const tomorrow = new Date(); const intakeStart = futureDailyStart;
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();
await app.inject({ await app.inject({
method: "POST", method: "POST",
@@ -1142,8 +1111,8 @@ describe("Integration Tests", () => {
method: "POST", method: "POST",
url: "/medications/usage", url: "/medications/usage",
payload: { payload: {
startDate: intakeStart, startDate: plannerWindowStart,
endDate: planEndStr, endDate: tenDayPlanEnd,
}, },
}); });
+151 -2
View File
@@ -6,22 +6,30 @@ import {
calculateDailyUsage, calculateDailyUsage,
calculateDepletionInfo, calculateDepletionInfo,
cleanOldIntakeReminders, cleanOldIntakeReminders,
countScheduledOccurrencesInRange,
createDefaultIntakeReminderState, createDefaultIntakeReminderState,
createDefaultReminderState, createDefaultReminderState,
forEachScheduledOccurrenceInRange,
formatInTimezone, formatInTimezone,
getAverageOccurrencesPerDay,
getCurrentHourInTimezone, getCurrentHourInTimezone,
getMaxScheduledGapDays,
getMsUntilNextCheck, getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime, getNextScheduledTime,
getTimezone, getTimezone,
getTodayInTimezone, getTodayInTimezone,
getTodaysIntakes, getTodaysIntakes,
getUpcomingIntakes, getUpcomingIntakes,
type Intake, type Intake,
normalizeIntake,
parseBlisters, parseBlisters,
parseIntakeReminderState, parseIntakeReminderState,
parseIntakesJson,
parseReminderState, parseReminderState,
parseTakenByJson, parseTakenByJson,
personTakesMedication, personTakesMedication,
type Weekday,
} from "../utils/scheduler-utils.js"; } from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests // 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("Scheduler Utils - Daily Usage Calculation", () => {
describe("calculateDailyUsage", () => { describe("calculateDailyUsage", () => {
it("should calculate daily usage for single daily dose", () => { 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("Scheduler Utils - Depletion Calculation", () => {
describe("calculateDepletionInfo", () => { describe("calculateDepletionInfo", () => {
it("should calculate days left correctly", () => { it("should calculate days left correctly", () => {
@@ -378,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
expect(result[0].pillWeightMg).toBe(500); 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 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 now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); 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", () => { it("should handle multiple blisters", () => {
-1
View File
@@ -10,7 +10,6 @@ import {
createTestMedication, createTestMedication,
createTestShareToken, createTestShareToken,
createTestUser, createTestUser,
setUserSettings,
type TestContext, type TestContext,
} from "./setup.js"; } from "./setup.js";
+328 -88
View File
@@ -6,14 +6,34 @@
import { getDateLocale, type Language } from "../i18n/translations.js"; import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.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) // 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 // New unified intake type with per-intake takenBy
export type Intake = { export type Intake = {
usage: number; usage: number;
every: number; every: number;
start: string; start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
intakeUnit?: "ml" | "tsp" | "tbsp" | null; intakeUnit?: "ml" | "tsp" | "tbsp" | null;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy) takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean; intakeRemindersEnabled: boolean;
@@ -22,6 +42,278 @@ export type Intake = {
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" => const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
value === "ml" || value === "tsp" || value === "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. * Normalize intake usage for stock math.
* *
@@ -225,15 +517,7 @@ export function parseIntakesJson(
try { try {
const parsed = JSON.parse(intakesJson); const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: Record<string, unknown>) => ({ return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
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,
}));
} }
} catch { } catch {
// Fall through to legacy parsing // Fall through to legacy parsing
@@ -243,14 +527,18 @@ export function parseIntakesJson(
// Fallback to legacy parallel arrays // Fallback to legacy parallel arrays
if (legacyRow) { if (legacyRow) {
const blisters = parseBlisters(legacyRow); const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({ return blisters.map((b) =>
usage: b.usage, normalizeIntake(
every: b.every, {
start: b.start, usage: b.usage,
intakeUnit: null, every: b.every,
takenBy: null, // Legacy format has no per-intake takenBy start: b.start,
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false, intakeUnit: null,
})); takenBy: null,
},
medicationIntakeRemindersEnabled ?? false
)
);
} }
return []; return [];
@@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[
/** Calculate daily usage from blisters */ /** Calculate daily usage from blisters */
export function calculateDailyUsage(blisters: Blister[]): number { 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 */ /** Calculate depletion information for a medication */
@@ -370,50 +658,31 @@ export function getTodaysIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) { for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[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 // Determine takenBy for this intake
// If intake has its own takenBy, use it; otherwise null (no specific person) // If intake has its own takenBy, use it; otherwise null (no specific person)
const effectiveTakenBy = intake.takenBy || null; const effectiveTakenBy = intake.takenBy || null;
// Find all occurrences that fall within today forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
let currentTime = startTime; const intakeDate = new Date(occurrenceMs);
result.push({
// If start is in the past, calculate the first occurrence on or after todayStart medName,
if (currentTime < todayStart.getTime()) { medicationId,
const elapsed = todayStart.getTime() - startTime; blisterIndex: blisterIdx,
const intervals = Math.floor(elapsed / intervalMs); usage: intake.usage,
currentTime = startTime + intervals * intervalMs; intakeTime: intakeDate,
} intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
// Collect all intakes for today minute: "2-digit",
while (currentTime <= todayEnd.getTime()) { timeZone: timezone,
if (currentTime >= todayStart.getTime()) { }),
const intakeDate = new Date(currentTime); takenBy: effectiveTakenBy,
result.push({ pillWeightMg,
medName, doseUnit,
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;
}
} }
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++) { for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[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 // Determine takenBy for this intake
const effectiveTakenBy = intake.takenBy || null; const effectiveTakenBy = intake.takenBy || null;
// Find the next scheduled intake time (could be today or in the future) const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
let nextTime = startTime; if (nextTime === null) continue;
// 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;
}
}
// Calculate when we should notify for this intake // Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000; const notifyTime = nextTime - minutesBefore * 60 * 1000;
+17 -7
View File
@@ -114,8 +114,10 @@ test.describe("Share Schedule", () => {
const personSelect = modal.locator("select").first(); const personSelect = modal.locator("select").first();
await expect(personSelect).toBeVisible(); await expect(personSelect).toBeVisible();
// Should contain Alice and Bob options // Should contain Alice and Bob options.
await expect(personSelect.locator("option")).toHaveCount(2); // 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 // Close
await page.locator("button.modal-close").click(); await page.locator("button.modal-close").click();
@@ -187,7 +189,7 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 }); await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
// The page should show Alice's medication name // 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 { try {
await expect(content).toBeVisible({ timeout: 10000 }); await expect(content).toBeVisible({ timeout: 10000 });
} catch { } catch {
@@ -236,12 +238,16 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 }); await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try { 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 { } catch {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 }); 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 // Visit Bob's share — should show Bob's med
@@ -251,12 +257,16 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 }); await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try { 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 { } catch {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 }); 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,
});
} }
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.20.1", "version": "1.20.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.20.1", "version": "1.20.2",
"dependencies": { "dependencies": {
"i18next": "^25.8.14", "i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
+10 -25
View File
@@ -20,11 +20,14 @@ import {
getMedDisplayName, getMedDisplayName,
getMedTotal, getMedTotal,
getPackageSize, getPackageSize,
type IntakeUnit,
isAmountBasedPackageType, isAmountBasedPackageType,
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
} from "../types"; } from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils"; 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 { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock"; import { splitCurrentBlisterStock } from "../utils/stock";
@@ -254,32 +257,16 @@ export function MedDetailModal({
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage; const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1)); const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle)); 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 (isLiquidContainerPackageType(selectedMed.packageType)) {
if (intakeUnit === "tsp") { return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
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")}`;
} }
if (isTubePackageType(selectedMed.packageType)) { if (isTubePackageType(selectedMed.packageType)) {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`; return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
} }
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`; return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
}; };
const scheduleIntakes = const scheduleIntakes = getMedicationIntakes(selectedMed);
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 hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true); const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => { const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull); let normalizedFull = Math.max(0, nextFull);
@@ -969,7 +956,7 @@ export function MedDetailModal({
</div> </div>
{/* Intake Schedule Section */} {/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && ( {scheduleIntakes.length > 0 && (
<div className="med-detail-section"> <div className="med-detail-section">
<h3> <h3>
{t("modal.intakeSchedule")}{" "} {t("modal.intakeSchedule")}{" "}
@@ -985,7 +972,7 @@ export function MedDetailModal({
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0); const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount; const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled === true; 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 ( return (
<div key={intakeKey} className="med-schedule-row blister-row-simple"> <div key={intakeKey} className="med-schedule-row blister-row-simple">
@@ -993,9 +980,7 @@ export function MedDetailModal({
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)} {getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`} {showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span> </span>
<span className="med-schedule-freq"> <span className="med-schedule-freq">{getIntakeFrequencyText(intake, t)}</span>
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
</span>
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>} {hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
<span className="med-schedule-time"> <span className="med-schedule-time">
{t("modal.at")}{" "} {t("modal.at")}{" "}
@@ -1166,7 +1151,7 @@ export function MedDetailModal({
<FilePenLine size={18} aria-hidden="true" /> <FilePenLine size={18} aria-hidden="true" />
</button> </button>
)} )}
{selectedMed.blisters.length > 0 && ( {scheduleIntakes.length > 0 && (
<button <button
className="secondary icon-only tooltip-trigger" className="secondary icon-only tooltip-trigger"
onClick={() => generateICS(selectedMed)} onClick={() => generateICS(selectedMed)}
+85 -11
View File
@@ -19,6 +19,13 @@ import {
PACKAGE_PROFILES, PACKAGE_PROFILES,
} from "../types"; } from "../types";
import { deriveTotal } from "../utils"; import { deriveTotal } from "../utils";
import {
getIntakeScheduleMode,
getWeekdayLabel,
hasSelectedWeekdays,
toggleWeekdaySelection,
WEEKDAY_CODES,
} from "../utils/intake-schedule";
import { DateInput } from "./DateInput"; import { DateInput } from "./DateInput";
import { FormNumberStepper } from "./FormNumberStepper"; import { FormNumberStepper } from "./FormNumberStepper";
@@ -57,7 +64,7 @@ export interface MobileEditModalProps {
onAddBlister: () => void; onAddBlister: () => void;
onRemoveBlister: (idx: number) => void; onRemoveBlister: (idx: number) => void;
// Intake helpers (new - with per-intake takenBy) // Intake helpers (new - with per-intake takenBy)
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void; onSetIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
onAddIntake: (takenBy?: string) => void; onAddIntake: (takenBy?: string) => void;
onRemoveIntake: (idx: number) => void; onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields // Value change handler for numeric fields
@@ -158,6 +165,24 @@ export function MobileEditModal({
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity"); const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills"); const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total"); const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
const weekdayOptions = useMemo(
() =>
WEEKDAY_CODES.map((day) => ({
value: day,
shortLabel: getWeekdayLabel(day, t, "short"),
longLabel: getWeekdayLabel(day, t, "long"),
})),
[t]
);
const hasWeekdaySelectionError = useCallback(
(intake: (typeof form.intakes)[number]) =>
getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays),
[]
);
const hasWeekdayScheduleError = useMemo(
() => form.intakes.some((intake) => hasWeekdaySelectionError(intake)),
[form.intakes, hasWeekdaySelectionError]
);
// Reset tab when modal opens // Reset tab when modal opens
useEffect(() => { useEffect(() => {
@@ -815,7 +840,9 @@ export function MobileEditModal({
)} )}
</div> </div>
{form.intakes.map((intake, idx) => { {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 ( return (
<div key={intakeKey} className="blister-row"> <div key={intakeKey} className="blister-row">
<label className="compact"> <label className="compact">
@@ -831,15 +858,60 @@ export function MobileEditModal({
/> />
</label> </label>
<label className="compact"> <label className="compact">
<span>{t("form.blisters.everyDays")}</span> <span>{t("form.blisters.scheduleMode")}</span>
<FormNumberStepper <select
value={intake.every} className="select-field"
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)} value={scheduleMode}
min={1} onChange={(e) =>
decrementLabel={decrementValueLabel} onSetIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
incrementLabel={incrementValueLabel} }
/> >
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
</select>
</label> </label>
{scheduleMode === "interval" ? (
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
) : (
<label className="compact full-row">
<span>{t("form.blisters.weekdays")}</span>
<div className="badges">
{weekdayOptions.map((weekday) => {
const isSelected = selectedWeekdays.includes(weekday.value);
return (
<button
key={weekday.value}
type="button"
className={isSelected ? "pill clickable" : "pill clickable neutral"}
aria-pressed={isSelected}
title={weekday.longLabel}
onClick={() =>
onSetIntakeValue(
idx,
"weekdays",
toggleWeekdaySelection(selectedWeekdays, weekday.value)
)
}
>
{weekday.shortLabel}
</button>
);
})}
</div>
{!readOnlyMode && hasWeekdaySelectionError(intake) && (
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
)}
</label>
)}
<label className="compact full-row"> <label className="compact full-row">
<span>{t("form.blisters.startDate")}</span> <span>{t("form.blisters.startDate")}</span>
<DateInput <DateInput
@@ -984,7 +1056,9 @@ export function MobileEditModal({
<button <button
type="submit" type="submit"
disabled={saving || (!formChanged && (formSaved || !!editingId))} disabled={saving || (!formChanged && (formSaved || !!editingId))}
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""} className={
hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError ? "has-validation-error" : ""
}
> >
{formSaved && !formChanged ? t("common.saved") : t("common.save")} {formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button> </button>
+30 -44
View File
@@ -10,6 +10,8 @@ import {
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
} from "../types"; } from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { MedicationAvatar } from "./MedicationAvatar"; import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf"; type ReportFormat = "txt" | "md" | "pdf";
@@ -290,20 +292,6 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
type TFn = (key: string, opts?: Record<string, unknown>) => string; type TFn = (key: string, opts?: Record<string, unknown>) => string;
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return "-";
return `${m[3]}.${m[2]}.${m[1]}`;
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return `${fmtDate(iso)}`;
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
}
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" { function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
if (isLiquidContainerPackageType(med.packageType)) return "form.ml"; if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications"; return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
@@ -353,7 +341,7 @@ function generateTextReport(
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`); const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
lines.push(h1(t("report.docTitle"))); lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`); lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push(""); lines.push("");
for (const med of meds) { for (const med of meds) {
@@ -373,8 +361,8 @@ function generateTextReport(
lines.push( lines.push(
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive")) item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
); );
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate))); if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), formatDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt))); if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(med.obsoleteAt)));
lines.push(""); lines.push("");
// Package / Stock // Package / Stock
@@ -391,24 +379,23 @@ function generateTextReport(
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t))); lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`)); lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate))); if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), formatDate(med.expiryDate)));
if (med.notes) lines.push(item(t("report.docNotes"), med.notes)); if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
lines.push(""); lines.push("");
// Intake Schedule // Intake Schedule
const allIntakes = med.intakes ?? med.blisters; const allIntakes = getMedicationIntakes(med);
const intakes = personFilter const intakes = personFilter
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string)) ? allIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allIntakes; : allIntakes;
if (intakes?.length) { if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule"))); lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) { for (const intake of intakes) {
let entry = getUsageText(med, intake.usage, t); let entry = getUsageText(med, intake.usage, t);
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`; entry += ` ${getIntakeFrequencyText(intake, t)}`;
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`; entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy) if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`; if (intake.intakeRemindersEnabled) entry += ` 🔔`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`); lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
} }
lines.push(""); lines.push("");
@@ -420,7 +407,7 @@ function generateTextReport(
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0))); lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0))); lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
if (med.prescriptionExpiryDate) if (med.prescriptionExpiryDate)
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate))); lines.push(item(t("report.docPrescriptionExpiry"), formatDate(med.prescriptionExpiryDate)));
lines.push(""); lines.push("");
} }
@@ -434,8 +421,8 @@ function generateTextReport(
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken))); lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
} }
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed))); if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt))); if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt))); if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else { } else {
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`); lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
} }
@@ -445,7 +432,7 @@ function generateTextReport(
if (data.refills.length > 0) { if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory"))); lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) { for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`; let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`; if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`); lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
} }
@@ -528,7 +515,7 @@ function buildPrintHtml(
for (const med of meds) { for (const med of meds) {
const data = reportData[med.id]; const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters; const intakes = getMedicationIntakes(med);
const displayName = getMedDisplayName(med); const displayName = getMedDisplayName(med);
const title = med.isObsolete const title = med.isObsolete
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>` ? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
@@ -560,11 +547,11 @@ function buildPrintHtml(
); );
if (med.medicationStartDate) if (med.medicationStartDate)
generalRows.push( generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>` `<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${formatDate(med.medicationStartDate)}</td></tr>`
); );
if (med.isObsolete && med.obsoleteAt) if (med.isObsolete && med.obsoleteAt)
generalRows.push( generalRows.push(
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>` `<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${formatDate(med.obsoleteAt)}</td></tr>`
); );
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`; const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
@@ -591,7 +578,7 @@ function buildPrintHtml(
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
if (med.expiryDate) if (med.expiryDate)
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${formatDate(med.expiryDate)}</td></tr>`;
if (med.notes) if (med.notes)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`; s += `</tbody></table>`;
@@ -599,18 +586,17 @@ function buildPrintHtml(
// Intake Schedule // Intake Schedule
const allPrintIntakes = intakes; const allPrintIntakes = intakes;
const filteredPrintIntakes = personFilter const filteredPrintIntakes = personFilter
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string)) ? allPrintIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allPrintIntakes; : allPrintIntakes;
if (filteredPrintIntakes?.length) { if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`; s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`; s += `<ul>`;
for (const intake of filteredPrintIntakes) { for (const intake of filteredPrintIntakes) {
let entry = escHtml(getUsageText(med, intake.usage, t)); let entry = escHtml(getUsageText(med, intake.usage, t));
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`; entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`; entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy) if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`; if (intake.intakeRemindersEnabled) entry += ` 🔔`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`; s += `<li>${entry}</li>`;
} }
s += `</ul>`; s += `</ul>`;
@@ -623,7 +609,7 @@ function buildPrintHtml(
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
if (med.prescriptionExpiryDate) if (med.prescriptionExpiryDate)
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${formatDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `</tbody></table>`; s += `</tbody></table>`;
} }
@@ -639,9 +625,9 @@ function buildPrintHtml(
if (data.dosesDismissed > 0) if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.firstDoseAt) if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt) if (data.lastDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${formatDate(data.lastDoseAt)}</td></tr>`;
s += `</tbody></table>`; s += `</tbody></table>`;
} else { } else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`; s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
@@ -652,7 +638,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`; s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`; s += `<ul>`;
for (const r of data.refills) { for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`; let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`; if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`; s += `<li>${entry}</li>`;
} }
@@ -708,7 +694,7 @@ function buildPrintHtml(
<body> <body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div> <div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1> <h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p> <p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
${sections.join("\n")} ${sections.join("\n")}
</body> </body>
</html>`; </html>`;
+24 -62
View File
@@ -13,11 +13,14 @@ import {
allowsPillFormSelection, allowsPillFormSelection,
getMedDisplayName, getMedDisplayName,
getMedTotal, getMedTotal,
type IntakeUnit,
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
type StockThresholds, type StockThresholds,
} from "../types"; } from "../types";
import { getSystemLocale } from "../utils/formatters"; import { getSystemLocale } from "../utils/formatters";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule"; import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage"; import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar"; import { MedicationAvatar } from "./MedicationAvatar";
@@ -40,16 +43,10 @@ export function SharedSchedule() {
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) => const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
isLiquidContainerPackageType(med?.packageType); isLiquidContainerPackageType(med?.packageType);
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 convertUsageForStock = ( const convertUsageForStock = (
usage: number, usage: number,
med: SharedScheduleData["medications"][number] | undefined, med: SharedScheduleData["medications"][number] | undefined,
unit: "ml" | "tsp" | "tbsp" | null | undefined unit: IntakeUnit | null | undefined
): number => { ): number => {
if (isTubePackageType(med?.packageType)) return 0; if (isTubePackageType(med?.packageType)) return 0;
if (!isLiquidContainerMed(med)) return usage; if (!isLiquidContainerMed(med)) return usage;
@@ -61,13 +58,7 @@ export function SharedSchedule() {
return String(rounded); return String(rounded);
}; };
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => { const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): 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 normalizedUsage = Number(usage); const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) { if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`; return `0 ${t("form.packageAmountUnitMl")}`;
@@ -78,13 +69,13 @@ export function SharedSchedule() {
} }
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit); const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`; return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
}; };
const formatDoseUsageLabel = ( const formatDoseUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined, med: SharedScheduleData["medications"][number] | undefined,
usage: number, usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null intakeUnit?: IntakeUnit | null
) => { ) => {
if (isLiquidContainerMed(med)) { if (isLiquidContainerMed(med)) {
return formatLiquidUsageLabel(usage, intakeUnit); return formatLiquidUsageLabel(usage, intakeUnit);
@@ -95,7 +86,7 @@ export function SharedSchedule() {
const formatTotalUsageLabel = ( const formatTotalUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined, med: SharedScheduleData["medications"][number] | undefined,
total: number, total: number,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }> doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => { ) => {
if (isLiquidContainerMed(med)) { if (isLiquidContainerMed(med)) {
if (doses && doses.length > 0) { if (doses && doses.length > 0) {
@@ -418,7 +409,7 @@ export function SharedSchedule() {
when: number; when: number;
medName: string; medName: string;
usage: number; usage: number;
intakeUnit?: "ml" | "tsp" | "tbsp" | null; intakeUnit?: IntakeUnit | null;
timeStr: string; timeStr: string;
isPast: boolean; isPast: boolean;
takenBy: string | null; // Per-intake takenBy (single person or null) takenBy: string | null; // Per-intake takenBy (single person or null)
@@ -426,15 +417,7 @@ export function SharedSchedule() {
}[] = []; }[] = [];
for (const med of data.medications) { for (const med of data.medications) {
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy) const intakes = getMedicationIntakes(med);
const intakes =
med.intakes ||
med.blisters.map((b) => ({
...b,
intakeUnit: null,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}));
intakes.forEach((intake, intakeIdx) => { intakes.forEach((intake, intakeIdx) => {
// Filter: for person-specific shares, include matching intakes plus shared-for-everyone intakes. // Filter: for person-specific shares, include matching intakes plus shared-for-everyone intakes.
@@ -443,9 +426,7 @@ export function SharedSchedule() {
const startDate = parseLocalDateTime(intake.start); const startDate = parseLocalDateTime(intake.start);
if (Number.isNaN(startDate.getTime())) return; if (Number.isNaN(startDate.getTime())) return;
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms) iterateIntakeOccurrences(intake, startDate, end, (d) => {
// This ensures identical timestamps even across DST changes
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) {
const t = d.getTime(); const t = d.getTime();
const isPast = d < todayStart; const isPast = d < todayStart;
// Use date-only timestamp for stable ID (immune to time changes) // Use date-only timestamp for stable ID (immune to time changes)
@@ -470,7 +451,7 @@ export function SharedSchedule() {
month: "short", month: "short",
}), }),
}); });
} });
}); });
} }
@@ -544,20 +525,12 @@ export function SharedSchedule() {
// Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels // Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
const coverageByMed = useMemo(() => { const coverageByMed = useMemo(() => {
if (!data) return {}; if (!data) return {};
const MS_PER_DAY = 86_400_000;
const now = Date.now(); const now = Date.now();
const calcMode = data.stockCalculationMode ?? "automatic"; const calcMode = data.stockCalculationMode ?? "automatic";
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {}; const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
for (const med of data.medications) { for (const med of data.medications) {
const intakes = const intakes = getMedicationIntakes(med);
med.intakes ||
med.blisters.map((b) => ({
...b,
intakeUnit: null,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}));
// Count unique people from all intakes (for per-intake takenBy) // Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>(); const uniquePeople = new Set<string>();
@@ -571,7 +544,7 @@ export function SharedSchedule() {
let dailyRate = 0; let dailyRate = 0;
intakes.forEach((intake) => { intakes.forEach((intake) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml"); const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0; const baseRate = usageForStock * getIntakeDailyRate(intake);
if (intake?.takenBy) { if (intake?.takenBy) {
dailyRate += baseRate; // Per-intake takenBy: 1 person dailyRate += baseRate; // Per-intake takenBy: 1 person
} else { } else {
@@ -586,18 +559,8 @@ export function SharedSchedule() {
// Time-based: every scheduled dose counts as consumed once its time has passed // Time-based: every scheduled dose counts as consumed once its time has passed
intakes.forEach((intake, blisterIdx) => { intakes.forEach((intake, blisterIdx) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml"); const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const blisterStart = parseLocalDateTime(intake.start).getTime(); const intakeStart = parseLocalDateTime(intake.start);
const period = Math.max(1, intake.every) * MS_PER_DAY; if (Number.isNaN(intakeStart.getTime())) return;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intakePerson = intake?.takenBy; const intakePerson = intake?.takenBy;
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null]; const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
@@ -606,16 +569,15 @@ export function SharedSchedule() {
let timeBasedConsumed = 0; let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0; let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) { iterateIntakeOccurrences(intake, intakeStart, new Date(now), (occurrence) => {
const occurrences = Math.floor((now - effectiveStart) / period) + 1; if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length; timeBasedConsumed += usageForStock * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date( lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(), occurrence.getFullYear(),
lastDoseTime.getMonth(), occurrence.getMonth(),
lastDoseTime.getDate() occurrence.getDate()
).getTime(); ).getTime();
} });
// Early intakes: future doses already marked as taken // Early intakes: future doses already marked as taken
const stockCorrectionDateOnly = const stockCorrectionDateOnly =
@@ -727,7 +689,7 @@ export function SharedSchedule() {
const renderDoseUsage = ( const renderDoseUsage = (
med: SharedScheduleData["medications"][number] | undefined, med: SharedScheduleData["medications"][number] | undefined,
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null } dose: { usage: number; intakeUnit?: IntakeUnit | null }
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit); ) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed) // Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
+10 -24
View File
@@ -5,11 +5,13 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components"; import { MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks/useEscapeKey"; import { useEscapeKey } from "../hooks/useEscapeKey";
import type { Coverage, Medication, StockThresholds } from "../types"; import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types"; import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles"; import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
import { formatNumber } from "../utils"; import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters"; import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule"; import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps { export interface UserFilterModalProps {
@@ -40,19 +42,9 @@ export function UserFilterModal({
); );
}; };
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => { const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): 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 formatIntakeUsageLabel = (
med: Medication,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
): string => {
if (isLiquidMedication(med)) { if (isLiquidMedication(med)) {
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`; return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
} }
if (isTubePackageType(med.packageType)) { if (isTubePackageType(med.packageType)) {
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`; return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
@@ -111,14 +103,9 @@ export function UserFilterModal({
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med); const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
// Get intakes relevant to this person // Get intakes relevant to this person
const personIntakes = ( const personIntakes = getMedicationIntakes(med).filter(
med.intakes || (intake) => intake.takenBy === null || intake.takenBy === selectedUser
med.blisters.map((b) => ({ );
...b,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}))
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
return ( return (
<div <div
@@ -146,7 +133,7 @@ export function UserFilterModal({
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`; const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}`;
const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined; const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined;
return ( return (
<span key={intakeKey} className="user-med-intake-item"> <span key={intakeKey} className="user-med-intake-item">
@@ -154,8 +141,7 @@ export function UserFilterModal({
{allowsPillFormSelection(med.packageType) && {allowsPillFormSelection(med.packageType) &&
med.pillWeightMg != null && med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "} ` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "} {getIntakeFrequencyText(intake, t)} {t("modal.at")} {timeStr}
{t("modal.at")} {timeStr}
</span> </span>
); );
})} })}
+9 -2
View File
@@ -9,6 +9,7 @@ import {
normalizePackageType, normalizePackageType,
} from "../types"; } from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters"; import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
export const defaultBlister = (): FormBlister => { export const defaultBlister = (): FormBlister => {
const now = new Date(); const now = new Date();
@@ -30,6 +31,8 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
every: "1", every: "1",
startDate: toDateValue(now), startDate: toDateValue(now),
startTime: toTimeValue(now), startTime: toTimeValue(now),
scheduleMode: "interval",
weekdays: [],
intakeUnit: "ml", intakeUnit: "ml",
takenBy, // Per-intake user assignment (empty string = null/everyone) takenBy, // Per-intake user assignment (empty string = null/everyone)
intakeRemindersEnabled: false, intakeRemindersEnabled: false,
@@ -93,7 +96,7 @@ export interface UseMedicationFormReturn {
addBlister: () => void; addBlister: () => void;
removeBlister: (idx: number) => void; removeBlister: (idx: number) => void;
// Intake management with per-intake takenBy // Intake management with per-intake takenBy
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void; setIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
addIntake: (takenBy?: string) => void; addIntake: (takenBy?: string) => void;
removeIntake: (idx: number) => void; removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void; startEdit: (med: Medication, openEditModal: () => void) => void;
@@ -189,7 +192,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
}, []); }, []);
// Intake management with per-intake takenBy // Intake management with per-intake takenBy
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => { const setIntakeValue = useCallback(<K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => {
setForm((prev) => { setForm((prev) => {
const next = [...prev.intakes]; const next = [...prev.intakes];
next[idx] = { ...next[idx], [field]: value }; next[idx] = { ...next[idx], [field]: value };
@@ -219,6 +222,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
every: String(i.every), every: String(i.every),
startDate: toDateValue(i.start), startDate: toDateValue(i.start),
startTime: toTimeValue(i.start), startTime: toTimeValue(i.start),
scheduleMode: (i.scheduleMode === "weekdays" ? "weekdays" : "interval") as FormIntake["scheduleMode"],
weekdays: normalizeWeekdays(i.weekdays),
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"], intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
takenBy: i.takenBy ?? "", // Convert null to empty string for form takenBy: i.takenBy ?? "", // Convert null to empty string for form
intakeRemindersEnabled: i.intakeRemindersEnabled, intakeRemindersEnabled: i.intakeRemindersEnabled,
@@ -228,6 +233,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
every: String(s.every), every: String(s.every),
startDate: toDateValue(s.start), startDate: toDateValue(s.start),
startTime: toTimeValue(s.start), startTime: toTimeValue(s.start),
scheduleMode: "interval" as const,
weekdays: [],
intakeUnit: "ml" as const, intakeUnit: "ml" as const,
takenBy: "", // Legacy blisters have no per-intake takenBy takenBy: "", // Legacy blisters have no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
+23
View File
@@ -257,8 +257,31 @@
"applications": "Anwendungen", "applications": "Anwendungen",
"applications_one": "Anwendung", "applications_one": "Anwendung",
"applications_other": "Anwendungen", "applications_other": "Anwendungen",
"scheduleMode": "Planmodus",
"scheduleModeInterval": "Alle X Tage",
"scheduleModeWeekdays": "Bestimmte Wochentage",
"everyDays": "Alle (Tage)", "everyDays": "Alle (Tage)",
"every": "alle", "every": "alle",
"weekdays": "Wochentage",
"weekdaysRequired": "Waehle mindestens einen Wochentag aus",
"weekdaysShort": {
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So"
},
"weekdaysLong": {
"mon": "Montag",
"tue": "Dienstag",
"wed": "Mittwoch",
"thu": "Donnerstag",
"fri": "Freitag",
"sat": "Samstag",
"sun": "Sonntag"
},
"from": "ab", "from": "ab",
"startDate": "Datum", "startDate": "Datum",
"startTime": "Uhrzeit", "startTime": "Uhrzeit",
+23
View File
@@ -257,8 +257,31 @@
"applications": "applications", "applications": "applications",
"applications_one": "application", "applications_one": "application",
"applications_other": "applications", "applications_other": "applications",
"scheduleMode": "Schedule mode",
"scheduleModeInterval": "Every X days",
"scheduleModeWeekdays": "Specific weekdays",
"everyDays": "Every (days)", "everyDays": "Every (days)",
"every": "every", "every": "every",
"weekdays": "Weekdays",
"weekdaysRequired": "Select at least one weekday",
"weekdaysShort": {
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
},
"weekdaysLong": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
},
"from": "from", "from": "from",
"startDate": "Date", "startDate": "Date",
"startTime": "Time", "startTime": "Time",
+12 -63
View File
@@ -9,12 +9,15 @@ import {
allowsPillFormSelection, allowsPillFormSelection,
type Coverage, type Coverage,
getMedDisplayName, getMedDisplayName,
type IntakeUnit,
isAmountBasedPackageType, isAmountBasedPackageType,
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
} from "../types"; } from "../types";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters"; import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule"; import { getIntakeDailyRate, getMedicationIntakes } from "../utils/intake-schedule";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
import { buildClearMissedPayload, expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
import { import {
formatFullBlisters, formatFullBlisters,
formatOpenBlisterAndLoose, formatOpenBlisterAndLoose,
@@ -141,41 +144,8 @@ export function DashboardPage() {
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length; const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
const getClearMissedPayload = () => {
const medicationIds = new Set<number>();
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 clearMissedDoses = async (missedCount: number) => {
const payload = getClearMissedPayload(); const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
if (payload.medicationIds.length === 0 || !payload.until) { if (payload.medicationIds.length === 0 || !payload.until) {
setShowClearMissedConfirm(false); setShowClearMissedConfirm(false);
return; return;
@@ -245,19 +215,7 @@ export function DashboardPage() {
return t("table.pillsCount", { count: Math.round(medsLeft) }); return t("table.pillsCount", { count: Math.round(medsLeft) });
}; };
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => { const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
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 normalizedUsage = Number(usage); const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) { if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`; return `0 ${t("form.packageAmountUnitMl")}`;
@@ -268,13 +226,13 @@ export function DashboardPage() {
} }
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit); 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 = ( const formatDoseUsageLabel = (
med: (typeof meds)[number] | undefined, med: (typeof meds)[number] | undefined,
usage: number, usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null intakeUnit?: IntakeUnit | null
) => { ) => {
if (isLiquidContainerPackageType(med?.packageType)) { if (isLiquidContainerPackageType(med?.packageType)) {
return formatLiquidUsageLabel(usage, intakeUnit); return formatLiquidUsageLabel(usage, intakeUnit);
@@ -288,8 +246,8 @@ export function DashboardPage() {
const formatTotalUsageLabel = ( const formatTotalUsageLabel = (
med: (typeof meds)[number] | undefined, med: (typeof meds)[number] | undefined,
total: number, total: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null, intakeUnit?: IntakeUnit | null,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }> doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => { ) => {
if (isLiquidContainerPackageType(med?.packageType)) { if (isLiquidContainerPackageType(med?.packageType)) {
if (doses && doses.length > 0) { if (doses && doses.length > 0) {
@@ -322,27 +280,18 @@ export function DashboardPage() {
const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => { const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => {
if (!med) return "-"; if (!med) return "-";
const intakes = const intakes = getMedicationIntakes(med);
med.intakes && med.intakes.length > 0
? med.intakes
: med.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
intakeUnit: null as "ml" | "tsp" | "tbsp" | null,
takenBy: null as string | null,
}));
if (intakes.length === 0) return "-"; if (intakes.length === 0) return "-";
let dailyTotal = 0; let dailyTotal = 0;
for (const intake of intakes) { for (const intake of intakes) {
const usage = Number(intake.usage); const usage = Number(intake.usage);
const every = Math.max(1, Number(intake.every) || 1);
if (!Number.isFinite(usage) || usage <= 0) continue; if (!Number.isFinite(usage) || usage <= 0) continue;
const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().length > 0; const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().length > 0;
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0); const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
const normalizedUsage = (usage * personMultiplier) / every; const normalizedUsage = usage * personMultiplier * getIntakeDailyRate(intake);
if (isLiquidContainerPackageType(med.packageType)) { if (isLiquidContainerPackageType(med.packageType)) {
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml"); dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
+174 -97
View File
@@ -33,6 +33,15 @@ import {
} from "../types"; } from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters"; import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload"; import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
import {
getIntakeFrequencyText,
getIntakeScheduleMode,
getMedicationIntakes,
getWeekdayLabel,
hasSelectedWeekdays,
toggleWeekdaySelection,
WEEKDAY_CODES,
} from "../utils/intake-schedule";
import { log } from "../utils/logger"; import { log } from "../utils/logger";
function userStorageKey(userId: number | undefined, key: string): string { function userStorageKey(userId: number | undefined, key: string): string {
@@ -311,6 +320,24 @@ export function MedicationsPage() {
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity"); const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills"); const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total"); const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
const weekdayOptions = useMemo(
() =>
WEEKDAY_CODES.map((day) => ({
value: day,
shortLabel: getWeekdayLabel(day, t, "short"),
longLabel: getWeekdayLabel(day, t, "long"),
})),
[t]
);
const hasWeekdaySelectionError = useCallback(
(intake: (typeof form.intakes)[number]) =>
getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays),
[]
);
const hasWeekdayScheduleError = useMemo(
() => form.intakes.some((intake) => hasWeekdaySelectionError(intake)),
[form.intakes, hasWeekdaySelectionError]
);
const getMedicationPackageTypeLabel = useCallback( const getMedicationPackageTypeLabel = useCallback(
(med: Medication) => { (med: Medication) => {
@@ -512,7 +539,7 @@ export function MedicationsPage() {
async function saveMedication(e: React.FormEvent) { async function saveMedication(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (readOnlyView) return; if (readOnlyView) return;
if (hasValidationErrors || dateConsistencyError) { if (hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError) {
setShowNameValidation(true); setShowNameValidation(true);
// Scroll to first visible error so the user sees what's wrong // Scroll to first visible error so the user sees what's wrong
const firstError = document.querySelector(".field-error"); const firstError = document.querySelector(".field-error");
@@ -534,8 +561,10 @@ export function MedicationsPage() {
// Prepare intakes data with per-intake takenBy // Prepare intakes data with per-intake takenBy
const intakes = form.intakes.map((intake) => ({ const intakes = form.intakes.map((intake) => ({
usage: Number(intake.usage) || 1, usage: Number(intake.usage) || 1,
every: Number(intake.every) || 1, every: getIntakeScheduleMode(intake) === "weekdays" ? 1 : Number(intake.every) || 1,
start: combineDateAndTime(intake.startDate, intake.startTime), start: combineDateAndTime(intake.startDate, intake.startTime),
scheduleMode: getIntakeScheduleMode(intake),
weekdays: getIntakeScheduleMode(intake) === "weekdays" ? [...(intake.weekdays ?? [])] : [],
intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null, intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null,
takenBy: intake.takenBy.trim() || null, // Empty string becomes null takenBy: intake.takenBy.trim() || null, // Empty string becomes null
intakeRemindersEnabled: intake.intakeRemindersEnabled, intakeRemindersEnabled: intake.intakeRemindersEnabled,
@@ -1050,15 +1079,12 @@ export function MedicationsPage() {
</div> </div>
</div> </div>
<div className="blister-list"> <div className="blister-list">
{(med.intakes ?? med.blisters).map((s, idx) => ( {getMedicationIntakes(med).map((s, idx) => (
<div key={`${med.id}-${idx}`} className="blister-row-simple"> <div key={`${med.id}-${idx}`} className="blister-row-simple">
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "} {s.usage} {getMedicationUsageUnitLabel(med, s.usage)} · {getIntakeFrequencyText(s, t)} ·{" "}
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
{t("form.blisters.from")} {formatDateTime(s.start)} {t("form.blisters.from")} {formatDateTime(s.start)}
{"takenBy" in s && (s as import("../types").Intake).takenBy && ( {s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span> {s.intakeRemindersEnabled && (
)}
{"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && (
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}> <span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
{" "} {" "}
<Bell size={12} aria-hidden="true" /> <Bell size={12} aria-hidden="true" />
@@ -1734,105 +1760,154 @@ export function MedicationsPage() {
</button> </button>
)} )}
</div> </div>
{form.intakes.map((intake, idx) => ( {form.intakes.map((intake, idx) => {
<div key={idx} className="blister-row"> const scheduleMode = getIntakeScheduleMode(intake);
<div className="blister-inputs"> const selectedWeekdays = intake.weekdays ?? [];
<label> return (
{getUsageLabel(intake.intakeUnit ?? "ml")} <div key={idx} className="blister-row">
<FormNumberStepper <div className="blister-inputs">
value={intake.usage}
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.blisters.everyDays")}
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.blisters.startDate")}
<DateInput
value={intake.startDate}
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label>
{t("form.blisters.startTime")}
<input
type="time"
value={intake.startTime}
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{isLiquidContainerPackageType(form.packageType) && (
<label> <label>
{t("form.blisters.intakeUnit")} {getUsageLabel(intake.intakeUnit ?? "ml")}
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.blisters.scheduleMode")}
<select <select
className="select-field" className="select-field"
value={intake.intakeUnit} value={scheduleMode}
onChange={(e) => onChange={(e) =>
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp") setIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
} }
> >
<option value="ml">{t("form.blisters.intakeUnitMl")}</option> <option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option> <option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
</select> </select>
</label> </label>
)} {scheduleMode === "interval" ? (
{form.takenBy.length === 0 ? null : ( <label>
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}> {t("form.blisters.everyDays")}
{t("form.blisters.takenByIntake")} <FormNumberStepper
<select value={intake.every}
className="select-field" onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
value={intake.takenBy} min={1}
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)} decrementLabel={decrementValueLabel}
> incrementLabel={incrementValueLabel}
{form.takenBy.map((person) => ( />
<option key={person} value={person}> </label>
{person} ) : (
</option> <label className="taken-by-field">
))} {t("form.blisters.weekdays")}
</select> <div className="badges">
</label> {weekdayOptions.map((weekday) => {
)} const isSelected = selectedWeekdays.includes(weekday.value);
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}> return (
<span className="blister-reminder-icon"> <button
<Bell size={14} aria-hidden="true" /> key={weekday.value}
</span> type="button"
<label className="toggle-switch small"> className={isSelected ? "pill clickable" : "pill clickable neutral"}
<input aria-pressed={isSelected}
type="checkbox" title={weekday.longLabel}
checked={intake.intakeRemindersEnabled} onClick={() =>
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)} setIntakeValue(
idx,
"weekdays",
toggleWeekdaySelection(selectedWeekdays, weekday.value)
)
}
>
{weekday.shortLabel}
</button>
);
})}
</div>
{!readOnlyView && hasWeekdaySelectionError(intake) && (
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
)}
</label>
)}
<label>
{t("form.blisters.startDate")}
<DateInput
value={intake.startDate}
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
/> />
<span className="toggle-slider"></span>
</label> </label>
<label>
{t("form.blisters.startTime")}
<input
type="time"
value={intake.startTime}
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{isLiquidContainerPackageType(form.packageType) && (
<label>
{t("form.blisters.intakeUnit")}
<select
className="select-field"
value={intake.intakeUnit}
onChange={(e) =>
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
}
>
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
</select>
</label>
)}
{form.takenBy.length === 0 ? null : (
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
{t("form.blisters.takenByIntake")}
<select
className="select-field"
value={intake.takenBy}
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="blister-reminder-icon">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div> </div>
{!readOnlyView && form.intakes.length > 1 && (
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => removeIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div> </div>
{!readOnlyView && form.intakes.length > 1 && ( );
<button })}
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => removeIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
))}
</div> </div>
</div> </div>
{/* end schedule tab */} {/* end schedule tab */}
@@ -1845,7 +1920,9 @@ export function MedicationsPage() {
<button <button
type="submit" type="submit"
disabled={saving || (!formChanged && (formSaved || !!editingId))} disabled={saving || (!formChanged && (formSaved || !!editingId))}
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""} className={
hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError ? "has-validation-error" : ""
}
> >
{formSaved && !formChanged ? t("common.saved") : t("common.save")} {formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button> </button>
+8 -52
View File
@@ -5,10 +5,11 @@ import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components"; import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
import { useAppContext } from "../context"; import { useAppContext } from "../context";
import type { Coverage } from "../types"; import type { Coverage, IntakeUnit } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types"; import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { formatNumber } from "../utils/formatters"; 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 // Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string { function userStorageKey(userId: number | undefined, key: string): string {
@@ -105,41 +106,8 @@ export function SchedulePage() {
status: { className: string; label: string } | null status: { className: string; label: string } | null
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule"; ) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
const getClearMissedPayload = () => {
const medicationIds = new Set<number>();
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 clearMissedDoses = async (missedCount: number) => {
const payload = getClearMissedPayload(); const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
if (payload.medicationIds.length === 0 || !payload.until) { if (payload.medicationIds.length === 0 || !payload.until) {
setShowClearMissedConfirm(false); setShowClearMissedConfirm(false);
return; return;
@@ -197,19 +165,7 @@ export function SchedulePage() {
? t("form.packageAmountUnitMl") ? t("form.packageAmountUnitMl")
: t("form.blisters.applications", { count: Math.abs(value) }); : t("form.blisters.applications", { count: Math.abs(value) });
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => { const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
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 normalizedUsage = Number(usage); const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) { if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`; return `0 ${t("form.packageAmountUnitMl")}`;
@@ -220,13 +176,13 @@ export function SchedulePage() {
} }
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit); 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 = ( const formatDoseUsageLabel = (
med: (typeof meds)[number] | undefined, med: (typeof meds)[number] | undefined,
usage: number, usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null intakeUnit?: IntakeUnit | null
) => { ) => {
if (isLiquidContainerPackageType(med?.packageType)) { if (isLiquidContainerPackageType(med?.packageType)) {
return formatLiquidUsageLabel(usage, intakeUnit); return formatLiquidUsageLabel(usage, intakeUnit);
@@ -240,7 +196,7 @@ export function SchedulePage() {
const formatTotalUsageLabel = ( const formatTotalUsageLabel = (
med: (typeof meds)[number] | undefined, med: (typeof meds)[number] | undefined,
total: number, total: number,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }> doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => { ) => {
if (isLiquidContainerPackageType(med?.packageType)) { if (isLiquidContainerPackageType(med?.packageType)) {
if (doses && doses.length > 0) { if (doses && doses.length > 0) {
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { MobileEditModal } from "../../components/MobileEditModal"; import { MobileEditModal } from "../../components/MobileEditModal";
import type { FormState } from "../../types"; import type { FormState, WeekdayCode } from "../../types";
const defaultForm: FormState = { const defaultForm: FormState = {
name: "", name: "",
@@ -429,6 +429,61 @@ describe("MobileEditModal blister management", () => {
expect(onSetIntakeValue).toHaveBeenCalled(); 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(<MobileEditModal {...defaultProps} form={form} formChanged={true} />);
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(<MobileEditModal {...defaultProps} form={form} onSetIntakeValue={onSetIntakeValue} />);
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", () => { describe("MobileEditModal form submission", () => {
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import ReportModal from "../../components/ReportModal"; import ReportModal from "../../components/ReportModal";
import type { Medication } from "../../types"; import type { Medication } from "../../types";
import { formatDate, formatDateTime } from "../../utils/formatters";
function createMedication(overrides: Partial<Medication> = {}): Medication { function createMedication(overrides: Partial<Medication> = {}): Medication {
return { return {
@@ -65,6 +66,53 @@ describe("ReportModal", () => {
expect(URL.createObjectURL).toHaveBeenCalled(); expect(URL.createObjectURL).toHaveBeenCalled();
}); });
it("renders shared formatter output in exported text reports", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 1,
automaticDosesTaken: 0,
dosesDismissed: 0,
firstDoseAt: "2026-02-03T12:00:00.000Z",
lastDoseAt: null,
refills: [],
},
}),
});
render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({
medicationStartDate: "2026-02-01",
blisters: [{ usage: 1, every: 1, start: "2026-02-02T08:30:00.000Z" }],
}),
]}
/>
);
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<typeof vi.fn>).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 () => { it("generates printable report when PDF format is selected", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const mockWrite = vi.fn(); const mockWrite = vi.fn();
@@ -83,16 +131,35 @@ describe("ReportModal", () => {
ok: true, ok: true,
json: async () => ({ json: async () => ({
1: { 1: {
dosesTaken: 0, dosesTaken: 1,
automaticDosesTaken: 0,
dosesDismissed: 0, dosesDismissed: 0,
firstDoseAt: null, firstDoseAt: "2026-03-03T12:00:00.000Z",
lastDoseAt: null, lastDoseAt: null,
refills: [], refills: [
{
packsAdded: 1,
loosePillsAdded: 0,
usedPrescription: false,
refillDate: "2026-03-04",
},
],
}, },
}), }),
}); });
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />); render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({
medicationStartDate: "2026-03-01",
blisters: [{ usage: 1, every: 1, start: "2026-03-02T08:30:00.000Z" }],
}),
]}
/>
);
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i })); fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => { await waitFor(() => {
@@ -101,6 +168,11 @@ describe("ReportModal", () => {
expect(mockClose).toHaveBeenCalled(); 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); expect(onClose).toHaveBeenCalledTimes(1);
}); });
@@ -253,6 +253,67 @@ describe("MedicationsPage", () => {
expect(scheduleTab).toHaveAttribute("aria-selected", "true"); 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", () => { it("opens report modal from list actions", () => {
renderPage(); renderPage();
fireEvent.click(screen.getByText("report.button")); fireEvent.click(screen.getByText("report.button"));
+50
View File
@@ -151,4 +151,54 @@ describe("generateICS", () => {
expect(() => generateICS(dailyMed)).not.toThrow(); expect(() => generateICS(dailyMed)).not.toThrow();
expect(() => generateICS(weeklyMed)).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=");
});
}); });
@@ -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 });
});
});
+223
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Coverage, Medication, StockThresholds } from "../../types"; import type { Coverage, Medication, StockThresholds } from "../../types";
import { import {
buildClearMissedPayload,
buildSchedulePreview, buildSchedulePreview,
calculateCoverage, calculateCoverage,
computeMissedPastDoseIds, 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.id)).toEqual(localResult.events.map((event) => event.id));
expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when)); 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", () => { describe("calculateCoverage", () => {
@@ -376,6 +404,41 @@ describe("calculateCoverage", () => {
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days 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", () => { it("per-intake takenBy counts person correctly in automatic mode", () => {
// When intakes have per-intake takenBy, each person-intake pair is counted // When intakes have per-intake takenBy, each person-intake pair is counted
const meds: Medication[] = [ 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<string>(),
new Set<string>([`${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<string>([`${aspirinDoseMarch10}-Alice`]),
new Set<string>()
);
expect(payload).toEqual({
medicationIds: [],
until: null,
});
});
});
// ============================================================================= // =============================================================================
// Test Helpers // Test Helpers
// ============================================================================= // =============================================================================
@@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => {
expect(past180.length).toBeGreaterThan(past90.length); 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,
});
});
});
+6
View File
@@ -19,6 +19,8 @@ import { isAmountBasedPackageType } from "./package-profiles";
// Common medication dose units // Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "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 MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
export type PillForm = "tablet" | "capsule"; export type PillForm = "tablet" | "capsule";
@@ -49,6 +51,8 @@ export type Intake = {
usage: number; usage: number;
every: number; every: number;
start: string; start: string;
scheduleMode?: ScheduleMode | null;
weekdays?: WeekdayCode[] | null;
intakeUnit?: IntakeUnit | null; intakeUnit?: IntakeUnit | null;
takenBy: string | null; // Per-intake user assignment (single person or null) takenBy: string | null; // Per-intake user assignment (single person or null)
intakeRemindersEnabled: boolean; intakeRemindersEnabled: boolean;
@@ -131,6 +135,8 @@ export type FormIntake = {
every: string; every: string;
startDate: string; startDate: string;
startTime: string; startTime: string;
scheduleMode?: ScheduleMode;
weekdays?: WeekdayCode[];
intakeUnit?: IntakeUnit; intakeUnit?: IntakeUnit;
takenBy: string; // Single person or empty string (empty = null for everyone) takenBy: string; // Single person or empty string (empty = null for everyone)
intakeRemindersEnabled: boolean; intakeRemindersEnabled: boolean;
+27 -7
View File
@@ -4,6 +4,13 @@
import type { Medication } from "../types"; import type { Medication } from "../types";
import { getMedDisplayName } from "../types"; import { getMedDisplayName } from "../types";
import {
getIntakeFrequencyText,
getIntakeScheduleMode,
getMedicationIntakes,
getWeekdayIcsCode,
normalizeWeekdays,
} from "./intake-schedule";
/** /**
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ) * Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
@@ -20,20 +27,33 @@ function formatICSDate(date: Date): string {
*/ */
export function generateICS(med: Medication): void { export function generateICS(med: Medication): void {
const displayName = getMedDisplayName(med); const displayName = getMedDisplayName(med);
const events = med.blisters const events = getMedicationIntakes(med)
.map((blister, idx) => { .map((intake, idx) => {
const start = new Date(blister.start); const start = new Date(intake.start);
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration 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 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 = [ const description = [
`Medication: ${displayName}`, `Medication: ${displayName}`,
med.genericName ? `Generic: ${med.genericName}` : "", med.genericName ? `Generic: ${med.genericName}` : "",
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "", med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
`Dosage: ${pillInfo}`, `Dosage: ${pillInfo}`,
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`, `Frequency: ${frequencyText}`,
med.notes ? `Notes: ${med.notes}` : "", med.notes ? `Notes: ${med.notes}` : "",
] ]
.filter(Boolean) .filter(Boolean)
@@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng
DTSTAMP:${formatICSDate(new Date())} DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(start)} DTSTART:${formatICSDate(start)}
DTEND:${formatICSDate(end)} DTEND:${formatICSDate(end)}
RRULE:FREQ=DAILY;INTERVAL=${interval} ${rrule}
SUMMARY:${summary} SUMMARY:${summary}
DESCRIPTION:${description} DESCRIPTION:${description}
BEGIN:VALARM BEGIN:VALARM
+139
View File
@@ -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<WeekdayCode> | null;
};
type Translate = (key: string, options?: Record<string, unknown>) => string;
export const WEEKDAY_CODES: WeekdayCode[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const WEEKDAY_LABELS: Record<WeekdayCode, { short: string; long: string; ics: string }> = {
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<WeekdayCode> | null): WeekdayCode[] {
if (!Array.isArray(weekdays) || weekdays.length === 0) return [];
const normalizedSet = new Set<WeekdayCode>();
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<WeekdayCode> | 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<WeekdayCode> | 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 });
}
+15
View File
@@ -0,0 +1,15 @@
import type { IntakeUnit } from "../types";
type Translate = (key: string, options?: Record<string, unknown>) => 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");
}
+77 -94
View File
@@ -2,17 +2,10 @@
// Schedule Building and Coverage Calculations // Schedule Building and Coverage Calculations
// ============================================================================= // =============================================================================
import type { import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types";
Blister,
Coverage,
Intake,
Medication,
PackageType,
ScheduleEvent,
StockStatus,
StockThresholds,
} from "../types";
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } 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 { export function parseLocalDateTime(isoString: string): Date {
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/); 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"; const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
if (!isLiquidStock) return usage; if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5; return convertLiquidUsageToMl(usage, intake.intakeUnit);
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;
} }
/** /**
@@ -90,13 +52,13 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => { meds.forEach((med) => {
const intakes = getIntakesForMed(med); const intakes = getMedicationIntakes(med);
intakes.forEach((intake, idx) => { intakes.forEach((intake, idx) => {
const start = parseLocalDateTime(intake.start); const start = parseLocalDateTime(intake.start);
if (Number.isNaN(start.getTime())) return; 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; const isPast = d < todayStart;
if (isPast && !includePast) continue; if (isPast && !includePast) return;
const whenMs = d.getTime(); const whenMs = d.getTime();
// Use date-only timestamp for stable ID (immune to time changes) // Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking // 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" }), dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
intakeRemindersEnabled: intake.intakeRemindersEnabled, intakeRemindersEnabled: intake.intakeRemindersEnabled,
}); });
} });
}); });
}); });
@@ -129,7 +91,7 @@ export function buildSchedulePreview(
events, events,
today: todayCount, today: todayCount,
nextThree: events.length, 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[] } { ): { low: Coverage[]; all: Coverage[] } {
const MS_PER_DAY = 86_400_000; const MS_PER_DAY = 86_400_000;
const now = Date.now(); const now = Date.now();
const nowDate = new Date(now);
const coverage: Coverage[] = meds.map((m) => { const coverage: Coverage[] = meds.map((m) => {
const intakes = getIntakesForMed(m); const intakes = getMedicationIntakes(m);
const blisters = getBlistersForMed(m);
// Count unique people from all intakes (for per-intake takenBy) // Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>(); const uniquePeople = new Set<string>();
intakes.forEach((intake) => { intakes.forEach((intake) => {
@@ -165,11 +127,9 @@ export function calculateCoverage(
// one person's dose — do NOT multiply by personCount again. // one person's dose — do NOT multiply by personCount again.
// For legacy intakes (no takenBy), the intake applies to ALL people. // For legacy intakes (no takenBy), the intake applies to ALL people.
let dailyRate = 0; let dailyRate = 0;
blisters.forEach((_s, idx) => { intakes.forEach((intake) => {
const intake = intakes[idx];
if (!intake) return;
const usageForStock = normalizeIntakeUsageForStock(intake, m); const usageForStock = normalizeIntakeUsageForStock(intake, m);
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0; const baseRate = usageForStock * getIntakeDailyRate(intake);
if (intake?.takenBy) { if (intake?.takenBy) {
// Per-intake takenBy: this intake is for exactly 1 person // Per-intake takenBy: this intake is for exactly 1 person
dailyRate += baseRate; dailyRate += baseRate;
@@ -189,29 +149,11 @@ export function calculateCoverage(
// time (early intake), that dose is also counted as consumed immediately. // time (early intake), that dose is also counted as consumed immediately.
// This prevents double-counting: once the scheduled time arrives, the dose // This prevents double-counting: once the scheduled time arrives, the dose
// was already counted via the early-taken path, not again via time. // was already counted via the early-taken path, not again via time.
blisters.forEach((s, blisterIdx) => { intakes.forEach((intake, blisterIdx) => {
const blisterStart = parseLocalDateTime(s.start).getTime(); const intakeStart = parseLocalDateTime(intake.start);
const period = Math.max(1, s.every) * MS_PER_DAY; if (Number.isNaN(intakeStart.getTime())) return;
const intake = intakes[blisterIdx];
if (!intake) return;
const usageForStock = normalizeIntakeUsageForStock(intake, m); 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; const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person // For per-intake takenBy, only count for that person
@@ -223,18 +165,15 @@ export function calculateCoverage(
let timeBasedConsumed = 0; let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0; let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) { iterateIntakeOccurrences(intake, intakeStart, nowDate, (occurrence) => {
const occurrences = Math.floor((now - effectiveStart) / period) + 1; if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length; timeBasedConsumed += usageForStock * peopleForThisIntake.length;
// Date-only timestamp of the last auto-consumed dose
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date( lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(), occurrence.getFullYear(),
lastDoseTime.getMonth(), occurrence.getMonth(),
lastDoseTime.getDate() occurrence.getDate()
).getTime(); ).getTime();
} });
// Early intakes: count future doses already marked as taken. // Early intakes: count future doses already marked as taken.
// The cutoff is the later of: last auto-consumed date or stock correction date. // 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 medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10); const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10); const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && blisters[blisterIdx]) { const intake = intakes[blisterIdx];
const intake = intakes[blisterIdx]; if (medId === m.id && intake) {
if (!intake) return;
const usageForStock = normalizeIntakeUsageForStock(intake, m); const usageForStock = normalizeIntakeUsageForStock(intake, m);
// Convert blister start to date-only for comparison (dose timestamps are date-only) // Convert blister start to date-only for comparison (dose timestamps are date-only)
const blisterStartDate = new Date(blisters[blisterIdx].start); const intakeStartDate = new Date(intake.start);
const blisterStartDateOnly = new Date( const intakeStartDateOnly = new Date(
blisterStartDate.getFullYear(), intakeStartDate.getFullYear(),
blisterStartDate.getMonth(), intakeStartDate.getMonth(),
blisterStartDate.getDate() intakeStartDate.getDate()
).getTime(); ).getTime();
// Use actual takenAt timestamp for stock correction comparison. // Use actual takenAt timestamp for stock correction comparison.
@@ -295,8 +233,8 @@ export function calculateCoverage(
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff; const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if ( if (
!Number.isNaN(blisterStartDateOnly) && !Number.isNaN(intakeStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly && doseTimestamp >= intakeStartDateOnly &&
afterCorrectionOrNoCorrectionMs afterCorrectionOrNoCorrectionMs
) { ) {
consumed += usageForStock; consumed += usageForStock;
@@ -618,3 +556,48 @@ export function computeMissedPastDoseIds(
); );
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)); 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<string>,
dismissedDoses: Set<string>
): { medicationIds: number[]; until: string | null } {
const medicationIds = new Set<number>();
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,
};
}