Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37fc2b8e66 | |||
| d434131d02 | |||
| b796e03bcb | |||
| e1b47e82b2 | |||
| 68ab79c713 |
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-615%2F615-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-631%2F631-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-807%2F807-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-833%2F833-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -119,6 +119,12 @@ Share your medication schedule with others via a public link.
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Medication Setup
|
||||||
|
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`
|
||||||
|
- Explicit review-and-apply flow with low-risk suggestions only
|
||||||
|
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
|
||||||
|
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
|
|||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
import { exportRoutes } from "./routes/export.js";
|
import { exportRoutes } from "./routes/export.js";
|
||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
|
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||||
import { medicationRoutes } from "./routes/medications.js";
|
import { medicationRoutes } from "./routes/medications.js";
|
||||||
import { oidcRoutes } from "./routes/oidc.js";
|
import { oidcRoutes } from "./routes/oidc.js";
|
||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
@@ -29,6 +30,7 @@ import { reportRoutes } from "./routes/report.js";
|
|||||||
import { settingsRoutes } from "./routes/settings.js";
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||||
|
import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
|||||||
{ name: "health", description: "Service health endpoints" },
|
{ name: "health", description: "Service health endpoints" },
|
||||||
{ name: "auth", description: "Authentication and profile endpoints" },
|
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||||
{ name: "api-keys", description: "Programmatic API key management" },
|
{ name: "api-keys", description: "Programmatic API key management" },
|
||||||
|
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
||||||
{ name: "settings", description: "User settings and notification test endpoints" },
|
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
@@ -206,6 +209,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(apiKeyRoutes);
|
await app.register(apiKeyRoutes);
|
||||||
await app.register(oidcRoutes);
|
await app.register(oidcRoutes);
|
||||||
await app.register(medicationRoutes);
|
await app.register(medicationRoutes);
|
||||||
|
await app.register(medicationEnrichmentRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
@@ -287,6 +291,7 @@ await app.register(authRoutes);
|
|||||||
await app.register(apiKeyRoutes);
|
await app.register(apiKeyRoutes);
|
||||||
await app.register(oidcRoutes);
|
await app.register(oidcRoutes);
|
||||||
await app.register(medicationRoutes);
|
await app.register(medicationRoutes);
|
||||||
|
await app.register(medicationEnrichmentRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
@@ -307,6 +312,13 @@ const start = async () => {
|
|||||||
error: (msg) => app.log.error(msg),
|
error: (msg) => app.log.error(msg),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startMedicationEnrichmentCatalogRefresh({
|
||||||
|
info: (msg: string) => app.log.info(msg),
|
||||||
|
debug: (msg: string) => app.log.debug(msg),
|
||||||
|
warn: (msg: string) => app.log.warn(msg),
|
||||||
|
error: (msg: string) => app.log.error(msg),
|
||||||
|
});
|
||||||
|
|
||||||
// Start the intake reminder scheduler (checks every minute)
|
// Start the intake reminder scheduler (checks every minute)
|
||||||
startIntakeReminderScheduler({
|
startIntakeReminderScheduler({
|
||||||
info: (msg) => app.log.info(msg),
|
info: (msg) => app.log.info(msg),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAuth } from "../plugins/auth.js";
|
||||||
|
import {
|
||||||
|
enrichMedicationSelection,
|
||||||
|
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||||
|
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||||
|
type MedicationEnrichmentEnrichRequest,
|
||||||
|
MedicationEnrichmentServiceError,
|
||||||
|
searchMedicationEnrichment,
|
||||||
|
} from "../services/medication-enrichment.js";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
|
const searchQuerySchema = z.object({
|
||||||
|
q: z.string().trim().min(1).max(120),
|
||||||
|
limit: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
|
||||||
|
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichBodySchema = z.object({
|
||||||
|
query: z.string().trim().min(1).max(120),
|
||||||
|
name: z.string().trim().min(1).max(140),
|
||||||
|
genericName: z.string().trim().max(140).nullable().optional(),
|
||||||
|
code: z.string().trim().min(1).max(160).nullable().optional(),
|
||||||
|
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchQueryOpenApiSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["q"],
|
||||||
|
properties: {
|
||||||
|
q: { type: "string", minLength: 1, maxLength: 120 },
|
||||||
|
limit: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "string", pattern: "^[0-9]+$" },
|
||||||
|
{
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||||
|
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const enrichBodyOpenApiSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["query", "name"],
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", minLength: 1, maxLength: 120 },
|
||||||
|
name: { type: "string", minLength: 1, maxLength: 140 },
|
||||||
|
genericName: { type: "string", nullable: true, maxLength: 140 },
|
||||||
|
code: { type: "string", nullable: true, maxLength: 160 },
|
||||||
|
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const strengthOptionSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
label: { type: "string" },
|
||||||
|
pillWeightMg: { type: "number", nullable: true },
|
||||||
|
doseUnit: {
|
||||||
|
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const searchResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string" },
|
||||||
|
normalizedQuery: { type: "string" },
|
||||||
|
hasMore: { type: "boolean" },
|
||||||
|
results: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
code: { type: "string" },
|
||||||
|
name: { type: "string" },
|
||||||
|
genericName: { type: "string", nullable: true },
|
||||||
|
authorisationHolder: { type: "string", nullable: true },
|
||||||
|
therapeuticArea: { type: "string", nullable: true },
|
||||||
|
matchType: { type: "string", enum: ["brand", "ingredient"] },
|
||||||
|
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
||||||
|
authorisationDate: { type: "string", nullable: true },
|
||||||
|
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const enrichResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
selection: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
genericName: { type: "string", nullable: true },
|
||||||
|
therapeuticArea: { type: "string", nullable: true },
|
||||||
|
indication: { type: "string", nullable: true },
|
||||||
|
atcCode: { type: "string", nullable: true },
|
||||||
|
source: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
genericName: { type: "string", nullable: true },
|
||||||
|
medicationForm: {
|
||||||
|
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
||||||
|
},
|
||||||
|
strengthOptions: { type: "array", items: strengthOptionSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
rxNormMatched: { type: "boolean" },
|
||||||
|
openFdaMatched: { type: "boolean" },
|
||||||
|
partial: { type: "boolean" },
|
||||||
|
note: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function sendServiceError(error: unknown, reply: FastifyReply) {
|
||||||
|
if (error instanceof MedicationEnrichmentServiceError) {
|
||||||
|
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(503).send({
|
||||||
|
error: "Medication enrichment request failed.",
|
||||||
|
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/medication-enrichment/search",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
querystring: searchQueryOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: searchResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
503: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = searchQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
code:
|
||||||
|
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||||
|
},
|
||||||
|
"[MedicationEnrichment] Search request failed"
|
||||||
|
);
|
||||||
|
return sendServiceError(error, reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
|
||||||
|
"/medication-enrichment/enrich",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: enrichBodyOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: enrichResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
503: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = enrichBodySchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await enrichMedicationSelection(parsed.data, request.log);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
code:
|
||||||
|
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||||
|
},
|
||||||
|
"[MedicationEnrichment] Enrich request failed"
|
||||||
|
);
|
||||||
|
return sendServiceError(error, reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
|
const { fetchMock, requireAuthMock } = vi.hoisted(() => ({
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
requireAuthMock: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/auth.js", () => ({
|
||||||
|
requireAuth: requireAuthMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmaRow(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
category: "Human",
|
||||||
|
medicine_status: "Authorised",
|
||||||
|
name_of_medicine: "Aspirin 500 mg tablets",
|
||||||
|
international_non_proprietary_name_common_name: "Acetylsalicylic acid",
|
||||||
|
active_substance: "Acetylsalicylic acid",
|
||||||
|
marketing_authorisation_developer_applicant_holder: "Bayer",
|
||||||
|
therapeutic_area_mesh: "Pain",
|
||||||
|
therapeutic_indication: "Pain relief",
|
||||||
|
atc_code_human: "N02BA01",
|
||||||
|
generic_or_hybrid: "No",
|
||||||
|
biosimilar: "No",
|
||||||
|
marketing_authorisation_date: "01/02/2024",
|
||||||
|
ema_product_number: "EMA-ASPIRIN",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js");
|
||||||
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
await app.register(sensible);
|
||||||
|
await app.register(medicationEnrichmentRoutes);
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("medication enrichment", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
requireAuthMock.mockReset();
|
||||||
|
requireAuthMock.mockImplementation(async () => {});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes German ingredient queries for EMA-backed search results", async () => {
|
||||||
|
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse([
|
||||||
|
createEmaRow({
|
||||||
|
name_of_medicine: "Tylenol 500 mg tablets",
|
||||||
|
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||||
|
active_substance: "Acetaminophen",
|
||||||
|
ema_product_number: "EMA-TYLENOL",
|
||||||
|
}),
|
||||||
|
createEmaRow({
|
||||||
|
name_of_medicine: "Ibuprofen 400 mg tablets",
|
||||||
|
international_non_proprietary_name_common_name: "Ibuprofen",
|
||||||
|
active_substance: "Ibuprofen",
|
||||||
|
ema_product_number: "EMA-IBUPROFEN",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/drugs.json?name=")) {
|
||||||
|
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(jsonResponse({ results: [] }));
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5);
|
||||||
|
|
||||||
|
expect(response.normalizedQuery).toBe("paracetamol 500 mg");
|
||||||
|
expect(response.results).toHaveLength(1);
|
||||||
|
expect(response.results[0]).toMatchObject({
|
||||||
|
code: "EMA-TYLENOL",
|
||||||
|
name: "Tylenol 500 mg tablets",
|
||||||
|
matchType: "ingredient",
|
||||||
|
source: "ema",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires auth and returns EMA search results from the route", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("/drugs.json?name=")) {
|
||||||
|
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(jsonResponse({ results: [] }));
|
||||||
|
}
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/medication-enrichment/search?q=aspirin&limit=1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(requireAuthMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
query: "aspirin",
|
||||||
|
normalizedQuery: "aspirin",
|
||||||
|
hasMore: false,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
code: "EMA-ASPIRIN",
|
||||||
|
name: "Aspirin 500 mg tablets",
|
||||||
|
source: "ema",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => {
|
||||||
|
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||||
|
}
|
||||||
|
if (url.includes("/drugs.json?name=semaglutide")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
drugGroup: {
|
||||||
|
conceptGroup: [
|
||||||
|
{
|
||||||
|
tty: "SBD",
|
||||||
|
conceptProperties: [
|
||||||
|
{
|
||||||
|
rxcui: "12345",
|
||||||
|
name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]",
|
||||||
|
synonym: "Wegovy 0.25 mg oral tablet",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
product_ndc: "00011-1111",
|
||||||
|
brand_name: "Ozempic",
|
||||||
|
generic_name: "Semaglutide",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
marketing_start_date: "20240101",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await searchMedicationEnrichment("Semaglutide", 3);
|
||||||
|
|
||||||
|
expect(response.hasMore).toBe(false);
|
||||||
|
expect(response.results).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "12345",
|
||||||
|
name: "Wegovy",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
source: "rxnorm",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "00011-1111",
|
||||||
|
name: "Ozempic",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
source: "openfda",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => {
|
||||||
|
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||||
|
}
|
||||||
|
if (url.includes("/drugs.json?name=")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
drugGroup: {
|
||||||
|
conceptGroup: [
|
||||||
|
{
|
||||||
|
tty: "SBD",
|
||||||
|
conceptProperties: [
|
||||||
|
{
|
||||||
|
rxcui: "1191",
|
||||||
|
name: "Aspirin 500 MG Oral Tablet [Aspirin]",
|
||||||
|
synonym: "Aspirin 500 mg oral tablet",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
product_ndc: "00011-1111",
|
||||||
|
brand_name: "Bayer Aspirin",
|
||||||
|
generic_name: "Acetylsalicylic acid",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
marketing_start_date: "20240101",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await searchMedicationEnrichment("Aspirin", 3);
|
||||||
|
|
||||||
|
expect(response.hasMore).toBe(false);
|
||||||
|
expect(response.results).toHaveLength(3);
|
||||||
|
expect(response.results[0]).toMatchObject({
|
||||||
|
code: "1191",
|
||||||
|
source: "rxnorm",
|
||||||
|
});
|
||||||
|
expect(response.results[1]).toMatchObject({
|
||||||
|
code: "00011-1111",
|
||||||
|
source: "openfda",
|
||||||
|
});
|
||||||
|
expect(response.results[2]).toMatchObject({
|
||||||
|
code: "EMA-ASPIRIN",
|
||||||
|
source: "ema",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates malformed search requests", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/medication-enrichment/search?q=",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns enrichment suggestions with optional RxNorm strength data", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse([
|
||||||
|
createEmaRow({
|
||||||
|
name_of_medicine: "Tylenol 500 mg tablets",
|
||||||
|
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||||
|
active_substance: "Acetaminophen",
|
||||||
|
ema_product_number: "EMA-TYLENOL",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
relatedGroup: {
|
||||||
|
conceptGroup: [
|
||||||
|
{
|
||||||
|
conceptProperties: [
|
||||||
|
{ name: "Acetaminophen 500 MG Oral Tablet" },
|
||||||
|
{ name: "Acetaminophen 650 MG Oral Tablet" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medication-enrichment/enrich",
|
||||||
|
payload: {
|
||||||
|
query: "Paracetamol",
|
||||||
|
name: "Tylenol 500 mg tablets",
|
||||||
|
genericName: "Acetaminophen",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
selection: {
|
||||||
|
name: "Tylenol 500 mg tablets",
|
||||||
|
genericName: "Acetaminophen",
|
||||||
|
source: "ema+rxnorm",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
medicationForm: "tablet",
|
||||||
|
strengthOptions: [
|
||||||
|
{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" },
|
||||||
|
{ label: "650 mg", pillWeightMg: 650, doseUnit: "mg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: true,
|
||||||
|
openFdaMatched: false,
|
||||||
|
partial: false,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
|
||||||
|
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse([
|
||||||
|
createEmaRow({
|
||||||
|
name_of_medicine: "Tylenol 500 mg tablets",
|
||||||
|
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||||
|
active_substance: "Acetaminophen",
|
||||||
|
ema_product_number: "EMA-TYLENOL",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
|
||||||
|
return Promise.reject(new Error("rxnorm timeout"));
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(jsonResponse({ results: [] }));
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await enrichMedicationSelection({
|
||||||
|
query: "Paracetamol",
|
||||||
|
name: "Tylenol 500 mg tablets",
|
||||||
|
genericName: "Acetaminophen",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.selection.source).toBe("ema");
|
||||||
|
expect(response.suggestions.strengthOptions).toEqual([]);
|
||||||
|
expect(response.meta).toEqual({
|
||||||
|
rxNormMatched: false,
|
||||||
|
openFdaMatched: false,
|
||||||
|
partial: true,
|
||||||
|
note: "Returned EMA enrichment without RxNorm suggestions.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => {
|
||||||
|
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("/rxcui/12345/related.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
relatedGroup: {
|
||||||
|
conceptGroup: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
product_ndc: "00011-1111",
|
||||||
|
brand_name: "Ozempic",
|
||||||
|
generic_name: "Semaglutide",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await enrichMedicationSelection({
|
||||||
|
query: "Ozempic",
|
||||||
|
name: "Ozempic",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
code: "12345",
|
||||||
|
source: "rxnorm",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
selection: {
|
||||||
|
name: "Ozempic",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
source: "rxnorm+openfda",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
medicationForm: "tablet",
|
||||||
|
strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: false,
|
||||||
|
openFdaMatched: true,
|
||||||
|
partial: false,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => {
|
||||||
|
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("search=product_ndc%3A%2200011-1111%22")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
product_ndc: "00011-1111",
|
||||||
|
brand_name: "US Ibuprofen",
|
||||||
|
generic_name: "Ibuprofen",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/rxcui.json?name=ibuprofen&search=2")) {
|
||||||
|
return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } }));
|
||||||
|
}
|
||||||
|
if (url.includes("/rxcui/161/related.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
relatedGroup: {
|
||||||
|
conceptGroup: [
|
||||||
|
{
|
||||||
|
conceptProperties: [
|
||||||
|
{ name: "Ibuprofen 200 MG Oral Tablet" },
|
||||||
|
{ name: "Ibuprofen 400 MG Oral Tablet" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await enrichMedicationSelection({
|
||||||
|
query: "US Ibuprofen",
|
||||||
|
name: "US Ibuprofen",
|
||||||
|
genericName: "Ibuprofen",
|
||||||
|
code: "00011-1111",
|
||||||
|
source: "openfda",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
selection: {
|
||||||
|
name: "US Ibuprofen",
|
||||||
|
genericName: "Ibuprofen",
|
||||||
|
source: "rxnorm+openfda",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
medicationForm: "tablet",
|
||||||
|
strengthOptions: [
|
||||||
|
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
||||||
|
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: true,
|
||||||
|
openFdaMatched: true,
|
||||||
|
partial: false,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not found when an explicit selection cannot be resolved", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()]));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medication-enrichment/enrich",
|
||||||
|
payload: {
|
||||||
|
query: "Unknown",
|
||||||
|
name: "Completely Different Medication",
|
||||||
|
genericName: "No match",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
code: "MEDICATION_ENRICHMENT_NOT_FOUND",
|
||||||
|
error: "Selected medication could not be resolved.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type {
|
||||||
|
MedicationEnrichmentEnrichResponse,
|
||||||
|
MedicationEnrichmentSearchResult,
|
||||||
|
MedicationEnrichmentStrengthOption,
|
||||||
|
} from "../types";
|
||||||
|
import { formatDate } from "../utils/formatters";
|
||||||
|
|
||||||
|
export interface MedicationEnrichmentViewModel {
|
||||||
|
query: string;
|
||||||
|
results: MedicationEnrichmentSearchResult[];
|
||||||
|
hasMoreResults?: boolean;
|
||||||
|
isSearching: boolean;
|
||||||
|
hasSearched: boolean;
|
||||||
|
searchError: string | null;
|
||||||
|
applyingCode: string | null;
|
||||||
|
activeResultCode: string | null;
|
||||||
|
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||||
|
enrichError: string | null;
|
||||||
|
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||||
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
appliedStrengthLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedicationEnrichmentSectionProps {
|
||||||
|
state: MedicationEnrichmentViewModel;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
onLoadMoreResults?: () => void;
|
||||||
|
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
|
||||||
|
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedicationEnrichmentSection({
|
||||||
|
state,
|
||||||
|
onQueryChange,
|
||||||
|
onSearch,
|
||||||
|
onLoadMoreResults,
|
||||||
|
onApplyResult,
|
||||||
|
onApplyStrength,
|
||||||
|
}: MedicationEnrichmentSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
|
||||||
|
const shouldAutoExpand =
|
||||||
|
state.isSearching ||
|
||||||
|
state.hasSearched ||
|
||||||
|
state.searchError !== null ||
|
||||||
|
state.enrichError !== null ||
|
||||||
|
state.results.length > 0 ||
|
||||||
|
state.appliedSelection !== null ||
|
||||||
|
state.strengthOptions.length > 0 ||
|
||||||
|
state.appliedStrengthLabel !== null ||
|
||||||
|
Boolean(state.meta?.partial);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
|
||||||
|
const autoExpandStateRef = useRef(shouldAutoExpand);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldAutoExpand && !autoExpandStateRef.current) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoExpandStateRef.current = shouldAutoExpand;
|
||||||
|
}, [shouldAutoExpand]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="full medication-enrichment-section">
|
||||||
|
<div className="medication-enrichment-header">
|
||||||
|
<div>
|
||||||
|
<h5 className="form-category-title medication-enrichment-title">{t("form.enrichment.title")}</h5>
|
||||||
|
<p className="sub medication-enrichment-collapsed-hint">{t("form.enrichment.collapsedHint")}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary small"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{isExpanded ? t("form.enrichment.toggleHide") : t("form.enrichment.toggleShow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="medication-enrichment-body">
|
||||||
|
<div className="medication-enrichment-helper-row">
|
||||||
|
<span className="status-chip small warning">{t("form.enrichment.coverageLabel")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
aria-expanded={showInfo}
|
||||||
|
onClick={() => setShowInfo((current) => !current)}
|
||||||
|
>
|
||||||
|
{showInfo ? t("form.enrichment.infoHide") : t("form.enrichment.infoShow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInfo ? (
|
||||||
|
<div className="medication-enrichment-info">
|
||||||
|
<p className="medication-enrichment-info-title">{t("form.enrichment.infoTitle")}</p>
|
||||||
|
<p className="sub medication-enrichment-description">{t("form.enrichment.description")}</p>
|
||||||
|
<p className="sub medication-enrichment-manual-hint">{t("form.enrichment.manualEntryHint")}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="full">
|
||||||
|
{t("form.enrichment.searchLabel")}
|
||||||
|
<div className="medication-enrichment-search-row">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={state.query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSearch) return;
|
||||||
|
onSearch();
|
||||||
|
}}
|
||||||
|
placeholder={t("form.enrichment.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
<button type="button" className="secondary small" onClick={onSearch} disabled={!canSearch}>
|
||||||
|
{state.isSearching ? t("form.enrichment.searching") : t("form.enrichment.searchAction")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
|
||||||
|
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
|
||||||
|
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
|
||||||
|
{state.hasSearched && !state.isSearching && state.results.length === 0 ? (
|
||||||
|
<p className="info-text">{t("form.enrichment.noResults")}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.results.length > 0 ? (
|
||||||
|
<div className="medication-enrichment-results">
|
||||||
|
{state.results.map((result) => {
|
||||||
|
const isActive = state.activeResultCode === result.code;
|
||||||
|
const hasDetails = Boolean(
|
||||||
|
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
|
||||||
|
);
|
||||||
|
const isDetailsExpanded = expandedResultCode === result.code;
|
||||||
|
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
|
||||||
|
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
|
||||||
|
let applyLabel = t("form.enrichment.applyAction");
|
||||||
|
if (state.applyingCode === result.code) {
|
||||||
|
applyLabel = t("form.enrichment.applying");
|
||||||
|
} else if (isActive && state.appliedSelection) {
|
||||||
|
applyLabel = t("form.enrichment.applied");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={result.code} className={`medication-enrichment-result${isActive ? " active" : ""}`}>
|
||||||
|
<div className="medication-enrichment-result-header">
|
||||||
|
<div className="medication-enrichment-result-names">
|
||||||
|
<strong>{result.name}</strong>
|
||||||
|
{result.genericName ? (
|
||||||
|
<span className="medication-enrichment-result-generic">{result.genericName}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="medication-enrichment-result-actions">
|
||||||
|
<span className={`pill ${sourceClass}`}>{t(`form.enrichment.sources.${result.source}`)}</span>
|
||||||
|
{result.source === "ema" ? (
|
||||||
|
<span className={`pill ${genericStatusClass}`}>
|
||||||
|
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{hasDetails ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
aria-expanded={isDetailsExpanded}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedResultCode((current) => (current === result.code ? null : result.code))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDetailsExpanded
|
||||||
|
? t("form.enrichment.details.hideAction")
|
||||||
|
: t("form.enrichment.details.showAction")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isActive ? "secondary small" : "primary small"}
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedResultCode(result.code);
|
||||||
|
onApplyResult(result);
|
||||||
|
}}
|
||||||
|
disabled={state.applyingCode === result.code}
|
||||||
|
>
|
||||||
|
{applyLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasDetails && isDetailsExpanded ? (
|
||||||
|
<dl className="medication-enrichment-result-meta">
|
||||||
|
{result.authorisationHolder ? (
|
||||||
|
<div>
|
||||||
|
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
|
||||||
|
<dd>{result.authorisationHolder}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result.therapeuticArea ? (
|
||||||
|
<div>
|
||||||
|
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
|
||||||
|
<dd>{result.therapeuticArea}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result.authorisationDate ? (
|
||||||
|
<div>
|
||||||
|
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
|
||||||
|
<dd>{formatDate(result.authorisationDate)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
|
||||||
|
<div className="medication-enrichment-results-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary small"
|
||||||
|
onClick={onLoadMoreResults}
|
||||||
|
disabled={state.isSearching || Boolean(state.applyingCode)}
|
||||||
|
>
|
||||||
|
{t("form.enrichment.showMoreAction")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? (
|
||||||
|
<div className="medication-enrichment-followup">
|
||||||
|
{state.appliedSelection ? (
|
||||||
|
<div>
|
||||||
|
<p className="success-text">{t("form.enrichment.applied")}</p>
|
||||||
|
<p className="sub medication-enrichment-selection-summary">
|
||||||
|
<strong>{state.appliedSelection.name}</strong>
|
||||||
|
{state.appliedSelection.genericName ? ` • ${state.appliedSelection.genericName}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.strengthOptions.length > 0 ? (
|
||||||
|
<div className="medication-enrichment-strengths">
|
||||||
|
<p className="medication-enrichment-strength-title">{t("form.enrichment.strengthTitle")}</p>
|
||||||
|
<p className="sub">{t("form.enrichment.strengthHint")}</p>
|
||||||
|
<div className="medication-enrichment-strength-list">
|
||||||
|
{state.strengthOptions.map((option) => {
|
||||||
|
const isSelected = state.appliedStrengthLabel === option.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.label}
|
||||||
|
type="button"
|
||||||
|
className={isSelected ? "primary small" : "secondary small"}
|
||||||
|
onClick={() => onApplyStrength(option)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.appliedStrengthLabel ? (
|
||||||
|
<p className="success-text">
|
||||||
|
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
import { useScrollLock } from "../hooks/useScrollLock";
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
import type {
|
||||||
|
DoseUnit,
|
||||||
|
FieldErrors,
|
||||||
|
FormBlister,
|
||||||
|
FormIntake,
|
||||||
|
FormState,
|
||||||
|
Medication,
|
||||||
|
MedicationEnrichmentSearchResult,
|
||||||
|
MedicationEnrichmentStrengthOption,
|
||||||
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
allowsPillFormSelection,
|
allowsPillFormSelection,
|
||||||
DOSE_UNITS,
|
DOSE_UNITS,
|
||||||
@@ -19,8 +28,17 @@ 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";
|
||||||
|
import type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
|
||||||
|
import { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
|
||||||
|
|
||||||
// Field limits for validation
|
// Field limits for validation
|
||||||
const FIELD_LIMITS = {
|
const FIELD_LIMITS = {
|
||||||
@@ -33,11 +51,33 @@ const FIELD_LIMITS = {
|
|||||||
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
|
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
|
||||||
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
|
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
|
||||||
|
|
||||||
|
const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = {
|
||||||
|
query: "",
|
||||||
|
results: [],
|
||||||
|
hasMoreResults: false,
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: false,
|
||||||
|
searchError: null,
|
||||||
|
applyingCode: null,
|
||||||
|
activeResultCode: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
enrichError: null,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
};
|
||||||
|
|
||||||
export interface MobileEditModalProps {
|
export interface MobileEditModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
editingId: number | null;
|
editingId: number | null;
|
||||||
form: FormState;
|
form: FormState;
|
||||||
onFormChange: (form: FormState) => void;
|
onFormChange: (form: FormState) => void;
|
||||||
|
medicationEnrichment?: MedicationEnrichmentViewModel;
|
||||||
|
onMedicationEnrichmentQueryChange?: (value: string) => void;
|
||||||
|
onMedicationEnrichmentSearch?: () => void;
|
||||||
|
onMedicationEnrichmentLoadMore?: () => void;
|
||||||
|
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
|
||||||
|
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
|
||||||
fieldErrors: FieldErrors;
|
fieldErrors: FieldErrors;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
formSaved: boolean;
|
formSaved: boolean;
|
||||||
@@ -57,7 +97,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
|
||||||
@@ -90,6 +130,12 @@ export function MobileEditModal({
|
|||||||
editingId,
|
editingId,
|
||||||
form,
|
form,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
|
medicationEnrichment = EMPTY_MEDICATION_ENRICHMENT,
|
||||||
|
onMedicationEnrichmentQueryChange = () => {},
|
||||||
|
onMedicationEnrichmentSearch = () => {},
|
||||||
|
onMedicationEnrichmentLoadMore = () => {},
|
||||||
|
onMedicationEnrichmentApply = () => {},
|
||||||
|
onMedicationEnrichmentStrengthApply = () => {},
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
saving,
|
saving,
|
||||||
formSaved,
|
formSaved,
|
||||||
@@ -158,6 +204,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(() => {
|
||||||
@@ -421,6 +485,14 @@ export function MobileEditModal({
|
|||||||
<span className="field-error">{fieldErrors.genericName}</span>
|
<span className="field-error">{fieldErrors.genericName}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={medicationEnrichment}
|
||||||
|
onQueryChange={onMedicationEnrichmentQueryChange}
|
||||||
|
onSearch={onMedicationEnrichmentSearch}
|
||||||
|
onLoadMoreResults={onMedicationEnrichmentLoadMore}
|
||||||
|
onApplyResult={onMedicationEnrichmentApply}
|
||||||
|
onApplyStrength={onMedicationEnrichmentStrengthApply}
|
||||||
|
/>
|
||||||
<div className="full date-pair-group">
|
<div className="full date-pair-group">
|
||||||
<label className="date-pair-field">
|
<label className="date-pair-field">
|
||||||
{t("form.medicationStartDate")}
|
{t("form.medicationStartDate")}
|
||||||
@@ -815,7 +887,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 +905,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 +1103,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type { MedDetailModalProps } from "./MedDetailModal";
|
|||||||
export { MedDetailModal } from "./MedDetailModal";
|
export { MedDetailModal } from "./MedDetailModal";
|
||||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||||
export { MedicationAvatar } from "./MedicationAvatar";
|
export { MedicationAvatar } from "./MedicationAvatar";
|
||||||
|
export type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
|
||||||
|
export { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
|
||||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||||
export { MobileEditModal } from "./MobileEditModal";
|
export { MobileEditModal } from "./MobileEditModal";
|
||||||
export { PasswordInput } from "./PasswordInput";
|
export { PasswordInput } from "./PasswordInput";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -225,6 +225,50 @@
|
|||||||
"weight": "z.B. 240",
|
"weight": "z.B. 240",
|
||||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||||
},
|
},
|
||||||
|
"enrichment": {
|
||||||
|
"title": "Optionale Medikamentensuche",
|
||||||
|
"coverageLabel": "Unvollständige freie Abdeckung",
|
||||||
|
"collapsedHint": "Öffne das nur, wenn du Suchvorschläge nutzen möchtest.",
|
||||||
|
"toggleShow": "Suche anzeigen",
|
||||||
|
"toggleHide": "Suche ausblenden",
|
||||||
|
"infoShow": "Infos zu den Quellen",
|
||||||
|
"infoHide": "Quellenhinweise ausblenden",
|
||||||
|
"infoTitle": "Was du erwarten kannst",
|
||||||
|
"description": "Durchsuche zuerst RxNorm und openFDA, nutze EMA nur als letzten Fallback und prüfe jeden Treffer, bevor du etwas ins Formular übernimmst.",
|
||||||
|
"searchLabel": "Medikamentenquellen durchsuchen",
|
||||||
|
"searchPlaceholder": "Nach Marke oder Wirkstoff suchen",
|
||||||
|
"searchAction": "Suchen",
|
||||||
|
"searching": "Suche läuft...",
|
||||||
|
"showMoreAction": "Mehr Treffer anzeigen",
|
||||||
|
"noResults": "Es wurden in der aktuellen Freiquellen-Suche keine Treffer gefunden. Du kannst das Medikament manuell weiter erfassen.",
|
||||||
|
"manualEntryHint": "Diese Hilfe ist optional und kann Medikamente, Stärken oder lokale Marktvarianten übersehen.",
|
||||||
|
"searchError": "Die Medikamentensuche ist momentan nicht verfügbar. Bitte fahre mit der manuellen Eingabe fort.",
|
||||||
|
"applyAction": "Übernehmen",
|
||||||
|
"applying": "Wird übernommen...",
|
||||||
|
"applied": "Ins Formular übernommen",
|
||||||
|
"applyError": "Das Autofill konnte nicht übernommen werden. Bitte bearbeite das Medikament manuell weiter.",
|
||||||
|
"partialNote": "Es waren nur teilweise Autofill-Vorschläge verfügbar. Prüfe die Felder vor dem Speichern.",
|
||||||
|
"strengthTitle": "Stärke-Vorschläge",
|
||||||
|
"strengthHint": "Wähle eine Stärke aus, um Dosis pro Tablette und Einheit zu aktualisieren.",
|
||||||
|
"appliedStrength": "Übernommene Stärke: {{label}}",
|
||||||
|
"details": {
|
||||||
|
"showAction": "Mehr Details",
|
||||||
|
"hideAction": "Weniger Details",
|
||||||
|
"authorisationHolder": "Zulassungsinhaber",
|
||||||
|
"therapeuticArea": "Therapiebereich",
|
||||||
|
"authorisationDate": "Zulassungsdatum"
|
||||||
|
},
|
||||||
|
"genericStatus": {
|
||||||
|
"generic": "Generikum",
|
||||||
|
"original": "Original",
|
||||||
|
"unknown": "Status unbekannt"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"ema": "EMA",
|
||||||
|
"rxnorm": "RxNorm",
|
||||||
|
"openfda": "openFDA (USA)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
|
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
|
||||||
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
|
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
|
||||||
@@ -257,8 +301,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",
|
||||||
|
|||||||
@@ -225,6 +225,50 @@
|
|||||||
"weight": "e.g. 240",
|
"weight": "e.g. 240",
|
||||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||||
},
|
},
|
||||||
|
"enrichment": {
|
||||||
|
"title": "Optional medication lookup",
|
||||||
|
"coverageLabel": "Incomplete free-source coverage",
|
||||||
|
"collapsedHint": "Open this only if you want lookup suggestions.",
|
||||||
|
"toggleShow": "Show lookup",
|
||||||
|
"toggleHide": "Hide lookup",
|
||||||
|
"infoShow": "About sources",
|
||||||
|
"infoHide": "Hide source notes",
|
||||||
|
"infoTitle": "What to expect",
|
||||||
|
"description": "Search RxNorm and openFDA first, use EMA as a last fallback, and review each result before applying anything to the form.",
|
||||||
|
"searchLabel": "Search medication sources",
|
||||||
|
"searchPlaceholder": "Search by brand or ingredient",
|
||||||
|
"searchAction": "Search",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"showMoreAction": "Show more results",
|
||||||
|
"noResults": "No matches were found in the current free-source search. You can continue entering the medication manually.",
|
||||||
|
"manualEntryHint": "This helper is optional and may miss medications, strengths, or local market variants.",
|
||||||
|
"searchError": "Medication lookup is currently unavailable. Please continue with manual entry.",
|
||||||
|
"applyAction": "Apply",
|
||||||
|
"applying": "Applying...",
|
||||||
|
"applied": "Applied to form",
|
||||||
|
"applyError": "Autofill could not be applied. Please keep editing the medication manually.",
|
||||||
|
"partialNote": "Only partial autofill suggestions were available. Review the fields before saving.",
|
||||||
|
"strengthTitle": "Strength suggestions",
|
||||||
|
"strengthHint": "Choose a strength to update dose per pill and unit.",
|
||||||
|
"appliedStrength": "Applied strength: {{label}}",
|
||||||
|
"details": {
|
||||||
|
"showAction": "More details",
|
||||||
|
"hideAction": "Less details",
|
||||||
|
"authorisationHolder": "Authorisation holder",
|
||||||
|
"therapeuticArea": "Therapeutic area",
|
||||||
|
"authorisationDate": "Authorisation date"
|
||||||
|
},
|
||||||
|
"genericStatus": {
|
||||||
|
"generic": "Generic",
|
||||||
|
"original": "Original",
|
||||||
|
"unknown": "Status unknown"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"ema": "EMA",
|
||||||
|
"rxnorm": "RxNorm",
|
||||||
|
"openfda": "openFDA (US)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
|
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
|
||||||
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
|
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
|
||||||
@@ -257,8 +301,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");
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ import {
|
|||||||
FormNumberStepper,
|
FormNumberStepper,
|
||||||
Lightbox,
|
Lightbox,
|
||||||
MedicationAvatar,
|
MedicationAvatar,
|
||||||
|
MedicationEnrichmentSection,
|
||||||
MobileEditModal,
|
MobileEditModal,
|
||||||
ReportModal,
|
ReportModal,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, FormState, Medication, PackageType } from "../types";
|
import type {
|
||||||
|
DoseUnit,
|
||||||
|
FormState,
|
||||||
|
Medication,
|
||||||
|
MedicationEnrichmentEnrichResponse,
|
||||||
|
MedicationEnrichmentSearchResponse,
|
||||||
|
MedicationEnrichmentSearchResult,
|
||||||
|
MedicationEnrichmentStrengthOption,
|
||||||
|
PackageType,
|
||||||
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
allowsPillFormSelection,
|
allowsPillFormSelection,
|
||||||
DOSE_UNITS,
|
DOSE_UNITS,
|
||||||
@@ -33,6 +43,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 {
|
||||||
@@ -40,6 +59,113 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
||||||
|
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
|
||||||
|
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
|
||||||
|
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
|
||||||
|
|
||||||
|
type MedicationEnrichmentState = {
|
||||||
|
query: string;
|
||||||
|
results: MedicationEnrichmentSearchResult[];
|
||||||
|
hasMoreResults: boolean;
|
||||||
|
resultLimit: number;
|
||||||
|
isSearching: boolean;
|
||||||
|
hasSearched: boolean;
|
||||||
|
searchError: string | null;
|
||||||
|
applyingCode: string | null;
|
||||||
|
activeResultCode: string | null;
|
||||||
|
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||||
|
enrichError: string | null;
|
||||||
|
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||||
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
appliedStrengthLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMedicationEnrichmentState(
|
||||||
|
query = "",
|
||||||
|
resultLimit = MEDICATION_ENRICHMENT_INITIAL_LIMIT
|
||||||
|
): MedicationEnrichmentState {
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
results: [],
|
||||||
|
hasMoreResults: false,
|
||||||
|
resultLimit,
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: false,
|
||||||
|
searchError: null,
|
||||||
|
applyingCode: null,
|
||||||
|
activeResultCode: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
enrichError: null,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null {
|
||||||
|
if (unit === "IU") return "units";
|
||||||
|
if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMedicationEnrichmentSuggestions(
|
||||||
|
form: FormState,
|
||||||
|
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
|
||||||
|
): FormState {
|
||||||
|
const nextForm: FormState = {
|
||||||
|
...form,
|
||||||
|
name: suggestions.name,
|
||||||
|
genericName: suggestions.genericName ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (suggestions.medicationForm === "tablet" || suggestions.medicationForm === "capsule") {
|
||||||
|
return {
|
||||||
|
...nextForm,
|
||||||
|
medicationForm: suggestions.medicationForm,
|
||||||
|
pillForm: suggestions.medicationForm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.medicationForm === "liquid" || suggestions.medicationForm === "topical") {
|
||||||
|
return {
|
||||||
|
...nextForm,
|
||||||
|
medicationForm: suggestions.medicationForm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMedicationEnrichmentStrength(
|
||||||
|
form: FormState,
|
||||||
|
option: MedicationEnrichmentStrengthOption
|
||||||
|
): FormState | null {
|
||||||
|
if (option.pillWeightMg === null) return null;
|
||||||
|
const doseUnit = normalizeMedicationEnrichmentDoseUnit(option.doseUnit);
|
||||||
|
if (!doseUnit) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
pillWeightMg: `${option.pillWeightMg}`,
|
||||||
|
doseUnit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMedicationEnrichmentErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const errorBody = (await response.json()) as { error?: string; message?: string };
|
||||||
|
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
|
||||||
|
return errorBody.error;
|
||||||
|
}
|
||||||
|
if (typeof errorBody?.message === "string" && errorBody.message.trim().length > 0) {
|
||||||
|
return errorBody.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep translated fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function MedicationsPage() {
|
export function MedicationsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -153,6 +279,88 @@ export function MedicationsPage() {
|
|||||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||||
|
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
|
||||||
|
createMedicationEnrichmentState()
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetMedicationEnrichment = useCallback((query = "") => {
|
||||||
|
setMedicationEnrichment(createMedicationEnrichmentState(query));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
query: value,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const performMedicationEnrichmentSearch = useCallback(
|
||||||
|
async (requestedLimit: number, preserveExistingResults = false) => {
|
||||||
|
const trimmedQuery = medicationEnrichment.query.trim();
|
||||||
|
if (!trimmedQuery) return;
|
||||||
|
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
|
||||||
|
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
query: trimmedQuery,
|
||||||
|
results: preserveExistingResults ? previous.results : [],
|
||||||
|
hasMoreResults: false,
|
||||||
|
resultLimit: limit,
|
||||||
|
isSearching: true,
|
||||||
|
hasSearched: preserveExistingResults ? previous.hasSearched : false,
|
||||||
|
searchError: null,
|
||||||
|
applyingCode: null,
|
||||||
|
...(preserveExistingResults
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
activeResultCode: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
enrichError: null,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
|
||||||
|
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.searchError")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
|
||||||
|
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
query: data.query,
|
||||||
|
results: Array.isArray(data.results) ? data.results : [],
|
||||||
|
hasMoreResults: Boolean(data.hasMore),
|
||||||
|
resultLimit: limit,
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: true,
|
||||||
|
searchError: null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
|
||||||
|
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
results: preserveExistingResults ? previous.results : [],
|
||||||
|
hasMoreResults: false,
|
||||||
|
resultLimit: limit,
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: true,
|
||||||
|
searchError: message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[medicationEnrichment.query, t]
|
||||||
|
);
|
||||||
|
|
||||||
const handlePendingMedicationImageSelection = useCallback(
|
const handlePendingMedicationImageSelection = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -249,6 +457,126 @@ export function MedicationsPage() {
|
|||||||
[deleteMedImage, loadAllMeds]
|
[deleteMedImage, loadAllMeds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
|
||||||
|
if (!allowsPillFormSelection(form.packageType)) return [];
|
||||||
|
|
||||||
|
return medicationEnrichment.strengthOptions.filter(
|
||||||
|
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
|
||||||
|
);
|
||||||
|
}, [form.packageType, medicationEnrichment.strengthOptions]);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentSearch = useCallback(async () => {
|
||||||
|
await performMedicationEnrichmentSearch(MEDICATION_ENRICHMENT_INITIAL_LIMIT);
|
||||||
|
}, [performMedicationEnrichmentSearch]);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentLoadMore = useCallback(async () => {
|
||||||
|
if (medicationEnrichment.isSearching || !medicationEnrichment.hasMoreResults) return;
|
||||||
|
await performMedicationEnrichmentSearch(
|
||||||
|
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
medicationEnrichment.hasMoreResults,
|
||||||
|
medicationEnrichment.isSearching,
|
||||||
|
medicationEnrichment.resultLimit,
|
||||||
|
performMedicationEnrichmentSearch,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentApply = useCallback(
|
||||||
|
async (result: MedicationEnrichmentSearchResult) => {
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
applyingCode: result.code,
|
||||||
|
activeResultCode: result.code,
|
||||||
|
enrichError: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/medication-enrichment/enrich", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: medicationEnrichment.query.trim() || result.name,
|
||||||
|
name: result.name,
|
||||||
|
genericName: result.genericName ?? null,
|
||||||
|
code: result.code,
|
||||||
|
source: result.source,
|
||||||
|
}),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.applyError")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
|
||||||
|
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
|
||||||
|
let appliedStrengthLabel: string | null = null;
|
||||||
|
|
||||||
|
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
|
||||||
|
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
|
||||||
|
if (autoAppliedForm) {
|
||||||
|
nextForm = autoAppliedForm;
|
||||||
|
appliedStrengthLabel = data.suggestions.strengthOptions[0].label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm(nextForm);
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
applyingCode: null,
|
||||||
|
activeResultCode: result.code,
|
||||||
|
appliedSelection: data.selection,
|
||||||
|
enrichError: null,
|
||||||
|
meta: data.meta,
|
||||||
|
strengthOptions: data.suggestions.strengthOptions,
|
||||||
|
appliedStrengthLabel,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.applyError");
|
||||||
|
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
applyingCode: null,
|
||||||
|
activeResultCode: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
enrichError: message,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form, medicationEnrichment.query, setForm, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentStrengthApply = useCallback(
|
||||||
|
(option: MedicationEnrichmentStrengthOption) => {
|
||||||
|
setForm((currentForm) => {
|
||||||
|
const nextForm = applyMedicationEnrichmentStrength(currentForm, option);
|
||||||
|
return nextForm ?? currentForm;
|
||||||
|
});
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
appliedStrengthLabel: option.label,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setForm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const medicationEnrichmentViewModel = useMemo(
|
||||||
|
() => ({
|
||||||
|
...medicationEnrichment,
|
||||||
|
strengthOptions: applicableMedicationEnrichmentStrengthOptions,
|
||||||
|
}),
|
||||||
|
[applicableMedicationEnrichmentStrengthOptions, medicationEnrichment]
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate total tablets
|
// Calculate total tablets
|
||||||
const totalTablets = useMemo(() => {
|
const totalTablets = useMemo(() => {
|
||||||
if (isAmountBasedPackageType(form.packageType)) {
|
if (isAmountBasedPackageType(form.packageType)) {
|
||||||
@@ -311,6 +639,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) => {
|
||||||
@@ -389,12 +735,14 @@ export function MedicationsPage() {
|
|||||||
if (pendingAction) {
|
if (pendingAction) {
|
||||||
// There's a pending action (e.g. switching to another medication) — reset and run it
|
// There's a pending action (e.g. switching to another medication) — reset and run it
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
pendingAction();
|
pendingAction();
|
||||||
} else if (source === "mobile-edit" && showEditModal) {
|
} else if (source === "mobile-edit" && showEditModal) {
|
||||||
clearEditMedIdParam();
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
@@ -422,6 +770,7 @@ export function MedicationsPage() {
|
|||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
@@ -512,7 +861,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 +883,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,
|
||||||
@@ -681,11 +1032,13 @@ export function MedicationsPage() {
|
|||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
setViewMode("grid");
|
setViewMode("grid");
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
window.history.back();
|
window.history.back();
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setViewMode("grid");
|
setViewMode("grid");
|
||||||
} else {
|
} else {
|
||||||
// Update originalForm so formChanged becomes false
|
// Update originalForm so formChanged becomes false
|
||||||
@@ -729,6 +1082,7 @@ export function MedicationsPage() {
|
|||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -751,6 +1105,7 @@ export function MedicationsPage() {
|
|||||||
clearEditMedIdParam();
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,6 +1119,7 @@ export function MedicationsPage() {
|
|||||||
}
|
}
|
||||||
hasDesktopFormHistoryState.current = false;
|
hasDesktopFormHistoryState.current = false;
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
@@ -807,6 +1163,7 @@ export function MedicationsPage() {
|
|||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
|
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
scrollToTopForDesktopEdit();
|
scrollToTopForDesktopEdit();
|
||||||
@@ -818,6 +1175,7 @@ export function MedicationsPage() {
|
|||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
|
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
scrollToTopForDesktopEdit();
|
scrollToTopForDesktopEdit();
|
||||||
@@ -828,6 +1186,7 @@ export function MedicationsPage() {
|
|||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
|
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
scrollToTopForDesktopEdit();
|
scrollToTopForDesktopEdit();
|
||||||
@@ -839,6 +1198,7 @@ export function MedicationsPage() {
|
|||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
|
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
scrollToTopForDesktopEdit();
|
scrollToTopForDesktopEdit();
|
||||||
@@ -848,6 +1208,7 @@ export function MedicationsPage() {
|
|||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -861,6 +1222,7 @@ export function MedicationsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
|
resetMedicationEnrichment();
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -903,6 +1265,7 @@ export function MedicationsPage() {
|
|||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
|
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||||
startEdit(medicationToEdit, openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
scrollToTopForDesktopEdit();
|
scrollToTopForDesktopEdit();
|
||||||
@@ -1050,15 +1413,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" />
|
||||||
@@ -1254,6 +1614,14 @@ export function MedicationsPage() {
|
|||||||
<span className="field-error">{fieldErrors.genericName}</span>
|
<span className="field-error">{fieldErrors.genericName}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={medicationEnrichmentViewModel}
|
||||||
|
onQueryChange={handleMedicationEnrichmentQueryChange}
|
||||||
|
onSearch={handleMedicationEnrichmentSearch}
|
||||||
|
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
|
||||||
|
onApplyResult={handleMedicationEnrichmentApply}
|
||||||
|
onApplyStrength={handleMedicationEnrichmentStrengthApply}
|
||||||
|
/>
|
||||||
<div className="full date-pair-group">
|
<div className="full date-pair-group">
|
||||||
<label className="date-pair-field">
|
<label className="date-pair-field">
|
||||||
{t("form.medicationStartDate")}
|
{t("form.medicationStartDate")}
|
||||||
@@ -1734,105 +2102,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 +2262,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>
|
||||||
@@ -1861,6 +2280,12 @@ export function MedicationsPage() {
|
|||||||
editingId={editingId}
|
editingId={editingId}
|
||||||
form={form}
|
form={form}
|
||||||
onFormChange={setForm}
|
onFormChange={setForm}
|
||||||
|
medicationEnrichment={medicationEnrichmentViewModel}
|
||||||
|
onMedicationEnrichmentQueryChange={handleMedicationEnrichmentQueryChange}
|
||||||
|
onMedicationEnrichmentSearch={handleMedicationEnrichmentSearch}
|
||||||
|
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
|
||||||
|
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
|
||||||
|
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
formSaved={formSaved}
|
formSaved={formSaved}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -2068,6 +2068,211 @@ button.has-validation-error {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 62%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-title {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-collapsed-hint,
|
||||||
|
.medication-enrichment-description,
|
||||||
|
.medication-enrichment-manual-hint,
|
||||||
|
.medication-enrichment-selection-summary {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-helper-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--info) 28%, var(--border-primary));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--accent-bg) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-info-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-search-row button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-results-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 68%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result.active {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-primary));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(47, 134, 246, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-names {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-names strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-generic {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-meta div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-meta dt {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-followup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px dashed var(--border-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-strengths {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-strength-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-strength-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-strength-list button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.medication-enrichment-header,
|
||||||
|
.medication-enrichment-result-header,
|
||||||
|
.medication-enrichment-search-row,
|
||||||
|
.medication-enrichment-helper-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-search-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.select-field.dose-unit-select:hover,
|
.select-field.dose-unit-select:hover,
|
||||||
.dose-unit-select:hover {
|
.dose-unit-select:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
MedicationEnrichmentSection,
|
||||||
|
type MedicationEnrichmentViewModel,
|
||||||
|
} from "../../components/MedicationEnrichmentSection";
|
||||||
|
import type { MedicationEnrichmentSearchResult, MedicationEnrichmentStrengthOption } from "../../types";
|
||||||
|
|
||||||
|
function createResult(overrides: Partial<MedicationEnrichmentSearchResult> = {}): MedicationEnrichmentSearchResult {
|
||||||
|
return {
|
||||||
|
code: "EMA-ASPIRIN",
|
||||||
|
name: "Aspirin 500 mg tablets",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
authorisationHolder: "Bayer",
|
||||||
|
therapeuticArea: "Pain",
|
||||||
|
matchType: "brand",
|
||||||
|
genericStatus: "original",
|
||||||
|
authorisationDate: "2024-02-01",
|
||||||
|
source: "ema",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStrengthOption(
|
||||||
|
overrides: Partial<MedicationEnrichmentStrengthOption> = {}
|
||||||
|
): MedicationEnrichmentStrengthOption {
|
||||||
|
return {
|
||||||
|
label: "500 mg",
|
||||||
|
pillWeightMg: 500,
|
||||||
|
doseUnit: "mg",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(overrides: Partial<MedicationEnrichmentViewModel> = {}): MedicationEnrichmentViewModel {
|
||||||
|
return {
|
||||||
|
query: "",
|
||||||
|
results: [],
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: false,
|
||||||
|
searchError: null,
|
||||||
|
applyingCode: null,
|
||||||
|
activeResultCode: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
enrichError: null,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MedicationEnrichmentSection", () => {
|
||||||
|
it("starts collapsed so the lookup stays optional by default", () => {
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState()}
|
||||||
|
onQueryChange={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onApplyResult={vi.fn()}
|
||||||
|
onApplyStrength={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("form.enrichment.title")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByPlaceholderText("form.enrichment.searchPlaceholder")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports explicit show and hide toggles for the lookup and source guidance", () => {
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState()}
|
||||||
|
onQueryChange={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onApplyResult={vi.fn()}
|
||||||
|
onApplyStrength={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
|
||||||
|
expect(screen.getByPlaceholderText("form.enrichment.searchPlaceholder")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.toggleHide" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("form.enrichment.infoTitle")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoShow" }));
|
||||||
|
expect(screen.getByText("form.enrichment.infoTitle")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.infoHide" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoHide" }));
|
||||||
|
expect(screen.queryByText("form.enrichment.infoTitle")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.infoShow" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleHide" }));
|
||||||
|
expect(screen.queryByPlaceholderText("form.enrichment.searchPlaceholder")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals guidance only when requested and wires search/apply actions", () => {
|
||||||
|
const onQueryChange = vi.fn();
|
||||||
|
const onSearch = vi.fn();
|
||||||
|
const onApplyResult = vi.fn();
|
||||||
|
const result = createResult();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState({ query: "Aspirin", results: [result] })}
|
||||||
|
onQueryChange={onQueryChange}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onApplyResult={onApplyResult}
|
||||||
|
onApplyStrength={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("form.enrichment.details.authorisationHolder")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoShow" }));
|
||||||
|
expect(screen.getByText("form.enrichment.infoTitle")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.description")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.manualEntryHint")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||||
|
target: { value: "Ibuprofen" },
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.details.showAction" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
|
||||||
|
|
||||||
|
expect(onQueryChange).toHaveBeenCalledWith("Ibuprofen");
|
||||||
|
expect(onSearch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onApplyResult).toHaveBeenCalledWith(result);
|
||||||
|
expect(screen.getByText("form.enrichment.details.authorisationHolder")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.details.therapeuticArea")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.genericStatus.original")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels RxNorm and openFDA results with their source badges", () => {
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState({
|
||||||
|
query: "Semaglutide",
|
||||||
|
results: [
|
||||||
|
createResult({
|
||||||
|
code: "RX-123",
|
||||||
|
name: "Wegovy",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
source: "rxnorm",
|
||||||
|
}),
|
||||||
|
createResult({
|
||||||
|
code: "NDC-123",
|
||||||
|
name: "Ozempic",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
source: "openfda",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
onQueryChange={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onApplyResult={vi.fn()}
|
||||||
|
onApplyStrength={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("form.enrichment.sources.rxnorm")).toBeInTheDocument();
|
||||||
|
const openFdaBadge = screen.getByText("form.enrichment.sources.openfda");
|
||||||
|
expect(openFdaBadge).toBeInTheDocument();
|
||||||
|
expect(openFdaBadge).toHaveClass("warning");
|
||||||
|
expect(screen.queryByText("form.enrichment.genericStatus.unknown")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a load-more action when the backend reports more results", () => {
|
||||||
|
const onLoadMoreResults = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState({
|
||||||
|
query: "Aspirin",
|
||||||
|
results: [createResult({ source: "rxnorm", code: "RX-123", name: "Aspirin" })],
|
||||||
|
hasMoreResults: true,
|
||||||
|
})}
|
||||||
|
onQueryChange={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onLoadMoreResults={onLoadMoreResults}
|
||||||
|
onApplyResult={vi.fn()}
|
||||||
|
onApplyStrength={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
|
||||||
|
|
||||||
|
expect(onLoadMoreResults).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can expand automatically when follow-up feedback exists", () => {
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState({
|
||||||
|
hasSearched: true,
|
||||||
|
searchError: "Lookup unavailable",
|
||||||
|
})}
|
||||||
|
onQueryChange={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onApplyResult={vi.fn()}
|
||||||
|
onApplyStrength={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("form.enrichment.searchPlaceholder")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Lookup unavailable")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows partial coverage feedback and optional strength suggestions", () => {
|
||||||
|
const onApplyStrength = vi.fn();
|
||||||
|
const strengthOption = createStrengthOption();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MedicationEnrichmentSection
|
||||||
|
state={createState({
|
||||||
|
hasSearched: true,
|
||||||
|
appliedSelection: {
|
||||||
|
name: "Aspirin 500 mg tablets",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
therapeuticArea: "Pain",
|
||||||
|
indication: "Pain relief",
|
||||||
|
atcCode: "N02BA01",
|
||||||
|
source: "ema",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: false,
|
||||||
|
openFdaMatched: false,
|
||||||
|
partial: true,
|
||||||
|
note: "Returned EMA enrichment without RxNorm suggestions.",
|
||||||
|
},
|
||||||
|
strengthOptions: [strengthOption],
|
||||||
|
appliedStrengthLabel: "500 mg",
|
||||||
|
})}
|
||||||
|
onQueryChange={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onApplyResult={vi.fn()}
|
||||||
|
onApplyStrength={onApplyStrength}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("form.enrichment.partialNote")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.applied")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("form.enrichment.strengthTitle")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "500 mg" }));
|
||||||
|
|
||||||
|
expect(onApplyStrength).toHaveBeenCalledWith(strengthOption);
|
||||||
|
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
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 type { MedicationEnrichmentViewModel } from "../../components/MedicationEnrichmentSection";
|
||||||
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: "",
|
||||||
@@ -92,6 +93,26 @@ const defaultProps = {
|
|||||||
onSaveMedication: vi.fn(),
|
onSaveMedication: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createMedicationEnrichmentState(
|
||||||
|
overrides: Partial<MedicationEnrichmentViewModel> = {}
|
||||||
|
): MedicationEnrichmentViewModel {
|
||||||
|
return {
|
||||||
|
query: "",
|
||||||
|
results: [],
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: false,
|
||||||
|
searchError: null,
|
||||||
|
applyingCode: null,
|
||||||
|
activeResultCode: null,
|
||||||
|
appliedSelection: null,
|
||||||
|
enrichError: null,
|
||||||
|
meta: null,
|
||||||
|
strengthOptions: [],
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("MobileEditModal", () => {
|
describe("MobileEditModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -161,6 +182,64 @@ describe("MobileEditModal", () => {
|
|||||||
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
|
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the shared medication enrichment section after generic name", () => {
|
||||||
|
render(<MobileEditModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const genericNameLabel = screen.getByText("form.genericName");
|
||||||
|
const enrichmentTitle = screen.getByText("form.enrichment.title");
|
||||||
|
|
||||||
|
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wires medication enrichment search and apply actions inside the mobile editor", () => {
|
||||||
|
const onMedicationEnrichmentQueryChange = vi.fn();
|
||||||
|
const onMedicationEnrichmentSearch = vi.fn();
|
||||||
|
const onMedicationEnrichmentApply = vi.fn();
|
||||||
|
const onMedicationEnrichmentStrengthApply = vi.fn();
|
||||||
|
const result = {
|
||||||
|
code: "RX-123",
|
||||||
|
name: "Wegovy",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
authorisationHolder: null,
|
||||||
|
therapeuticArea: null,
|
||||||
|
matchType: "brand" as const,
|
||||||
|
genericStatus: "unknown" as const,
|
||||||
|
authorisationDate: null,
|
||||||
|
source: "rxnorm" as const,
|
||||||
|
};
|
||||||
|
const strengthOption = { label: "0.25 mg", pillWeightMg: 0.25, doseUnit: "mg" as const };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MobileEditModal
|
||||||
|
{...defaultProps}
|
||||||
|
medicationEnrichment={createMedicationEnrichmentState({
|
||||||
|
query: "Wegovy",
|
||||||
|
results: [result],
|
||||||
|
strengthOptions: [strengthOption],
|
||||||
|
})}
|
||||||
|
onMedicationEnrichmentQueryChange={onMedicationEnrichmentQueryChange}
|
||||||
|
onMedicationEnrichmentSearch={onMedicationEnrichmentSearch}
|
||||||
|
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
||||||
|
onMedicationEnrichmentStrengthApply={onMedicationEnrichmentStrengthApply}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.toggleHide" })).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||||
|
target: { value: "Ozempic" },
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "0.25 mg" }));
|
||||||
|
|
||||||
|
expect(onMedicationEnrichmentQueryChange).toHaveBeenCalledWith("Ozempic");
|
||||||
|
expect(onMedicationEnrichmentSearch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result);
|
||||||
|
expect(onMedicationEnrichmentStrengthApply).toHaveBeenCalledWith(strengthOption);
|
||||||
|
});
|
||||||
|
|
||||||
it("groups medication start and end date fields in one stacked date pair", () => {
|
it("groups medication start and end date fields in one stacked date pair", () => {
|
||||||
render(<MobileEditModal {...defaultProps} />);
|
render(<MobileEditModal {...defaultProps} />);
|
||||||
|
|
||||||
@@ -429,6 +508,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"));
|
||||||
@@ -431,4 +492,158 @@ describe("MedicationsPage form interactions", () => {
|
|||||||
expect(resetForm).toHaveBeenCalledTimes(1);
|
expect(resetForm).toHaveBeenCalledTimes(1);
|
||||||
expect(pushStateSpy).toHaveBeenCalledWith({ modal: "edit" }, "");
|
expect(pushStateSpy).toHaveBeenCalledWith({ modal: "edit" }, "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the shared medication enrichment section after generic name on desktop", () => {
|
||||||
|
renderPage();
|
||||||
|
openNewMedicationForm();
|
||||||
|
|
||||||
|
const genericNameLabel = screen.getByText("form.genericName");
|
||||||
|
const enrichmentTitle = screen.getByText("form.enrichment.title");
|
||||||
|
|
||||||
|
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches and applies medication enrichment suggestions through the desktop form", async () => {
|
||||||
|
const setForm = vi.fn();
|
||||||
|
mockFormHookValue = createMockFormHook({ setForm });
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.startsWith("/api/medication-enrichment/search?")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
query: "Aspirin",
|
||||||
|
normalizedQuery: "aspirin",
|
||||||
|
hasMore: url.includes("limit=6"),
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
code: "RX-ASPIRIN",
|
||||||
|
name: "Aspirin",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
authorisationHolder: null,
|
||||||
|
therapeuticArea: null,
|
||||||
|
matchType: "ingredient",
|
||||||
|
genericStatus: "unknown",
|
||||||
|
authorisationDate: null,
|
||||||
|
source: "rxnorm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "EMA-ASPIRIN",
|
||||||
|
name: "Aspirin 500 mg tablets",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
authorisationHolder: "Bayer",
|
||||||
|
therapeuticArea: "Pain",
|
||||||
|
matchType: "brand",
|
||||||
|
genericStatus: "original",
|
||||||
|
authorisationDate: "2024-02-01",
|
||||||
|
source: "ema",
|
||||||
|
},
|
||||||
|
...(url.includes("limit=12")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
code: "NDC-ASPIRIN",
|
||||||
|
name: "Bayer Aspirin",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
authorisationHolder: null,
|
||||||
|
therapeuticArea: null,
|
||||||
|
matchType: "brand",
|
||||||
|
genericStatus: "unknown",
|
||||||
|
authorisationDate: null,
|
||||||
|
source: "openfda",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/medication-enrichment/enrich") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
selection: {
|
||||||
|
name: "Aspirin",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
therapeuticArea: "Pain",
|
||||||
|
indication: "Pain relief",
|
||||||
|
atcCode: "N02BA01",
|
||||||
|
source: "rxnorm",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
name: "Aspirin",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: true,
|
||||||
|
openFdaMatched: false,
|
||||||
|
partial: false,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ ok: true, json: async () => [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
openNewMedicationForm();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||||
|
target: { value: " Aspirin " },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("Aspirin 500 mg tablets");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("Bayer Aspirin");
|
||||||
|
expect(screen.queryByRole("button", { name: "form.enrichment.showMoreAction" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "form.enrichment.applyAction" })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: "Aspirin",
|
||||||
|
name: "Aspirin",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
code: "RX-ASPIRIN",
|
||||||
|
source: "rxnorm",
|
||||||
|
}),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
expect(setForm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Aspirin",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
pillForm: "tablet",
|
||||||
|
pillWeightMg: "500",
|
||||||
|
doseUnit: "mg",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,11 +19,71 @@ 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";
|
||||||
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
|
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
|
||||||
export type PackageAmountUnit = "ml" | "g";
|
export type PackageAmountUnit = "ml" | "g";
|
||||||
|
export type MedicationEnrichmentDoseUnit = DoseUnit | "IU" | "drops" | "puffs";
|
||||||
|
export type MedicationEnrichmentMatchType = "brand" | "ingredient";
|
||||||
|
export type MedicationEnrichmentGenericStatus = "generic" | "original" | "unknown";
|
||||||
|
export type MedicationEnrichmentSearchSource = "ema" | "rxnorm" | "openfda";
|
||||||
|
export type MedicationEnrichmentSource =
|
||||||
|
| MedicationEnrichmentSearchSource
|
||||||
|
| "ema+rxnorm"
|
||||||
|
| "ema+openfda"
|
||||||
|
| "rxnorm+openfda"
|
||||||
|
| "ema+rxnorm+openfda";
|
||||||
|
|
||||||
|
export type MedicationEnrichmentSearchResult = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
genericName: string | null;
|
||||||
|
authorisationHolder: string | null;
|
||||||
|
therapeuticArea: string | null;
|
||||||
|
matchType: MedicationEnrichmentMatchType;
|
||||||
|
genericStatus: MedicationEnrichmentGenericStatus;
|
||||||
|
authorisationDate: string | null;
|
||||||
|
source: MedicationEnrichmentSearchSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationEnrichmentSearchResponse = {
|
||||||
|
query: string;
|
||||||
|
normalizedQuery: string;
|
||||||
|
hasMore: boolean;
|
||||||
|
results: MedicationEnrichmentSearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationEnrichmentStrengthOption = {
|
||||||
|
label: string;
|
||||||
|
pillWeightMg: number | null;
|
||||||
|
doseUnit: MedicationEnrichmentDoseUnit | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationEnrichmentEnrichResponse = {
|
||||||
|
selection: {
|
||||||
|
name: string;
|
||||||
|
genericName: string | null;
|
||||||
|
therapeuticArea: string | null;
|
||||||
|
indication: string | null;
|
||||||
|
atcCode: string | null;
|
||||||
|
source: MedicationEnrichmentSource;
|
||||||
|
};
|
||||||
|
suggestions: {
|
||||||
|
name: string;
|
||||||
|
genericName: string | null;
|
||||||
|
medicationForm: MedicationForm | null;
|
||||||
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: boolean;
|
||||||
|
openFdaMatched: boolean;
|
||||||
|
partial: boolean;
|
||||||
|
note: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
||||||
{ value: "mg", label: "mg" },
|
{ value: "mg", label: "mg" },
|
||||||
@@ -49,6 +109,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 +193,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