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",
"version": "1.20.1",
"version": "1.20.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.20.1",
"version": "1.20.2",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
+11 -5
View File
@@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url";
import type { Client } from "@libsql/client";
import type { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import {
forEachScheduledOccurrenceInRange,
getDateOnlyTimestamp,
getScheduleMatchWindowMs,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
@@ -363,9 +369,9 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
if (every <= 0 || Number.isNaN(start.getTime())) continue;
const validDates = new Set<number>();
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
}
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
});
validDatesByIntake.set(idx, validDates);
}
@@ -388,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
const intake = intakes[intakeIdx];
if (!intake) continue;
const halfInterval = (intake.every * MS_PER_DAY) / 2;
const halfInterval = getScheduleMatchWindowMs(intake);
let bestMatch: number | null = null;
let bestDist = Infinity;
+27 -19
View File
@@ -16,14 +16,14 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.3";
const EXPORT_VERSION = "1.4";
// =============================================================================
// Zod Schemas for Import Validation
@@ -33,6 +33,8 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
@@ -237,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number;
every: number;
start: string;
scheduleMode: "interval" | "weekdays";
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean;
takenBy: string | null;
@@ -252,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: intake.usage,
every: intake.every,
start: intake.start,
intakeUnit: null,
scheduleMode: intake.scheduleMode ?? "interval",
weekdays: intake.weekdays ?? [],
intakeUnit: intake.intakeUnit ?? null,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
@@ -671,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules to both legacy and new formats
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
// Build intakesJson array (new unified format with per-intake takenBy)
const intakesJson = JSON.stringify(
med.schedules.map((s) => ({
usage: s.usage,
every: s.every,
start: s.start,
intakeUnit: s.intakeUnit ?? null,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
);
const intakesJson = JSON.stringify(normalizedSchedules);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await db
.insert(medications)
+84 -104
View File
@@ -29,7 +29,13 @@ import {
PACKAGE_TYPES,
} from "../utils/package-profiles.js";
import {
countScheduledOccurrencesInRange,
forEachScheduledOccurrenceInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
getScheduleMatchWindowMs,
type Intake,
normalizeIntake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
@@ -100,6 +106,8 @@ const intakeSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime({ local: true }),
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
@@ -274,6 +282,11 @@ const intakeOpenApiSchema = {
usage: { type: "number", minimum: 0 },
every: { type: "integer", minimum: 1 },
start: { type: "string", description: "ISO datetime string; timezone suffix optional." },
scheduleMode: { type: "string", enum: ["interval", "weekdays"] },
weekdays: {
type: "array",
items: { type: "string", enum: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] },
},
intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] },
takenBy: { type: ["string", "null"], maxLength: 100 },
intakeRemindersEnabled: { type: "boolean" },
@@ -359,6 +372,8 @@ const medicationBodyOpenApiSchema = {
usage: 1,
every: 8,
start: "2026-03-11T08:00:00.000Z",
scheduleMode: "interval",
weekdays: [],
takenBy: "Daniel",
intakeRemindersEnabled: true,
},
@@ -664,25 +679,20 @@ export async function medicationRoutes(app: FastifyInstance) {
// Convert to unified intakes format
let intakes: Intake[];
if (inputIntakes) {
// New format with per-intake takenBy
intakes = inputIntakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
intakeUnit: i.intakeUnit ?? null,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
intakes = inputIntakes.map((intake) => normalizeIntake(intake));
} else if (inputBlisters) {
// Legacy format - convert to new format
intakes = inputBlisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
intakes = inputBlisters.map((blister) =>
normalizeIntake(
{
usage: blister.usage,
every: blister.every,
start: blister.start,
intakeUnit: null,
takenBy: null,
},
intakeRemindersEnabled ?? false
)
);
} else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
}
@@ -840,25 +850,20 @@ export async function medicationRoutes(app: FastifyInstance) {
// Convert to unified intakes format
let intakes: Intake[];
if (inputIntakes) {
// New format with per-intake takenBy
intakes = inputIntakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
intakeUnit: i.intakeUnit ?? null,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
intakes = inputIntakes.map((intake) => normalizeIntake(intake));
} else if (inputBlisters) {
// Legacy format - convert to new format
intakes = inputBlisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
intakes = inputBlisters.map((blister) =>
normalizeIntake(
{
usage: blister.usage,
every: blister.every,
start: blister.start,
intakeUnit: null,
takenBy: null,
},
intakeRemindersEnabled ?? false
)
);
} else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
}
@@ -942,8 +947,7 @@ export async function medicationRoutes(app: FastifyInstance) {
if (allDoses.length > 0) {
// Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs
const now = new Date();
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const MS_PER_DAY = 86_400_000;
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) {
const oldIntake = oldIntakes[idx];
@@ -954,44 +958,45 @@ export async function medicationRoutes(app: FastifyInstance) {
const oldStart = parseLocalDateTime(oldIntake.start);
const newStart = parseLocalDateTime(newIntake.start);
const oldEvery = oldIntake.every;
const newEvery = newIntake.every;
// Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs)
// Check if start date or schedule changed (time-of-day changes don't matter for dateOnlyMs)
const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime();
const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime();
if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) {
const scheduleUnchanged =
oldStartDateOnly === newStartDateOnly &&
oldIntake.every === newIntake.every &&
oldIntake.scheduleMode === newIntake.scheduleMode &&
(oldIntake.weekdays ?? []).join(",") === (newIntake.weekdays ?? []).join(",");
if (scheduleUnchanged) {
continue; // No schedule change that affects dose IDs
}
// Build set of new valid dateOnlyMs values for this intake
const newDates = new Set<number>();
for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) {
newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
}
forEachScheduledOccurrenceInRange(newIntake, newStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
newDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
});
// Build set of old dateOnlyMs values with mapping to nearest new date
const oldToNewMap = new Map<number, number>();
for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) {
const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
// Find the closest new date within ±(newEvery/2) days
const halfInterval = (newEvery * MS_PER_DAY) / 2;
const scheduleMatchWindowMs = getScheduleMatchWindowMs(newIntake);
forEachScheduledOccurrenceInRange(oldIntake, oldStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
const oldDateMs = getDateOnlyTimestamp(new Date(occurrenceMs));
let bestMatch: number | null = null;
let bestDist = Infinity;
let bestDistance = Infinity;
for (const newDateMs of newDates) {
const dist = Math.abs(newDateMs - oldDateMs);
if (dist < bestDist && dist <= halfInterval) {
bestDist = dist;
const distance = Math.abs(newDateMs - oldDateMs);
if (distance < bestDistance && distance <= scheduleMatchWindowMs) {
bestDistance = distance;
bestMatch = newDateMs;
}
}
if (bestMatch !== null && bestMatch !== oldDateMs) {
oldToNewMap.set(oldDateMs, bestMatch);
// Remove matched new date to prevent double-mapping
newDates.delete(bestMatch);
}
}
});
// Apply migrations to dose tracking entries
if (oldToNewMap.size > 0) {
@@ -1503,6 +1508,8 @@ export async function medicationRoutes(app: FastifyInstance) {
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
every: i.every,
start: i.start,
scheduleMode: i.scheduleMode,
weekdays: i.weekdays,
}));
const pillsPerBlister = row.pillsPerBlister ?? 1;
const packCount = row.packCount ?? 1;
@@ -1523,8 +1530,6 @@ export async function medicationRoutes(app: FastifyInstance) {
// Count consumed pills by generating expected doses and checking if they're taken
let consumedUntilNow = 0;
const msPerDay = 86400000;
if (isTopical) {
consumedUntilNow = 0;
} else if (stockCalculationMode === "automatic") {
@@ -1532,16 +1537,11 @@ export async function medicationRoutes(app: FastifyInstance) {
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
: blisterStart;
if (effectiveStart === null) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
@@ -1559,25 +1559,20 @@ export async function medicationRoutes(app: FastifyInstance) {
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now.getTime()) {
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
blister,
effectiveStart,
now.getTime()
);
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
@@ -1768,34 +1763,19 @@ export async function medicationRoutes(app: FastifyInstance) {
}
function calculateUsageInRange(
blisters: Array<{ usage: number; every: number; start: string }>,
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
start: Date,
end: Date
) {
if (end.getTime() <= start.getTime()) {
return 0;
}
let total = 0;
const msPerDay = 86400000;
blisters.forEach((blister) => {
const blisterStart = parseLocalDateTime(blister.start);
if (Number.isNaN(blisterStart.getTime())) return;
const every = Math.max(1, blister.every);
// Skip ahead to the first occurrence at or after start to avoid
// iterating through months/years of past doses
const dt = new Date(blisterStart);
if (dt < start) {
const daysToSkip = Math.floor((start.getTime() - dt.getTime()) / (every * msPerDay));
dt.setDate(dt.getDate() + daysToSkip * every);
// Fine-tune: advance until we reach or pass start
while (dt < start) {
dt.setDate(dt.getDate() + every);
}
}
// Count occurrences in [start, end)
for (; dt < end; dt.setDate(dt.getDate() + every)) {
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
total += blister.usage;
}
});
});
return Number(total.toFixed(2));
}
+11 -19
View File
@@ -1,14 +1,14 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
getAverageOccurrencesPerDay,
getNextScheduledOccurrenceTime,
getTodayInTimezone,
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type MedicationRow = typeof medications.$inferSelect;
@@ -60,35 +60,27 @@ function computeCapacity(medication: MedicationRow): number {
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
return intakes.reduce((sum, intake) => {
if (intake.every <= 0) return sum;
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
return sum + normalizedUsage / intake.every;
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
}, 0);
}
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
const today = parseDateOnly(todayDateOnly);
let nextDate: Date | null = null;
let nextOccurrenceMs: number | null = null;
for (const intake of intakes) {
if (intake.every <= 0) continue;
const startDate = parseLocalDateTime(intake.start);
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
let candidate = startDateOnly;
if (candidate.getTime() < today.getTime()) {
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
const intervals = Math.ceil(elapsedDays / intake.every);
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
if (occurrenceMs === null) {
continue;
}
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
nextDate = candidate;
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
nextOccurrenceMs = occurrenceMs;
}
}
return nextDate ? toDateOnlyString(nextDate) : null;
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
}
function computeTakenAmount(
@@ -188,7 +180,7 @@ export function buildSharedMedicationOverview(options: {
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
const depletionDate =
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
return {
name: medication.name,
+17 -24
View File
@@ -1,6 +1,9 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
countScheduledOccurrencesInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
@@ -10,7 +13,6 @@ import {
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getDoseTakenAtMs(dose: DoseRow): number {
@@ -60,15 +62,11 @@ export function computeMedicationCurrentStock(options: {
const intakeStart = parseLocalDateTime(intake.start).getTime();
if (Number.isNaN(intakeStart)) return;
const period = Math.max(1, intake.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = intakeStart;
}
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
: intakeStart;
if (effectiveStart === null) return;
let peopleForThisIntake: Array<string | null>;
if (intake.takenBy) {
@@ -81,25 +79,20 @@ export function computeMedicationCurrentStock(options: {
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= nowMs) {
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
intake,
effectiveStart,
nowMs
);
consumed += occurrences * usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
for (const dose of relevantDoses) {
+19 -25
View File
@@ -18,10 +18,13 @@ import {
import {
type Blister,
calculateDepletionInfo,
countScheduledOccurrencesInRange,
createDefaultReminderState,
formatInTimezone,
getCurrentHourInTimezone,
getDateOnlyTimestamp,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
@@ -271,7 +274,6 @@ async function getMedicationsNeedingReminder(
const lowStock: LowStockItem[] = [];
const now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) {
const packageType = normalizePackageType(row.packageType);
@@ -288,6 +290,8 @@ async function getMedicationsNeedingReminder(
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
every: i.every,
start: i.start,
scheduleMode: i.scheduleMode,
weekdays: i.weekdays,
}));
const originalTotalPills = isAmountBasedPackageType(packageType)
@@ -304,16 +308,11 @@ async function getMedicationsNeedingReminder(
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
: blisterStart;
if (effectiveStart === null) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
@@ -331,25 +330,20 @@ async function getMedicationsNeedingReminder(
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
blister,
effectiveStart,
now
);
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
+26 -57
View File
@@ -942,17 +942,17 @@ describe("Integration Tests", () => {
// ---------------------------------------------------------------------------
describe("Planner usage calculation", () => {
const plannerWindowStart = "2030-01-15T00:00:00.000Z";
const futureDailyStart = "2030-01-15T08:00:00.000Z";
const futureEveningStart = "2030-01-15T20:00:00.000Z";
const tenDayPlanEnd = "2030-01-24T23:59:59.999Z";
const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z";
it("should calculate correct usage for daily medication", async () => {
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
// Schedule: 1 pill daily starting tomorrow (future date)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill daily starting on a fixed future winter date.
// This avoids daylight-saving-time edge cases in local test environments.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -972,8 +972,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr, // 10 days
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -988,15 +988,8 @@ describe("Integration Tests", () => {
it("should detect insufficient stock", async () => {
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
// Schedule: 1 pill daily starting tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill daily starting on a fixed future winter date.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1016,8 +1009,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -1029,15 +1022,8 @@ describe("Integration Tests", () => {
it("should calculate weekly medication usage correctly", async () => {
// Create medication: 10 pills total
// Schedule: 1 pill every 7 days starting tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill every 7 days starting on a fixed future winter date.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1056,8 +1042,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: thirtyFiveDayPlanEnd,
},
});
@@ -1070,18 +1056,8 @@ describe("Integration Tests", () => {
it("should handle multiple intake schedules per medication", async () => {
// Create medication with morning and evening doses
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const morningStart = tomorrow.toISOString();
const eveningStart = new Date(tomorrow);
eveningStart.setHours(20, 0, 0, 0);
const eveningStartStr = eveningStart.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
const morningStart = futureDailyStart;
const eveningStartStr = futureEveningStart;
await app.inject({
method: "POST",
@@ -1103,8 +1079,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: morningStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -1116,14 +1092,7 @@ describe("Integration Tests", () => {
it("should calculate correct blisters needed", async () => {
// 10 pills per blister, need 25 pills → need 3 blisters
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1142,8 +1111,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
+151 -2
View File
@@ -6,22 +6,30 @@ import {
calculateDailyUsage,
calculateDepletionInfo,
cleanOldIntakeReminders,
countScheduledOccurrencesInRange,
createDefaultIntakeReminderState,
createDefaultReminderState,
forEachScheduledOccurrenceInRange,
formatInTimezone,
getAverageOccurrencesPerDay,
getCurrentHourInTimezone,
getMaxScheduledGapDays,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
normalizeIntake,
parseBlisters,
parseIntakeReminderState,
parseIntakesJson,
parseReminderState,
parseTakenByJson,
personTakesMedication,
type Weekday,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
@@ -267,6 +275,77 @@ describe("Scheduler Utils - Blister Parsing", () => {
});
});
describe("Scheduler Utils - Intake Schedule Normalization", () => {
describe("normalizeIntake", () => {
it("keeps interval schedules backward-compatible by default", () => {
const intake = normalizeIntake({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
});
expect(intake).toMatchObject({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
});
});
it("normalizes malformed weekday schedules to the start date weekday", () => {
const intake = normalizeIntake({
usage: 1,
every: 99,
start: "2025-01-06T08:00:00",
scheduleMode: "weekdays",
weekdays: ["bogus", null],
});
expect(intake.scheduleMode).toBe("weekdays");
expect(intake.every).toBe(1);
expect(intake.weekdays).toEqual(["mon"]);
});
});
describe("parseIntakesJson", () => {
it("falls back to legacy interval data when unified intakes are absent", () => {
const intakes = parseIntakesJson(
null,
{
usageJson: "[1,2]",
everyJson: "[1,3]",
startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]',
},
true
);
expect(intakes).toEqual([
{
usage: 1,
every: 1,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
{
usage: 2,
every: 3,
start: "2025-01-02T20:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
]);
});
});
});
describe("Scheduler Utils - Daily Usage Calculation", () => {
describe("calculateDailyUsage", () => {
it("should calculate daily usage for single daily dose", () => {
@@ -306,6 +385,71 @@ describe("Scheduler Utils - Daily Usage Calculation", () => {
});
});
describe("Scheduler Utils - Schedule Occurrence Calculation", () => {
it("calculates average usage and gap length for weekday schedules", () => {
const weekdaysSchedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5);
expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3);
expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5);
expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2);
});
it("finds the next weekday occurrence after a given timestamp", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime();
const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs);
expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime());
});
it("iterates weekday occurrences in canonical order within a range", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["wed", "mon", "fri"] satisfies Weekday[],
};
const occurrences: number[] = [];
forEachScheduledOccurrenceInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime(),
(occurrenceMs) => {
occurrences.push(occurrenceMs);
}
);
expect(occurrences.sort((a, b) => a - b)).toEqual([
new Date(2025, 0, 6, 9, 0, 0).getTime(),
new Date(2025, 0, 8, 9, 0, 0).getTime(),
new Date(2025, 0, 10, 9, 0, 0).getTime(),
]);
expect(
countScheduledOccurrencesInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime()
)
).toEqual({
count: 3,
lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(),
});
});
});
describe("Scheduler Utils - Depletion Calculation", () => {
describe("calculateDepletionInfo", () => {
it("should calculate days left correctly", () => {
@@ -378,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
expect(result[0].pillWeightMg).toBe(500);
});
it("should skip blisters with zero interval", () => {
it("should treat zero interval as a daily fallback", () => {
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
medName: "TestMed",
usage: 1,
takenBy: null,
});
});
it("should handle multiple blisters", () => {
-1
View File
@@ -10,7 +10,6 @@ import {
createTestMedication,
createTestShareToken,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
+328 -88
View File
@@ -6,14 +6,34 @@
import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
export type IntakeScheduleMode = "interval" | "weekdays";
type ScheduleLike = {
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
export type Blister = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// New unified intake type with per-intake takenBy
export type Intake = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
@@ -22,6 +42,278 @@ export type Intake = {
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
value === "ml" || value === "tsp" || value === "tbsp";
const weekdayToJavascriptDay: Record<Weekday, number> = {
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
sun: 0,
};
function isWeekday(value: unknown): value is Weekday {
return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
}
function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
return value === "weekdays" ? "weekdays" : "interval";
}
function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime();
}
export function getWeekdayFromDate(date: Date): Weekday {
const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
return weekday ?? "mon";
}
export function getWeekdayFromStart(start: string): Weekday {
const startDate = parseLocalDateTime(start);
if (Number.isNaN(startDate.getTime())) {
return "mon";
}
return getWeekdayFromDate(startDate);
}
export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
if (!Array.isArray(value)) {
return [getWeekdayFromStart(start)];
}
const uniqueWeekdays = new Set<Weekday>();
for (const weekday of value) {
if (isWeekday(weekday)) {
uniqueWeekdays.add(weekday);
}
}
const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
}
function createOccurrenceAtDate(date: Date, startDate: Date): number {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
startDate.getHours(),
startDate.getMinutes(),
startDate.getSeconds(),
startDate.getMilliseconds()
).getTime();
}
function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
if (schedule.scheduleMode !== "weekdays") {
return [];
}
if (schedule.weekdays && schedule.weekdays.length > 0) {
return schedule.weekdays;
}
return [getWeekdayFromStart(schedule.start)];
}
export function getAverageOccurrencesPerDay(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode === "weekdays") {
return getNormalizedWeekdays(schedule).length / 7;
}
return 1 / Math.max(1, schedule.every);
}
export function getMaxScheduledGapDays(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode !== "weekdays") {
return Math.max(1, schedule.every);
}
const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
if (weekdays.length === 0) {
return 7;
}
let maxGap = 0;
for (let index = 0; index < weekdays.length; index++) {
const current = weekdays[index];
const next = weekdays[(index + 1) % weekdays.length];
const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
if (gap > maxGap) {
maxGap = gap;
}
}
return maxGap || 7;
}
export function getScheduleMatchWindowMs(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
}
export function getNextScheduledOccurrenceTime(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
fromMs: number,
inclusive: boolean = true
): number | null {
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime)) {
return null;
}
const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
if (startTime >= lowerBound) {
return startTime;
}
const intervals = Math.ceil((lowerBound - startTime) / period);
return startTime + intervals * period;
}
const candidateStart = Math.max(lowerBound, startTime);
const candidateDateOnly = toDateOnly(new Date(candidateStart));
let nextOccurrence: number | null = null;
for (const weekday of getNormalizedWeekdays(schedule)) {
const candidateDate = new Date(candidateDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
candidateDate.setDate(candidateDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
if (occurrenceMs < candidateStart) {
candidateDate.setDate(candidateDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
}
if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
nextOccurrence = occurrenceMs;
}
}
return nextOccurrence;
}
export function forEachScheduledOccurrenceInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number,
callback: (occurrenceMs: number) => void
): void {
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
return;
}
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime) || rangeEndMs < startTime) {
return;
}
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
let occurrenceMs = startTime;
if (occurrenceMs < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
occurrenceMs += intervals * period;
}
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs);
}
}
return;
}
const lowerBound = Math.max(rangeStartMs, startTime);
const firstDateOnly = toDateOnly(new Date(lowerBound));
for (const weekday of getNormalizedWeekdays(schedule)) {
const occurrenceDate = new Date(firstDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
if (occurrenceMs < lowerBound) {
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
while (occurrenceMs <= rangeEndMs) {
callback(occurrenceMs);
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
}
}
export function countScheduledOccurrencesInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number
): { count: number; lastOccurrenceMs: number | null } {
let count = 0;
let lastOccurrenceMs: number | null = null;
forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
count += 1;
if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
lastOccurrenceMs = occurrenceMs;
}
});
return { count, lastOccurrenceMs };
}
export function normalizeIntake(
value: {
usage?: unknown;
every?: unknown;
start?: unknown;
scheduleMode?: unknown;
weekdays?: unknown;
intakeUnit?: unknown;
takenBy?: unknown;
intakeRemindersEnabled?: unknown;
},
defaultIntakeRemindersEnabled: boolean = false
): Intake {
const start = typeof value.start === "string" ? value.start : new Date().toISOString();
const scheduleMode = normalizeScheduleMode(value.scheduleMode);
let every = 1;
if (scheduleMode !== "weekdays") {
if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
every = value.every;
}
}
return {
usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
every,
start,
scheduleMode,
weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
intakeRemindersEnabled:
typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
};
}
/**
* Normalize intake usage for stock math.
*
@@ -225,15 +517,7 @@ export function parseIntakesJson(
try {
const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: Record<string, unknown>) => ({
usage: typeof intake.usage === "number" ? intake.usage : 0,
every: typeof intake.every === "number" ? intake.every : 1,
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
intakeRemindersEnabled:
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
}));
return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
}
} catch {
// Fall through to legacy parsing
@@ -243,14 +527,18 @@ export function parseIntakesJson(
// Fallback to legacy parallel arrays
if (legacyRow) {
const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
return blisters.map((b) =>
normalizeIntake(
{
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null,
},
medicationIntakeRemindersEnabled ?? false
)
);
}
return [];
@@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[
/** Calculate daily usage from blisters */
export function calculateDailyUsage(blisters: Blister[]): number {
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
}
/** Calculate depletion information for a medication */
@@ -370,50 +658,31 @@ export function getTodaysIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
// If intake has its own takenBy, use it; otherwise null (no specific person)
const effectiveTakenBy = intake.takenBy || null;
// Find all occurrences that fall within today
let currentTime = startTime;
// If start is in the past, calculate the first occurrence on or after todayStart
if (currentTime < todayStart.getTime()) {
const elapsed = todayStart.getTime() - startTime;
const intervals = Math.floor(elapsed / intervalMs);
currentTime = startTime + intervals * intervalMs;
}
// Collect all intakes for today
while (currentTime <= todayEnd.getTime()) {
if (currentTime >= todayStart.getTime()) {
const intakeDate = new Date(currentTime);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
}
currentTime += intervalMs;
}
forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
const intakeDate = new Date(occurrenceMs);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
});
}
return result;
return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
}
/**
@@ -444,40 +713,11 @@ export function getUpcomingIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
const effectiveTakenBy = intake.takenBy || null;
// Find the next scheduled intake time (could be today or in the future)
let nextTime = startTime;
// If start is in the past, calculate occurrences
if (nextTime < now) {
const elapsed = now - startTime;
const intervals = Math.floor(elapsed / intervalMs);
// Check the current occurrence (today's scheduled time, even if past)
const currentOccurrence = startTime + intervals * intervalMs;
// And the next occurrence
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
// If today's occurrence notification time falls in current minute and intake hasn't happened
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
// but the intake time is still in the future — include it so the advance
// reminder can still be sent rather than falling into a dead zone.
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
}
}
const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
if (nextTime === null) continue;
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;
+17 -7
View File
@@ -114,8 +114,10 @@ test.describe("Share Schedule", () => {
const personSelect = modal.locator("select").first();
await expect(personSelect).toBeVisible();
// Should contain Alice and Bob options
await expect(personSelect.locator("option")).toHaveCount(2);
// Should contain Alice and Bob options.
// The dialog can also include an "all people" option, so assert presence instead of exact count.
await expect(personSelect.locator('option[value="Alice"]')).toBeAttached();
await expect(personSelect.locator('option[value="Bob"]')).toBeAttached();
// Close
await page.locator("button.modal-close").click();
@@ -187,7 +189,7 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
// The page should show Alice's medication name
const content = sharedSchedule.getByText(MED_ALICE);
const content = sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first();
try {
await expect(content).toBeVisible({ timeout: 10000 });
} catch {
@@ -236,12 +238,16 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try {
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
timeout: 10000,
});
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
timeout: 10000,
});
}
// Visit Bob's share — should show Bob's med
@@ -251,12 +257,16 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try {
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
timeout: 10000,
});
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
timeout: 10000,
});
}
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-frontend",
"version": "1.20.1",
"version": "1.20.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.20.1",
"version": "1.20.2",
"dependencies": {
"i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1",
+10 -25
View File
@@ -20,11 +20,14 @@ import {
getMedDisplayName,
getMedTotal,
getPackageSize,
type IntakeUnit,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock";
@@ -254,32 +257,16 @@ export function MedDetailModal({
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
const getScheduleUsageLabel = (usage: number, intakeUnit?: IntakeUnit | null) => {
if (isLiquidContainerPackageType(selectedMed.packageType)) {
if (intakeUnit === "tsp") {
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
}
if (intakeUnit === "tbsp") {
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
}
return `${usage} ${t("form.packageAmountUnitMl")}`;
return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (isTubePackageType(selectedMed.packageType)) {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
const scheduleIntakes =
selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: false,
intakeUnit: null,
}));
const scheduleIntakes = getMedicationIntakes(selectedMed);
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull);
@@ -969,7 +956,7 @@ export function MedDetailModal({
</div>
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
@@ -985,7 +972,7 @@ export function MedDetailModal({
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled === true;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div key={intakeKey} className="med-schedule-row blister-row-simple">
@@ -993,9 +980,7 @@ export function MedDetailModal({
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span>
<span className="med-schedule-freq">
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
</span>
<span className="med-schedule-freq">{getIntakeFrequencyText(intake, t)}</span>
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
<span className="med-schedule-time">
{t("modal.at")}{" "}
@@ -1166,7 +1151,7 @@ export function MedDetailModal({
<FilePenLine size={18} aria-hidden="true" />
</button>
)}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<button
className="secondary icon-only tooltip-trigger"
onClick={() => generateICS(selectedMed)}
+85 -11
View File
@@ -19,6 +19,13 @@ import {
PACKAGE_PROFILES,
} from "../types";
import { deriveTotal } from "../utils";
import {
getIntakeScheduleMode,
getWeekdayLabel,
hasSelectedWeekdays,
toggleWeekdaySelection,
WEEKDAY_CODES,
} from "../utils/intake-schedule";
import { DateInput } from "./DateInput";
import { FormNumberStepper } from "./FormNumberStepper";
@@ -57,7 +64,7 @@ export interface MobileEditModalProps {
onAddBlister: () => void;
onRemoveBlister: (idx: number) => void;
// 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;
onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields
@@ -158,6 +165,24 @@ export function MobileEditModal({
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
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
useEffect(() => {
@@ -815,7 +840,9 @@ export function MobileEditModal({
)}
</div>
{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 (
<div key={intakeKey} className="blister-row">
<label className="compact">
@@ -831,15 +858,60 @@ export function MobileEditModal({
/>
</label>
<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}
/>
<span>{t("form.blisters.scheduleMode")}</span>
<select
className="select-field"
value={scheduleMode}
onChange={(e) =>
onSetIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
}
>
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
</select>
</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">
<span>{t("form.blisters.startDate")}</span>
<DateInput
@@ -984,7 +1056,9 @@ export function MobileEditModal({
<button
type="submit"
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")}
</button>
+30 -44
View File
@@ -10,6 +10,8 @@ import {
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { MedicationAvatar } from "./MedicationAvatar";
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;
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" {
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
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}`);
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("");
for (const med of meds) {
@@ -373,8 +361,8 @@ function generateTextReport(
lines.push(
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.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), formatDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(med.obsoleteAt)));
lines.push("");
// Package / Stock
@@ -391,24 +379,23 @@ function generateTextReport(
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
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));
lines.push("");
// Intake Schedule
const allIntakes = med.intakes ?? med.blisters;
const allIntakes = getMedicationIntakes(med);
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;
if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) {
let entry = getUsageText(med, intake.usage, t);
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${getIntakeFrequencyText(intake, t)}`;
entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
lines.push("");
@@ -420,7 +407,7 @@ function generateTextReport(
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
if (med.prescriptionExpiryDate)
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
lines.push(item(t("report.docPrescriptionExpiry"), formatDate(med.prescriptionExpiryDate)));
lines.push("");
}
@@ -434,8 +421,8 @@ function generateTextReport(
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
}
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.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else {
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
}
@@ -445,7 +432,7 @@ function generateTextReport(
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
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")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
@@ -528,7 +515,7 @@ function buildPrintHtml(
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const intakes = getMedicationIntakes(med);
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
@@ -560,11 +547,11 @@ function buildPrintHtml(
);
if (med.medicationStartDate)
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)
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>`;
@@ -591,7 +578,7 @@ function buildPrintHtml(
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>`;
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)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`;
@@ -599,18 +586,17 @@ function buildPrintHtml(
// Intake Schedule
const allPrintIntakes = intakes;
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;
if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`;
for (const intake of filteredPrintIntakes) {
let entry = escHtml(getUsageText(med, intake.usage, t));
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`;
}
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.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
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>`;
}
@@ -639,9 +625,9 @@ function buildPrintHtml(
if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
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)
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>`;
} else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
@@ -652,7 +638,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
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>`;
s += `<li>${entry}</li>`;
}
@@ -708,7 +694,7 @@ function buildPrintHtml(
<body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<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")}
</body>
</html>`;
+24 -62
View File
@@ -13,11 +13,14 @@ import {
allowsPillFormSelection,
getMedDisplayName,
getMedTotal,
type IntakeUnit,
isLiquidContainerPackageType,
isTubePackageType,
type StockThresholds,
} from "../types";
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 { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
@@ -40,16 +43,10 @@ export function SharedSchedule() {
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
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 = (
usage: number,
med: SharedScheduleData["medications"][number] | undefined,
unit: "ml" | "tsp" | "tbsp" | null | undefined
unit: IntakeUnit | null | undefined
): number => {
if (isTubePackageType(med?.packageType)) return 0;
if (!isLiquidContainerMed(med)) return usage;
@@ -61,13 +58,7 @@ export function SharedSchedule() {
return String(rounded);
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`;
@@ -78,13 +69,13 @@ export function SharedSchedule() {
}
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 = (
med: SharedScheduleData["medications"][number] | undefined,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
intakeUnit?: IntakeUnit | null
) => {
if (isLiquidContainerMed(med)) {
return formatLiquidUsageLabel(usage, intakeUnit);
@@ -95,7 +86,7 @@ export function SharedSchedule() {
const formatTotalUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => {
if (isLiquidContainerMed(med)) {
if (doses && doses.length > 0) {
@@ -418,7 +409,7 @@ export function SharedSchedule() {
when: number;
medName: string;
usage: number;
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
intakeUnit?: IntakeUnit | null;
timeStr: string;
isPast: boolean;
takenBy: string | null; // Per-intake takenBy (single person or null)
@@ -426,15 +417,7 @@ export function SharedSchedule() {
}[] = [];
for (const med of data.medications) {
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
const intakes =
med.intakes ||
med.blisters.map((b) => ({
...b,
intakeUnit: null,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}));
const intakes = getMedicationIntakes(med);
intakes.forEach((intake, intakeIdx) => {
// 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);
if (Number.isNaN(startDate.getTime())) return;
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
// This ensures identical timestamps even across DST changes
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) {
iterateIntakeOccurrences(intake, startDate, end, (d) => {
const t = d.getTime();
const isPast = d < todayStart;
// Use date-only timestamp for stable ID (immune to time changes)
@@ -470,7 +451,7 @@ export function SharedSchedule() {
month: "short",
}),
});
}
});
});
}
@@ -544,20 +525,12 @@ export function SharedSchedule() {
// Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
const coverageByMed = useMemo(() => {
if (!data) return {};
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const calcMode = data.stockCalculationMode ?? "automatic";
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
for (const med of data.medications) {
const intakes =
med.intakes ||
med.blisters.map((b) => ({
...b,
intakeUnit: null,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}));
const intakes = getMedicationIntakes(med);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
@@ -571,7 +544,7 @@ export function SharedSchedule() {
let dailyRate = 0;
intakes.forEach((intake) => {
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) {
dailyRate += baseRate; // Per-intake takenBy: 1 person
} else {
@@ -586,18 +559,8 @@ export function SharedSchedule() {
// Time-based: every scheduled dose counts as consumed once its time has passed
intakes.forEach((intake, blisterIdx) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const blisterStart = parseLocalDateTime(intake.start).getTime();
const period = Math.max(1, intake.every) * MS_PER_DAY;
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 intakeStart = parseLocalDateTime(intake.start);
if (Number.isNaN(intakeStart.getTime())) return;
const intakePerson = intake?.takenBy;
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
@@ -606,16 +569,15 @@ export function SharedSchedule() {
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
iterateIntakeOccurrences(intake, intakeStart, new Date(now), (occurrence) => {
if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
occurrence.getFullYear(),
occurrence.getMonth(),
occurrence.getDate()
).getTime();
}
});
// Early intakes: future doses already marked as taken
const stockCorrectionDateOnly =
@@ -727,7 +689,7 @@ export function SharedSchedule() {
const renderDoseUsage = (
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);
// 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 { MedicationAvatar } from "../components";
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 { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -40,19 +42,9 @@ export function UserFilterModal({
);
};
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 formatIntakeUsageLabel = (
med: Medication,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
): string => {
const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): string => {
if (isLiquidMedication(med)) {
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`;
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (isTubePackageType(med.packageType)) {
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
@@ -111,14 +103,9 @@ export function UserFilterModal({
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
// Get intakes relevant to this person
const personIntakes = (
med.intakes ||
med.blisters.map((b) => ({
...b,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}))
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
const personIntakes = getMedicationIntakes(med).filter(
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
);
return (
<div
@@ -146,7 +133,7 @@ export function UserFilterModal({
hour: "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;
return (
<span key={intakeKey} className="user-med-intake-item">
@@ -154,8 +141,7 @@ export function UserFilterModal({
{allowsPillFormSelection(med.packageType) &&
med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
{t("modal.at")} {timeStr}
{getIntakeFrequencyText(intake, t)} {t("modal.at")} {timeStr}
</span>
);
})}
+9 -2
View File
@@ -9,6 +9,7 @@ import {
normalizePackageType,
} from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
export const defaultBlister = (): FormBlister => {
const now = new Date();
@@ -30,6 +31,8 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
scheduleMode: "interval",
weekdays: [],
intakeUnit: "ml",
takenBy, // Per-intake user assignment (empty string = null/everyone)
intakeRemindersEnabled: false,
@@ -93,7 +96,7 @@ export interface UseMedicationFormReturn {
addBlister: () => void;
removeBlister: (idx: number) => void;
// 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;
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
@@ -189,7 +192,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
}, []);
// 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) => {
const next = [...prev.intakes];
next[idx] = { ...next[idx], [field]: value };
@@ -219,6 +222,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
every: String(i.every),
startDate: toDateValue(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"],
takenBy: i.takenBy ?? "", // Convert null to empty string for form
intakeRemindersEnabled: i.intakeRemindersEnabled,
@@ -228,6 +233,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
scheduleMode: "interval" as const,
weekdays: [],
intakeUnit: "ml" as const,
takenBy: "", // Legacy blisters have no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
+23
View File
@@ -257,8 +257,31 @@
"applications": "Anwendungen",
"applications_one": "Anwendung",
"applications_other": "Anwendungen",
"scheduleMode": "Planmodus",
"scheduleModeInterval": "Alle X Tage",
"scheduleModeWeekdays": "Bestimmte Wochentage",
"everyDays": "Alle (Tage)",
"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",
"startDate": "Datum",
"startTime": "Uhrzeit",
+23
View File
@@ -257,8 +257,31 @@
"applications": "applications",
"applications_one": "application",
"applications_other": "applications",
"scheduleMode": "Schedule mode",
"scheduleModeInterval": "Every X days",
"scheduleModeWeekdays": "Specific weekdays",
"everyDays": "Every (days)",
"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",
"startDate": "Date",
"startTime": "Time",
+12 -63
View File
@@ -9,12 +9,15 @@ import {
allowsPillFormSelection,
type Coverage,
getMedDisplayName,
type IntakeUnit,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
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 {
formatFullBlisters,
formatOpenBlisterAndLoose,
@@ -141,41 +144,8 @@ export function DashboardPage() {
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 payload = getClearMissedPayload();
const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
if (payload.medicationIds.length === 0 || !payload.until) {
setShowClearMissedConfirm(false);
return;
@@ -245,19 +215,7 @@ export function DashboardPage() {
return t("table.pillsCount", { count: Math.round(medsLeft) });
};
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
if (unit === "tsp") return usage * 5;
if (unit === "tbsp") return usage * 15;
return usage;
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`;
@@ -268,13 +226,13 @@ export function DashboardPage() {
}
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
};
const formatDoseUsageLabel = (
med: (typeof meds)[number] | undefined,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
intakeUnit?: IntakeUnit | null
) => {
if (isLiquidContainerPackageType(med?.packageType)) {
return formatLiquidUsageLabel(usage, intakeUnit);
@@ -288,8 +246,8 @@ export function DashboardPage() {
const formatTotalUsageLabel = (
med: (typeof meds)[number] | undefined,
total: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
intakeUnit?: IntakeUnit | null,
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => {
if (isLiquidContainerPackageType(med?.packageType)) {
if (doses && doses.length > 0) {
@@ -322,27 +280,18 @@ export function DashboardPage() {
const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => {
if (!med) return "-";
const intakes =
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,
}));
const intakes = getMedicationIntakes(med);
if (intakes.length === 0) return "-";
let dailyTotal = 0;
for (const intake of intakes) {
const usage = Number(intake.usage);
const every = Math.max(1, Number(intake.every) || 1);
if (!Number.isFinite(usage) || usage <= 0) continue;
const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().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)) {
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
+174 -97
View File
@@ -33,6 +33,15 @@ import {
} from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
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";
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 currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
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(
(med: Medication) => {
@@ -512,7 +539,7 @@ export function MedicationsPage() {
async function saveMedication(e: React.FormEvent) {
e.preventDefault();
if (readOnlyView) return;
if (hasValidationErrors || dateConsistencyError) {
if (hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError) {
setShowNameValidation(true);
// Scroll to first visible error so the user sees what's wrong
const firstError = document.querySelector(".field-error");
@@ -534,8 +561,10 @@ export function MedicationsPage() {
// Prepare intakes data with per-intake takenBy
const intakes = form.intakes.map((intake) => ({
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),
scheduleMode: getIntakeScheduleMode(intake),
weekdays: getIntakeScheduleMode(intake) === "weekdays" ? [...(intake.weekdays ?? [])] : [],
intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null,
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
intakeRemindersEnabled: intake.intakeRemindersEnabled,
@@ -1050,15 +1079,12 @@ export function MedicationsPage() {
</div>
</div>
<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">
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "}
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} · {getIntakeFrequencyText(s, t)} ·{" "}
{t("form.blisters.from")} {formatDateTime(s.start)}
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
)}
{"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && (
{s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
{s.intakeRemindersEnabled && (
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
{" "}
<Bell size={12} aria-hidden="true" />
@@ -1734,105 +1760,154 @@ export function MedicationsPage() {
</button>
)}
</div>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<div className="blister-inputs">
<label>
{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.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) && (
{form.intakes.map((intake, idx) => {
const scheduleMode = getIntakeScheduleMode(intake);
const selectedWeekdays = intake.weekdays ?? [];
return (
<div key={idx} className="blister-row">
<div className="blister-inputs">
<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
className="select-field"
value={intake.intakeUnit}
value={scheduleMode}
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="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</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)}
{scheduleMode === "interval" ? (
<label>
{t("form.blisters.everyDays")}
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
) : (
<label className="taken-by-field">
{t("form.blisters.weekdays")}
<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={() =>
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>
{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>
{!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>
{!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>
{/* end schedule tab */}
@@ -1845,7 +1920,9 @@ export function MedicationsPage() {
<button
type="submit"
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")}
</button>
+8 -52
View File
@@ -5,10 +5,11 @@ import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import type { Coverage, IntakeUnit } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { formatNumber } from "../utils/formatters";
import { isDoseDismissed } from "../utils/schedule";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
@@ -105,41 +106,8 @@ export function SchedulePage() {
status: { className: string; label: string } | null
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
const getClearMissedPayload = () => {
const medicationIds = new Set<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 payload = getClearMissedPayload();
const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
if (payload.medicationIds.length === 0 || !payload.until) {
setShowClearMissedConfirm(false);
return;
@@ -197,19 +165,7 @@ export function SchedulePage() {
? t("form.packageAmountUnitMl")
: t("form.blisters.applications", { count: Math.abs(value) });
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
if (unit === "tsp") return usage * 5;
if (unit === "tbsp") return usage * 15;
return usage;
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`;
@@ -220,13 +176,13 @@ export function SchedulePage() {
}
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
};
const formatDoseUsageLabel = (
med: (typeof meds)[number] | undefined,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
intakeUnit?: IntakeUnit | null
) => {
if (isLiquidContainerPackageType(med?.packageType)) {
return formatLiquidUsageLabel(usage, intakeUnit);
@@ -240,7 +196,7 @@ export function SchedulePage() {
const formatTotalUsageLabel = (
med: (typeof meds)[number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => {
if (isLiquidContainerPackageType(med?.packageType)) {
if (doses && doses.length > 0) {
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import type { FormEvent } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MobileEditModal } from "../../components/MobileEditModal";
import type { FormState } from "../../types";
import type { FormState, WeekdayCode } from "../../types";
const defaultForm: FormState = {
name: "",
@@ -429,6 +429,61 @@ describe("MobileEditModal blister management", () => {
expect(onSetIntakeValue).toHaveBeenCalled();
}
});
it("shows weekday controls and validation error for weekday schedules", () => {
const form = {
...defaultForm,
name: "Weekday Med",
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
scheduleMode: "weekdays" as const,
weekdays: [],
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
render(<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", () => {
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ReportModal from "../../components/ReportModal";
import type { Medication } from "../../types";
import { formatDate, formatDateTime } from "../../utils/formatters";
function createMedication(overrides: Partial<Medication> = {}): Medication {
return {
@@ -65,6 +66,53 @@ describe("ReportModal", () => {
expect(URL.createObjectURL).toHaveBeenCalled();
});
it("renders shared formatter output in exported text reports", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<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 () => {
const onClose = vi.fn();
const mockWrite = vi.fn();
@@ -83,16 +131,35 @@ describe("ReportModal", () => {
ok: true,
json: async () => ({
1: {
dosesTaken: 0,
dosesTaken: 1,
automaticDosesTaken: 0,
dosesDismissed: 0,
firstDoseAt: null,
firstDoseAt: "2026-03-03T12:00:00.000Z",
lastDoseAt: null,
refills: [],
refills: [
{
packsAdded: 1,
loosePillsAdded: 0,
usedPrescription: false,
refillDate: "2026-03-04",
},
],
},
}),
});
render(<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 }));
await waitFor(() => {
@@ -101,6 +168,11 @@ describe("ReportModal", () => {
expect(mockClose).toHaveBeenCalled();
});
const [html] = mockWrite.mock.calls.at(-1) ?? [];
expect(html).toContain(formatDate("2026-03-01"));
expect(html).toContain(formatDateTime("2026-03-02T08:30:00.000Z"));
expect(html).toContain(formatDate("2026-03-03T12:00:00.000Z"));
expect(html).toContain(formatDate("2026-03-04"));
expect(onClose).toHaveBeenCalledTimes(1);
});
@@ -253,6 +253,67 @@ describe("MedicationsPage", () => {
expect(scheduleTab).toHaveAttribute("aria-selected", "true");
});
it("shows weekday controls and validation error in the desktop schedule form", () => {
mockFormHookValue = createMockFormHook({
formChanged: true,
form: {
...createMockFormHook().form,
name: "Weekday Med",
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
scheduleMode: "weekdays" as const,
weekdays: [],
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
});
renderPage();
openNewMedicationForm();
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument();
expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument();
expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument();
expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error");
});
it("toggles weekday selections in the desktop schedule form", () => {
const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({
setIntakeValue,
form: {
...createMockFormHook().form,
name: "Weekday Med",
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
scheduleMode: "weekdays" as const,
weekdays: ["wed"] as const,
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
});
renderPage();
openNewMedicationForm();
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon"));
expect(setIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]);
});
it("opens report modal from list actions", () => {
renderPage();
fireEvent.click(screen.getByText("report.button"));
+50
View File
@@ -151,4 +151,54 @@ describe("generateICS", () => {
expect(() => generateICS(dailyMed)).not.toThrow();
expect(() => generateICS(weeklyMed)).not.toThrow();
});
it("exports weekday schedules with a weekly BYDAY rule", async () => {
const med = createTestMed({
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
});
generateICS(med);
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
const content = await blobArg.text();
expect(content).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR");
expect(content).not.toContain("RRULE:FREQ=DAILY;INTERVAL=1");
});
it("keeps interval schedules exported as daily interval rules", async () => {
const med = createTestMed({
intakes: [
{
usage: 1,
every: 2,
start: "2024-03-15T09:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
});
generateICS(med);
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
const content = await blobArg.text();
expect(content).toContain("RRULE:FREQ=DAILY;INTERVAL=2");
expect(content).not.toContain("BYDAY=");
});
});
@@ -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 type { Coverage, Medication, StockThresholds } from "../../types";
import {
buildClearMissedPayload,
buildSchedulePreview,
calculateCoverage,
computeMissedPastDoseIds,
@@ -278,6 +279,33 @@ describe("buildSchedulePreview", () => {
expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id));
expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when));
});
it("falls back legacy blisters to schedule events with a null intake unit", () => {
const meds: Medication[] = [
{
id: 1,
name: "Legacy Liquid",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 120,
looseTablets: 120,
takenBy: [],
packageType: "liquid_container",
medicationForm: "liquid",
blisters: [{ usage: 2, every: 1, start: "2024-03-15T09:00:00" }],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
expect(result.totalBlisters).toBe(1);
expect(result.events[0]).toMatchObject({
usage: 2,
intakeUnit: null,
});
});
});
describe("calculateCoverage", () => {
@@ -376,6 +404,41 @@ describe("calculateCoverage", () => {
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days
});
it("converts liquid intake units to ml for automatic coverage calculations", () => {
const meds: Medication[] = [
{
id: 1,
name: "Liquid Med",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 120,
looseTablets: 120,
takenBy: [],
packageType: "liquid_container",
medicationForm: "liquid",
blisters: [],
intakes: [
{
usage: 2,
every: 1,
start: "2024-03-14T09:00:00",
intakeUnit: "tbsp",
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
expect(result.all[0].medsLeft).toBe(60);
expect(result.all[0].daysLeft).toBe(2);
});
it("per-intake takenBy counts person correctly in automatic mode", () => {
// When intakes have per-intake takenBy, each person-intake pair is counted
const meds: Medication[] = [
@@ -1987,6 +2050,83 @@ describe("dose tracking survives medication edits (regression)", () => {
});
});
describe("buildClearMissedPayload", () => {
it("collects unique missed medication ids and the latest missed day", () => {
const march10 = new Date("2024-03-10T09:00:00Z");
const march11 = new Date("2024-03-11T09:00:00Z");
const aspirinDoseMarch10 = "1-0-1710061200000";
const aspirinDoseMarch11 = "1-0-1710147600000";
const vitaminDDoseMarch11 = "2-0-1710147600000";
const calciumDoseMarch11 = "3-0-1710147600000";
const pastDays = [
{
date: march10,
meds: [{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["John"] }] }],
},
{
date: march11,
meds: [
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch11, takenBy: ["John"] }] },
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch11, takenBy: [] }] },
{ medName: "Calcium", doses: [{ id: calciumDoseMarch11, takenBy: [] }] },
],
},
];
const medications = [
{ id: 1, name: "Aspirin", dismissedUntil: null },
{ id: 2, name: "Vitamin D", dismissedUntil: null },
{ id: 3, name: "Calcium", dismissedUntil: "2024-03-11" },
];
const payload = buildClearMissedPayload(
pastDays,
medications,
new Set<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
// =============================================================================
@@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => {
expect(past180.length).toBeGreaterThan(past90.length);
});
});
describe("weekday intake schedules", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-18T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("builds preview events only on selected weekdays", () => {
const meds: Medication[] = [
{
id: 1,
name: "Weekday Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
packageType: "blister",
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
const weekdayDateStrings = result.events.slice(0, 3).map((event) => event.dateStr);
expect(weekdayDateStrings).toEqual(["Mon, Mar 18", "Wed, Mar 20", "Fri, Mar 22"]);
expect(result.totalBlisters).toBe(1);
});
it("uses weekday schedules when calculating coverage", () => {
const meds: Medication[] = [
{
id: 1,
name: "Weekday Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
takenBy: [],
packageType: "blister",
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const preview = buildSchedulePreview(meds, "en", false);
const coverage = calculateCoverage(meds, preview.events, "en", 7, "automatic", new Set());
expect(coverage.all[0]).toMatchObject({
name: "Weekday Med",
medsLeft: 9,
daysLeft: 21,
});
});
});
+6
View File
@@ -19,6 +19,8 @@ import { isAmountBasedPackageType } from "./package-profiles";
// Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
export type ScheduleMode = "interval" | "weekdays";
export type WeekdayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
export type PillForm = "tablet" | "capsule";
@@ -49,6 +51,8 @@ export type Intake = {
usage: number;
every: number;
start: string;
scheduleMode?: ScheduleMode | null;
weekdays?: WeekdayCode[] | null;
intakeUnit?: IntakeUnit | null;
takenBy: string | null; // Per-intake user assignment (single person or null)
intakeRemindersEnabled: boolean;
@@ -131,6 +135,8 @@ export type FormIntake = {
every: string;
startDate: string;
startTime: string;
scheduleMode?: ScheduleMode;
weekdays?: WeekdayCode[];
intakeUnit?: IntakeUnit;
takenBy: string; // Single person or empty string (empty = null for everyone)
intakeRemindersEnabled: boolean;
+27 -7
View File
@@ -4,6 +4,13 @@
import type { Medication } from "../types";
import { getMedDisplayName } from "../types";
import {
getIntakeFrequencyText,
getIntakeScheduleMode,
getMedicationIntakes,
getWeekdayIcsCode,
normalizeWeekdays,
} from "./intake-schedule";
/**
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
@@ -20,20 +27,33 @@ function formatICSDate(date: Date): string {
*/
export function generateICS(med: Medication): void {
const displayName = getMedDisplayName(med);
const events = med.blisters
.map((blister, idx) => {
const start = new Date(blister.start);
const events = getMedicationIntakes(med)
.map((intake, idx) => {
const start = new Date(intake.start);
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
const interval = blister.every;
const interval = intake.every;
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
const pillInfo = `${intake.usage} pill${intake.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${intake.usage * med.pillWeightMg} mg)` : ""}`;
const summary = `💊 ${displayName} - ${pillInfo}`;
const weekdayCodes = normalizeWeekdays(intake.weekdays);
const frequencyText =
getIntakeScheduleMode(intake) === "weekdays"
? weekdayCodes.map(getWeekdayIcsCode).join(", ")
: getIntakeFrequencyText(intake, (key, options) => {
if (key === "common.daily") return "daily";
if (key === "common.everyNDays") return `every ${options?.count ?? interval} days`;
return key;
});
const rrule =
getIntakeScheduleMode(intake) === "weekdays" && weekdayCodes.length > 0
? `RRULE:FREQ=WEEKLY;BYDAY=${weekdayCodes.map(getWeekdayIcsCode).join(",")}`
: `RRULE:FREQ=DAILY;INTERVAL=${interval}`;
const description = [
`Medication: ${displayName}`,
med.genericName ? `Generic: ${med.genericName}` : "",
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
`Dosage: ${pillInfo}`,
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
`Frequency: ${frequencyText}`,
med.notes ? `Notes: ${med.notes}` : "",
]
.filter(Boolean)
@@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng
DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(start)}
DTEND:${formatICSDate(end)}
RRULE:FREQ=DAILY;INTERVAL=${interval}
${rrule}
SUMMARY:${summary}
DESCRIPTION:${description}
BEGIN:VALARM
+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
// =============================================================================
import type {
Blister,
Coverage,
Intake,
Medication,
PackageType,
ScheduleEvent,
StockStatus,
StockThresholds,
} from "../types";
import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "./intake-schedule";
import { convertLiquidUsageToMl } from "./intake-units";
export function parseLocalDateTime(isoString: string): Date {
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
@@ -39,38 +32,7 @@ function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5;
if (intake.intakeUnit === "tbsp") return usage * 15;
return usage;
}
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
*/
function getIntakesForMed(med: Medication): Intake[] {
// Use new intakes array if available and non-empty
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
// Fallback to legacy blisters (convert to Intake format)
return med.blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
/**
* Get blisters for a medication (for backward compatibility with coverage calculations)
*/
function getBlistersForMed(med: Medication): Blister[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
return med.blisters;
return convertLiquidUsageToMl(usage, intake.intakeUnit);
}
/**
@@ -90,13 +52,13 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
const intakes = getIntakesForMed(med);
const intakes = getMedicationIntakes(med);
intakes.forEach((intake, idx) => {
const start = parseLocalDateTime(intake.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
iterateIntakeOccurrences(intake, start, end, (d) => {
const isPast = d < todayStart;
if (isPast && !includePast) continue;
if (isPast && !includePast) return;
const whenMs = d.getTime();
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
@@ -113,7 +75,7 @@ export function buildSchedulePreview(
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
intakeRemindersEnabled: intake.intakeRemindersEnabled,
});
}
});
});
});
@@ -129,7 +91,7 @@ export function buildSchedulePreview(
events,
today: todayCount,
nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
totalBlisters: meds.reduce((acc, med) => acc + getMedicationIntakes(med).length, 0),
};
}
@@ -147,10 +109,10 @@ export function calculateCoverage(
): { low: Coverage[]; all: Coverage[] } {
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const nowDate = new Date(now);
const coverage: Coverage[] = meds.map((m) => {
const intakes = getIntakesForMed(m);
const blisters = getBlistersForMed(m);
const intakes = getMedicationIntakes(m);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
@@ -165,11 +127,9 @@ export function calculateCoverage(
// one person's dose — do NOT multiply by personCount again.
// For legacy intakes (no takenBy), the intake applies to ALL people.
let dailyRate = 0;
blisters.forEach((_s, idx) => {
const intake = intakes[idx];
if (!intake) return;
intakes.forEach((intake) => {
const usageForStock = normalizeIntakeUsageForStock(intake, m);
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
const baseRate = usageForStock * getIntakeDailyRate(intake);
if (intake?.takenBy) {
// Per-intake takenBy: this intake is for exactly 1 person
dailyRate += baseRate;
@@ -189,29 +149,11 @@ export function calculateCoverage(
// time (early intake), that dose is also counted as consumed immediately.
// This prevents double-counting: once the scheduled time arrives, the dose
// was already counted via the early-taken path, not again via time.
blisters.forEach((s, blisterIdx) => {
const blisterStart = parseLocalDateTime(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
const intake = intakes[blisterIdx];
if (!intake) return;
intakes.forEach((intake, blisterIdx) => {
const intakeStart = parseLocalDateTime(intake.start);
if (Number.isNaN(intakeStart.getTime())) return;
const usageForStock = normalizeIntakeUsageForStock(intake, m);
// After a stock correction, start counting consumption from the NEXT
// scheduled dose on this blister's grid, because the user's pill count
// already reflects all consumption up to the correction time.
// We align to the schedule grid so that e.g. correction at 15:40 with
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
// tomorrow's dose (24h later as the old code did).
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
@@ -223,18 +165,15 @@ export function calculateCoverage(
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
// Date-only timestamp of the last auto-consumed dose
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
iterateIntakeOccurrences(intake, intakeStart, nowDate, (occurrence) => {
if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
occurrence.getFullYear(),
occurrence.getMonth(),
occurrence.getDate()
).getTime();
}
});
// Early intakes: count future doses already marked as taken.
// The cutoff is the later of: last auto-consumed date or stock correction date.
@@ -276,16 +215,15 @@ export function calculateCoverage(
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && blisters[blisterIdx]) {
const intake = intakes[blisterIdx];
if (!intake) return;
const intake = intakes[blisterIdx];
if (medId === m.id && intake) {
const usageForStock = normalizeIntakeUsageForStock(intake, m);
// Convert blister start to date-only for comparison (dose timestamps are date-only)
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
const intakeStartDate = new Date(intake.start);
const intakeStartDateOnly = new Date(
intakeStartDate.getFullYear(),
intakeStartDate.getMonth(),
intakeStartDate.getDate()
).getTime();
// Use actual takenAt timestamp for stock correction comparison.
@@ -295,8 +233,8 @@ export function calculateCoverage(
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
!Number.isNaN(intakeStartDateOnly) &&
doseTimestamp >= intakeStartDateOnly &&
afterCorrectionOrNoCorrectionMs
) {
consumed += usageForStock;
@@ -618,3 +556,48 @@ export function computeMissedPastDoseIds(
);
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id));
}
export function buildClearMissedPayload(
pastDays: ReadonlyArray<{
date: Date;
meds: ReadonlyArray<{
medName: string;
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
}>;
}>,
medications: ReadonlyArray<{ id: number; name: string; genericName?: string | null; dismissedUntil?: string | null }>,
takenDoses: Set<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,
};
}