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