Compare commits

...

5 Commits

Author SHA1 Message Date
Daniel Volz 37fc2b8e66 chore: release v1.21.0 (#467) 2026-03-20 21:02:28 +01:00
github-actions[bot] d434131d02 chore: update test count badges [skip ci] 2026-03-20 19:43:52 +00:00
Daniel Volz b796e03bcb feat: add medication enrichment lookup to the medication editor
* feat: add medication enrichment lookup

* fix: avoid double unescape in enrichment sanitization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

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